大数跨境

YouTube 如何处理下载视频

YouTube 如何处理下载视频 索引目录
2025-05-15
2
导读:关注【索引目录】服务号,更多精彩内容等你来探索!关于 YouTube Premium,我一直很好奇如何下载视频并离线观看。

关注【索引目录】服务号,更多精彩内容等你来探索!

关于 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中,它支持存储BlobArrayBuffer,非常适合保存视频数据。
播放视频时,这些分块会通过reduce函数聚合,并存储到<video>标签或更高级的文件中MediaSource

const chunk = {
videoId: "Prince Rouge",
chunkIndex: 5,
quality: "720p",
data: Blob // or ArrayBuffer
}

稍后,为了重播视频,这个ArrayBufferBlob将被重新组合,并输入到中MediaSource。请注意,这里的chunkIndex 是 5,这意味着这是完整视频n 个块中的第五个块。

为什么要使用这些工具?

为什么IndexedDB
对于浏览器端存储,我们有:

  • localStorage— 限制为~5MB

  • sessionStorage— 也很小,不跨标签共享

  • 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 引导的。



为了简单起见:

  1. 用户选择一个视频(他们不上传,我们只是将其存储到内部浏览器数据库中)

  2. 我们将其分成 1MB 的块

  3. IndexedDB使用密钥保存每个块

  4. 重建并使用<video>

步骤:

  1. 构建 UI(基于 Tailwind,因此跳过细节)

  2. 将视频分块

  3. 保存至IndexedDB

  4. 检索并喂给<video>游戏

  5. (可选)从 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;
}
};

好吧,在代码中,我用注释标记了两个地方:INJA1INJA2(我总是用这个词来调试 )。在第一部分,它非常简单——我们只是保存了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 需要改进 ## 最后的话 好奇心是好事。最近我一直在四处探索,试图理解底层机制。



【声明】内容源于网络
0
0
索引目录
索引目录是一家专注于医疗、技术开发、物联网应用等领域的创新型公司。我们致力于为客户提供高质量的服务和解决方案,推动技术与行业发展。
内容 444
粉丝 0
索引目录 索引目录是一家专注于医疗、技术开发、物联网应用等领域的创新型公司。我们致力于为客户提供高质量的服务和解决方案,推动技术与行业发展。
总阅读544
粉丝0
内容444