Souvenirs : une vidéo YouTube se colle sous toutes ses formes

Le champ « Un lien YouTube » acceptait tout lien en youtube.com, mais la
visionneuse ne lisait que watch?v= et youtu.be : un lien d'intégration
(/embed/), un Short ou un direct s'enregistrait puis s'affichait « Lien
YouTube invalide », et le code <iframe> du bouton Intégrer était refusé.

Une seule fonction (utils/youtube.js) retrouve l'identifiant de la vidéo
dans le lien de la barre d'adresse, de partage, d'intégration, nocookie,
un Short, un direct ou le code <iframe> complet. La saisie enregistre
toujours le lien watch?v= et la visionneuse s'appuie sur la même fonction,
donc les liens /embed/ déjà enregistrés se lisent aussi.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-11-12 13:37:18 +01:00
1 parent f8d1a48240
commit 652fe49e8d
4 files changed
+92 -12

No files matched your search

@@ -9,6 +9,7 @@ import { t } from '../i18n/index.js'
import ImmichAssetPicker from './ImmichAssetPicker.jsx'
import { fetchImmichConfig } from '../api/immichApi.js'
import { blurPlaceholder } from '../utils/blurhash.js'
import { youtubeWatchUrl } from '../utils/youtube.js'
const MEDIA_TYPES = [
{ value: 'photo', label: t('Photo'), Icon: Image },
@@ -24,7 +25,6 @@ const FAMILY_TYPES = ['photo', 'video', 'audio', 'citation', 'document']
const TYPE_NAMES = { photo: t('Photo'), video: t('Vidéo'), audio: t('Voix'), citation: t('Citation'), document: t('Document'), geojson: t('Carte'), gpx: t('Trace GPX') }
const TYPE_SHORT = { video: t('Vidéo'), audio: t('Voix'), citation: '«', document: t('Doc') }
const YOUTUBE_RE = /^https:\/\/(www\.)?(youtube\.com|youtu\.be)\//
const HTTP_URL_RE = /^https?:\/\/\S+$/i
const MediaManagerPanel = ({
@@ -90,7 +90,7 @@ const MediaManagerPanel = ({
}
if (isYoutubeVideo) {
return YOUTUBE_RE.test(youtubeUrl.trim())
return youtubeWatchUrl(youtubeUrl) !== null
}
return Boolean(file)
@@ -134,7 +134,7 @@ const MediaManagerPanel = ({
source: source.trim() || null,
mediaType,
citationText: citationText.trim(),
youtubeUrl: isYoutubeVideo ? youtubeUrl.trim() : null,
youtubeUrl: isYoutubeVideo ? youtubeWatchUrl(youtubeUrl) : null,
submittedByLabel: canManage ? null : contributorName.trim(),
})
}
@@ -280,7 +280,9 @@ const MediaManagerPanel = ({
<label htmlFor="mediaUploadYoutube">{t('Lien de la vidéo')}</label>
<input
id="mediaUploadYoutube"
type="url"
type="text"
inputMode="url"
autoComplete="off"
value={youtubeUrl}
onChange={(event) => setYoutubeUrl(event.target.value)}
placeholder="https://www.youtube.com/watch?v=…"
+4 -8
View File
@@ -11,6 +11,7 @@ import {
import './MediaViewer.css'
import { t } from '../i18n/index.js'
import { firstNameLabel } from '../utils/personName.js'
import { youtubeEmbedUrl } from '../utils/youtube.js'
import { eventTitle } from '../utils/lifeTimeline.js'
import { getAccountErrorMessage } from '../utils/errorMessages.js'
import { growFromTransform, takeOrigin } from '../utils/openOrigin.js'
@@ -343,20 +344,15 @@ const MediaViewer = ({
}
if (normalizedMimeType === 'video/youtube') {
// Extract YouTube video ID and build embed URL
const watchMatch = mediaUrl.match(/[?&]v=([^&]+)/)
const shortMatch = mediaUrl.match(/youtu\.be\/([^/?]+)/)
const videoId = (watchMatch?.[1] || shortMatch?.[1] || '').split(/[?#]/)[0]
// Un identifiant YouTube fait 11 caractères : tout le reste est refusé plutôt qu'intégré
if (!/^[A-Za-z0-9_-]{11}$/.test(videoId)) {
// Toute forme de lien YouTube (barre d'adresse, partage, intégration, Short) ; le reste est refusé plutôt qu'intégré
const embedUrl = youtubeEmbedUrl(mediaUrl)
if (!embedUrl) {
return (
<div className="media-video-container">
<div className="media-video-text">{t('Lien YouTube invalide')}</div>
</div>
)
}
// youtube-nocookie : pas de cookie YouTube tant que la vidéo n'est pas lancée
const embedUrl = `https://www.youtube-nocookie.com/embed/${videoId}`
return (
<div className="media-video-container media-video-container--youtube">
<iframe
+38
View File
@@ -0,0 +1,38 @@
// Une vidéo YouTube se reconnaît à son identifiant de 11 caractères, quelle que soit
// la forme collée : lien de la barre d'adresse, lien de partage, lien d'intégration,
// Short, direct, ou le code <iframe> complet donné par le bouton Intégrer.
const VIDEO_ID_RE = /^[A-Za-z0-9_-]{11}$/
const YOUTUBE_HOST_RE = /^(?:www\.|m\.|music\.)?(?:youtube\.com|youtube-nocookie\.com|youtu\.be)$/i
const IFRAME_SRC_RE = /<iframe[^>]*\ssrc\s*=\s*["']([^"']+)["']/i
const PATH_ID_RE = /^\/(?:embed|shorts|live|v)\/([^/?#]+)/
export const youtubeVideoId = (value) => {
if (typeof value !== 'string') return null
const text = value.trim()
const iframeSrc = text.match(IFRAME_SRC_RE)?.[1]
let url
try {
url = new URL(iframeSrc ?? text)
} catch {
return null
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') return null
if (!YOUTUBE_HOST_RE.test(url.hostname)) return null
const id = url.hostname.toLowerCase().endsWith('youtu.be')
? url.pathname.slice(1).split('/')[0]
: url.searchParams.get('v') ?? url.pathname.match(PATH_ID_RE)?.[1]
return id && VIDEO_ID_RE.test(id) ? id : null
}
// Le lien enregistré est toujours la forme courte de la barre d'adresse
export const youtubeWatchUrl = (value) => {
const id = youtubeVideoId(value)
return id ? `https://www.youtube.com/watch?v=${id}` : null
}
// youtube-nocookie : pas de cookie YouTube tant que la vidéo n'est pas lancée
export const youtubeEmbedUrl = (value) => {
const id = youtubeVideoId(value)
return id ? `https://www.youtube-nocookie.com/embed/${id}` : null
}
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { youtubeEmbedUrl, youtubeVideoId, youtubeWatchUrl } from './youtube.js'
const ID = 'dQw4w9WgXcQ'
describe('vidéos YouTube', () => {
it.each([
[`https://www.youtube.com/watch?v=${ID}`],
[`https://youtube.com/watch?v=${ID}&t=42s`],
[`https://m.youtube.com/watch?feature=share&v=${ID}`],
[`https://youtu.be/${ID}`],
[`https://youtu.be/${ID}?si=abc123`],
[`https://www.youtube.com/embed/${ID}`],
[`https://www.youtube.com/embed/${ID}?start=10`],
[`https://www.youtube-nocookie.com/embed/${ID}`],
[`https://www.youtube.com/shorts/${ID}`],
[`https://www.youtube.com/live/${ID}?feature=shared`],
[` https://www.youtube.com/watch?v=${ID} `],
[`<iframe width="560" height="315" src="https://www.youtube.com/embed/${ID}?si=xYz" title="YouTube video player" frameborder="0" allowfullscreen></iframe>`],
])('retrouve la vidéo dans %s', (value) => {
expect(youtubeVideoId(value)).toBe(ID)
})
it.each([
[''],
[null],
['pas un lien'],
[`https://vimeo.com/${ID}`],
[`https://www.youtube.com.example.org/watch?v=${ID}`],
['https://www.youtube.com/watch?v=trop-court'],
['https://www.youtube.com/@chaine'],
[`javascript:alert(1)//youtube.com/watch?v=${ID}`],
[`<iframe src="https://evil.example/embed/${ID}"></iframe>`],
])('refuse %s', (value) => {
expect(youtubeVideoId(value)).toBeNull()
})
it('enregistre le lien de la barre d adresse et intègre sans cookie', () => {
const iframe = `<iframe src="https://www.youtube.com/embed/${ID}?si=xYz"></iframe>`
expect(youtubeWatchUrl(iframe)).toBe(`https://www.youtube.com/watch?v=${ID}`)
expect(youtubeEmbedUrl(`https://www.youtube.com/shorts/${ID}`)).toBe(`https://www.youtube-nocookie.com/embed/${ID}`)
})
})