From 10c8e81a746827760553a4eb1023658311e40115 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 22:18:30 +0000 Subject: [PATCH 1/4] Initial plan From 994dcd182a372d1cc4ea1c175052ce6867ad5ed5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 22:21:42 +0000 Subject: [PATCH 2/4] Add transcript time-range filtering via URL params Agent-Logs-Url: https://github.com/lstrzepek/obsidian-yt-transcript/sessions/1f86f41e-0423-407f-93fe-e03e2eef4888 Co-authored-by: lstrzepek <185352+lstrzepek@users.noreply.github.com> --- README.md | 9 +++ src/obsidian/views/transcript-view.ts | 23 +++++--- src/transcript/format.ts | 15 +++-- src/transcript/range.ts | 23 ++++++++ src/youtube/url.ts | 80 +++++++++++++++++++++++++++ tests/transcript/format.test.ts | 17 ++++++ tests/transcript/range.test.ts | 40 ++++++++++++++ tests/youtube/url.test.ts | 55 +++++++++++++++++- 8 files changed, 250 insertions(+), 12 deletions(-) create mode 100644 src/transcript/range.ts create mode 100644 tests/transcript/range.test.ts diff --git a/README.md b/README.md index ba5e3af..1a62626 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/obsidian/views/transcript-view.ts b/src/obsidian/views/transcript-view.ts index 90190f7..9936ba5 100644 --- a/src/obsidian/views/transcript-view.ts +++ b/src/obsidian/views/transcript-view.ts @@ -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"; @@ -30,9 +32,16 @@ export class TranscriptView extends ItemView { }): Promise { 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(); @@ -40,7 +49,7 @@ export class TranscriptView extends ItemView { cls: "yt-transcript__header", }); headerEl.createEl("div", { - text: transcript.title, + text: filteredTranscript.title, cls: "yt-transcript__video-title", }); const closeBtn = headerEl.createEl("button", { @@ -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( @@ -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"); @@ -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), }, }); diff --git a/src/transcript/format.ts b/src/transcript/format.ts index a87403f..a1ac7e8 100644 --- a/src/transcript/format.ts +++ b/src/transcript/format.ts @@ -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"; @@ -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); } } diff --git a/src/transcript/range.ts b/src/transcript/range.ts new file mode 100644 index 0000000..3a011a1 --- /dev/null +++ b/src/transcript/range.ts @@ -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; + }); +} diff --git a/src/youtube/url.ts b/src/youtube/url.ts index edd3b3d..152bcf0 100644 --- a/src/youtube/url.ts +++ b/src/youtube/url.ts @@ -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; @@ -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); +} diff --git a/tests/transcript/format.test.ts b/tests/transcript/format.test.ts index 0df1526..a18fd10 100644 --- a/tests/transcript/format.test.ts +++ b/tests/transcript/format.test.ts @@ -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", () => { diff --git a/tests/transcript/range.test.ts b/tests/transcript/range.test.ts new file mode 100644 index 0000000..6bcf2fa --- /dev/null +++ b/tests/transcript/range.test.ts @@ -0,0 +1,40 @@ +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"]); + }); +}); diff --git a/tests/youtube/url.test.ts b/tests/youtube/url.test.ts index 8ded73d..8996051 100644 --- a/tests/youtube/url.test.ts +++ b/tests/youtube/url.test.ts @@ -1,4 +1,8 @@ -import { extractYouTubeUrlFromText, isValidYouTubeUrl } from "src/youtube/url"; +import { + extractYouTubeTimeRange, + extractYouTubeUrlFromText, + isValidYouTubeUrl, +} from "src/youtube/url"; describe("youtube/url", () => { describe("isValidYouTubeUrl", () => { @@ -183,4 +187,53 @@ 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("ignores invalid ranges where end is before start", () => { + expect( + extractYouTubeTimeRange( + "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=200&end=100", + ), + ).toEqual({ + startMs: 200000, + }); + }); + + it("returns empty range when URL is invalid", () => { + expect(extractYouTubeTimeRange("not a url")).toEqual({}); + }); + }); }); From 20247bd89d4decc779e5cdf62a230f71bbbff5ae Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 22:22:54 +0000 Subject: [PATCH 3/4] Add edge-case tests for transcript range filtering Agent-Logs-Url: https://github.com/lstrzepek/obsidian-yt-transcript/sessions/1f86f41e-0423-407f-93fe-e03e2eef4888 Co-authored-by: lstrzepek <185352+lstrzepek@users.noreply.github.com> --- tests/transcript/range.test.ts | 9 +++++++++ tests/youtube/url.test.ts | 10 ++++++++++ 2 files changed, 19 insertions(+) diff --git a/tests/transcript/range.test.ts b/tests/transcript/range.test.ts index 6bcf2fa..fbcabe1 100644 --- a/tests/transcript/range.test.ts +++ b/tests/transcript/range.test.ts @@ -37,4 +37,13 @@ describe("filterTranscriptLinesByRange", () => { }).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"]); + }); }); diff --git a/tests/youtube/url.test.ts b/tests/youtube/url.test.ts index 8996051..066098e 100644 --- a/tests/youtube/url.test.ts +++ b/tests/youtube/url.test.ts @@ -232,6 +232,16 @@ describe("youtube/url", () => { }); }); + 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({}); }); From d3b5c94a51f1703195fbe626aefa376aca85acc1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 22:23:54 +0000 Subject: [PATCH 4/4] Clarify invalid range test naming Agent-Logs-Url: https://github.com/lstrzepek/obsidian-yt-transcript/sessions/1f86f41e-0423-407f-93fe-e03e2eef4888 Co-authored-by: lstrzepek <185352+lstrzepek@users.noreply.github.com> --- tests/youtube/url.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/youtube/url.test.ts b/tests/youtube/url.test.ts index 066098e..1435cce 100644 --- a/tests/youtube/url.test.ts +++ b/tests/youtube/url.test.ts @@ -222,7 +222,7 @@ describe("youtube/url", () => { }); }); - it("ignores invalid ranges where end is before start", () => { + it("omits endMs when end is before start", () => { expect( extractYouTubeTimeRange( "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=200&end=100",