Skip to content
Merged
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

## [4.5.3] - 2026-10-27
### Added
- Kick adapter. Channel pages embed `https://player.kick.com/<slug>` (16:9). Video pages read the title, thumbnail and author name from Kick's public API. Clips have no embed code because Kick does not document a clip player

### Fixed
- An Open Graph video served as HLS (`.m3u8`, `application/x-mpegURL` or `application/vnd.apple.mpegurl`, also read from `og:video:type`) no longer produces an `<iframe>` pointing to the playlist
- Twitch video iframes use the documented `video=v<ID>` parameter instead of `id`
- Twitch clip URLs (`clips.twitch.tv/<slug>` and `twitch.tv/<channel>/clip/<slug>`) produce a `clips.twitch.tv/embed` iframe when `twitch:parent` is set

## [4.5.2] - 2026-10-27
### Added
- oEmbed responses of type `photo` without `html` now produce an `<img>` embed code from their `url`, `width` and `height` [#584]
Expand Down Expand Up @@ -327,6 +336,7 @@ Full library refactoring.
[#574]: https://github.com/oscarotero/Embed/issues/574
[#577]: https://github.com/oscarotero/Embed/issues/577

[4.5.3]: https://github.com/php-embed/Embed/compare/v4.5.2...v4.5.3
[4.5.2]: https://github.com/php-embed/Embed/compare/v4.5.1...v4.5.2
[4.5.1]: https://github.com/php-embed/Embed/compare/v4.5.0...v4.5.1
[4.5.0]: https://github.com/php-embed/Embed/compare/v4.4.19...v4.5.0
Expand Down
45 changes: 45 additions & 0 deletions src/Adapters/Kick/Api.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php
declare(strict_types = 1);

namespace Embed\Adapters\Kick;

use Embed\HttpApiTrait;

/**
* Public Kick API data for a VOD page, which has no metadata in its html.
*/
class Api
{
use HttpApiTrait;

/**
* @return array<string, mixed>
*/
protected function fetchData(): array
{
$video = Extractor::parseVideoPath($this->extractor->getUri()->getPath());

if ($video === null) {
return [];
}

[$channel, $uuid] = $video;
$crawler = $this->extractor->getCrawler();

$this->endpoint = $crawler->createUri("https://kick.com/api/v1/video/{$uuid}");
$data = $this->fetchJSON($this->endpoint);

// The single video endpoint does not always include a thumbnail,
// but the channel video list does.
$list = $this->fetchJSON($crawler->createUri('https://kick.com/api/v2/channels/'.rawurlencode($channel).'/videos'));

foreach ($list as $item) {
if (is_array($item) && is_array($item['video'] ?? null) && ($item['video']['uuid'] ?? null) === $uuid) {
$data['list_item'] = $item;
break;
}
}

return $data;
}
}
20 changes: 20 additions & 0 deletions src/Adapters/Kick/Detectors/AuthorName.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php
declare(strict_types = 1);

namespace Embed\Adapters\Kick\Detectors;

use Embed\Detectors\AuthorName as Detector;

/**
* @extends Detector<\Embed\Adapters\Kick\Extractor>
*/
class AuthorName extends Detector
{
public function detect(): ?string
{
$api = $this->extractor->getApi();

return $api->str('livestream', 'channel', 'user', 'username')
?? parent::detect();
}
}
36 changes: 36 additions & 0 deletions src/Adapters/Kick/Detectors/Code.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php
declare(strict_types = 1);

namespace Embed\Adapters\Kick\Detectors;

use Embed\Adapters\Kick\Extractor;
use Embed\Detectors\Code as Detector;
use Embed\EmbedCode;
use function Embed\html;

/**
* @extends Detector<\Embed\Adapters\Kick\Extractor>
*/
class Code extends Detector
{
public function detect(): ?EmbedCode
{
$path = $this->extractor->getUri()->getPath();
$channel = Extractor::parseChannelPath($path);

if ($channel !== null) {
return new EmbedCode(html('iframe', [
'src' => 'https://player.kick.com/'.rawurlencode($channel),
'frameborder' => 0,
'allowfullscreen' => 'true',
'scrolling' => 'no',
'width' => 640,
'height' => 360,
]), 640, 360);
}

// Kick has no documented player URL for VODs and clips, and their
// og:video is an HLS playlist that cannot be used as an iframe.
return null;
}
}
22 changes: 22 additions & 0 deletions src/Adapters/Kick/Detectors/Image.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php
declare(strict_types = 1);

namespace Embed\Adapters\Kick\Detectors;

use Embed\Detectors\Image as Detector;
use Psr\Http\Message\UriInterface;

/**
* @extends Detector<\Embed\Adapters\Kick\Extractor>
*/
class Image extends Detector
{
public function detect(): ?UriInterface
{
$api = $this->extractor->getApi();

return $api->url('list_item', 'thumbnail', 'src')
?? $api->url('livestream', 'thumbnail')
?? parent::detect();
}
}
21 changes: 21 additions & 0 deletions src/Adapters/Kick/Detectors/Title.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php
declare(strict_types = 1);

namespace Embed\Adapters\Kick\Detectors;

use Embed\Detectors\Title as Detector;

/**
* @extends Detector<\Embed\Adapters\Kick\Extractor>
*/
class Title extends Detector
{
public function detect(): ?string
{
$api = $this->extractor->getApi();

return $api->str('livestream', 'session_title')
?? $api->str('list_item', 'session_title')
?? parent::detect();
}
}
69 changes: 69 additions & 0 deletions src/Adapters/Kick/Extractor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php
declare(strict_types = 1);

namespace Embed\Adapters\Kick;

use Embed\Extractor as Base;
use Embed\Http\Crawler;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\UriInterface;

/**
* @template-extends Base<\Embed\Detectors\Detector<self>>
*/
class Extractor extends Base
{
/** Top level pages that are not channels */
private const RESERVED_PATHS = ['browse', 'categories', 'category', 'following', 'search', 'dashboard', 'settings', 'privacy-policy', 'terms-of-service', 'community-guidelines'];

private Api $api;

public function __construct(
UriInterface $uri,
RequestInterface $request,
ResponseInterface $response,
Crawler $crawler
) {
parent::__construct($uri, $request, $response, $crawler);
$this->api = new Api($this);
}

public function getApi(): Api
{
return $this->api;
}

public function createCustomDetectors(): array
{
return [
'authorName' => new Detectors\AuthorName($this),
'code' => new Detectors\Code($this),
'image' => new Detectors\Image($this),
'title' => new Detectors\Title($this),
];
}

/**
* Returns the channel slug and the VOD uuid of a video URL.
*
* @return array{0: string, 1: string}|null
*/
public static function parseVideoPath(string $path): ?array
{
if (preg_match('#^/([\w-]+)/videos/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/?$#i', $path, $matches) === 1) {
return [$matches[1], strtolower($matches[2])];
}

return null;
}

public static function parseChannelPath(string $path): ?string
{
if (preg_match('#^/([\w-]+)/?$#', $path, $matches) === 1 && !in_array(strtolower($matches[1]), self::RESERVED_PATHS, true)) {
return $matches[1];
}

return null;
}
}
33 changes: 32 additions & 1 deletion src/Adapters/Twitch/Detectors/Code.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,29 @@ private function fallback(): ?EmbedCode
$id = self::getVideoId($path);
if ($id !== null) {
$code = $parent !== null
? self::generateIframeCode(['id' => $id, 'parent' => $parent])
? self::generateIframeCode(['video' => "v{$id}", 'parent' => $parent])
: self::generateJsCode('video', $id);
return new EmbedCode($code, 620, 378);
}

$id = self::getClipId($this->extractor->getUri()->getHost(), $path);
if ($id !== null) {
if ($parent === null) {
// Clips only have an iframe player, and it requires a parent.
return null;
}
$query = http_build_query(['clip' => $id, 'parent' => $parent, 'autoplay' => 'false']);
$code = html('iframe', [
'src' => "https://clips.twitch.tv/embed?{$query}",
'frameborder' => 0,
'allowfullscreen' => 'true',
'scrolling' => 'no',
'height' => 378,
'width' => 620,
]);
return new EmbedCode($code, 620, 378);
}

$id = self::getChannelId($path);
if ($id !== null) {
$code = $parent !== null
Expand All @@ -51,6 +69,19 @@ private static function getVideoId(string $path): ?string
return null;
}

private static function getClipId(string $host, string $path): ?string
{
if (preg_match('#^/\w+/clip/([\w-]+)$#', $path, $matches) === 1) {
return $matches[1];
}

if ($host === 'clips.twitch.tv' && preg_match('#^/([\w-]+)$#', $path, $matches) === 1 && $matches[1] !== 'embed') {
return $matches[1];
}

return null;
}

private static function getChannelId(string $path): ?string
{
if (preg_match('#^/(\w+)$#', $path, $matches) === 1) {
Expand Down
14 changes: 13 additions & 1 deletion src/Detectors/Code.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
*/
class Code extends Detector
{
private const HLS_TYPES = ['application/x-mpegurl', 'application/vnd.apple.mpegurl'];

public function detect(): ?EmbedCode
{
$result = $this->detectFromEmbed();
Expand Down Expand Up @@ -90,13 +92,23 @@ private function detectFromOpenGraph(): ?EmbedCode
$type = $metas->str('og:video_type');
}

// HLS playlists cannot be used as an iframe src. og:video:type is only
// read for this check, so existing pages keep their current code.
$declaredType = strtolower((string) $metas->str('og:video:type'));
if (strtolower($type ?? '') === 'm3u8' || in_array($declaredType, self::HLS_TYPES, true)) {
return null;
}

$width = $metas->int('twitter:player:width');
$height = $metas->int('twitter:player:height');

switch ($type) {
switch ($type !== null ? strtolower($type) : null) {
case 'swf':
case 'application/x-shockwave-flash':
return null; //Ignore flash
case 'application/x-mpegurl':
case 'application/vnd.apple.mpegurl':
return null; //HLS playlists cannot be used as an iframe src
case 'mp4':
case 'ogg':
case 'ogv':
Expand Down
1 change: 1 addition & 0 deletions src/ExtractorFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class ExtractorFactory
'imageshack.com' => Adapters\ImageShack\Extractor::class,
'youtube.com' => Adapters\Youtube\Extractor::class,
'twitch.tv' => Adapters\Twitch\Extractor::class,
'kick.com' => Adapters\Kick\Extractor::class,
'bandcamp.com' => Adapters\Bandcamp\Extractor::class,
'twitter.com' => Adapters\Twitter\Extractor::class,
'x.com' => Adapters\Twitter\Extractor::class,
Expand Down
20 changes: 20 additions & 0 deletions tests/PagesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,26 @@ public function testTwitch()
$this->assertEmbed('https://www.twitch.tv/twit');
}

public function testTwitchClip()
{
$this->assertEmbed('https://clips.twitch.tv/IncredulousAbstemiousFennelImGlitch');
}

public function testKickChannel()
{
$this->assertEmbed('https://kick.com/xqc');
}

public function testKickVideo()
{
$this->assertEmbed('https://kick.com/xqc/videos/4aeefefc-cd67-413e-8107-14327b21f5a3');
}

public function testKickClip()
{
$this->assertEmbed('https://kick.com/xqc/clips/clip_01M5YVZS3AF6NPDY1RMFXKRSWK');
}

public function testTwitter()
{
$this->assertEmbed('https://twitter.com/pepephone/status/436461658601713664');
Expand Down
Loading
Loading