Skip to content

Under the Hood of Video Metadata: How Bluvy Tube Reimagines the Experience Without Hosting a Single File

Bluvy Core
Sep 15, 20269 min read

hen you launch a video on YouTube, you are not just watching a sequence of moving images.

You get a polished title, a description with links, interactive chapters on the progress bar, multilingual subtitles, and video recommendations in your language.

All of these features rely on what we call metadata: data that describes and structures content.

On a traditional centralized platform, this is easy: everything is stored on the platform's servers.

But how can we recreate that experience on Bluvy Tube, the first video platform built on the Bluesky network and AT Protocol, when we do not host the videos ourselves and want creators to retain 100% control over their content?

Here is a look under the hood at our technical architecture, combining decentralized protocols, real-time data processing, Symfony, and video integration.


1. The challenge: decentralized video is still "raw"

When a user publishes a video on Bluesky, the network creates a standard post (app.bsky.feed.post).

That post contains the minimum technical information:

  • a short text, meaning the post itself;
  • a file identifier, the blob CID, pointing to the Bluesky video CDN;
  • a raw thumbnail;
  • the video's basic dimensions, such as width and height.

We can summarize the original structure like this:

Original Bluesky post:
[Post text] + [Raw video link] + [Thumbnail]

For a mobile social feed, that is enough.

But for a true consumer video platform, a lot is missing.

How do you add chapters to a 10-minute video so viewers can jump directly to the section they care about?

How do you provide WebVTT subtitles for accessibility?

How do you provide a proper title without being limited by the size of the original post?

And above all: how do you store these enrichments without turning the platform back into a centralized and proprietary system?


2. The technical anatomy of an ATProto Embed: the 3 forms of video

To properly extract a video from the Bluesky network, our backend has to deal with the polymorphic structure of ATProto embeds.

In our VideoEmbedExtractor class, we handle three distinct variants depending on where the data comes from:

┌─────────────────────────────────────────────────────────────┐
│                 The 3 Faces of an Embed                     │
├──────────────────────────┬──────────────────────────────────┤
│ 1. app.bsky.embed.video  │ Raw data from the PDS            │
│    (Raw Record)          │ - blob link (video CID)          │
│                          │ - thumb link (thumbnail CID)     │
│                          │ - aspectRatio (width/height)     │
├──────────────────────────┼──────────────────────────────────┤
│ 2. app.bsky.embed.video  │ Data hydrated by the AppView     │
│    #view                 │ - HLS playlist (.m3u8)           │
│                          │ - CDN thumbnail URL              │
│                          │ - alt text & dimensions          │
├──────────────────────────┼──────────────────────────────────┤
│ 3. recordWithMedia#view  │ Video embedded inside a          │
│                          │ quoted post (video quote-post)   │
└──────────────────────────┴──────────────────────────────────┘

Each video points to a standardized streaming playback URL:

https://video.bsky.app/watch/{did}/{videoRef}/playlist.m3u8

This adaptive HLS (HTTP Live Streaming) stream is what our media player consumes directly.

This step is important: before even dealing with enriched metadata, Bluvy Tube must be able to correctly identify a video, regardless of how its embed is exposed within the ATProto ecosystem.


3. tube.bluvy.video.metadata: putting the data back in the creator's hands

Rather than locking creator customizations inside our own database, we created an open schema in the form of an ATProto Lexicon, called tube.bluvy.video.metadata.

Whenever a creator edits their title, adds subtitles, or creates chapters from the Bluvy Tube dashboard, this information is synchronized directly to their own PDS (Personal Data Server).

The goal is to preserve the modern experience of a video platform while keeping enriched data attached to the creator.

What does this video passport look like?

Here is the exact structure of the metadata published to the creator's PDS:

