-
Notifications
You must be signed in to change notification settings - Fork 14
feat(markdown): support embedding YouTube and Kaltura videos via link-interception #5147
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
rschlaefli
wants to merge
11
commits into
v3
Choose a base branch
from
embed-external-video-support
base: v3
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from 8 commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
5f51ea2
feat(markdown): support embedding YouTube and Kaltura videos via link…
rschlaefli cdff503
feat(markdown): support kaltura secure iframe embeds with same-domain…
rschlaefli 5e034b3
feat(markdown): embed kaltura using secure playkit login-bypass player
rschlaefli 45ea342
refactor(markdown): apply independent code review refinements for saf…
rschlaefli 14d3f11
Merge remote-tracking branch 'origin/v3' into embed-external-video-su…
rschlaefli 162a9d6
style: format .github/scripts/get-shard-files.js
rschlaefli c6ff279
Merge remote-tracking branch 'origin/v3' into embed-external-video-su…
rschlaefli 4048755
docs: document markdown link interception and playkit video bypass in…
rschlaefli da09b34
fix(markdown): enforce host verification checks before falling back t…
rschlaefli b2ea123
Merge remote-tracking branch 'origin/v3' into embed-external-video-su…
rschlaefli 8b14a23
test(markdown): add unit tests for VideoEmbed parsers & apply droid f…
rschlaefli File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,68 +1,70 @@ | ||
| const fs = require('fs'); | ||
| const path = require('path'); | ||
| const fs = require('fs') | ||
| const path = require('path') | ||
|
|
||
| const shardIndex = parseInt(process.argv[2], 10); | ||
| const shardIndex = parseInt(process.argv[2], 10) | ||
| if (isNaN(shardIndex) || shardIndex < 1 || shardIndex > 5) { | ||
| console.error('Invalid shard index. Must be between 1 and 5.'); | ||
| process.exit(1); | ||
| console.error('Invalid shard index. Must be between 1 and 5.') | ||
| process.exit(1) | ||
| } | ||
|
|
||
| const testsDir = path.join(__dirname, '../../playwright/tests'); | ||
| const allFiles = fs.readdirSync(testsDir).filter(file => file.endsWith('.spec.ts')); | ||
| const testsDir = path.join(__dirname, '../../playwright/tests') | ||
| const allFiles = fs | ||
| .readdirSync(testsDir) | ||
| .filter((file) => file.endsWith('.spec.ts')) | ||
|
|
||
| // Load timings from playwright/timings.json | ||
| const timingsPath = path.join(__dirname, '../../playwright/timings.json'); | ||
| let timings = { durations: [] }; | ||
| const timingsPath = path.join(__dirname, '../../playwright/timings.json') | ||
| let timings = { durations: [] } | ||
| if (fs.existsSync(timingsPath)) { | ||
| try { | ||
| timings = JSON.parse(fs.readFileSync(timingsPath, 'utf8')); | ||
| timings = JSON.parse(fs.readFileSync(timingsPath, 'utf8')) | ||
| } catch (err) { | ||
| console.error('Error parsing timings.json:', err); | ||
| console.error('Error parsing timings.json:', err) | ||
| } | ||
| } | ||
|
|
||
| // Map of spec file to duration | ||
| const durationMap = new Map(); | ||
| const durationMap = new Map() | ||
| for (const entry of timings.durations) { | ||
| // Normalize spec path to just the filename | ||
| const baseName = path.basename(entry.spec); | ||
| durationMap.set(baseName, entry.duration); | ||
| const baseName = path.basename(entry.spec) | ||
| durationMap.set(baseName, entry.duration) | ||
| } | ||
|
|
||
| // Map each file to its estimated/historical duration | ||
| const filesWithDuration = allFiles.map(file => { | ||
| const filesWithDuration = allFiles.map((file) => { | ||
| return { | ||
| file, | ||
| duration: durationMap.has(file) ? durationMap.get(file) : 30 // default to 30s for new tests | ||
| }; | ||
| }); | ||
| duration: durationMap.has(file) ? durationMap.get(file) : 30, // default to 30s for new tests | ||
| } | ||
| }) | ||
|
|
||
| // Sort files by duration descending for the greedy bin-packing algorithm | ||
| filesWithDuration.sort((a, b) => b.duration - a.duration); | ||
| filesWithDuration.sort((a, b) => b.duration - a.duration) | ||
|
|
||
| // Initialize 5 shards | ||
| const numShards = 5; | ||
| const numShards = 5 | ||
| const shards = Array.from({ length: numShards }, () => ({ | ||
| files: [], | ||
| totalDuration: 0 | ||
| })); | ||
| totalDuration: 0, | ||
| })) | ||
|
|
||
| // Distribute files using greedy bin-packing | ||
| for (const item of filesWithDuration) { | ||
| // Find the shard with the smallest total duration | ||
| let minShard = shards[0]; | ||
| let minShard = shards[0] | ||
| for (let i = 1; i < numShards; i++) { | ||
| if (shards[i].totalDuration < minShard.totalDuration) { | ||
| minShard = shards[i]; | ||
| minShard = shards[i] | ||
| } | ||
| } | ||
| minShard.files.push(item.file); | ||
| minShard.totalDuration += item.duration; | ||
| minShard.files.push(item.file) | ||
| minShard.totalDuration += item.duration | ||
| } | ||
|
|
||
| // Select the files for the requested shard | ||
| const targetFiles = shards[shardIndex - 1].files; | ||
| const targetFiles = shards[shardIndex - 1].files | ||
|
|
||
| // Map files to their relative path from the playwright package directory | ||
| const relativePaths = targetFiles.map(file => `tests/${file}`); | ||
| console.log(relativePaths.join(' ')); | ||
| const relativePaths = targetFiles.map((file) => `tests/${file}`) | ||
| console.log(relativePaths.join(' ')) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,145 @@ | ||
| import React from 'react' | ||
|
|
||
| export function getYoutubeId(url: string): string | null { | ||
| try { | ||
| const parsed = new URL(url) | ||
| if ( | ||
| parsed.hostname === 'youtube.com' || | ||
| parsed.hostname.endsWith('.youtube.com') || | ||
| parsed.hostname === 'youtu.be' || | ||
| parsed.hostname.endsWith('.youtu.be') | ||
| ) { | ||
| if (parsed.hostname.endsWith('youtu.be')) { | ||
| const id = parsed.pathname.slice(1) | ||
| if (/^[a-zA-Z0-9_-]{11}$/.test(id)) { | ||
| return id | ||
| } | ||
| } | ||
| if (parsed.pathname.startsWith('/embed/')) { | ||
| const id = parsed.pathname.split('/')[2] | ||
| if (id && /^[a-zA-Z0-9_-]{11}$/.test(id)) { | ||
| return id | ||
| } | ||
| } | ||
| const v = parsed.searchParams.get('v') | ||
| if (v && /^[a-zA-Z0-9_-]{11}$/.test(v)) { | ||
| return v | ||
| } | ||
| } | ||
| } catch { | ||
| // Ignore URL parse error for relative/broken URLs | ||
| } | ||
|
|
||
| const regExp = | ||
| /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/ | ||
| const match = url.match(regExp) | ||
| const id = match?.[2] | ||
| return id && /^[a-zA-Z0-9_-]{11}$/.test(id) ? id : null | ||
|
greptile-apps[bot] marked this conversation as resolved.
Outdated
greptile-apps[bot] marked this conversation as resolved.
Outdated
greptile-apps[bot] marked this conversation as resolved.
Outdated
greptile-apps[bot] marked this conversation as resolved.
Outdated
|
||
| } | ||
|
|
||
| export function getKalturaId(url: string): string | null { | ||
| try { | ||
| const parsedUrl = new URL(url) | ||
| const host = parsedUrl.hostname.toLowerCase() | ||
| const isKalturaHost = | ||
| host === 'kaltura.com' || | ||
| host.endsWith('.kaltura.com') || | ||
| host === 'cast.switch.ch' || | ||
| host.endsWith('.cast.switch.ch') | ||
| if (!isKalturaHost) { | ||
| return null | ||
| } | ||
|
|
||
| const mediaSpaceMatch = url.match( | ||
| /\/media\/(?:t\/|[^/]+\/)?([01]_[a-zA-Z0-9]{8})(?:[/?#]|$)/ | ||
| ) | ||
| if (mediaSpaceMatch && mediaSpaceMatch[1]) { | ||
| return mediaSpaceMatch[1] | ||
| } | ||
|
|
||
| const entryIdPathMatch = url.match( | ||
| /\/entryId\/([01]_[a-zA-Z0-9]{8})(?:[/?#]|$)/i | ||
| ) | ||
| if (entryIdPathMatch && entryIdPathMatch[1]) { | ||
| return entryIdPathMatch[1] | ||
| } | ||
|
|
||
| const entryId = parsedUrl.searchParams.get('entry_id') | ||
| if (entryId && /^[01]_[a-zA-Z0-9]{8}$/.test(entryId)) { | ||
| return entryId | ||
| } | ||
| } catch { | ||
| // Ignore URL parse error | ||
| } | ||
| return null | ||
| } | ||
|
|
||
| export function getKalturaUiConfId(url: string): string | null { | ||
| try { | ||
| const pathMatch = url.match(/\/uiConfId\/(\d+)/i) | ||
| if (pathMatch && pathMatch[1]) { | ||
| return pathMatch[1] | ||
| } | ||
| const parsedUrl = new URL(url) | ||
| const queryId = parsedUrl.searchParams.get('uiconf_id') | ||
| if (queryId && /^\d+$/.test(queryId)) { | ||
| return queryId | ||
| } | ||
| } catch { | ||
| // Ignore URL parse error | ||
| } | ||
| return null | ||
| } | ||
|
|
||
| export function getKalturaPartnerId(url: string): string | null { | ||
| try { | ||
| const pathMatch = url.match(/\/partner_id\/(\d+)/i) | ||
| if (pathMatch && pathMatch[1]) { | ||
| return pathMatch[1] | ||
| } | ||
| const parsedUrl = new URL(url) | ||
| const queryId = parsedUrl.searchParams.get('partner_id') | ||
| if (queryId && /^\d+$/.test(queryId)) { | ||
| return queryId | ||
| } | ||
| } catch { | ||
| // Ignore URL parse error | ||
| } | ||
| return null | ||
| } | ||
|
|
||
| interface VideoEmbedProps { | ||
| provider: 'youtube' | 'kaltura' | ||
| videoId: string | ||
| partnerId?: string | null | ||
| uiConfId?: string | null | ||
| } | ||
|
|
||
| export function VideoEmbed({ | ||
| provider, | ||
| videoId, | ||
| partnerId, | ||
| uiConfId, | ||
| }: VideoEmbedProps): React.ReactElement { | ||
| const src = | ||
| provider === 'youtube' | ||
| ? `https://www.youtube.com/embed/${videoId}` | ||
| : `https://api.cast.switch.ch/p/${partnerId || '106'}/embedPlaykitJs/uiconf_id/${uiConfId || '23449004'}/partner_id/${partnerId || '106'}?iframeembed=true&playerId=kaltura_player&entry_id=${videoId}` | ||
|
|
||
| return ( | ||
| <div className="my-4 aspect-video w-full overflow-hidden rounded-md border border-slate-200"> | ||
| <iframe | ||
| title={ | ||
| provider === 'youtube' | ||
| ? 'YouTube video player' | ||
| : 'Kaltura video player' | ||
| } | ||
| src={src} | ||
| className="h-full w-full border-0" | ||
| allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" | ||
| allowFullScreen | ||
| loading="lazy" | ||
| /> | ||
| </div> | ||
| ) | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.