Normalize YouTube Shorts links to watch URLs
This commit is contained in:
@@ -36,6 +36,15 @@ export interface ExtractedMedia {
|
||||
title: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorts links break the embed player: it needs `watch?v=`. Rewrite them for storage so the
|
||||
* viewer, "View full", and refreshes all use the canonical form. Other URLs pass through.
|
||||
*/
|
||||
export function normalizeYouTubeUrl(url: string): string {
|
||||
const match = /^https?:\/\/(?:www\.|m\.)?youtube\.com\/shorts\/([A-Za-z0-9_-]{6,})/.exec(url);
|
||||
return match ? `https://www.youtube.com/watch?v=${match[1]}` : url;
|
||||
}
|
||||
|
||||
function extension(filename: string | null | undefined): string {
|
||||
const match = /\.([a-z0-9]+)$/i.exec(filename ?? "");
|
||||
return match ? match[1].toLowerCase() : "";
|
||||
@@ -75,9 +84,11 @@ function hostname(url: string | null): string | null {
|
||||
|
||||
function extractEmbed(embed: CoreEmbed): ExtractedMedia | null {
|
||||
const provider = embed.provider ?? hostname(embed.url);
|
||||
const embedUrl = embed.url ? normalizeYouTubeUrl(embed.url) : null;
|
||||
const videoUrl = embed.videoUrl ? normalizeYouTubeUrl(embed.videoUrl) : null;
|
||||
const common = {
|
||||
source: "embed" as const,
|
||||
playUrl: embed.videoUrl ?? null,
|
||||
playUrl: videoUrl,
|
||||
provider,
|
||||
downloaded: false,
|
||||
size: null,
|
||||
@@ -91,7 +102,7 @@ function extractEmbed(embed: CoreEmbed): ExtractedMedia | null {
|
||||
return {
|
||||
...common,
|
||||
kind: "video",
|
||||
url: embed.url ?? embed.videoUrl,
|
||||
url: embedUrl ?? videoUrl ?? "",
|
||||
contentType: null,
|
||||
previewUrl: embed.thumbnailUrl ?? embed.imageUrl
|
||||
};
|
||||
@@ -102,7 +113,7 @@ function extractEmbed(embed: CoreEmbed): ExtractedMedia | null {
|
||||
return {
|
||||
...common,
|
||||
kind: "gif",
|
||||
url: embed.url ?? embed.imageUrl ?? "",
|
||||
url: embedUrl ?? embed.imageUrl ?? "",
|
||||
contentType: null,
|
||||
previewUrl: embed.thumbnailUrl ?? embed.imageUrl
|
||||
};
|
||||
@@ -112,7 +123,7 @@ function extractEmbed(embed: CoreEmbed): ExtractedMedia | null {
|
||||
return {
|
||||
...common,
|
||||
kind: "image",
|
||||
url: embed.url ?? embed.imageUrl,
|
||||
url: embedUrl ?? embed.imageUrl,
|
||||
contentType: null,
|
||||
previewUrl: embed.thumbnailUrl ?? embed.imageUrl
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "n
|
||||
import { join } from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { extractMedia } from "./extract.js";
|
||||
import { extractMedia, normalizeYouTubeUrl } from "./extract.js";
|
||||
import { aspectCondition, resolutionCondition } from "./filter-presets.js";
|
||||
import { emptyMediaCounts, type CoreMessage, type MediaKind, type ScrapeMeta } from "./types.js";
|
||||
|
||||
@@ -281,6 +281,86 @@ export class ScrapeStore {
|
||||
this.db.exec("ALTER TABLE media ADD COLUMN title_attempts INTEGER NOT NULL DEFAULT 0");
|
||||
this.db.exec("ALTER TABLE media ADD COLUMN title_error TEXT");
|
||||
}
|
||||
|
||||
this.migrateYouTubeShorts();
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time rewrite of youtube.com/shorts links to watch URLs. The embed player needs `v=`,
|
||||
* so old rows with a shorts URL would never play. `PRAGMA user_version` makes it idempotent
|
||||
* per scrape database.
|
||||
*/
|
||||
private migrateYouTubeShorts(): void {
|
||||
const version = this.db.prepare("PRAGMA user_version").get() as { user_version?: number } | undefined;
|
||||
if ((version?.user_version ?? 0) >= 1) return;
|
||||
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
"SELECT * FROM media WHERE url LIKE '%youtube.com/shorts/%' OR play_url LIKE '%youtube.com/shorts/%'"
|
||||
)
|
||||
.all() as unknown as MediaRow[];
|
||||
|
||||
if (rows.length) {
|
||||
const update = this.db.prepare("UPDATE media SET url = ?, play_url = ? WHERE id = ?");
|
||||
const findTarget = this.db.prepare("SELECT id FROM media WHERE url = ? AND id != ?");
|
||||
const merge = this.db.prepare(
|
||||
`UPDATE media SET
|
||||
preview_hash = COALESCE(preview_hash, ?),
|
||||
preview_bytes = COALESCE(preview_bytes, ?),
|
||||
preview_ext = COALESCE(preview_ext, ?),
|
||||
file_path = COALESCE(file_path, ?),
|
||||
file_hash = COALESCE(file_hash, ?),
|
||||
file_bytes = COALESCE(file_bytes, ?),
|
||||
downloaded = MAX(downloaded, ?),
|
||||
title = COALESCE(title, ?),
|
||||
title_key = COALESCE(title_key, ?),
|
||||
play_url = COALESCE(play_url, ?)
|
||||
WHERE id = ?`
|
||||
);
|
||||
const relink = this.db.prepare(
|
||||
"UPDATE OR IGNORE media_sources SET media_id = ? WHERE media_id = ?"
|
||||
);
|
||||
const dropLinks = this.db.prepare("DELETE FROM media_sources WHERE media_id = ?");
|
||||
const drop = this.db.prepare("DELETE FROM media WHERE id = ?");
|
||||
|
||||
this.db.exec("BEGIN");
|
||||
try {
|
||||
for (const row of rows) {
|
||||
const url = normalizeYouTubeUrl(row.url);
|
||||
const playUrl = row.play_url ? normalizeYouTubeUrl(row.play_url) : null;
|
||||
const target = findTarget.get(url, row.id) as { id: number } | undefined;
|
||||
|
||||
// The same video was already indexed under its watch URL: fold this row in.
|
||||
if (!target) {
|
||||
update.run(url, playUrl, row.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
merge.run(
|
||||
row.preview_hash,
|
||||
row.preview_bytes,
|
||||
row.preview_ext,
|
||||
row.file_path,
|
||||
row.file_hash,
|
||||
row.file_bytes,
|
||||
row.downloaded,
|
||||
row.title,
|
||||
row.title_key,
|
||||
playUrl,
|
||||
target.id
|
||||
);
|
||||
relink.run(target.id, row.id);
|
||||
dropLinks.run(row.id);
|
||||
drop.run(row.id);
|
||||
}
|
||||
this.db.exec("COMMIT");
|
||||
} catch (error) {
|
||||
this.db.exec("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
this.db.exec("PRAGMA user_version = 1");
|
||||
}
|
||||
|
||||
close(): void {
|
||||
|
||||
Reference in New Issue
Block a user