{
  "$type": "tube.bluvy.video.metadata",
  "postUri": "at://did:plc:creator123/app.bsky.feed.post/3kwxyz789",
  "video": {
    "$type": "blob",
    "ref": { "$link": "bafkreiaxxxxxxxxxxxxxxx" },
    "mimeType": "video/mp4"
  },
  "title": "Understanding Black Holes in 5 Minutes",
  "description": "A complete exploration of gravitational singularities...",
  "language": "en",
  "category": "science",
  "secondaryCategories": ["space", "physics"],
  "duration": 312,
  "isAiGenerated": false,
  "chapters": [
    {
      "title": "Introduction",
      "startTimeSeconds": 0
    },
    {
      "title": "What is the event horizon?",
      "startTimeSeconds": 85
    },
    {
      "title": "Conclusion",
      "startTimeSeconds": 260
    }
  ],
  "captions": [
    {
      "lang": "en",
      "vtt": {
        "$type": "blob",
        "ref": { "$link": "bafkreiyyyyyyyyyyyyyyy" },
        "mimeType": "text/vtt",
        "size": 2450
      }
    }
  ]
}

This structure makes it possible to associate a video with everything needed for a complete experience: title, description, language, categories, duration, chapters, and subtitles.

Why does this matter?

Because this metadata is not locked inside Bluvy Tube.

If another video application emerges within the Bluesky ecosystem tomorrow, it can read the same data without asking us for permission.

The creator remains the sole owner of their work and its enrichments.


4. The engineering pipeline: Node.js as the sentinel, Symfony as the orchestrator

To make all of this work without slowing the system down, the architecture relies on a clear separation of responsibilities:

Bluesky Firehose (Jetstream) → Node.js Indexer → Internal API → Symfony 7 → Database

Each component has a specific role.

The Node.js indexer: the sentinel

The Node.js indexer is the only component connected directly to Bluesky's continuous stream through Jetstream v2.

It has to process a large volume of events and filter the incoming data so that only eligible video posts are retained.

It handles, among other things:

  • filtering millions of incoming events;
  • identifying posts containing videos;
  • pre-calculating the exact duration from the HLS (.m3u8) stream;
  • sending cleaned data to our backend.

The idea is to perform identification and pre-processing before the data reaches the main application.

Symfony: the orchestrator

The Symfony 7 application then receives these pre-filtered videos through an internal API.

It is responsible for:

  • moderation rules;
  • discovery;
  • data structuring;
  • editing enriched information;
  • persistence in the database.

This separation prevents Symfony from having to carry the entire ingestion workload of the Firehose.

Why split the Doctrine model into two entities?

To efficiently handle ingestion while still providing creators with a complete editing environment, we separated the data model into two entities linked by a 1:1 relationship:

┌────────────────────────────────────────────────────────┐
│                      Table: videos                     │
│  - Immutable ATProto identity (rkey, CID, creator)     │
│  - Indexing & moderation status                         │
│  - Technical dimensions & original language            │
└───────────────────────────┬────────────────────────────┘
                            │ 1:1 relationship

┌────────────────────────────────────────────────────────┐
│                  Table: video_details                  │
│  - Custom title and description                         │
│  - Calculated actual duration                           │
│  - AI indicator (isAiGenerated)                         │
│  - PDS synchronization state                            │
└────────────────────────────────────────────────────────┘

The videos table

This table deliberately remains lightweight.

It mainly contains the identification and indexing information required to process videos:

  • ATProto identity;
  • rkey;
  • CID;
  • creator;
  • moderation status;
  • technical dimensions;
  • original language.

During re-ingestion or historical scans, inserts and updates can therefore happen quickly without manipulating unnecessary fields.

The video_details table

This second table acts as the enrichment layer.

It contains, among other things:

  • the custom title;
  • the description;
  • the calculated actual duration;
  • the isAiGenerated indicator;
  • the synchronization state with the PDS.

With optimized relationship loading (fetch: EAGER), Symfony can retrieve the video and its details in a single SQL query using a JOIN, enabling fast access for users.


5. The Embed Player: integrate Bluvy Tube anywhere

Like YouTube or Vimeo, Bluvy Tube provides a standalone embeddable player (<iframe>) through a dedicated controller, EmbedController.

For example:

<iframe 
  src="https://tube.bluvy.app/embed/at/did:plc:creator123/3kwxyz789" 
  width="100%" 
  height="450" 
  frameborder="0" 
  allowfullscreen>
