关注【索引目录】服务号,更多精彩内容等你来探索!
关于 YouTube Premium,我一直很好奇如何下载视频并离线观看。但 YouTube 不会直接给你一个.mp4或类似的文件。你需要重新登录,前往下载区,然后从那里访问。
最大的问题是:这些视频存储在哪里?如何存储?更有趣的是,我们如何才能自己实现一个(更简单的)版本?
让我们将其分解为几个简单的步骤:
将视频分割成更小的块,以便更好地存储和流式传输
将数据块存储在 IndexedDB 中 — 并进行一些编码和附加操作
离线检索和播放视频(解密并进行一些附加操作后)
就是这样!
核心机制
YouTube 无疑是目前最牛逼的服务之一。解释其幕后运作(甚至不包括算法和盈利方面)真的很难。以下是简化版:
内容创作者上传了一段视频,比如说 4K 画质。YouTube 的内部服务会自动将该视频转换为多种画质,并将它们存储在某个地方(我们并不清楚具体位置)。因此,虽然原始视频是以高清格式上传的,但也会保存低画质版本。这很正常——毕竟设备、带宽和用户使用环境都有所不同。
一旦存储,用户就可以访问。播放视频时,你可能注意到,你正在观看的部分之前的部分已经下载完毕(或者更好的是,已经缓冲了)。
YouTube 遵循以下几条规则:
即使视频长度约为 54 分钟,它也无法缓冲整个视频。
如果您观看的是第 2 分钟,则无需缓冲到第 40 分钟 - 它可能只需要缓冲到第 6 分钟。
这可以避免浪费资源并允许根据连接质量进行灵活的流式传输。
这种逻辑对于直播也非常有效。
为了提供最佳用户体验,YouTube 会将视频拆分成更小的片段。我不清楚具体的逻辑——一个 1 小时的视频可能会被拆分成 60 个 1 分钟的片段,或者 120 个 30 秒的片段,甚至可能取决于视频的大小/长度。但这种拆分确实会发生,我们稍后会演示一下。
另外,我们不能只是把 MP4 文件传递给<video>标签就完事了。如果我们要分块播放,就需要一种方法将它们输入到播放器中。这时MediaSource API 就派上用场了(YouTube 也使用了该 API)。它让我们可以完全控制播放,动态推送视频块、调整比特率等等。
到目前为止,我们已经概述了 YouTube 的逻辑。(以上是我的发现——也欢迎您提出宝贵意见!)
愿景
我们不是 YouTube,但我们好奇心很强,想构建一个类似的产品。
这里我们不讨论视频流传输。我们只关注 Premium 用户完全下载视频以供离线使用的情况。但正如你所知,YouTube 不会提供可下载的 MP4 文件。相反,它会将视频(加密)存储在类似 .mp4 的文件中IndexedDB。
视频被分块并存储在IndexedDB中,它支持存储Blob和ArrayBuffer,非常适合保存视频数据。
播放视频时,这些分块会通过reduce函数聚合,并存储到<video>标签或更高级的文件中MediaSource。
const chunk = {
videoId: "Prince Rouge",
chunkIndex: 5,
quality: "720p",
data: Blob // or ArrayBuffer
}
稍后,为了重播视频,这个ArrayBuffer或Blob将被重新组合,并输入到中MediaSource。请注意,这里的chunkIndex 是 5,这意味着这是完整视频n 个块中的第五个块。
为什么要使用这些工具?
为什么IndexedDB?
对于浏览器端存储,我们有:
localStorage— 限制为~5MBsessionStorage— 也很小,不跨标签共享cookies— 适用于其他用例
这使得IndexedDB成为大型二进制存储的唯一可行选择。
为什么使用Blob& ArrayBuffer?
你需要处理文件对象——这就是 & 的作用Blob所在。 & 充当和ArrayBuffer之间的桥梁(不完全一样,但类似)。你将一个 blob 转换为 buffer,然后将其传递给或。BlobMediaSourceMediaSource<video>
const res = await fetch('video.mp4');
const blob = await res.blob();
// And later
const reader = new FileReader();
reader.readAsArrayBuffer(blob);
为什么MediaSource?
因为 basic<video>标签只适用于完整的文件 URL。但有了MediaSource,我们可以:
逐块添加视频数据
动态缓冲
从内存(或磁盘)、工作者、数据库加载
实时流
构建完全自定义的视频播放器
const mediaSource = new MediaSource();
video.src = URL.createObjectURL(mediaSource);
mediaSource.addEventListener("sourceopen", () => {
const sourceBuffer = mediaSource.addSourceBuffer('video/mp4; codecs="avc1.42E01E"');
// Append chunks manually
fetchChunk().then((chunk) => {
sourceBuffer.appendBuffer(chunk);
});
});
就是这样。
编排——将所有内容整合在一起
您可以在此处看到完整的示例应用程序- 它是通过 Vite 引导的。
为了简单起见:
用户选择一个视频(他们不上传,我们只是将其存储到内部浏览器数据库中)
我们将其分成 1MB 的块
IndexedDB使用密钥保存每个块重建并使用
<video>
步骤:
构建 UI(基于 Tailwind,因此跳过细节)
将视频分块
保存至
IndexedDB检索并喂给
<video>游戏(可选)从 IndexedDB 中删除
还有一个调试按钮,可以使用<pre>标签显示数据库内容(或检查 DevTools 中的“应用程序”选项卡)。
分块
我们有一个函数,可以从输入中读取文件,将其转换为ArrayBuffer,然后将其切成 1MB 的块。一个简单的 while 循环处理分块。
const arrayBuffer = await videoFile.arrayBuffer();
const chunkSize = 1024 * 1024; // 1MB chunks
const chunks = [];
let offset = 0;
while (offset < arrayBuffer.byteLength) {
const size = Math.min(chunkSize, arrayBuffer.byteLength - offset);
const chunk = arrayBuffer.slice(offset, offset + size);
chunks.push(chunk);
offset += size;
}
存储
我们将视频分成:
元数据(例如文件名、大小、总块)
实际块
这是一个对象的示例metadata:
const metadata = {
id: videoId,
title: videoFile.name,
mimeType: videoFile.type,
size: arrayBuffer.byteLength,
chunkCount: chunks.length, // this is important
dateAdded: new Date().toISOString()
};
现在我们到了存储数据的部分IndexedDB。在这里,你可以使用类似 ORM 的库(例如idb ),也可以直接使用它。由于我们这里不启动 Apollo,所以我选择不使用任何库。
首先,我们需要创建一个数据库,然后重用同一个实例来对其运行查询——无论是保存数据、读取、删除还是其他任何操作。
let dbInstance = null;
// Initialize the database once and store the connection
const initDB = () => {
return new Promise((resolve, reject) => {
if (dbInstance) {
// Using existing database connection
resolve(dbInstance);
return;
}
const request = indexedDB.open('VideoStorageDB', 1);
request.onerror = (event) => {
console.error("IndexedDB error:", event.target.error);
reject(event.target.error);
};
request.onupgradeneeded = (event) => {
// Upgrading database schema
const db = event.target.result;
// Create the metadata store
if (!db.objectStoreNames.contains('metadata')) {
db.createObjectStore('metadata', { keyPath: 'id' });
}
// Create the chunks store
if (!db.objectStoreNames.contains('chunks')) {
db.createObjectStore('chunks', { keyPath: 'id' });
}
};
request.onsuccess = (event) => {
dbInstance = event.target.result;
// Handle connection errors
dbInstance.onerror = (event) => {
console.error("Database error:", event.target.error);
};
resolve(dbInstance);
};
});
};
我们使用简单的单例模式来初始化和重用数据库实例。
const storeCompleteVideo = async (metadata, chunks) => {
try {
const db = await initDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(['metadata', 'chunks'], 'readwrite');
transaction.onerror = (event) => {
reject(event.target.error);
};
transaction.oncomplete = () => {
console.log(`Video ${metadata.id} stored successfully with all ${chunks.length} chunks`);
resolve(metadata);
};
const metadataStore = transaction.objectStore('metadata');
const chunksStore = transaction.objectStore('chunks');
// INJA 1
metadataStore.put(metadata);
// INJA 2
for (let i = 0; i < chunks.length; i++) {
const chunkData = {
id: `${metadata.id}_chunk_${i}`,
videoId: metadata.id,
chunkIndex: i,
data: chunks[i] // ArrayBuffer chunk
};
chunksStore.put(chunkData);
}
});
} catch (error) {
console.error('Error storing video:', error);
throw error;
}
};
好吧,在代码中,我用注释标记了两个地方:INJA1和INJA2(我总是用这个词来调试 )。在第一部分,它非常简单——我们只是保存了metadata。没什么特别的——它是一个简单的对象。这里唯一的黄金点是chunkCount,我们稍后会用到它。
在第 2 部分,事情变得更加有趣。在这里,我们循环遍历之前创建的视频块,并将每个块存储为我之前展示的对象。数据已经格式化了ArrayBuffer——我们只需要保存它。每个块都有自己的index,稍后重建视频进行播放时我们会用到它。
如果一切顺利的话,我们的数据就会存储在IndexedDB。
至此,我们基本上完成了 90% 的流程。剩下的就是检索数据并将其输入到<video>标签中。再说一遍,我们的播放器只是一个普通的视频标签,我们向其中输入数据。这里没有什么特别或自定义的功能。
检索和播放
我们有一个readVideoFromIndexedDB功能:
// Get a specific chunk
const getVideoChunk = async (chunkId) => {
try {
const db = await initDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(['chunks'], 'readonly');
const store = transaction.objectStore('chunks');
const request = store.get(chunkId);
request.onsuccess = () => {
resolve(request.result);
};
request.onerror = (event) => {
console.error(`Error getting chunk ${chunkId}:`, event.target.error);
reject(event.target.error);
};
});
} catch (error) {
console.error("Error in getVideoChunk:", error);
throw error;
}
};
// Read all chunks for a video and combine them
const readVideoFromIndexedDB = async (videoId) => {
try {
// Get the metadata
const metadata = await getVideoMetadata(videoId);
if (!metadata) {
throw new Error(`Video metadata not found for ID: ${videoId}`);
}
// Get all chunks in sequence to ensure correct order
const chunks = [];
for (let i = 0; i < metadata.chunkCount; i++) {
const chunkId = `${videoId}_chunk_${i}`;
const chunk = await getVideoChunk(chunkId);
if (!chunk) {
throw new Error(`Chunk ${i} missing for video ${videoId}`);
}
chunks.push(chunk.data);
}
// INJA 1
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
const combinedArray = new Uint8Array(totalLength);
let offset = 0;
// INJA 2
for (const chunk of chunks) {
combinedArray.set(new Uint8Array(chunk), offset);
offset += chunk.byteLength;
}
return {
data: combinedArray.buffer,
type: metadata.mimeType || 'video/mp4'
};
} catch (error) {
console.error("Error reading video:", error);
throw error;
}
};
好吧,代码有点长,但基本都是之前讲过的内容。首先,我们调用readVideoFromIndexedDB函数——这个函数负责获取视频,按顺序组合所有数据块,并将结果传递给标签。同样<video>,你会看到这里有两条INJA注释——它们有更详细的解释。
我们首先读取 ,metadata获取视频的常规信息。我们使用该chunkCount属性来了解视频总共有多少个块——我们将在循环中使用它来逐个获取它们。
现在,关于ArrayBuffer:它是一个低级二进制数据结构(根据 MDN)。如果要使用它,您必须创建一个固定大小的缓冲区——而且它是不可变的。因此,要使用它,您需要一个所谓的“视图”,而这正是 所Uint8Array提供的。您可以将其视为一个帮助我们更轻松地处理原始数据的接口。
我希望我能很好地解释这一点——一开始对我来说也有点难以理解。
合并后:
const blob = new Blob([combinedBuffer], { type: mimeType });
const url = URL.createObjectURL(blob);
video.src = url;
好了,一切就绪了。
我们取出之前创建的缓冲区,将其转换为Blob,并从中生成一个 URL,然后将该 URL 设置为src标签的<video>。
如果一切顺利的话——我们现在有一个可以使用的离线视频系统IndexedDB。
最后的想法
这篇文章花了不少时间写,读起来可能也花了不少时间。我跳过了几个我不知道该如何处理的高级主题(例如,加密只提到了,却没有实现)。
需要改进的地方:
尚不支持缩略图
YouTube 分别存储音频和视频
我们预先合并所有数据 — — 因此无需进行渐进式缓冲/预加载
仅适用于用户上传的文件 - 可以扩展以使用 URL
UI 需要改进 ## 最后的话 好奇心是好事。最近我一直在四处探索,试图理解底层机制。

