Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@

This new command is perfect for mobile users and provides a seamless note-taking experience.

### Fetch only a specific transcript segment

You can limit transcript output to a time window by adding start/end query params to the YouTube URL:

- `&start=2:48&end=7:59`
- `&t=2m48s&end=479`

Works in both the insert command and side panel view.

## Classic: Side panel workflow (Desktop-Optimized)

1. In editor window select link to Youtube video
Expand Down
23 changes: 16 additions & 7 deletions src/obsidian/views/transcript-view.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { ItemView, Menu, WorkspaceLeaf } from "obsidian";

import { getTranscriptBlocks } from "src/transcript/blocks";
import { filterTranscriptLinesByRange } from "src/transcript/range";
import { formatTimestamp } from "src/transcript/timestamp";
import type { TranscriptBlock, TranscriptResponse } from "src/transcript/types";
import { buildTimestampUrl, extractYouTubeTimeRange } from "src/youtube/url";

import { highlightText } from "../highlight";
import type YTranscriptPlugin from "../plugin";
Expand Down Expand Up @@ -30,17 +32,24 @@ export class TranscriptView extends ItemView {
}): Promise<void> {
const { url, transcript } = state;
if (!url || !transcript) return;
const filteredTranscript: TranscriptResponse = {
...transcript,
lines: filterTranscriptLinesByRange(
transcript.lines,
extractYouTubeTimeRange(url),
),
};

const { timestampMod } = this.plugin.settings;
this.videoTitle = transcript.title;
this.videoTitle = filteredTranscript.title;

this.contentEl.empty();

const headerEl = this.contentEl.createEl("div", {
cls: "yt-transcript__header",
});
headerEl.createEl("div", {
text: transcript.title,
text: filteredTranscript.title,
cls: "yt-transcript__video-title",
});
const closeBtn = headerEl.createEl("button", {
Expand All @@ -50,17 +59,17 @@ export class TranscriptView extends ItemView {
});
closeBtn.addEventListener("click", () => this.leaf.detach());

if (transcript.lines.length === 0) {
if (filteredTranscript.lines.length === 0) {
this.contentEl.createEl("div", {
text: "No transcript found for this video.",
});
return;
}

this.renderSearchInput(url, transcript, timestampMod);
this.renderSearchInput(url, filteredTranscript, timestampMod);

this.dataContainerEl = this.contentEl.createEl("div");
this.renderTranscriptionBlocks(url, transcript, timestampMod, "");
this.renderTranscriptionBlocks(url, filteredTranscript, timestampMod, "");
}

private renderSearchInput(
Expand All @@ -87,7 +96,7 @@ export class TranscriptView extends ItemView {
return blocks
.map((block) => {
const { quote, quoteTimeOffset } = block;
const href = url + "&t=" + Math.floor(quoteTimeOffset / 1000);
const href = buildTimestampUrl(url, quoteTimeOffset);
return `[${formatTimestamp(quoteTimeOffset)}](${href}) ${quote}`;
})
.join("\n");
Expand Down Expand Up @@ -119,7 +128,7 @@ export class TranscriptView extends ItemView {
const linkEl = createEl("a", {
text: formatTimestamp(quoteTimeOffset),
attr: {
href: url + "&t=" + Math.floor(quoteTimeOffset / 1000),
href: buildTimestampUrl(url, quoteTimeOffset),
},
});

Expand Down
15 changes: 11 additions & 4 deletions src/transcript/format.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { buildTimestampUrl } from "src/youtube/url";
import { buildTimestampUrl, extractYouTubeTimeRange } from "src/youtube/url";

import { getTranscriptBlocks } from "./blocks";
import { filterTranscriptLinesByRange } from "./range";
import { formatTimestamp } from "./timestamp";
import type { TranscriptResponse } from "./types";

Expand All @@ -20,15 +21,21 @@ export function formatTranscript(
if (transcript.lines.length === 0) return "";

const normalized = normalizeOptions(options);
const filteredLines = filterTranscriptLinesByRange(
transcript.lines,
extractYouTubeTimeRange(url),
);
if (filteredLines.length === 0) return "";
const filteredTranscript = { ...transcript, lines: filteredLines };

switch (normalized.template) {
case "minimal":
return formatMinimal(transcript);
return formatMinimal(filteredTranscript);
case "rich":
return formatRich(transcript, url, normalized);
return formatRich(filteredTranscript, url, normalized);
case "standard":
default:
return formatStandard(transcript, url, normalized);
return formatStandard(filteredTranscript, url, normalized);
}
}

Expand Down
23 changes: 23 additions & 0 deletions src/transcript/range.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { TranscriptLine } from "./types";

export interface TranscriptLineRange {
startMs?: number;
endMs?: number;
}

export function filterTranscriptLinesByRange(
lines: TranscriptLine[],
range: TranscriptLineRange,
): TranscriptLine[] {
const { startMs, endMs } = range;
if (startMs === undefined && endMs === undefined) return lines;

return lines.filter((line) => {
const lineStart = line.offset;
const lineEnd = line.offset + line.duration;

if (startMs !== undefined && lineEnd <= startMs) return false;
if (endMs !== undefined && lineStart >= endMs) return false;
return true;
});
}
80 changes: 80 additions & 0 deletions src/youtube/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ const YOUTUBE_DOMAINS = new Set([
"www.youtu.be",
]);

export interface YouTubeTimeRange {
startMs?: number;
endMs?: number;
}

export function isValidYouTubeUrl(url: string | null | undefined): boolean {
if (!url || typeof url !== "string") return false;

Expand Down Expand Up @@ -58,3 +63,78 @@ export function buildTimestampUrl(url: string, offsetMs: number): string {
return url;
}
}

export function extractYouTubeTimeRange(url: string): YouTubeTimeRange {
if (!url || typeof url !== "string") return {};

try {
const parsed = new URL(url);
const startSeconds = parseTimestampSeconds(
parsed.searchParams.get("start") ?? parsed.searchParams.get("t"),
);
const endSeconds = parseTimestampSeconds(parsed.searchParams.get("end"));
const hashTimestampSeconds = parseTimestampSeconds(
parsed.hash.startsWith("#t=") ? parsed.hash.slice(3) : null,
);

const startMs = toMilliseconds(startSeconds ?? hashTimestampSeconds);
const endMs = toMilliseconds(endSeconds);

if (
startMs !== undefined &&
endMs !== undefined &&
endMs <= startMs
) {
return { startMs };
}

return { startMs, endMs };
} catch {
return {};
}
}

function parseTimestampSeconds(value: string | null): number | undefined {
if (!value) return undefined;
const normalized = value.trim();
if (normalized.length === 0) return undefined;

if (/^\d+(\.\d+)?$/.test(normalized)) {
const numeric = Number.parseFloat(normalized);
return Number.isFinite(numeric) ? numeric : undefined;
}

if (normalized.includes(":")) {
const parts = normalized.split(":");
if (parts.length < 2 || parts.length > 3) return undefined;
if (!parts.every((part) => /^\d+$/.test(part))) return undefined;

const numbers = parts.map((part) => Number.parseInt(part, 10));
if (parts.length === 2) {
const [minutes, seconds] = numbers;
return minutes * 60 + seconds;
}
const [hours, minutes, seconds] = numbers;
return hours * 3600 + minutes * 60 + seconds;
}

const unitMatch = normalized.match(
/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+(?:\.\d+)?)s?)?$/i,
);
if (!unitMatch) return undefined;
const [, hoursPart, minutesPart, secondsPart] = unitMatch;
if (!hoursPart && !minutesPart && !secondsPart) return undefined;

const hours = hoursPart ? Number.parseInt(hoursPart, 10) : 0;
const minutes = minutesPart ? Number.parseInt(minutesPart, 10) : 0;
const seconds = secondsPart ? Number.parseFloat(secondsPart) : 0;

const total = hours * 3600 + minutes * 60 + seconds;
return Number.isFinite(total) ? total : undefined;
}

function toMilliseconds(seconds: number | undefined): number | undefined {
if (seconds === undefined) return undefined;
if (seconds < 0) return undefined;
return Math.floor(seconds * 1000);
}
17 changes: 17 additions & 0 deletions tests/transcript/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,23 @@ describe("formatTranscript", () => {
expect(result).toContain("&list=PLtest&t=0)");
expect(result).toContain("&list=PLtest&t=5)");
});

it("should only include transcript lines within requested time range", () => {
const result = formatTranscript(
mockTranscriptResponse,
`${testUrl}&start=0:05&end=0:11`,
{
timestampMod: 1,
template: "standard",
},
);

expect(result).not.toContain("Hello world");
expect(result).toContain("of the transcript");
expect(result).toContain("formatting system");
expect(result).toContain("with multiple lines");
expect(result).not.toContain("for comprehensive testing");
});
});

describe("rich template", () => {
Expand Down
49 changes: 49 additions & 0 deletions tests/transcript/range.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { filterTranscriptLinesByRange } from "src/transcript/range";
import type { TranscriptLine } from "src/transcript/types";

describe("filterTranscriptLinesByRange", () => {
const lines: TranscriptLine[] = [
{ text: "line 1", offset: 0, duration: 2000 },
{ text: "line 2", offset: 2000, duration: 2000 },
{ text: "line 3", offset: 4000, duration: 2000 },
{ text: "line 4", offset: 6000, duration: 2000 },
];

it("returns all lines when no range is provided", () => {
expect(filterTranscriptLinesByRange(lines, {})).toEqual(lines);
});

it("filters lines before start time", () => {
expect(
filterTranscriptLinesByRange(lines, {
startMs: 3000,
}).map((line) => line.text),
).toEqual(["line 2", "line 3", "line 4"]);
});

it("filters lines after end time", () => {
expect(
filterTranscriptLinesByRange(lines, {
endMs: 4500,
}).map((line) => line.text),
).toEqual(["line 1", "line 2", "line 3"]);
});

it("filters to an inclusive overlap window", () => {
expect(
filterTranscriptLinesByRange(lines, {
startMs: 3000,
endMs: 6500,
}).map((line) => line.text),
).toEqual(["line 2", "line 3", "line 4"]);
});

it("includes a line when the requested range falls fully inside that line", () => {
expect(
filterTranscriptLinesByRange(lines, {
startMs: 4500,
endMs: 4700,
}).map((line) => line.text),
).toEqual(["line 3"]);
});
});
65 changes: 64 additions & 1 deletion tests/youtube/url.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { extractYouTubeUrlFromText, isValidYouTubeUrl } from "src/youtube/url";
import {
extractYouTubeTimeRange,
extractYouTubeUrlFromText,
isValidYouTubeUrl,
} from "src/youtube/url";

describe("youtube/url", () => {
describe("isValidYouTubeUrl", () => {
Expand Down Expand Up @@ -183,4 +187,63 @@ describe("youtube/url", () => {
expect(result).toBe("https://www.youtube.com/watch?v=dQw4w9WgXcQ");
});
});

describe("extractYouTubeTimeRange", () => {
it("extracts start and end from query parameters", () => {
expect(
extractYouTubeTimeRange(
"https://www.youtube.com/watch?v=dQw4w9WgXcQ&start=2:48&end=7:59",
),
).toEqual({
startMs: 168000,
endMs: 479000,
});
});

it("extracts start from t parameter", () => {
expect(
extractYouTubeTimeRange(
"https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=2m48s",
),
).toEqual({
startMs: 168000,
endMs: undefined,
});
});

it("extracts start from hash timestamp", () => {
expect(
extractYouTubeTimeRange(
"https://www.youtube.com/watch?v=dQw4w9WgXcQ#t=2:48",
),
).toEqual({
startMs: 168000,
endMs: undefined,
});
});

it("omits endMs when end is before start", () => {
expect(
extractYouTubeTimeRange(
"https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=200&end=100",
),
).toEqual({
startMs: 200000,
});
});

it("treats equal start and end as start-only range", () => {
expect(
extractYouTubeTimeRange(
"https://www.youtube.com/watch?v=dQw4w9WgXcQ&start=100&end=100",
),
).toEqual({
startMs: 100000,
});
});

it("returns empty range when URL is invalid", () => {
expect(extractYouTubeTimeRange("not a url")).toEqual({});
});
});
});