Skip to content

Commit aaf40ad

Browse files
committed
fix: accept nested YouTube Takeout parts
1 parent 749ac32 commit aaf40ad

4 files changed

Lines changed: 59 additions & 5 deletions

File tree

‎apps/web/src/lib/prepare-youtube-takeout.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { m } from "../paraglide/messages.js";
33
import type { PortabilityPreparationProgress } from "./portability-preparation-progress";
44
import { isTakeoutStorageQuotaError } from "./takeout-errors";
55
import { TakeoutZipReader } from "./takeout-zip-reader";
6-
import { isRootZipPart, isTakeoutMetadata, streamTakeoutPart } from "./youtube-takeout-archive";
6+
import { isNestedZipPart, isTakeoutMetadata, streamTakeoutPart } from "./youtube-takeout-archive";
77
import {
88
MAX_IN_MEMORY_BYTES,
99
MAX_METADATA_BYTES,
@@ -54,7 +54,7 @@ export async function prepareYoutubeTakeout(
5454
if (entry.directory) continue;
5555
sourceReader.registerEntry(entry);
5656
if (isTakeoutMetadata(entry.filename)) metadata.push(entry);
57-
else if (isRootZipPart(entry.filename)) {
57+
else if (isNestedZipPart(entry.filename)) {
5858
if (entry.encrypted || entry.uncompressedSize > MAX_NESTED_BYTES) {
5959
throw new Error(m.portability_takeout_metadata_limit());
6060
}

‎apps/web/src/lib/youtube-takeout-archive.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,12 @@ export function isTakeoutMetadata(name: string): boolean {
77
return /youtube.*\.(csv|html|json)$/i.test(name);
88
}
99

10-
export function isRootZipPart(name: string): boolean {
11-
return !name.includes("/") && /\.zip$/i.test(name);
10+
export function isNestedZipPart(name: string): boolean {
11+
return /\.zip$/i.test(name);
12+
}
13+
14+
export function isTakeoutZipPart(name: string): boolean {
15+
return /(?:^|\/)takeout-\d{8}t\d{6}z-\d+-\d+\.zip$/i.test(name);
1216
}
1317

1418
export async function isYoutubeTakeoutArchive(file: Blob): Promise<boolean> {
@@ -21,7 +25,7 @@ export async function isYoutubeTakeoutArchive(file: Blob): Promise<boolean> {
2125
sourceReader.registerEntry(entry);
2226
const name = entry.filename.replaceAll("\\", "/");
2327
if (name.toLowerCase().startsWith("takeout/") && isTakeoutMetadata(name)) return true;
24-
if (/^takeout-\d{8}t\d{6}z-\d+-\d+\.zip$/i.test(name)) return true;
28+
if (isTakeoutZipPart(name)) return true;
2529
}
2630
} catch {
2731
return false;

‎apps/web/tests/prepare-youtube-takeout.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,32 @@ test("extracts metadata from multiple ZIP parts and excludes media", async () =>
6666
}
6767
});
6868

69+
test("extracts metadata from ZIP parts nested inside a folder", async () => {
70+
const part = new ZipWriter(new BlobWriter());
71+
await part.add(
72+
"Takeout/YouTube y YouTube Music/suscripciones/suscripciones.csv",
73+
new TextReader(
74+
"ID de canal,URL del canal,Título del canal\nUC1,https://www.youtube.com/channel/UC1,Canal",
75+
),
76+
);
77+
const outer = new ZipWriter(new BlobWriter());
78+
await outer.add(
79+
"downloads/takeout-20260905T223911Z-2-001.zip",
80+
new BlobReader(await part.close()),
81+
);
82+
83+
const result = await prepareYoutubeTakeout(new File([await outer.close()], "takeout.zip"));
84+
const reader = new ZipReader(new BlobReader(result));
85+
try {
86+
const entries = await reader.getEntries();
87+
expect(entries.map((entry) => entry.filename)).toEqual([
88+
"Takeout/YouTube y YouTube Music/suscripciones/suscripciones.csv",
89+
]);
90+
} finally {
91+
await reader.close();
92+
}
93+
});
94+
6995
test("rejects a video-only part instead of starting an empty import", async () => {
7096
const writer = new ZipWriter(new BlobWriter());
7197
await writer.add("Takeout/YouTube/videos/upload.mp4", new TextReader("video"));

‎apps/web/tests/takeout-zip-reader.test.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,19 @@ test("detects a direct Google Takeout archive from its YouTube metadata paths",
104104
expect(await isYoutubeTakeoutArchive(file)).toBe(true);
105105
});
106106

107+
test("detects a Spanish Google Takeout archive from its localized YouTube metadata paths", async () => {
108+
const writer = new ZipWriter(new BlobWriter());
109+
await writer.add(
110+
"Takeout/YouTube y YouTube Music/suscripciones/suscripciones.csv",
111+
new TextReader(
112+
"ID de canal,URL del canal,Título del canal\nUC1,https://www.youtube.com/channel/UC1,Canal",
113+
),
114+
);
115+
const file = new File([await writer.close()], "takeout.zip");
116+
117+
expect(await isYoutubeTakeoutArchive(file)).toBe(true);
118+
});
119+
107120
test("detects Google Takeout transfer archives before decompressing their parts", async () => {
108121
const writer = new ZipWriter(new BlobWriter());
109122
await writer.add(
@@ -114,3 +127,14 @@ test("detects Google Takeout transfer archives before decompressing their parts"
114127

115128
expect(await isYoutubeTakeoutArchive(file)).toBe(true);
116129
});
130+
131+
test("detects Takeout transfer parts nested inside a folder", async () => {
132+
const writer = new ZipWriter(new BlobWriter());
133+
await writer.add(
134+
"downloads/takeout-20260905T223911Z-2-001.zip",
135+
new TextReader("not a real nested archive"),
136+
);
137+
const file = new File([await writer.close()], "transfer-01a07621.zip");
138+
139+
expect(await isYoutubeTakeoutArchive(file)).toBe(true);
140+
});

0 commit comments

Comments
 (0)