</iframe>

The idea is to make it possible to embed a Bluvy Tube video into another application, a personal website, an article, or any web page that supports iframes.

What happens under the hood

Persistent and decentralized URLs

The player can be called either through an internal identifier:

/embed/video/42

or directly through the author's ATProto address:

/embed/at/{did}/{rkey}

The second approach keeps the reference directly linked to the ATProto identity and the original post.

Opportunistic on-demand indexing

If someone embeds a video that has not yet been indexed by our sentinel, EmbedController can query the Bluesky AppView directly.

The video record is then created on the fly in the background while playback starts immediately.

The user therefore does not need to wait for a regular indexing cycle to discover the video.

A clean and respectful player

The embedded player is deliberately minimal:

  • no unnecessary navigation components;
  • no search bar;
  • no banners;
  • lightweight loading;
  • respect for privacy.

The goal is simple: the embed should do one thing — play the video.


6. Chapters and duration: two challenges solved without files on disk

Dynamic WebVTT chapters

Modern video players can consume chapters through a WebVTT track:

<track kind="chapters">

Instead of creating and storing thousands of static .vtt files on disk for every chapterized video, Symfony generates the stream on the fly from the VideoChapter entities stored in the database.

For example:

GET /watch/3kwxyz789/chapters.vtt

returns:

WEBVTT

00:00:00.000 --> 00:01:25.000
Introduction

00:01:25.000 --> 00:04:20.000
What is the event horizon?

00:04:20.000 --> 00:05:12.000
Conclusion

The video player can then use this information to automatically divide the progress bar into interactive, clickable segments.

No permanent chapter file needs to be stored on disk.

Precise duration detection

Bluesky does not always provide the exact duration of a video in the post metadata.

To address this, our indexer directly analyzes the HLS manifest (.m3u8) provided by the Bluesky CDN when the video is first detected.

It then calculates the sum of the video segments to obtain an accurate duration, down to a tenth of a second, before passing the information to Symfony.

This provides a consistent reference for:

  • the displayed duration;
  • player progress;
  • chapter positioning;
  • any interface that depends on the video's timing.

7. Transparency and moderation: citizen metadata

Metadata is not only there to improve the interface.

It is also a key part of how we approach content distribution and moderation.

AI transparency

The isAiGenerated field allows creators, as well as our automated filters, to indicate that content has been synthesized or generated using artificial intelligence.

This information can then be displayed as an explicit badge for viewers.

Respecting language

The language field identifies the original language of the content according to the international BCP-47 standard.

This allows our algorithm to better surface French-language content to our European audience while also making it easier to discover internationally.

Moderation traceability

Each video also retains the information required to track its moderation checks:

  • safety labels;
  • human review;
  • creator verification status;
  • moderation history.

Moderation therefore becomes part of the content's history rather than an isolated piece of data.


In summary

Building video on a decentralized network does not mean giving up the comfort of modern platforms.

It is possible to provide titles, descriptions, categories, chapters, subtitles, language metadata, external embedding, and AI transparency, without turning Bluvy Tube into the exclusive owner of all this data.

Our architecture is based on a clear separation of responsibilities:

AT Protocol for identity, data ownership, and portability.

Node.js and Jetstream for real-time ingestion and pre-processing.

Symfony for application logic, moderation, discovery, and editing.

The database for delivering the fast experience users expect.

This hybrid architecture allows Bluvy Tube to remain performant while staying true to the principles of decentralization.

The creator enriches their video through Bluvy Tube, but those enrichments remain attached to their identity and their PDS.

And most importantly, another application could use the same metadata tomorrow without having to rebuild the entire ecosystem.

The speed and experience of a modern video platform, with the ownership and portability principles of AT Protocol.

That is exactly the direction we want to take Bluvy Tube.

Are you a creator on Bluesky? Head to Bluvy Tube to enrich your videos while keeping control of your data.

Did you enjoy this article?

Recommend it — Standard Reader surfaces well-loved writing to more readers across the network.

Across the AtmosphereDiscussions