` in `/opt/homebrew/etc/httpd/httpd.conf`.
Then run:
```
brew services start httpd
```
3. Now that you are hosting a website, get the current working directory of your terminal into the folder that is online.
- On Linux, use `cd /var/www/html/`
- On Windows, use `cd C:\wamp64\www\`
- On MacOS, use `cd /opt/homebrew/var/www/`
4. Clone this repository by using:
```sh
git clone https://github.com/Benjamin-Loison/YouTube-operational-API
```
5. Install Protobuf dependency:
### On Linux (Ubuntu, Debian and Mint):
```sh
sudo apt install composer protobuf-compiler
```
### On Windows:
Download [composer](https://github.com/composer/windows-setup/releases/latest).
Download [protoc](https://github.com/protocolbuffers/protobuf/releases/latest).
### On MacOS:
```sh
brew install composer protobuf
```
In `YouTube-operational-API/` clone folder:
```sh
composer require google/protobuf
```
Generate code of PHP objects from `.proto` prototypes:
### On Linux and MacOS:
```sh
protoc --php_out=proto/php/ --proto_path=proto/prototypes/ $(find proto/prototypes/ -type f)
```
### On Windows:
```batch
for /f "usebackq tokens=*" %a in (`dir /S /B "proto/prototypes"`) do protoc --php_out=proto/php/ --proto_path=proto/prototypes/ %a
```
6. Verify that your API instance is reachable by trying to access:
- On Linux and Windows: http://localhost/YouTube-operational-API/
- On MacOS: http://localhost:8080/YouTube-operational-API/
If you want me to advertise your instance (if you have opened your port, and have a fixed IP address or a domain name), please use below contacts.
## Run the API with Docker
1. Install [Docker](https://www.docker.com) and make sure that its daemon is running.
2. Create a `.env` file and update it with your preferred port:
```sh
cp .env.sample .env
```
3. Start the container with `docker-compose`:
```sh
# start in the foreground
docker-compose up
# start in the background
docker-compose up -d
```
4. Verify that your API instance is reachable by trying to access:
- http://localhost:8080 (update preferred port if not 8080)
## Contact:
- [Matrix](https://yt.lemnoslife.com/matrix)
- [Discord](https://yt.lemnoslife.com/discord)
## Contributing:
See [`CONTRIBUTING.md`](https://github.com/Benjamin-Loison/YouTube-operational-API/blob/main/CONTRIBUTING.md).
================================================
FILE: addKey.php
================================================
[
'ignore_errors' => true,
]
];
$content = getJSON("https://www.googleapis.com/youtube/v3/videos?part=snippet&id=mWdFMNQBcjs&key=$key", $httpOptions, false);
// The force secret is used to store the YouTube Data API v3 even if it's not having quota, as we assume that the trusted instance that send it to this one has checked that it has quota.
if ($content['items'][0]['snippet']['title'] === 'A public video' || (isset($_GET['forceSecret']) && $_GET['forceSecret'] === ADD_KEY_FORCE_SECRET)) {
file_put_contents(KEYS_FILE, ($keysContent === '' || $keysContent === false ? '' : "\n") . $key, FILE_APPEND);
// Avoid sending another time the given key to all instances.
if (!isset($_GET['forceSecret'])) {
foreach (ADD_KEY_TO_INSTANCES as $addKeyToInstance) {
getRemote($addKeyToInstance . "addKey.php?key=$key&forceSecret=" . ADD_KEY_FORCE_SECRET);
}
}
echo 'YouTube Data API v3 key added.';
} elseif ($content['error']['errors'][0]['reason'] === 'quotaExceeded') {
// As users can set `Queries per minute` quota to 0, we avoid denial-of-service by not considering them.
echo 'Not adding YouTube Data API v3 key having quota exceeded.';
} else {
// This YouTube Data API API v3 keys isn't assigned or there is another error.
echo 'Incorrect YouTube Data API v3 key.';
}
} else {
echo 'This YouTube Data API v3 key is already in the list.';
}
} else {
echo "The key provided isn't a YouTube Data API v3 key.";
}
}
================================================
FILE: channels.php
================================================
'youtube#channel',
'etag' => 'NotImplemented',
'id' => $id
];
$continuationTokenProvided = $continuationToken != '';
if ($options['status']) {
$result = getJSONFromHTML("https://www.youtube.com/channel/$id", forceLanguage: true, verifiesChannelRedirection: true);
$status = $result['alerts'][0]['alertRenderer']['text']['simpleText'];
$item['status'] = $status;
}
if ($options['upcomingEvents']) {
$upcomingEvents = [];
$result = getJSONFromHTML("https://www.youtube.com/channel/$id", forceLanguage: true, verifiesChannelRedirection: true);
$subItems = getTabs($result)[0]['tabRenderer']['content']['sectionListRenderer']['contents'][0]['itemSectionRenderer']['contents'][0]['shelfRenderer']['content']['horizontalListRenderer']['items'];
foreach ($subItems as $subItem) {
$path = 'gridVideoRenderer/upcomingEventData';
if (doesPathExist($subItem, $path)) {
$subItem = $subItem['gridVideoRenderer'];
foreach (['navigationEndpoint', 'menu', 'trackingParams', 'thumbnailOverlays'] as $toRemove) {
unset($subItem[$toRemove]);
}
array_push($upcomingEvents, $subItem);
}
}
$item['upcomingEvents'] = $upcomingEvents;
}
if ($options['shorts']) {
if (!$continuationTokenProvided) {
$result = getJSONFromHTML("https://www.youtube.com/channel/$id/shorts", forceLanguage: true, verifiesChannelRedirection: true);
$visitorData = getVisitorData($result);
$tab = getTabByName($result, 'Shorts');
$tabRenderer = $tab['tabRenderer'];
$richGridRenderer = $tabRenderer['content']['richGridRenderer'];
if ($order === 'viewCount') {
$nextPageToken = $richGridRenderer['header']['feedFilterChipBarRenderer']['contents'][1]['chipCloudChipRenderer']['navigationEndpoint']['continuationCommand']['token'];
if($nextPageToken !== null) {
$continuationToken = urldecode("$nextPageToken,$visitorData");
return getItem($id, $order, $continuationToken);
}
}
} else {
$result = getContinuationJson($continuationToken);
}
$shorts = [];
if (!$continuationTokenProvided) {
$reelShelfRendererItems = $richGridRenderer['contents'];
}
else {
$onResponseReceivedActions = $result['onResponseReceivedActions'];
$onResponseReceivedAction = $onResponseReceivedActions[count($onResponseReceivedActions) - 1];
$continuationItems = getValue($onResponseReceivedAction, 'appendContinuationItemsAction', 'reloadContinuationItemsCommand');
$reelShelfRendererItems = $continuationItems['continuationItems'];
}
foreach($reelShelfRendererItems as $reelShelfRendererItem) {
if(!array_key_exists('richItemRenderer', $reelShelfRendererItem))
continue;
$shortsLockupViewModel = $reelShelfRendererItem['richItemRenderer']['content']['shortsLockupViewModel'];
$overlayMetadata = $shortsLockupViewModel['overlayMetadata'];
$reelWatchEndpoint = $shortsLockupViewModel['onTap']['innertubeCommand']['reelWatchEndpoint'];
$short = [
'videoId' => $reelWatchEndpoint['videoId'],
'viewCount' => getIntValue($overlayMetadata['secondaryText']['content'], 'view'),
'title' => $overlayMetadata['primaryText']['content'],
// Both `sqp` and `rs` parameters are required to crop correctly the thumbnail.
'thumbnail' => $shortsLockupViewModel['thumbnail']['sources'][0],
'frame0Thumbnail' => $reelWatchEndpoint['thumbnail']['thumbnails'],
];
if (!$continuationTokenProvided) {
$browseEndpoint = $tabRenderer['endpoint']['browseEndpoint'];
$short['channelHandle'] = substr($browseEndpoint['canonicalBaseUrl'], 1);
$short['channelId'] = $browseEndpoint['browseId'];
}
array_push($shorts, $short);
}
$item['shorts'] = $shorts;
if($reelShelfRendererItems != null && count($reelShelfRendererItems) > 48) {
$nextPageToken = $reelShelfRendererItems[48]['continuationItemRenderer']['continuationEndpoint']['continuationCommand']['token'];
$item['nextPageToken'] = urldecode("$nextPageToken,$visitorData");
}
}
if ($options['community']) {
if (!$continuationTokenProvided) {
define('COMMUNITY_TAB_NAME', 'Posts');
$result = getJSONFromHTML("https://www.youtube.com/channel/$id/" . strtolower(COMMUNITY_TAB_NAME), forceLanguage: true, verifiesChannelRedirection: true);
} else {
$result = getContinuationJson($continuationToken);
}
$community = [];
$contents = null;
if (!$continuationTokenProvided) {
$tab = getTabByName($result, COMMUNITY_TAB_NAME);
$contents = $tab['tabRenderer']['content']['sectionListRenderer']['contents'][0]['itemSectionRenderer']['contents'];
} else {
$contents = $result['onResponseReceivedEndpoints'][0]['appendContinuationItemsAction']['continuationItems'];
}
foreach ($contents as $content) {
// What is the purpose of this condition?
if (!array_key_exists('backstagePostThreadRenderer', $content)) {
continue;
}
$post = getCommunityPostFromContent($content);
array_push($community, $post);
}
$item['community'] = $community;
if ($contents !== null && array_key_exists('continuationItemRenderer', end($contents))) {
$item['nextPageToken'] = urldecode(end($contents)['continuationItemRenderer']['continuationEndpoint']['continuationCommand']['token']);
}
}
if ($options['channels']) {
if (!$continuationTokenProvided) {
$result = getJSONFromHTML("https://www.youtube.com/channel/$id/channels", forceLanguage: true, verifiesChannelRedirection: true);
$tab = getTabByName($result, 'Channels');
$sectionListRenderer = $tab['tabRenderer']['content']['sectionListRenderer'];
$contents = array_map(fn($content) => $content['itemSectionRenderer']['contents'][0], $sectionListRenderer['contents']);
$itemsArray = [];
foreach($contents as $content)
{
if (array_key_exists('shelfRenderer', $content)) {
$sectionTitle = $content['shelfRenderer']['title']['runs'][0]['text'];
$content = $content['shelfRenderer']['content'];
$content = getValue($content, 'horizontalListRenderer', 'expandedShelfContentsRenderer');
} else {
$sectionTitle = $sectionListRenderer['subMenu']['channelSubMenuRenderer']['contentTypeSubMenuItems'][0]['title'];
$content = $content['gridRenderer'];
}
array_push($itemsArray, [$sectionTitle, $content['items']]);
}
} else {
$result = getContinuationJson($continuationToken);
$itemsArray = [[null, getContinuationItems($result)]];
}
$channelSections = [];
foreach($itemsArray as [$sectionTitle, $items]) {
$sectionChannels = [];
$nextPageToken = null;
$lastChannelItem = !empty($items) ? end($items) : [];
$path = 'continuationItemRenderer/continuationEndpoint/continuationCommand/token';
if (doesPathExist($lastChannelItem, $path)) {
$nextPageToken = urldecode(getValue($lastChannelItem, $path));
$items = array_slice($items, 0, count($items) - 1);
}
foreach($items as $sectionChannelItem) {
$gridChannelRenderer = $sectionChannelItem[getValue($sectionChannelItem, 'gridChannelRenderer', 'channelRenderer')];
// Condition required for channel `UC-1BnotsIsigEK4zLw20IDQ` which doesn't have a `CHANNELS` tab and using the `channel/CHANNEL_ID/channels` URL shows the `HOME` channel tab content.
if($gridChannelRenderer === null) {
goto breakChannelSectionsTreatment;
}
$thumbnails = [];
foreach($gridChannelRenderer['thumbnail']['thumbnails'] as $thumbnail) {
$thumbnail['url'] = 'https://' . substr($thumbnail['url'], 2);
array_push($thumbnails, $thumbnail);
}
$subscriberCount = getIntValue($gridChannelRenderer['subscriberCountText']['simpleText'], 'subscriber');
// Have observed the singular case for the channel: https://www.youtube.com/channel/UCbOoDorgVGd-4vZdIrU4C1A
$channel = [
'channelId' => $gridChannelRenderer['channelId'],
'title' => $gridChannelRenderer['title']['simpleText'],
'thumbnails' => $thumbnails,
'videoCount' => intval(str_replace(',', '', $gridChannelRenderer['videoCountText']['runs'][0]['text'])),
'subscriberCount' => $subscriberCount
];
array_push($sectionChannels, $channel);
}
array_push($channelSections, [
'title' => $sectionTitle,
'sectionChannels' => $sectionChannels,
'nextPageToken' => $nextPageToken
]);
}
breakChannelSectionsTreatment:
$item['channelSections'] = $channelSections;
}
if ($options['about']) {
$result = getJSONFromHTML("https://www.youtube.com/channel/$id/about", forceLanguage: true, verifiesChannelRedirection: true);
$c4TabbedHeaderRenderer = $result['header']['c4TabbedHeaderRenderer'];
$item['countryChannelId'] = $c4TabbedHeaderRenderer['channelId'];
$tab = getTabByName($result, 'About');
$resultCommon = $result['onResponseReceivedEndpoints'][0]['showEngagementPanelEndpoint']['engagementPanel']['engagementPanelSectionListRenderer']['content']['sectionListRenderer']['contents'][0]['itemSectionRenderer']['contents'][0]['aboutChannelRenderer']['metadata']['aboutChannelViewModel'];
$about['stats'] = [
'joinedDate' => strtotime(str_replace('Joined ', '', $resultCommon['joinedDateText']['content'])),
// Could try to find a YouTube channel with a single view to make sure it displays "view" and not "views".
'viewCount' => getIntValue($resultCommon['viewCountText'], 'view'),
'subscriberCount' => getIntValue($c4TabbedHeaderRenderer['subscriberCountText']['simpleText'], 'subscriber'),
'videoCount' => getIntValue($resultCommon['videoCountText'], 'video')
];
$about['description'] = $resultCommon['description'];
$about['details'] = [
'location' => $resultCommon['country']
];
$linksObjects = $resultCommon['links'];
$links = [];
foreach ($linksObjects as $linkObject) {
$linkObject = $linkObject['channelExternalLinkViewModel'];
$url = $linkObject['link']['commandRuns'][0]['onTap']['innertubeCommand']['urlEndpoint']['url'];
$urlComponents = parse_url($url);
parse_str($urlComponents['query'], $params);
$link = [
'url' => getValue($params, 'q', defaultValue: $url),
'title' => $linkObject['title']['content'],
'favicon' => $linkObject['favicon']['sources']
];
array_push($links, $link);
}
$about['links'] = $links;
$about['handle'] = substr($result['contents']['twoColumnBrowseResultsRenderer']['tabs'][0]['tabRenderer']['endpoint']['browseEndpoint']['canonicalBaseUrl'], 1);
$item['about'] = $about;
}
if ($options['approval']) {
$result = getJSONFromHTML("https://www.youtube.com/channel/$id", forceLanguage: true, verifiesChannelRedirection: true);
$item['approval'] = end(explode(', ', $result['header']['pageHeaderRenderer']['content']['pageHeaderViewModel']['title']['dynamicTextViewModel']['rendererContext']['accessibilityContext']['label']));
}
if ($options['snippet']) {
$result = getJSONFromHTML("https://www.youtube.com/channel/$id", verifiesChannelRedirection: true);
$c4TabbedHeaderRenderer = $result['header']['c4TabbedHeaderRenderer'];
$c4TabbedHeaderRendererKeys = ['avatar', 'banner', 'tvBanner', 'mobileBanner'];
$snippet = array_combine($c4TabbedHeaderRendererKeys, array_map(fn($c4TabbedHeaderRendererKey) => $c4TabbedHeaderRenderer[$c4TabbedHeaderRendererKey]['thumbnails'], $c4TabbedHeaderRendererKeys));
$item['snippet'] = $snippet;
}
if ($options['membership']) {
$result = getJSONFromHTML("https://www.youtube.com/channel/$id");
$item['isMembershipEnabled'] = doesPathExist($result, 'header/c4TabbedHeaderRenderer/sponsorButton') || getValue($result, 'header/pageHeaderRenderer/content/pageHeaderViewModel/actions/flexibleActionsViewModel/actionsRows/0/actions/1/buttonViewModel/targetId', $defaultValue = null) === 'sponsorships-button';
}
if ($options['playlists']) {
if (!$continuationTokenProvided) {
$result = getJSONFromHTML("https://www.youtube.com/channel/$id/playlists", forceLanguage: true, verifiesChannelRedirection: true);
$tab = getTabByName($result, 'Playlists');
if ($tab === null) {
die(returnItems([]));
}
$sectionListRenderer = $tab['tabRenderer']['content']['sectionListRenderer'];
$contents = array_map(fn($content) => $content['itemSectionRenderer']['contents'][0], $sectionListRenderer['contents']);
$itemsArray = [];
foreach($contents as $content)
{
if (array_key_exists('shelfRenderer', $content)) {
$sectionTitle = $content['shelfRenderer']['title']['runs'][0]['text'];
$content = $content['shelfRenderer']['content'];
$content = getValue($content, 'horizontalListRenderer', 'expandedShelfContentsRenderer');
} else {
$sectionTitle = $sectionListRenderer['subMenu']['channelSubMenuRenderer']['contentTypeSubMenuItems'][0]['title'];
$content = $content['gridRenderer'];
}
array_push($itemsArray, [$sectionTitle, $content['items']]);
}
} else {
$result = getContinuationJson($continuationToken);
$itemsArray = [[null, getContinuationItems($result)]];
}
// Note that if there is a `Created playlist`, then there isn't any pagination mechanism on YouTube UI.
// This comment was assuming that they were only `Created playlists` and `Saved playlists`, which isn't the case.
$c4TabbedHeaderRenderer = $result['header']['c4TabbedHeaderRenderer'];
$authorChannelName = $c4TabbedHeaderRenderer['title'];
$authorChannelHandle = $c4TabbedHeaderRenderer['channelHandleText']['runs'][0]['text'];
$authorChannelApproval = $c4TabbedHeaderRenderer['badges'][0]['metadataBadgeRenderer']['tooltip'];
$playlistSections = [];
foreach($itemsArray as [$sectionTitle, $items]) {
// Note that empty playlists aren't listed at all.
$sectionPlaylists = [];
$path = 'continuationItemRenderer/continuationEndpoint/continuationCommand/token';
$lastItem = !empty($items) ? end($items) : [];
if (doesPathExist($lastItem, $path)) {
$nextPageToken = getValue($lastItem, $path);
$items = array_slice($items, 0, count($items) - 1);
}
$isCreatedPlaylists = $sectionTitle === 'Created playlists';
foreach($items as $sectionPlaylistItem) {
if (array_key_exists('showRenderer', $sectionPlaylistItem)) {
continue;
}
$playlistRenderer = getValue($sectionPlaylistItem, 'gridPlaylistRenderer', defaultValue: getValue($sectionPlaylistItem, 'playlistRenderer', 'gridShowRenderer'));
$runs = $playlistRenderer['shortBylineText']['runs'];
if ($isCreatedPlaylists) {
$runs = [null];
}
$authors = !empty($runs) ? array_values(array_filter(array_map(function($shortBylineRun) use ($isCreatedPlaylists, $authorChannelName, $authorChannelHandle, $id, $authorChannelApproval) {
$shortBylineNavigationEndpoint = $shortBylineRun['navigationEndpoint'];
$channelHandle = $shortBylineNavigationEndpoint['commandMetadata']['webCommandMetadata']['url'];
return [
// The following fields `channel*` are `null` without additional code for the `Created playlists` section if there are multiple videos in this section.
'channelName' => $isCreatedPlaylists ? $authorChannelName : $shortBylineRun['text'],
'channelHandle' => $isCreatedPlaylists ? $authorChannelHandle : (str_starts_with($channelHandle, "/@") ? substr($channelHandle, 1) : null),
'channelId' => $isCreatedPlaylists ? $id : $shortBylineNavigationEndpoint['browseEndpoint']['browseId'],
'channelApproval' => $isCreatedPlaylists ? $authorChannelApproval : $playlistRenderer['ownerBadges'][0]['metadataBadgeRenderer']['tooltip'],
];
}, $runs), fn($author) => $author['channelName'] !== ', ')) : [];
$thumbnailRenderer = $playlistRenderer['thumbnailRenderer'];
// For unknown reasons, the playlist `OLAK5uy_ku1ocdOmuBzWb3XrtrAQglseslpye5eIw` has achieved to have a custom thumbnail according to YouTube UI source code.
// The playlist `Playlist with a thumbnail different than the first video one` on https://www.youtube.com/@anothertestagain5569/playlists isn't detected as using a custom thumbnail.
$isThumbnailAVideo = $thumbnailRenderer === null || array_key_exists('playlistVideoThumbnailRenderer', $thumbnailRenderer);
$thumbnailRendererField = 'playlist' . ($isThumbnailAVideo ? 'Video' : 'Custom') . 'ThumbnailRenderer';
if (!array_key_exists($thumbnailRendererField, $thumbnailRenderer)) {
$thumbnailRendererField = 'showCustomThumbnailRenderer';
}
$thumbnailVideo = getVideoFromItsThumbnails($thumbnailRenderer[$thumbnailRendererField]['thumbnail'], $isThumbnailAVideo);
$firstVideos = getFirstVideos($playlistRenderer);
$title = $playlistRenderer['title'];
if (array_key_exists('playlistId', $playlistRenderer)) {
$id = $playlistRenderer['playlistId'];
} else {
$browseId = $playlistRenderer['navigationEndpoint']['browseEndpoint']['browseId'];
// For instance https://www.youtube.com/@FlyMinimal/playlists contains https://www.youtube.com/show/SCG2QET_lsEE-lsp4Kjk7HjA while neither `SCG2QET_lsEE-lsp4Kjk7HjA` nor `G2QET_lsEE-lsp4Kjk7HjA` are correct playlist ids.
// While https://www.youtube.com/@Goldenmoustache/playlists contains https://www.youtube.com/playlist?list=PLHYVKdTa8XHWIEnerFQ-X2dQT5liZpDix which is encoded as `VLPLHYVKdTa8XHWIEnerFQ-X2dQT5liZpDix` in `$browseId` which still seems more appropriate than the `$playlistRenderer['navigationEndpoint']['commandMetadata']['webCommandMetadata']['url']` which contains `/playlist?list=PLHYVKdTa8XHWIEnerFQ-X2dQT5liZpDix`.
// Note that https://www.youtube.com/@FlyMinimal/playlists only contain in its source code `SCG2QET_lsEE-lsp4Kjk7HjA`.
// Only `$browseId` and `url` are common fields to both of these edge cases.
if (str_starts_with($browseId, 'VL')) {
$browseId = substr($browseId, 2);
}
$id = $browseId;
}
$videoCount = intval(getValue($playlistRenderer, 'videoCountText', 'thumbnailOverlays/0/thumbnailOverlayBottomPanelRenderer/text')['runs'][0]['text']);
$sectionPlaylist = [
'id' => $id,
'thumbnailVideo' => $thumbnailVideo,
'firstVideos' => $firstVideos,
'title' => getValue($title, 'runs/0/text', 'simpleText'),
'videoCount' => $videoCount,
'authors' => $authors,
// Does it always start with `Updated `?
// Note that for channels we don't have this field.
'publishedTimeText' => $playlistRenderer['publishedTimeText']['simpleText']
];
array_push($sectionPlaylists, $sectionPlaylist);
}
array_push($playlistSections, [
'title' => $sectionTitle,
'playlists' => $sectionPlaylists,
'nextPageToken' => $nextPageToken
]);
}
$item['playlistSections'] = $playlistSections;
}
if ($options['popular'])
{
$getRendererItems = function($result)
{
$contents = getTabs($result)[0]['tabRenderer']['content']['sectionListRenderer']['contents'];
$shelfRendererPath = 'itemSectionRenderer/contents/0/shelfRenderer';
$content = array_values(array_filter($contents, fn($content) => getValue($content, $shelfRendererPath)['title']['runs'][0]['text'] == 'Popular'))[0];
$shelfRenderer = getValue($content, $shelfRendererPath);
$gridRendererItems = $shelfRenderer['content']['gridRenderer']['items'];
return $gridRendererItems;
};
$item['popular'] = getVideos($item, "https://www.youtube.com/channel/$id", $getRendererItems, $continuationToken);
}
if ($options['recent'])
{
$item['recent'] = getVideos($item, "https://www.youtube.com/channel/$id/recent", fn($result) => getTabByName($result, 'Recent')['tabRenderer']['content']['sectionListRenderer']['contents'][0]['itemSectionRenderer']['contents'][0]['gridRenderer']['items'], $continuationToken);
}
if ($options['letsPlay'])
{
$letsPlay = [];
$result = getJSONFromHTML("https://www.youtube.com/channel/$id/letsplay", forceLanguage: true);
$gridRendererItems = getTabByName($result, 'Let\'s play')['tabRenderer']['content']['sectionListRenderer']['contents'][0]['itemSectionRenderer']['contents'][0]['shelfRenderer']['content']['gridRenderer']['items'];
foreach($gridRendererItems as $gridRendererItem)
{
$gridPlaylistRenderer = $gridRendererItem['gridPlaylistRenderer'];
$titleRun = $gridPlaylistRenderer['title']['runs'][0];
$playlistAuthorRun = $gridPlaylistRenderer['longBylineText']['runs'][0];
$playlistAuthorBrowseEndpoint = $playlistAuthorRun['navigationEndpoint']['browseEndpoint'];
array_push($letsPlay, [
'id' => $gridPlaylistRenderer['playlistId'],
'thumbnails' => $gridPlaylistRenderer['thumbnail']['thumbnails'],
'title' => $titleRun['text'],
'firstVideos' => getFirstVideos($gridPlaylistRenderer),
'videoCount' => intval($gridPlaylistRenderer['videoCountText']['runs'][0]['text']),
'authorName' => $playlistAuthorRun['text'],
'authorChannelId' => $playlistAuthorBrowseEndpoint['browseId'],
'authorChannelHandle' => substr($playlistAuthorBrowseEndpoint['canonicalBaseUrl'], 1),
]);
}
$item['letsPlay'] = $letsPlay;
}
return $item;
}
function returnItems($items)
{
$answer = [
'kind' => 'youtube#channelListResponse',
'etag' => 'NotImplemented',
'items' => $items
];
// should add in some way the pageInfo ?
return json_encode($answer, JSON_PRETTY_PRINT);
}
function getAPI($ids, $order, $continuationToken)
{
$items = [];
foreach ($ids as $id) {
array_push($items, getItem($id, $order, $continuationToken));
}
return returnItems($items);
}
function getVisitorData($result)
{
return $result['responseContext']['webResponseContextExtensionData']['ytConfigData']['visitorData'];
}
function getVideo($gridRendererItem)
{
$gridVideoRenderer = $gridRendererItem['gridVideoRenderer'];
$run = $gridVideoRenderer['shortBylineText']['runs'][0];
$browseEndpoint = $run['navigationEndpoint']['browseEndpoint'];
$title = $gridVideoRenderer['title'];
$publishedAt = getPublishedAt(end(explode('views', $title['accessibility']['accessibilityData']['label'])));
return [
'videoId' => $gridVideoRenderer['videoId'],
'thumbnails' => $gridVideoRenderer['thumbnail']['thumbnails'],
'title' => $title['runs'][0]['text'],
'publishedAt' => $publishedAt,
'views' => getIntFromViewCount($gridVideoRenderer['viewCountText']['simpleText']),
'channelTitle' => $run['text'],
'channelId' => $browseEndpoint['browseId'],
'channelHandle' => substr($browseEndpoint['canonicalBaseUrl'], 1),
'duration' => getIntFromDuration($gridVideoRenderer['thumbnailOverlays'][0]['thumbnailOverlayTimeStatusRenderer']['text']['simpleText']),
'approval' => $gridVideoRenderer['ownerBadges'][0]['metadataBadgeRenderer']['tooltip'],
];
}
function getVideos(&$item, $url, $getGridRendererItems, $continuationToken)
{
$videos = [];
if ($continuationToken === '') {
$result = getJSONFromHTML($url, forceLanguage: true);
$gridRendererItems = $getGridRendererItems($result);
$visitorData = getVisitorData($result);
}
else
{
$result = getContinuationJson($continuationToken);
$gridRendererItems = getContinuationItems($result);
}
foreach($gridRendererItems as $gridRendererItem)
{
if(!array_key_exists('continuationItemRenderer', $gridRendererItem))
{
array_push($videos, getVideo($gridRendererItem));
}
}
if($gridRendererItem != null && array_key_exists('continuationItemRenderer', $gridRendererItem))
{
$item['nextPageToken'] = $gridRendererItem['continuationItemRenderer']['continuationEndpoint']['continuationCommand']['token'] . ',' . $visitorData;
}
return $videos;
}
function getVideoFromItsThumbnails($videoThumbnails, $isVideo = true) {
$videoThumbnails = $videoThumbnails['thumbnails'];
// Maybe we can simplify URLs, as we used to as follows, but keep in mind that the resolution or the access may then become incorrect for both kind of thumbnails.
//$videoThumbnails[0]['url'] = explode('?', $videoThumbnails[0]['url'])[0];
$videoId = $isVideo ? substr($videoThumbnails[0]['url'], 23, 11) : null;
return [
'id' => $videoId,
'thumbnails' => $videoThumbnails
];
}
function getFirstVideos($playlistRenderer)
{
$firstVideos = array_key_exists('thumbnail', $playlistRenderer) ? [getVideoFromItsThumbnails($playlistRenderer['thumbnail'])] : array_map(fn($videoThumbnails) => getVideoFromItsThumbnails($videoThumbnails), getValue($playlistRenderer, 'thumbnails', defaultValue: []));
$sidebarThumbnails = $playlistRenderer['sidebarThumbnails'];
$secondToFourthVideo = $sidebarThumbnails !== null ? array_map(fn($videoThumbnails) => getVideoFromItsThumbnails($videoThumbnails), $sidebarThumbnails) : [];
$firstVideos = array_merge($firstVideos, $secondToFourthVideo);
return $firstVideos;
}
================================================
FILE: commentThreads.php
================================================
[
'client' => [
'clientName' => 'WEB',
'clientVersion' => MUSIC_VERSION
]
],
'continuation' => $continuationToken
];
$opts = [
'http' => [
'method' => 'POST',
'header' => ['Content-Type: application/json'],
'content' => json_encode($rawData),
]
];
$result = getJSON('https://www.youtube.com/youtubei/v1/' . ($videoId !== null ? 'next' : 'browse') . '?key=' . UI_KEY, $opts);
if ($order === 'time' && $simulatedContinuation) {
$continuationToken = $result['onResponseReceivedEndpoints'][0]['reloadContinuationItemsCommand']['continuationItems'][0]['commentsHeaderRenderer']['sortMenu']['sortFilterSubMenuRenderer']['subMenuItems'][1]['serviceEndpoint']['continuationCommand']['token'];
return getAPI($videoId, $commentId, null, $continuationToken);
}
} else {
$result = getJSONFromHTML("https://www.youtube.com/watch?v=$videoId");
$continuationToken = end($result['contents']['twoColumnWatchNextResults']['results']['results']['contents'])['itemSectionRenderer']['contents'][0]['continuationItemRenderer']['continuationEndpoint']['continuationCommand']['token'];
if($continuationToken != '') {
return getAPI($videoId, $commentId, $order, $continuationToken, true);
}
}
$answerItems = [];
$items = $result['frameworkUpdates']['entityBatchUpdate']['mutations'];
$isTopLevelComment = true;
foreach ($items as $item) {
$payload = $item['payload'];
if (array_key_exists('engagementToolbarStateEntityPayload', $payload)) {
$answerItems[$item['entityKey']]['snippet']['topLevelComment']['snippet']['creatorHeart'] = $payload['engagementToolbarStateEntityPayload']['heartState'] == 'TOOLBAR_HEART_STATE_HEARTED';
}
if (!array_key_exists('commentEntityPayload', $payload)) {
continue;
}
$comment = $payload['commentEntityPayload'];
$properties = $comment['properties'];
$author = $comment['author'];
$toolbar = $comment['toolbar'];
$publishedAt = $properties['publishedTime'];
$publishedAt = str_replace(' (edited)', '', $publishedAt, $count);
$internalSnippet = [
'content' => $properties['content']['content'],
'publishedAt' => $publishedAt,
'wasEdited' => $count > 0,
'authorChannelId' => $author['channelId'],
'authorHandle' => $author['displayName'],
'authorName' => str_replace('❤ by ', '', $toolbar['heartActiveTooltip']),
'authorAvatar' => $comment['avatar']['image']['sources'][0],
'isCreator' => $author['isCreator'],
'isArtist' => $author['isArtist'],
'likeCount' => getIntValue($toolbar['likeCountLiked']),
'totalReplyCount' => intval($toolbar['replyCount']),
'videoCreatorHasReplied' => false,
'isPinned' => false,
];
$commentId = $properties['commentId'];
$answerItem = [
'kind' => 'youtube#comment' . ($isTopLevelComment ? 'Thread' : ''),
'etag' => 'NotImplemented',
'id' => $commentId,
'snippet' => ($isTopLevelComment ? [
'topLevelComment' => [
'kind' => 'youtube#comment',
'etag' => 'NotImplemented',
'id' => $commentId,
'snippet' => $internalSnippet
]
] : $internalSnippet)
];
$answerItems[$properties['toolbarStateKey']] = $answerItem;
}
$continuationItems = $result['onResponseReceivedEndpoints'][1]['reloadContinuationItemsCommand']['continuationItems'];
foreach ($continuationItems as $item) {
$commentThreadRenderer = $item['commentThreadRenderer'];
$toolbarStateKey = $commentThreadRenderer['commentViewModel']['commentViewModel']['toolbarStateKey'];
// How to avoid repeating path?
if (doesPathExist($commentThreadRenderer, 'replies/commentRepliesRenderer/viewRepliesCreatorThumbnail')) {
$answerItems[$toolbarStateKey]['snippet']['topLevelComment']['snippet']['videoCreatorHasReplied'] = true;
}
if (doesPathExist($commentThreadRenderer, 'commentViewModel/commentViewModel/pinnedText')) {
$answerItems[$toolbarStateKey]['snippet']['topLevelComment']['snippet']['isPinned'] = true;
}
if ($toolbarStateKey !== null) {
$answerItems[$toolbarStateKey]['snippet']['topLevelComment']['snippet']['nextPageToken'] = $commentThreadRenderer['replies']['commentRepliesRenderer']['contents'][0]['continuationItemRenderer']['continuationEndpoint']['continuationCommand']['token'];
}
}
$answerItems = array_values($answerItems);
$nextContinuationToken = $continuationItems[20]['continuationItemRenderer']['continuationEndpoint']['continuationCommand']['token'];
$answer = [
'kind' => 'youtube#comment' . ($isTopLevelComment ? 'Thread' : '') . 'ListResponse',
'etag' => 'NotImplemented',
'pageInfo' => [
'totalResults' => intval($result['onResponseReceivedEndpoints'][0]['reloadContinuationItemsCommand']['continuationItems'][0]['commentsHeaderRenderer']['countText']['runs'][0]['text']),
'resultsPerPage' => $isTopLevelComment ? 20 : 10
]
];
if ($nextContinuationToken != '') {
$answer['nextPageToken'] = $nextContinuationToken;
}
$answer['items'] = $answerItems;
return json_encode($answer, JSON_PRETTY_PRINT);
}
================================================
FILE: common.php
================================================
instanceKey isn't correct!");
}
}
else
{
die('This instance requires that you provide the appropriate instanceKey parameter!');
}
}
function getContextFromOpts($opts)
{
if (GOOGLE_ABUSE_EXEMPTION !== '') {
// Can maybe leverage an approach like [issues/321](https://github.com/Benjamin-Loison/YouTube-operational-API/issues/321).
$cookieToAdd = 'GOOGLE_ABUSE_EXEMPTION=' . GOOGLE_ABUSE_EXEMPTION;
// Can't we simplify the following code?
if (array_key_exists('http', $opts)) {
$http = $opts['http'];
if (array_key_exists('header', $http)) {
$headers = $http['header'];
$isThereACookieHeader = false;
foreach ($headers as $headerIndex => $header) {
if (str_starts_with($header, 'Cookie: ')) {
$opts['http']['header'][$headerIndex] = "$header; $cookieToAdd";
$isThereACookieHeader = true;
break;
}
}
if (!$isThereACookieHeader) {
array_push($opts['http']['header'], "Cookie: $cookieToAdd");
}
}
} else {
$opts = [
'http' => [
'header' => [
"Cookie: $cookieToAdd"
]
]
];
}
}
$context = stream_context_create($opts);
return $context;
}
function getHeadersFromOpts($url, $opts)
{
$context = getContextFromOpts($opts);
$headers = get_headers($url, true, $context);
return $headers;
}
function fileGetContentsAndHeadersFromOpts($url, $opts)
{
if(HTTPS_PROXY_ADDRESS !== '')
{
if(!array_key_exists('http', $opts))
{
$opts['http'] = [];
}
$opts['http']['proxy'] = 'tcp://' . HTTPS_PROXY_ADDRESS . ':' . HTTPS_PROXY_PORT;
$opts['http']['request_fulluri'] = true;
if(HTTPS_PROXY_USERNAME !== '')
{
$headers = getValue($opts['http'], 'header', $defaultValue = []);
array_push($headers, 'Proxy-Authorization: Basic ' . base64_encode(HTTPS_PROXY_USERNAME . ':' . HTTPS_PROXY_PASSWORD));
$opts['http']['header'] = $headers;
}
}
$context = getContextFromOpts($opts);
$result = file_get_contents($url, false, $context);
return [$result, $http_response_header];
}
function isRedirection($url)
{
$opts = [
'http' => [
'ignore_errors' => true,
'follow_location' => false,
]
];
$http_response_header = getHeadersFromOpts($url, $opts);
$code = intval(explode(' ', $http_response_header[0])[1]);
if (in_array($code, HTTP_CODES_DETECTED_AS_SENDING_UNUSUAL_TRAFFIC)) {
detectedAsSendingUnusualTraffic();
}
return $code == 303;
}
function getRemote($url, $opts = [], $verifyTrafficIfForbidden = true)
{
[$result, $headers] = fileGetContentsAndHeadersFromOpts($url, $opts);
foreach (HTTP_CODES_DETECTED_AS_SENDING_UNUSUAL_TRAFFIC as $HTTP_CODE_DETECTED_AS_SENDING_UNUSUAL_TRAFFIC) {
if (str_contains($headers[0], strval($HTTP_CODE_DETECTED_AS_SENDING_UNUSUAL_TRAFFIC)) && ($HTTP_CODE_DETECTED_AS_SENDING_UNUSUAL_TRAFFIC != 403 || $verifyTrafficIfForbidden)) {
detectedAsSendingUnusualTraffic();
}
}
return $result;
}
function dieWithJsonMessage($message, $code = 400)
{
$error = [
'code' => $code,
'message' => $message
];
$result = [
'error' => $error
];
die(json_encode($result, JSON_PRETTY_PRINT));
}
function detectedAsSendingUnusualTraffic()
{
dieWithJsonMessage('YouTube has detected unusual traffic from this YouTube operational API instance. Please try your request again later or see alternatives at https://github.com/Benjamin-Loison/YouTube-operational-API/issues/11', 403);
}
function getJSON($url, $opts = [], $verifyTrafficIfForbidden = true)
{
return json_decode(getRemote($url, $opts, $verifyTrafficIfForbidden), true);
}
function getJSONStringFromHTMLScriptPrefix($html, $scriptPrefix)
{
$html = explode(';', explode("\">$scriptPrefix", $html, 3)[1], 2)[0];
return $html;
}
function getJSONFromHTMLScriptPrefix($html, $scriptPrefix)
{
$html = getJSONStringFromHTMLScriptPrefix($html, $scriptPrefix);
return json_decode($html, true);
}
function getJSONStringFromHTML($html, $scriptVariable = '', $prefix = 'var ')
{
// don't use as default variable because getJSONFromHTML call this function with empty string
if ($scriptVariable === '') {
$scriptVariable = 'ytInitialData';
}
return getJSONStringFromHTMLScriptPrefix($html, "$prefix$scriptVariable = ");
}
function getJSONFromHTML($url, $opts = [], $scriptVariable = '', $prefix = 'var ', $forceLanguage = false, $verifiesChannelRedirection = false)
{
if($forceLanguage) {
$HEADER = 'Accept-Language: en';
if(!doesPathExist($opts, 'http/header')) {
$opts['http']['header'] = [$HEADER];
} else {
array_push($opts['http']['header'], $HEADER);
}
}
$html = getRemote($url, $opts);
$jsonStr = getJSONStringFromHTML($html, $scriptVariable, $prefix);
$json = json_decode($jsonStr, true);
if($verifiesChannelRedirection)
{
$redirectedToChannelIdPath = 'onResponseReceivedActions/0/navigateAction/endpoint/browseEndpoint/browseId';
if(doesPathExist($json, $redirectedToChannelIdPath))
{
$redirectedToChannelId = getValue($json, $redirectedToChannelIdPath);
$url = preg_replace('/[\w\-_]{24}/', $redirectedToChannelId, $url);
// Does a redirection of redirection for a channel exist?
return getJSONFromHTML($url, $opts, $scriptVariable, $prefix, $forceLanguage, $verifiesChannelRedirection);
}
}
return $json;
}
function checkRegex($regex, $str)
{
return preg_match("/^$regex$/", $str) === 1;
}
function isContinuationToken($continuationToken)
{
return checkRegex('[\w=\-_]+', $continuationToken);
}
function isContinuationTokenAndVisitorData($continuationTokenAndVisitorData)
{
return checkRegex('[\w=\-_]+,[\w=\-_]*', $continuationTokenAndVisitorData);
}
function isPlaylistId($playlistId)
{
return checkRegex('[\w\-_]+', $playlistId);
}
// What's the minimal length ?
// Are there forbidden characters?
function isCId($cId)
{
return true;
}
function isUsername($username)
{
return checkRegex('\w+', $username);
}
function isChannelId($channelId)
{
return checkRegex('UC[\w\-_]{22}', $channelId);
}
function isVideoId($videoId)
{
return checkRegex('[\w\-_]{11}', $videoId);
}
function isHashtag($hashtag)
{
return true; // checkRegex('[\w_]+', $hashtag); // 'é' is a valid hashtag for instance
}
function isSAPISIDHASH($SAPISIDHASH)
{
return checkRegex('[1-9]\d{9}_[a-f\d]{40}', $SAPISIDHASH);
}
function isQuery($q)
{
return true; // should restrain
}
function isClipId($clipId)
{
return checkRegex('Ug[\w\-_]{34}', $clipId);
}
function isEventType($eventType)
{
return in_array($eventType, ['completed', 'live', 'upcoming']);
}
function isPositiveInteger($s)
{
return preg_match("/^\d+$/", $s);
}
function isYouTubeDataAPIV3Key($youtubeDataAPIV3Key)
{
return checkRegex('AIzaSy[A-D][\w\-_]{32}', $youtubeDataAPIV3Key);
}
function isHandle($handle)
{
return checkRegex('@[\w\-_.]{3,}', $handle);
}
function isPostId($postId)
{
return (checkRegex('Ug[w-z][\w\-_]{16}4AaABCQ', $postId) || checkRegex('Ugkx[\w\-_]{32}', $postId));
}
function isCommentId($commentId)
{
return checkRegex('Ug[w-z][\w\-_]{16}4AaABAg(|.[\w\-]{22})', $commentId);
}
// Assume `$path !== ''`.
function doesPathExist($json, $path)
{
if ($json === null) {
return false;
}
$parts = explode('/', $path);
$partsCount = count($parts);
if ($partsCount == 1) {
return array_key_exists($path, $json);
}
return array_key_exists($parts[0], $json) && doesPathExist($json[$parts[0]], join('/', array_slice($parts, 1, $partsCount - 1)));
}
function getValue($json, $path, $defaultPath = null, $defaultValue = null)
{
// Alternatively could make a distinct return for `getValue` depending on path found or not to avoid `null` ambiguity.
if(!doesPathExist($json, $path))
{
return $defaultPath !== null ? getValue($json, $defaultPath) : $defaultValue;
}
$parts = explode('/', $path);
$partsCount = count($parts);
if ($partsCount == 1) {
return $json[$path];
}
$value = getValue($json[$parts[0]], join('/', array_slice($parts, 1, $partsCount - 1)));
return $value;
}
function getIntValue($unitCount, $unit = '')
{
$unitCount = str_replace(',', '', $unitCount);
$unitCount = str_replace(" {$unit}s", '', $unitCount);
$unitCount = str_replace(" $unit", '', $unitCount);
if($unitCount === 'No') {
$unitCount = '0';
}
$unitCount = str_replace('K', '*1_000', $unitCount);
$unitCount = str_replace('M', '*1_000_000', $unitCount);
$unitCount = str_replace('B', '*1_000_000_000', $unitCount);
if(checkRegex('[\d_.*KMB]+', $unitCount)) {
$unitCount = eval("return round($unitCount);");
}
return intval($unitCount);
}
function getCommunityPostFromContent($content)
{
$backstagePost = $content['backstagePostThreadRenderer']['post']; // for posts that are shared from other channels
$common = getValue($backstagePost, 'backstagePostRenderer', 'sharedPostRenderer');
$id = $common['postId'];
$channelId = $common['authorEndpoint']['browseEndpoint']['browseId'];
// Except for `Image`, all other posts require text.
$contentText = [];
$textContent = getValue($common, 'contentText', 'content'); // sharedPosts have the same content just in slightly different positioning
foreach ($textContent['runs'] as $textCommon) {
$contentTextItem = ['text' => $textCommon['text']];
if (array_key_exists('navigationEndpoint', $textCommon)) {
// `$url` isn't defined.
if (str_starts_with($url, 'https://www.youtube.com/redirect?')) {
// `$text` isn't defined here.
$contentTextItem['url'] = $text;
} else {
$navigationEndpoint = $textCommon['navigationEndpoint'];
$url = getValue($navigationEndpoint, 'commandMetadata/webCommandMetadata/url', 'browseEndpoint/canonicalBaseUrl');
$contentTextItem['url'] = "https://www.youtube.com$url";
}
}
array_push($contentText, $contentTextItem);
}
$backstageAttachment = [];
if (array_key_exists('backstageAttachment', $common)) {
$backstageAttachment = $common['backstageAttachment'];
}
$images = [];
if (array_key_exists('backstageImageRenderer', $backstageAttachment)) {
$images = [$backstageAttachment['backstageImageRenderer']['image']];
} else if (array_key_exists('postMultiImageRenderer', $backstageAttachment)) {
foreach($backstageAttachment['postMultiImageRenderer']['images'] as $image) {
array_push($images, $image['backstageImageRenderer']['image']);
}
}
$videoId = getValue($backstageAttachment, 'videoRenderer/videoId');
$date = $common['publishedTimeText']['runs'][0]['text'];
$edited = str_ends_with($date, ' (edited)');
$date = str_replace(' (edited)', '', $date);
$date = str_replace('shared ', '', $date);
$sharedPostId = getValue($common, 'originalPost/backstagePostRenderer/postId');
$poll = null;
if (array_key_exists('pollRenderer', $backstageAttachment)) {
$pollRenderer = $backstageAttachment['pollRenderer'];
$choices = [];
foreach ($pollRenderer['choices'] as $choice) {
$returnedChoice = $choice['text']['runs'][0];
$returnedChoice['image'] = $choice['image'];
$returnedChoice['voteRatio'] = $choice['voteRatioIfNotSelected'];
array_push($choices, $returnedChoice);
}
$totalVotesStr = $pollRenderer['totalVotes']['simpleText'];
// What if no vote? Note that haven't seen a poll with a single vote.
$totalVotes = intval(str_replace(' vote', '', str_replace(' votes', '', $totalVotesStr)));
$poll = [
'choices' => $choices,
'totalVotes' => $totalVotes
];
}
$likes = getIntValue(getValue($common, 'voteCount/simpleText', defaultValue : 0));
// Retrieving comments when using `community?part=snippet` requires another HTTPS request to `browse` YouTube UI endpoint.
// sharedPosts do not have 'actionButtons' so this next line will end up defaulting to 0 $comments
$commentsPath = 'actionButtons/commentActionButtonsRenderer/replyButton/buttonRenderer';
$commentsCommon = doesPathExist($common, $commentsPath) ? getValue($common, $commentsPath) : $common;
$post = [
'id' => $id,
'channelId' => $channelId,
'channelName' => $common['authorText']['runs'][0]['text'],
'channelHandle' => substr($common['authorEndpoint']['browseEndpoint']['canonicalBaseUrl'], 1),
'channelThumbnails' => array_map(function($thumbnail) { $thumbnail['url'] = 'https:' . $thumbnail['url']; return $thumbnail; }, $common['authorThumbnail']['thumbnails']),
'date' => $date,
'contentText' => $contentText,
'likes' => $likes,
'videoId' => $videoId,
'images' => $images,
'poll' => $poll,
'edited' => $edited,
'sharedPostId' => $sharedPostId,
];
if(array_key_exists('text', $commentsCommon))
{
$commentsCount = getIntValue($commentsCommon['text']['simpleText']);
$post['commentsCount'] = $commentsCount;
}
return $post;
}
function getIntFromViewCount($viewCount)
{
if ($viewCount === 'No views') {
$viewCount = 0;
} else {
foreach([',', ' views', 'view'] as $toRemove) {
$viewCount = str_replace($toRemove, '', $viewCount);
}
} // don't know if the 1 view case is useful
$viewCount = intval($viewCount);
return $viewCount;
}
function getIntFromDuration($timeStr)
{
$isNegative = $timeStr[0] === '-';
if ($isNegative) {
$timeStr = substr($timeStr, 1);
}
$format = 'j:H:i:s';
$timeParts = explode(':', $timeStr);
$timePartsCount = count($timeParts);
$minutes = $timeParts[$timePartsCount - 2];
$timeParts[$timePartsCount - 2] = strlen($minutes) == 1 ? "0$minutes" : $minutes;
$timeStr = implode(':', $timeParts);
for ($timePartsIndex = 0; $timePartsIndex < 4 - $timePartsCount; $timePartsIndex++) {
$timeStr = "00:$timeStr";
}
while (date_parse_from_format($format, $timeStr) === false) {
$format = substr($format, 2);
}
$timeComponents = date_parse_from_format($format, $timeStr);
$timeInt = $timeComponents['day'] * (3600 * 24) +
$timeComponents['hour'] * 3600 +
$timeComponents['minute'] * 60 +
$timeComponents['second'];
return ($isNegative ? -1 : 1) * $timeInt;
}
function getFirstNodeContainingPath($nodes, $path) {
return array_values(array_filter($nodes, fn($node) => doesPathExist($node, $path)))[0];
}
function getTabByName($result, $tabName) {
if (array_key_exists('contents', $result)) {
return array_values(array_filter(getTabs($result), fn($tab) => (getValue($tab, 'tabRenderer/title') === $tabName)))[0];
} else {
return null;
}
}
function getPublishedAt($publishedAtRaw) {
$publishedAtStr = str_replace('ago', '', $publishedAtRaw);
$publishedAtStr = str_replace('seconds', '* 1 +', $publishedAtStr);
$publishedAtStr = str_replace('second', '* 1 +', $publishedAtStr);
$publishedAtStr = str_replace('minutes', '* 60 +', $publishedAtStr);
$publishedAtStr = str_replace('minute', '* 60 +', $publishedAtStr);
$publishedAtStr = str_replace('hours', '* 3600 +', $publishedAtStr);
$publishedAtStr = str_replace('hour', '* 3600 +', $publishedAtStr);
$publishedAtStr = str_replace('days', '* 86400 +', $publishedAtStr);
$publishedAtStr = str_replace('day', '* 86400 +', $publishedAtStr);
$publishedAtStr = str_replace('weeks', '* 604800 +', $publishedAtStr);
$publishedAtStr = str_replace('week', '* 604800 +', $publishedAtStr);
$publishedAtStr = str_replace('months', '* 2592000 +', $publishedAtStr); // not sure
$publishedAtStr = str_replace('month', '* 2592000 +', $publishedAtStr);
$publishedAtStr = str_replace('years', '* 31104000 +', $publishedAtStr); // not sure
$publishedAtStr = str_replace('year', '* 31104000 +', $publishedAtStr);
// To remove last ` +`.
$publishedAtStr = substr($publishedAtStr, 0, strlen($publishedAtStr) - 2);
$publishedAtStr = str_replace(' ', '', $publishedAtStr); // "security"
$publishedAtStr = str_replace(',', '', $publishedAtStr);
$publishedAtStrLen = strlen($publishedAtStr);
// "security"
for ($publishedAtStrIndex = $publishedAtStrLen - 1; $publishedAtStrIndex >= 0; $publishedAtStrIndex--) {
$publishedAtChar = $publishedAtStr[$publishedAtStrIndex];
if (!str_contains('+*0123456789', $publishedAtChar)) {
$publishedAtStr = substr($publishedAtStr, $publishedAtStrIndex + 1, $publishedAtStrLen - $publishedAtStrIndex - 1);
break;
}
}
$publishedAt = time() - eval("return $publishedAtStr;");
// the time is not perfectly accurate this way
return $publishedAt;
}
function test()
{
global $test;
return isset($test);
}
function getContinuationItems($result)
{
return $result['onResponseReceivedActions'][0]['appendContinuationItemsAction']['continuationItems'];
}
function getTabs($result)
{
return $result['contents']['twoColumnBrowseResultsRenderer']['tabs'];
}
function getContinuationJson($continuationToken)
{
$containsVisitorData = str_contains($continuationToken, ',');
if($containsVisitorData)
{
$continuationTokenParts = explode(',', $continuationToken);
$continuationToken = $continuationTokenParts[0];
}
$rawData = [
'context' => [
'client' => [
'clientName' => 'WEB',
'clientVersion' => MUSIC_VERSION
]
],
'continuation' => $continuationToken
];
if($containsVisitorData)
{
$rawData['context']['client']['visitorData'] = $continuationTokenParts[1];
}
$http = [
'header' => [
'Content-Type: application/json'
],
'method' => 'POST',
'content' => json_encode($rawData)
];
$httpOptions = [
'http' => $http
];
$result = getJSON('https://www.youtube.com/youtubei/v1/browse?key=' . UI_KEY, $httpOptions);
return $result;
}
function verifyMultipleIdsConfiguration($realIds, $field) {
if (count($realIds) >= 2 && !MULTIPLE_IDS_ENABLED) {
dieWithJsonMessage("Multiple {$field}s are disabled on this instance");
}
}
function verifyTooManyIds($realIds, $field) {
if (count($realIds) > MULTIPLE_IDS_MAXIMUM) {
dieWithJsonMessage("Too many $field");
}
}
function verifyMultipleIds($realIds, $field = 'id') {
verifyMultipleIdsConfiguration($realIds, $field);
verifyTooManyIds($realIds, $field);
}
function getMultipleIds($field) {
$realIdsString = $_GET[$field];
$realIds = explode(',', $realIdsString);
verifyMultipleIds($realIds);
return $realIds;
}
function includeOnceProto($proto) {
$COMMON_PATH = 'proto/php';
include_once "$COMMON_PATH/$proto.php";
include_once "$COMMON_PATH/GPBMetadata/$proto.php";
}
function includeOnceProtos($protos) {
require_once __DIR__ . '/vendor/autoload.php';
foreach($protos as $proto) {
includeOnceProto($proto);
}
}
// Source: https://www.php.net/manual/en/function.base64-encode.php#103849
function base64url_encode($data) {
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
?>
================================================
FILE: community.php
================================================
"${k}${separator}${v}", array_keys($anArray), array_values($anArray));
}
function getAPI($postId, $channelId, $order)
{
$currentTime = time();
$SAPISID = 'CENSORED';
$__Secure_3PSID = 'CENSORED';
$ORIGIN = 'https://www.youtube.com';
$SAPISIDHASH = "${currentTime}_" . sha1("$currentTime $SAPISID $ORIGIN");
$subBrowse = new \SubBrowse();
$subBrowse->setPostId($postId);
$browse = new \Browse();
$browse->setEndpoint('community');
$browse->setSubBrowse($subBrowse);
$params = base64_encode($browse->serializeToString());
$rawData = [
'context' => [
'client' => [
'clientName' => 'WEB',
'clientVersion' => MUSIC_VERSION
]
],
'browseId' => $channelId,
'params' => $params,
];
$opts = [
'http' => [
'method' => 'POST',
'header' => implodeArray([
'Content-Type' => 'application/json',
'Origin' => $ORIGIN,
'Authorization' => "SAPISIDHASH $SAPISIDHASH",
'Cookie' => implode('; ', implodeArray([
'__Secure-3PSID' => $__Secure_3PSID,
'__Secure-3PAPISID' => $SAPISID,
], '=')),
], ': '),
'content' => json_encode($rawData),
]
];
$result = getJSON('https://www.youtube.com/youtubei/v1/browse', $opts);
$contents = getTabByName($result, 'Community')['tabRenderer']['content']['sectionListRenderer']['contents'];
$content = $contents[0]['itemSectionRenderer']['contents'][0];
$post = getCommunityPostFromContent($content);
$continuationToken = urldecode($contents[1]['itemSectionRenderer']['contents'][0]['continuationItemRenderer']['continuationEndpoint']['continuationCommand']['token']);
if ($order === 'time') {
$result = getContinuationJson($continuationToken);
$continuationToken = urldecode($result['onResponseReceivedEndpoints'][0]['reloadContinuationItemsCommand']['continuationItems'][0]['commentsHeaderRenderer']['sortMenu']['sortFilterSubMenuRenderer']['subMenuItems'][1]['serviceEndpoint']['continuationCommand']['token']);
}
$comments = [
'nextPageToken' => $continuationToken
];
$post['comments'] = $comments;
$answerItem = [
'kind' => 'youtube#community',
'etag' => 'NotImplemented',
'id' => $postId,
'snippet' => $post
];
$answer = [
'kind' => 'youtube#communityListResponse',
'etag' => 'NotImplemented'
];
$answer['items'] = [$answerItem];
return json_encode($answer, JSON_PRETTY_PRINT);
}
================================================
FILE: configuration.php
================================================
================================================
FILE: constants.php
================================================
================================================
FILE: docker-compose.yml
================================================
version: '3.8'
services:
api:
image: youtube-operational-api
build: .
restart: on-failure
ports:
- ${EXPOSED_HTTP_PORT}:80
================================================
FILE: index.php
================================================
YouTube operational API
$name";
}
function yt()
{
echo 'YouTube Data API v3';
}
function getUrl($parameters)
{
return urldecode(http_build_query(array_combine(array_keys($parameters), array_map(fn($parameterValue) => gettype($parameterValue) === 'string' ? $parameterValue : implode(',', $parameterValue), array_values($parameters)))));
}
function feature($feature)
{
$suburl = "$feature[0]/list";
$webpage = explode('/', $suburl, 2)[0];
$url = getUrl($feature[1]) . (count($feature) >= 3 ? '(&' . getUrl($feature[2]) . ')' : '');
$name = ucfirst(str_replace('/', ': ', $suburl));
echo "Based on $name: " . url(WEBSITE_URL . "$webpage?$url") . '
';
}
$features = [
[
'channels',
[
'part' => [
'status',
'upcomingEvents',
'shorts',
'community',
'channels',
'about',
'approval',
'playlists',
'snippet',
'membership',
'popular',
'recent',
'letsPlay',
],
'cId' => 'C_ID',
'id' => 'CHANNEL_ID',
'handle' => 'HANDLE',
'forUsername' => 'USERNAME',
'raw' => 'RAW',
'order' => 'viewCount',
],
[
'pageToken' => 'PAGE_TOKEN',
],
],
[
'commentThreads',
[
'part' => [
'snippet',
'replies',
],
'id' => 'COMMENT_ID',
'videoId' => 'VIDEO_ID',
'order' => [
'relevance',
'time',
],
],
[
'pageToken' => 'PAGE_TOKEN',
],
],
[
'playlists',
[
'part' => [
'snippet',
'statistics',
],
'id' => 'PLAYLIST_ID',
],
],
[
'playlistItems',
[
'part' => [
'snippet',
],
'playlistId' => 'PLAYLIST_ID',
],
[
'pageToken' => 'PAGE_TOKEN',
],
],
[
'search',
[
'part' => [
'id',
'snippet',
],
'q' => 'QUERY',
'channelId' => 'CHANNEL_ID',
'eventType' => 'upcoming',
'hashtag' => 'HASH_TAG',
'type' => [
'video',
'short',
],
'order' => [
'viewCount',
'relevance',
],
],
[
'pageToken' => 'PAGE_TOKEN',
],
],
[
'videos',
[
'part' => [
'id',
'status',
'contentDetails',
'music',
'short',
'impressions',
'musics',
'isPaidPromotion',
'isPremium',
'isMemberOnly',
'mostReplayed',
'qualities',
'captions',
'chapters',
'isOriginal',
'isRestricted',
'snippet',
'clip',
'activity',
'explicitLyrics',
'statistics',
],
'id' => 'VIDEO_ID',
'clipId' => 'CLIP_ID',
'SAPISIDHASH' => 'YOUR_SAPISIDHASH',
],
]
];
?>
YouTube operational API works when fails.
Current implemented features:
[
'snippet',
],
'id' => 'POST_ID',
'channelId' => 'CHANNEL_ID',
'order' => [
'relevance',
'time',
],
],
],
[
'lives',
[
'part' => [
'donations',
'sponsorshipGifts',
'memberships',
'poll',
],
'id' => 'VIDEO_ID',
],
],
[
'liveChats',
[
'part' => [
'snippet',
'participants',
],
'id' => 'VIDEO_ID',
'time' => 'TIME_MS',
],
],
];
foreach ($features as $feature) {
echo "" . url(WEBSITE_URL . "$feature[0]?" . getUrl($feature[1])) . "
";
}
?>
Make request WITHOUT ANY KEY:
To make ANY request WITHOUT ANY KEY/USING YOUR QUOTA, you can use:
For instance with YOUR_REQUEST being you can use: instead of
I may add in the future limitation per IP etc if the quota need to be better shared among the persons using this API.
Currently this service is powered by keys.
Share your YouTube Data API v3 key to power the no-key service: >
Open-source:
The source code is available on GitHub:
Contact:
If a feature you are looking for which isn't working on , ask kindly with the below contact:
-
-
$hash";
}
echo "
This instance (" . SERVER_NAME . ") uses $version";
?>
================================================
FILE: keys.php
================================================
All keys have all worked at least once and some keys may have extended quota (more than 10,000).
================================================
FILE: liveChats.php
================================================
[
'client' => [
'clientName' => 'WEB',
'clientVersion' => MUSIC_VERSION
]
],
'continuation' => strval($continuation),
'currentPlayerState' => [
'playerOffsetMs' => $_GET['time']
]
];
$opts = [
'http' => [
'header' => ['Content-Type: application/json'],
'method' => 'POST',
'content' => json_encode($rawData),
]
];
$result = getJSON('https://www.youtube.com/youtubei/v1/live_chat/get_live_chat_replay?key=' . UI_KEY, $opts);
$item = [
'kind' => 'youtube#video',
'etag' => 'NotImplemented',
'id' => $id
];
if ($options['snippet']) {
$snippet = [];
$actions = $result['continuationContents']['liveChatContinuation']['actions'];
foreach ($actions as $action) {
$replayChatItemAction = $action['replayChatItemAction'];
$liveChatTextMessageRenderer = $replayChatItemAction['actions'][0]['addChatItemAction']['item']['liveChatTextMessageRenderer'];
if ($liveChatTextMessageRenderer != null) {
$message = [
'id' => urldecode($liveChatTextMessageRenderer['id']),
'message' => $liveChatTextMessageRenderer['message']['runs'],
'authorName' => $liveChatTextMessageRenderer['authorName']['simpleText'],
'authorThumbnails' => $liveChatTextMessageRenderer['authorPhoto']['thumbnails'],
'timestampAbsoluteUsec' => intval($liveChatTextMessageRenderer['timestampUsec']),
'authorChannelId' => $liveChatTextMessageRenderer['authorExternalChannelId'],
'timestamp' => getIntFromDuration($liveChatTextMessageRenderer['timestampText']['simpleText']),
'videoOffsetTimeMsec' => intval($replayChatItemAction['videoOffsetTimeMsec'])
];
array_push($snippet, $message);
}
}
$item['snippet'] = $snippet;
}
if ($options['participants']) {
$participants = [];
$opts = [
'http' => [
'header' => ['User-Agent: ' . USER_AGENT],
]
];
$result = getJSONFromHTML("https://www.youtube.com/live_chat?continuation=$continuation", $opts, 'window["ytInitialData"]', '');
$participants = array_slice($result['continuationContents']['liveChatContinuation']['actions'], 1);
$item['participants'] = $participants;
}
return $item;
}
function getAPI($ids)
{
$items = [];
foreach ($ids as $id) {
array_push($items, getItem($id));
}
$answer = [
'kind' => 'youtube#videoListResponse',
'etag' => 'NotImplemented',
'items' => $items
];
return json_encode($answer, JSON_PRETTY_PRINT);
}
================================================
FILE: lives.php
================================================
[
'user_agent' => USER_AGENT,
'header' => ['Accept-Language: en'],
]
];
$result = getJSONFromHTML("https://www.youtube.com/live_chat?v=$id", $opts, 'window["ytInitialData"]', '');
$item = [
'kind' => 'youtube#video',
'etag' => 'NotImplemented',
'id' => $id
];
$actions = $result['contents']['liveChatRenderer']['actions'];
if ($options['donations']) {
$donations = [];
foreach ($actions as $action) {
$donation = $action['addLiveChatTickerItemAction']['item']['liveChatTickerPaidMessageItemRenderer']['showItemEndpoint']['showLiveChatItemEndpoint']['renderer']['liveChatPaidMessageRenderer'];
if ($donation != null) {
array_push($donations, $donation);
}
}
$item['donations'] = $donations;
}
if ($options['sponsorshipGifts']) {
function getCleanAuthorBadge($authorBadgeRaw)
{
$liveChatAuthorBadgeRenderer = $authorBadgeRaw['liveChatAuthorBadgeRenderer'];
$authorBadge = [
'tooltip' => $liveChatAuthorBadgeRenderer['tooltip'],
'customThumbnail' => $liveChatAuthorBadgeRenderer['customThumbnail']['thumbnails']
];
return $authorBadge;
}
function cleanMembershipOrSponsorship($raw, $isMembership) {
$common = $isMembership ? $raw : $raw['header']['liveChatSponsorshipsHeaderRenderer'];
$primaryText = implode('', array_map(fn($run) => $run['text'], $common[$isMembership ? 'headerPrimaryText' : 'primaryText']['runs']));
$subText = $raw['headerSubtext']['simpleText'];
$authorBadges = array_map('getCleanAuthorBadge', $common['authorBadges']);
$clean = [
'id' => $raw['id'],
'timestamp' => intval($raw['timestampUsec']),
'authorChannelId' => $raw['authorExternalChannelId'],
'authorName' => $common['authorName']['simpleText'],
'authorPhoto' => $common['authorPhoto']['thumbnails'],
'primaryText' => $primaryText,
'subText' => $subText,
'authorBadges' => $authorBadges,
];
return $clean;
}
$sponsorshipGifts = [];
foreach ($actions as $action) {
$sponsorshipGift = $action['addChatItemAction']['item']['liveChatSponsorshipsGiftPurchaseAnnouncementRenderer'];
if ($sponsorshipGift != null)
{
array_push($sponsorshipGifts, cleanMembershipOrSponsorship($sponsorshipGift, false));
}
}
$item['sponsorshipGifts'] = $sponsorshipGifts;
}
if ($options['memberships']) {
$memberships = [];
foreach ($actions as $action) {
$membership = $action['addChatItemAction']['item']['liveChatMembershipItemRenderer'];
if ($membership != null)
{
array_push($memberships, cleanMembershipOrSponsorship($membership, true));
}
}
$item['memberships'] = $memberships;
}
if ($options['poll']) {
$firstAction = $actions[0];
if(array_key_exists('showLiveChatActionPanelAction', $firstAction)) {
$pollRenderer = $firstAction['showLiveChatActionPanelAction']['panelToShow']['liveChatActionPanelRenderer']['contents']['pollRenderer'];
$pollHeaderRenderer = $pollRenderer['header']['pollHeaderRenderer'];
$liveChatPollStateEntity = $result['frameworkUpdates']['entityBatchUpdate']['mutations'][0]['payload']['liveChatPollStateEntity'];
$metadataTextRuns = explode(' • ', $liveChatPollStateEntity['metadataText']['runs'][0]['text']);
$poll = [
'question' => $liveChatPollStateEntity['collapsedMetadataText']['runs'][2]['text'],
'choices' => array_map(fn($choiceText, $choiceRatio) => [
'text' => $choiceText['text']['runs'][0]['text'],
'voteRatio' => $choiceRatio['value']['voteRatio'],
], $pollRenderer['choices'], $liveChatPollStateEntity['pollChoiceStates']),
'channelName' => $metadataTextRuns[0],
'timestamp' => str_replace("\u{00a0}", ' ', $metadataTextRuns[1]),
'totalVotes' => intval(str_replace(' votes', '', $metadataTextRuns[2])),
'channelThumbnails' => $pollHeaderRenderer['thumbnail']['thumbnails'],
];
}
$item['poll'] = $poll;
}
return $item;
}
function getAPI($ids)
{
$items = [];
foreach ($ids as $id) {
array_push($items, getItem($id));
}
$answer = [
'kind' => 'youtube#videoListResponse',
'etag' => 'NotImplemented',
'items' => $items
];
return json_encode($answer, JSON_PRETTY_PRINT);
}
================================================
FILE: noKey/.htaccess
================================================
Options +FollowSymLinks
RewriteEngine on
RewriteRule ^.*$ index.php
================================================
FILE: noKey/index.php
================================================
['ignore_errors' => true]];
$context = stream_context_create($options);
function myDie($content)
{
global $keysCount;
if(isset($_GET['monitoring']))
{
$data = json_decode($content, true);
$data['monitoring'] = $keysCount;
$content = json_encode($data, JSON_PRETTY_PRINT);
}
die($content);
}
/// is there any way someone may get the keys out ? could restrict syntax with the one of the official API but that's not that much clean
// Tries to proceed to the request with an API key and if running out of quota, then use for this and following requests the API key used the longest time ago.
for ($keysIndex = 0; $keysIndex < $keysCount; $keysIndex++) {
$key = $keys[$keysIndex];
$realUrl = $url . $key;
$response = file_get_contents($realUrl, false, $context);
$response = str_replace($key, '!Please contact Benjamin Loison to tell him how you did that!', $response); // quite good but not perfect
// no need to check for ip leak
$json = json_decode($response, true);
if (array_key_exists('error', $json)) {
$error = $json['error'];
if ($error['errors'][0]['domain'] !== 'youtube.quota') {
$message = $error['message'];
// As there are many different kind of errors other than the quota one, we could just proceed to a test verifying that the expected result is returned, as when adding a key.
if ($message === 'API key expired. Please renew the API key.' or str_ends_with($message, 'has been suspended.') or $message === 'API key not valid. Please pass a valid API key.' or $message === 'API Key not found. Please pass a valid API key.' or str_starts_with($message, 'YouTube Data API v3 has not been used in project ') or str_ends_with($message, 'are blocked.') or checkRegex('The provided API key has an IP address restriction\. The originating IP address of the call \(([\da-f:]{4,39}|\d{1,3}.\d{1,3}.\d{1,3}.\d{1,3})\) violates this restriction\.', $message)) {
// Removes this API key as it won't be useful anymore.
$newKeys = array_merge(array_slice($keys, $keysIndex + 1), array_slice($keys, 0, $keysIndex));
$toWrite = implode("\n", $newKeys);
file_put_contents(KEYS_FILE, $toWrite);
// Skips to next API key.
// Decrements `keysIndex` as it will be incremented due to `continue`.
$keysIndex -= 1;
$keysCount -= 1;
$keys = $newKeys;
continue;
}
// If such an error occur, returns it to the end-user, as made exceptions for out of quota and expired keys, should also consider transient, backend and suspension errors.
// As managed in YouTube-comments-graph: https://github.com/Benjamin-Loison/YouTube-comments-graph/blob/993429770417bdfa4fdf176c473ff1bfe7ed21ae/CPP/main.cpp#L55-L60
myDie($response);
}
} else {
if ($keysIndex !== 0) {
// As the request is successful with this API key, prioritize this key and all the following ones over the first ones.
$newKeys = array_merge(array_slice($keys, $keysIndex), array_slice($keys, 0, $keysIndex));
$toWrite = implode("\n", $newKeys);
file_put_contents(KEYS_FILE, $toWrite);
}
// Returns the proceeded response to the end-user.
myDie($response);
}
}
$message = 'The request cannot be completed because the YouTube operational API run out of quota. Please try again later.';
$errors = [
'message' => $message,
'domain' => 'youtube.quota',
'reason' => 'quotaExceeded'
];
$error = [
'code' => 403,
'message' => $message,
'errors' => [$errors]
];
$json = ['error' => $error];
myDie(json_encode($json, JSON_PRETTY_PRINT));
================================================
FILE: playlistItems.php
================================================
[
'client' => [
'clientName' => 'WEB',
'clientVersion' => MUSIC_VERSION
]
],
'continuation' => $continuationToken
];
$http['method'] = 'POST';
$http['header'] = ['Content-Type: application/json'];
$http['content'] = json_encode($rawData);
} else {
$url = "https://www.youtube.com/playlist?list=$playlistId";
$http['header'] = ['Accept-Language: en'];
}
$httpOptions = [
'http' => $http
];
if ($continuationTokenProvided) {
$result = getJSON($url, $httpOptions);
} else {
$result = getJSONFromHTML($url, $httpOptions);
}
$answerItems = [];
$items = $continuationTokenProvided ? getContinuationItems($result) : getTabs($result)[0]['tabRenderer']['content']['sectionListRenderer']['contents'][0]['itemSectionRenderer']['contents'][0]['playlistVideoListRenderer']['contents'];
$itemsCount = count($items);
for ($itemsIndex = 0; $itemsIndex < $itemsCount - 1; $itemsIndex++) {
$item = $items[$itemsIndex];
$playlistVideoRenderer = $item['playlistVideoRenderer'];
$videoId = $playlistVideoRenderer['videoId'];
$title = $playlistVideoRenderer['title']['runs'][0]['text'];
$publishedAt = getPublishedAt($playlistVideoRenderer['videoInfo']['runs'][2]['text']);
$thumbnails = $playlistVideoRenderer['thumbnail']['thumbnails'];
$answerItem = [
'kind' => 'youtube#playlistItem',
'etag' => 'NotImplemented',
'snippet' => [
'publishedAt' => $publishedAt,
'title' => $title,
'thumbnails' => $thumbnails,
'resourceId' => [
'kind' => 'youtube#video',
'videoId' => $videoId
]
]
];
array_push($answerItems, $answerItem);
}
$nextContinuationToken = urldecode($items[100]['continuationItemRenderer']['continuationEndpoint']['continuationCommand']['token']); // it doesn't seem random but hard to reverse-engineer
$answer = [
'kind' => 'youtube#playlistItemListResponse',
'etag' => 'NotImplemented'
];
// order matter or could afterwards sort by an (official YT API) arbitrary order (not alphabetical)
// seems to be this behavior with the official API
if ($nextContinuationToken != '') {
$answer['nextPageToken'] = $nextContinuationToken;
}
$answer['items'] = $answerItems;
return json_encode($answer, JSON_PRETTY_PRINT);
}
================================================
FILE: playlists.php
================================================
4_000]],
];
include_once 'common.php';
$realOptions = [
'snippet',
'statistics',
];
// really necessary ?
foreach ($realOptions as $realOption) {
$options[$realOption] = false;
}
if (isset($_GET['part'], $_GET['id'])) {
$part = $_GET['part'];
$parts = explode(',', $part, count($realOptions));
foreach ($parts as $part) {
if (!in_array($part, $realOptions)) {
dieWithJsonMessage("Invalid part $part");
} else {
$options[$part] = true;
}
}
$ids = $_GET['id'];
$realIds = explode(',', $ids);
verifyMultipleIds($realIds);
foreach ($realIds as $realId) {
if (!isPlaylistId($realId)) {
dieWithJsonMessage('Invalid id');
}
}
echo getAPI($realIds);
} else if(!test()) {
dieWithJsonMessage('Required parameters not provided');
}
function getItem($id)
{
global $options;
$result = getJSONFromHTML("https://www.youtube.com/playlist?list=$id", forceLanguage: true);
$item = [
'kind' => 'youtube#playlist',
'etag' => 'NotImplemented'
];
if ($options['snippet']) {
$title = $result['metadata']['playlistMetadataRenderer']['title'];
$item['snippet'] = [
'title' => $title
];
}
if ($options['statistics']) {
$viewCount = $result['sidebar']['playlistSidebarRenderer']['items'][0]['playlistSidebarPrimaryInfoRenderer']['stats'][1]['simpleText'];
$viewCount = getIntFromViewCount($viewCount);
$videoCount = intval(str_replace(',', '', $result['header']['playlistHeaderRenderer']['numVideosText']['runs'][0]['text']));
$item['statistics'] = [
'viewCount' => $viewCount,
'videoCount' => $videoCount
];
}
return $item;
}
function getAPI($ids)
{
$items = [];
foreach ($ids as $id) {
array_push($items, getItem($id));
}
$answer = [
'kind' => 'youtube#playlistListResponse',
'etag' => 'NotImplemented',
'items' => $items
];
return json_encode($answer, JSON_PRETTY_PRINT);
}
================================================
FILE: proto/php/.gitignore
================================================
# Ignore everything in this directory
*
# Except this file
!.gitignore
================================================
FILE: proto/prototypes/browse.proto
================================================
syntax = "proto3";
message Browse {
string endpoint = 2;
SubBrowse subBrowse = 25;
}
message SubBrowse {
string postId = 22;
}
================================================
FILE: proto/prototypes/browse_shorts.proto
================================================
syntax = "proto3";
message BrowseShorts {
Sub0BrowseShorts two = 2;
int32 three = 3;
string four = 4;
}
message Sub0BrowseShorts {
string two = 2;
string three = 3;
}
message Sub1BrowseShorts {
Sub2BrowseShorts two = 2;
}
message Sub2BrowseShorts {
Sub3BrowseShorts eighteen = 18;
}
message Sub3BrowseShorts {
Sub4_7BrowseShorts seven = 7;
Sub4_9BrowseShorts nine = 9;
}
message Sub4_7BrowseShorts {
int32 twelve = 12;
}
message Sub4_9BrowseShorts {
}
================================================
FILE: search.php
================================================
[
'client' => [
'clientName' => 'WEB',
'clientVersion' => MUSIC_VERSION
]
],
];
if(isset($_GET['type']) && $_GET['type'] === 'short') {
$sub1BrowseShorts = (new \Sub1BrowseShorts())
->setTwo((new \Sub2BrowseShorts())
->setEighteen((new \Sub3BrowseShorts())
->setSeven((new \Sub4_7BrowseShorts())
->setTwelve(26))
->setNine(new \Sub4_9BrowseShorts())));
$browseShorts = (new \BrowseShorts())
->setTwo((new \Sub0BrowseShorts())
->setTwo($_GET['q'])
->setThree(base64_encode($sub1BrowseShorts->serializeToString())))
->setThree(52047873)
->setFour('search-page');
$continuation = base64url_encode($browseShorts->serializeToString());
$rawData['continuation'] = $continuation;
} else {
$rawData['query'] = str_replace('"', '\"', $_GET['q']);
if($typeBase64 !== '') {
$rawData['params'] = $typeBase64;
}
}
if($continuationTokenProvided) {
$rawData['continuation'] = $continuationToken;
}
$opts = [
'http' => [
'method' => 'POST',
'header' => ['Content-Type: application/json'],
'content' => json_encode($rawData),
]
];
$json = getJSON('https://www.youtube.com/youtubei/v1/search?key=' . UI_KEY, $opts);
if(isset($_GET['type']) && $_GET['type'] === 'short')
{
$items = $json['onResponseReceivedCommands'][0]['reloadContinuationItemsCommand']['continuationItems'][0]['twoColumnSearchResultsRenderer']['primaryContents']['sectionListRenderer']['contents'][0]['itemSectionRenderer']['contents'];
}
else
{
$items = ($continuationTokenProvided ? $json['onResponseReceivedCommands'][0]['appendContinuationItemsAction']['continuationItems'] : $json['contents']['twoColumnSearchResultsRenderer']['primaryContents']['sectionListRenderer']['contents'])[0]['itemSectionRenderer']['contents'];
}
} else { // if (isset($_GET['channelId']))
$orderBase64 = 'EgZ2aWRlb3MYASAAMAE=';
$rawData = [
'context' => [
'client' => [
'clientName' => 'WEB',
'clientVersion' => CLIENT_VERSION
]
],
'browseId' => $_GET['channelId'],
'params' => $orderBase64
];
if($continuationTokenProvided) {
$rawData['continuation'] = $continuationToken;
}
$opts = [
'http' => [
'method' => 'POST',
'header' => ['Content-Type: application/json'],
'content' => json_encode($rawData),
]
];
$result = getJSON('https://www.youtube.com/youtubei/v1/browse?key=' . UI_KEY, $opts);
// repeated on official API but not in UI requests
//if(!$continuationTokenProvided)
// $regionCode = $result['topbar']['desktopTopbarRenderer']['countryCode'];
$items = $continuationTokenProvided ? getContinuationItems($result) : getTabs($result)[1]['tabRenderer']['content']['sectionListRenderer']['contents'][0]['itemSectionRenderer']['contents'][0]['gridRenderer']['items'];
}
$answerItems = [];
$itemsCount = count($items);
for ($itemsIndex = 0; $itemsIndex < $itemsCount - ($continuationTokenProvided || $_GET['hashtag'] ? 1 : 0); $itemsIndex++) { // check upper bound for hashtags
$item = $items[$itemsIndex];
$path = '';
if (isset($_GET['hashtag'])) {
$path = 'richItemRenderer/content/videoRenderer';
} elseif (isset($_GET['q'])) {
$path = 'videoRenderer';
// Skip `People also watched`.
if(!array_key_exists($path, $item)) {
continue;
}
} else {
$path = 'gridVideoRenderer';
}
$gridVideoRenderer = getValue($item, $path);
$answerItem = [
'kind' => 'youtube#searchResult',
'etag' => 'NotImplemented'
];
if ($options['id']) {
$videoId = $gridVideoRenderer['videoId'];
$answerItem['id'] = [
'kind' => 'youtube#video',
'videoId' => $videoId
];
}
if ($options['snippet']) {
$title = $gridVideoRenderer['title']['runs'][0]['text'];
$run = $gridVideoRenderer['ownerText']['runs'][0];
$browseEndpoint = $run['navigationEndpoint']['browseEndpoint'];
$channelId = $browseEndpoint['browseId'];
$views = getIntFromViewCount($gridVideoRenderer['viewCountText']['simpleText']);
$badges = $gridVideoRenderer['badges'];
$badges = !empty($badges) ? array_map(fn($badge) => $badge['metadataBadgeRenderer']['label'], $badges) : [];
$chapters = $gridVideoRenderer['expandableMetadata']['expandableMetadataRenderer']['expandedContent']['horizontalCardListRenderer']['cards'];
$chapters = !empty($chapters) ? array_map(function($chapter) {
$macroMarkersListItemRenderer = $chapter['macroMarkersListItemRenderer'];
return [
'title' => $macroMarkersListItemRenderer['title']['simpleText'],
'time' => getIntFromDuration($macroMarkersListItemRenderer['timeDescription']['simpleText']),
'thumbnails' => $macroMarkersListItemRenderer['thumbnail']['thumbnails']
]; }, $chapters) : [];
$channelHandle = substr($browseEndpoint['canonicalBaseUrl'], 1);
$answerItem['snippet'] = [
'channelId' => $channelId,
'title' => $title,
'thumbnails' => $gridVideoRenderer['thumbnail']['thumbnails'],
'channelTitle' => $run['text'],
'channelHandle' => $channelHandle[0] === '@' ? $channelHandle : null,
'timestamp' => $gridVideoRenderer['publishedTimeText']['simpleText'],
'duration' => getIntFromDuration($gridVideoRenderer['lengthText']['simpleText']),
'views' => $views,
'badges' => $badges,
'channelApproval' => $gridVideoRenderer['ownerBadges'][0]['metadataBadgeRenderer']['tooltip'],
'channelThumbnails' => $gridVideoRenderer['channelThumbnailSupportedRenderers']['channelThumbnailWithLinkRenderer']['thumbnail']['thumbnails'],
'detailedMetadataSnippet' => $gridVideoRenderer['detailedMetadataSnippets'][0]['snippetText']['runs'],
'chapters' => $chapters
];
}
array_push($answerItems, $answerItem);
}
if (isset($_GET['hashtag'])) {
$nextContinuationToken = $itemsCount > 60 ? $items[60] : '';
} else {
$nextContinuationToken = $itemsCount > 30 ? $items[30] : '';
} // it doesn't seem random but hard to reverse-engineer
if ($nextContinuationToken !== '') {
$nextContinuationToken = $nextContinuationToken['continuationItemRenderer']['continuationEndpoint']['continuationCommand']['token'];
}
if (isset($_GET['q'])) {
$nextContinuationToken = ($continuationTokenProvided ? $json['onResponseReceivedCommands'][0]['appendContinuationItemsAction']['continuationItems'] : $json['contents']['twoColumnSearchResultsRenderer']['primaryContents']['sectionListRenderer']['contents'])[1]['continuationItemRenderer']['continuationEndpoint']['continuationCommand']['token'];
}
$nextContinuationToken = urldecode($nextContinuationToken);
$answer = [
'kind' => 'youtube#searchListResponse',
'etag' => 'NotImplemented'
];
// order matter or could afterwards sort by an (official YT API) arbitrary order (not alphabetical)
// seems to be this behavior with the official API
if ($nextContinuationToken != '') {
$answer['nextPageToken'] = $nextContinuationToken;
}
//if(!$continuationTokenProvided) // doesn't seem accurate
// $answer['regionCode'] = $regionCode;
$answer['items'] = $answerItems;
return json_encode($answer, JSON_PRETTY_PRINT);
}
================================================
FILE: tools/checkOperationnalAPI.py
================================================
#!/usr/bin/env python
import requests
query = 'test'
videoIds = []
def getVideos(pageToken = '', callsIndex = 0):
global videoIds
url = f'http://localhost/search?part=id&q={query}&type=video'
if pageToken != '':
url += '&pageToken=' + pageToken
data = requests.get(url).json()
for item in data['items']:
videoId = item['id']['videoId']
if not videoId in videoIds:
videoIds += [videoId]
print(len(videoIds), callsIndex)
if 'nextPageToken' in data:
getVideos(data['nextPageToken'], callsIndex + 1)
getVideos()
================================================
FILE: tools/getJSONPathFromKey.py
================================================
#!/usr/bin/env python
'''
This script purpose is to ease retrieving the JSON path associated to an interested YouTube data entry.
For instance when looking for a feature on YouTube UI let say a YouTube video title that we want to automate the retrieval we plug as `filePath` of this script the returned YouTube UI HTML. This script will extract and update to the provided `filePath` the relevant JSON encoded in the appropriate JavaScript variable. Then this script looks recursively for the entry concerning the specific video title you are looking for.
For instance:
```bash
curl -s 'https://www.youtube.com/watch?v=jNQXAC9IVRw' > jNQXAC9IVRw.html
./getJSONPathFromKey.py jNQXAC9IVRw.html | grep 'Me at the zoo$'
```
```
105 /contents/twoColumnWatchNextResults/results/results/contents/0/videoPrimaryInfoRenderer/title/runs/0/text Me at the zoo
101 /playerOverlays/playerOverlayRenderer/videoDetails/playerOverlayVideoDetailsRenderer/title/simpleText Me at the zoo
156 /engagementPanels/2/engagementPanelSectionListRenderer/content/structuredDescriptionContentRenderer/items/0/videoDescriptionHeaderRenderer/title/runs/0/text Me at the zoo
170 /engagementPanels/2/engagementPanelSectionListRenderer/content/structuredDescriptionContentRenderer/items/3/reelShelfRenderer/items/0/reelItemRenderer/headline/simpleText Me at the zoo
171 /engagementPanels/2/engagementPanelSectionListRenderer/content/structuredDescriptionContentRenderer/items/3/reelShelfRenderer/items/24/reelItemRenderer/headline/simpleText этому видио 17 лет - Me at the zoo
```
The first number is the path length to ease considering the shortest one.
If for some reason you know the entry name but not the path you can provide the optional `entryName` `./getJSONPathFromKey.py jNQXAC9IVRw.html entryName` to get the whole path.
As there are potentially multiple JavaScript variable names you can provide as the third argument the interesting JavaScript variable name.
'''
import sys
import json
from lxml import html
def treatKey(obj, path, key):
objKey = obj[key]
objKeyType = type(objKey)
value = objKey if (not objKeyType is dict and not objKeyType is list) else ''
# used to be a print
return (path + '/' + key, value)
def _finditem(obj, key, path = ''):
objType = type(obj)
results = []
if objType is dict:
keys = obj.keys()
if key == '':
for keyTmp in keys:
results += [treatKey(obj, path, keyTmp)]
elif key in keys:
results += [treatKey(obj, path, key)]
for keyTmp in keys:
res = _finditem(obj[keyTmp], key, path + '/' + keyTmp)
if res != []:
results += res
elif objType is list:
objLen = len(obj)
for objIndex in range(objLen):
objEl = obj[objIndex]
res = _finditem(objEl, key, path + '/' + str(objIndex))
if res != []:
results += res
return results
filePath = sys.argv[1]
key = sys.argv[2] if len(sys.argv) >= 3 else ''
# `ytVariableName` could be for instance 'ytInitialPlayerResponse'
ytVariableName = sys.argv[3] if len(sys.argv) >= 4 else 'ytInitialData'
# if not found from key could search by value
# that way could find easily shortest path to get the value as sometimes the value is repeated multiple times
with open(filePath) as f:
try:
json.load(f)
isJSON = True
except:
isJSON = False
if not isJSON:
with open(filePath) as f:
content = f.read()
# Should use a JavaScript parser instead of proceeding that way.
# Same comment concerning `getJSONStringFromHTMLScriptPrefix`, note that both parsing methods should be identical.
tree = html.fromstring(content)
ytVariableDeclaration = ytVariableName + ' = '
for script in tree.xpath('//script'):
scriptContent = script.text_content()
if ytVariableDeclaration in scriptContent:
newContent = scriptContent.split(ytVariableDeclaration)[1][:-1]
break
with open(filePath, 'w') as f:
f.write(newContent)
with open(filePath) as f:
data = json.load(f)
with open(filePath, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii = False, indent = 4)
pathValues = _finditem(data, key)
if pathValues != []:
longestPath = len(str(max([len(path) for path, _ in pathValues])))
for path, value in pathValues:
pathLength = ' ' * (longestPath - len(str(len(path)))) + str(len(path))
print(pathLength, path, value)
================================================
FILE: tools/minimizeCURL.py
================================================
#!/usr/bin/env python
## /!\ Assume that the content of `curlCommandFilePath` is trusted /!\
# TODO: precising or/and lowering this trust level would be interesting
# Note that this algorithm doesn't currently minimize specifically YouTube requests (like `--data-raw/context/client/clientVersion`), should potentially add in the future useless character removal which would be a more general but incomplete approach.
'''
For the moment this algorithm only removes unnecessary:
- headers
- URL parameters
- cookies
- raw data
'''
import shlex
import subprocess
import json
import copy
import sys
from urllib.parse import urlparse, parse_qs, quote_plus
import re
import os
# Could precise the input file and possibly remove the output one as the minimized requests start to be short.
if len(sys.argv) < 3:
print('Usage: ./minimizeCURL curlCommand.txt "Wanted output"')
exit(1)
curlCommandFilePath = sys.argv[1]
wantedOutput = sys.argv[2].encode('utf-8')
# The purpose of these parameters is to reduce requests done when developing this script:
removeHeaders = True
removeUrlParameters = True
removeCookies = True
removeRawData = True
PRINT_TRY_TO_REMOVE = False
VERIFY_INITIAL_COMMAND = True
def printTryToRemove(toRemove):
if PRINT_TRY_TO_REMOVE:
print(f'Try to remove: {toRemove}!')
# Pay attention to provide a command giving plaintext output, so might required to remove `Accept-Encoding` HTTPS header.
with open(curlCommandFilePath) as f:
command = f.read()
def executeCommand(command):
# `stderr = subprocess.DEVNULL` is used to get rid of curl progress.
# Could also add `-s` curl argument.
try:
result = subprocess.check_output(command, shell = True, stderr = subprocess.DEVNULL)
except:
return b''
return result
def getCommandScript(fileName):
return f'{fileName}.sh'
def writeCommand(fileName, command):
with open(getCommandScript(fileName), 'w') as f:
f.write(command)
PARTIALLY_MINIMIZED_CURL_SCRIPT_NAME = 'partially_minimized_curl'
def isCommandStillFine(command):
result = executeCommand(command)
isCommandStillFineResult = wantedOutput in result
# [Benjamin-Loison/cpython/issues/48](https://github.com/Benjamin-Loison/cpython/issues/48)
if isCommandStillFineResult:
writeCommand(PARTIALLY_MINIMIZED_CURL_SCRIPT_NAME, command)
return isCommandStillFineResult
def getCommandLengthFormatted(command):
return f'{len(command):,}'
def printThatCommandIsStillFine(command):
print(f'Command with length {getCommandLengthFormatted(command)} is still fine.')
# For Chromium support:
command = command.replace(' \\\n ', '')
print(f'Initial command length: {getCommandLengthFormatted(command)}.')
# To verify that the user provided the correct `wantedOutput` to keep during the minimization.
if VERIFY_INITIAL_COMMAND and not isCommandStillFine(command):
print('The wanted output isn\'t contained in the result of the original curl command!')
exit(1)
if removeHeaders:
print('Removing headers')
# Should try to minimize the number of requests done, by testing half of parameters at each request.
previousArgumentsIndex = 0
while True:
changedSomething = False
arguments = shlex.split(command)
for argumentsIndex in range(previousArgumentsIndex, len(arguments) - 1):
argument, nextArgument = arguments[argumentsIndex : argumentsIndex + 2]
if argument == '-H':
previousCommand = command
printTryToRemove(arguments[argumentsIndex : argumentsIndex + 2])
del arguments[argumentsIndex : argumentsIndex + 2]
command = shlex.join(arguments)
if isCommandStillFine(command):
printThatCommandIsStillFine(command)
previousArgumentsIndex = argumentsIndex
changedSomething = True
break
else:
command = previousCommand
arguments = shlex.split(command)
if not changedSomething:
break
if removeUrlParameters:
print('Removing URL parameters')
arguments = shlex.split(command)
for argumentsIndex, argument in enumerate(arguments):
if re.match('https?://', argument):
urlIndex = argumentsIndex
break
def getUrl(urlParsed, query):
return urlParsed._replace(query = '&'.join([f'{quote_plus(parameter)}={quote_plus(query[parameter][0])}' for parameter in query])).geturl()
url = arguments[urlIndex]
previousKeyIndex = 0
while True:
changedSomething = False
urlParsed = urlparse(url)
query = parse_qs(urlParsed.query, keep_blank_values = True)
for keyIndex, key in enumerate(list(query)[previousKeyIndex:]):
previousQuery = copy.deepcopy(query)
printTryToRemove(key)
del query[key]
# Make a function with below code.
url = getUrl(urlParsed, query)
arguments[urlIndex] = url
command = shlex.join(arguments)
if isCommandStillFine(command):
printThatCommandIsStillFine(command)
changedSomething = True
previousKeyIndex = keyIndex
break
else:
query = previousQuery
url = getUrl(urlParsed, query)
arguments[urlIndex] = url
command = shlex.join(arguments)
if not changedSomething:
break
if removeCookies:
print('Removing cookies')
COOKIES_PREFIX = 'Cookie: '
COOKIES_PREFIX_LEN = len(COOKIES_PREFIX)
cookiesIndex = None
arguments = shlex.split(command)
for argumentsIndex, argument in enumerate(arguments):
# For Chromium support:
if argument[:COOKIES_PREFIX_LEN].title() == COOKIES_PREFIX:
cookiesIndex = argumentsIndex
arguments[cookiesIndex] = COOKIES_PREFIX + argument[COOKIES_PREFIX_LEN:]
break
if cookiesIndex is not None:
cookies = arguments[cookiesIndex]
previousCookiesParsedIndex = 0
while True:
changedSomething = False
cookiesParsed = cookies.replace(COOKIES_PREFIX, '').split('; ')
for cookiesParsedIndex, cookie in enumerate(cookiesParsed[previousCookiesParsedIndex:]):
cookiesParsedCopy = cookiesParsed[:]
printTryToRemove(cookie)
del cookiesParsedCopy[cookiesParsedIndex]
arguments[cookiesIndex] = COOKIES_PREFIX + '; '.join(cookiesParsedCopy)
command = shlex.join(arguments)
if isCommandStillFine(command):
printThatCommandIsStillFine(command)
changedSomething = True
previousCookiesParsedIndex = cookiesParsedIndex
cookies = '; '.join(cookiesParsedCopy)
break
else:
arguments[cookiesIndex] = COOKIES_PREFIX + '; '.join(cookiesParsed)
command = shlex.join(arguments)
if not changedSomething:
break
if removeRawData:
print('Removing raw data')
rawDataIndex = None
isJson = False
arguments = shlex.split(command)
for argumentsIndex, argument in enumerate(arguments):
if argumentsIndex > 0 and arguments[argumentsIndex - 1] == '--data-raw':
rawDataIndex = argumentsIndex
try:
json.loads(argument)
isJson = True
except:
pass
break
if rawDataIndex is not None:
rawData = arguments[rawDataIndex]
# Could interwine both cases but don't seem to clean much the code due to `getPaths` notably.
# Just firstly making a common function to all parts minimizer would make sense.
if not isJson:
previousRawDataPartsIndex = 0
while rawData != '':
changedSomething = False
rawDataParts = rawData.split('&')
for rawDataPartsIndex, rawDataPart in enumerate(rawDataParts[previousRawDataPartsIndex:]):
rawDataPartsCopy = copy.deepcopy(rawDataParts)
printTryToRemove(rawDataPartsCopy[rawDataPartsIndex])
del rawDataPartsCopy[rawDataPartsIndex]
arguments[rawDataIndex] = '&'.join(rawDataPartsCopy)
command = shlex.join(arguments)
if isCommandStillFine(command):
printThatCommandIsStillFine(command)
changedSomething = True
previousRawDataPartsIndex = rawDataPartsIndex
rawData = '&'.join(rawDataPartsCopy)
break
else:
arguments[rawDataIndex] = '&'.join(rawDataParts)
command = shlex.join(arguments)
if not changedSomething:
break
# JSON recursive case.
else:
def getPaths(d):
if isinstance(d, dict):
for key, value in d.items():
yield f'/{key}'
yield from (f'/{key}{p}' for p in getPaths(value))
elif isinstance(d, list):
for i, value in enumerate(d):
yield f'/{i}'
yield from (f'/{i}{p}' for p in getPaths(value))
# If a single unknown entry is necessary, then this algorithm seems to most efficiently goes from parents to children if necessary to remove other entries. Hence, it seems to proceed in a linear number of HTTPS requests and not a quadratic one.
# Try until no more change to remove unnecessary entries. If assume a logical behavior as just mentioned, would not a single loop iteration be enough? Not with current design, see (1).
previousPathsIndex = 0
while True:
changedSomething = False
rawDataParsed = json.loads(rawData)
# Note that the path goes from parents to children if necessary which is quite a wanted behavior to quickly remove useless chunks.
paths = getPaths(rawDataParsed)
# For all entries, copy current `rawData` and try to remove an entry.
for pathsIndex, path in enumerate(list(paths)[previousPathsIndex:]):
# Copy current `rawData`.
rawDataParsedCopy = copy.deepcopy(rawDataParsed)
# Remove an entry.
# Pay attention that integer keys need to be consider as such, so not as `str` as face a `list` instead of a `dict`.
entry = rawDataParsedCopy
pathParts = path[1:].split('/')
for pathPart in pathParts[:-1]:
pathPart = pathPart if not pathPart.isdigit() else int(pathPart)
entry = entry[pathPart]
lastPathPart = pathParts[-1]
lastPathPart = lastPathPart if not lastPathPart.isdigit() else int(lastPathPart)
printTryToRemove(path)
del entry[lastPathPart]
# Test if the removed entry was necessary.
arguments[rawDataIndex] = json.dumps(rawDataParsedCopy)
command = shlex.join(arguments)
# (1) If it was unnecessary, then reconsider paths excluding possible children paths of this unnecessary entry, ensuring optimized complexity it seems.
if isCommandStillFine(command):
printThatCommandIsStillFine(command)
changedSomething = True
previousPathsIndex = pathsIndex
rawData = json.dumps(rawDataParsedCopy)
break
# If it was necessary, we consider possible children paths of this necessary entry and other paths.
else:
arguments[rawDataIndex] = json.dumps(rawDataParsed)
command = shlex.join(arguments)
# If a loop iteration considering all paths, does not change anything, then the request cannot be minimized further.
if not changedSomething:
break
command = command.replace(' --compressed', '')
command = command.replace(' --data-raw \'\'', '')
HTTP_METHOD = ' -X POST'
if HTTP_METHOD in command:
previousCommand = command
command = command.replace(HTTP_METHOD, '')
if not isCommandStillFine(command):
command = previousCommand
# First test `print`ing, before potentially removing `minimized_curl` writing.
print(command)
writeCommand('minimized_curl', command)
os.remove(getCommandScript(PARTIALLY_MINIMIZED_CURL_SCRIPT_NAME))
================================================
FILE: tools/simplifyCURL.py
================================================
#!/usr/bin/env python
# in fact this tool can be used not only for YouTube but for all automatization based on HTTP
import os
import subprocess
import re
import json
isWSL = True
def getPath(path):
return path if not isWSL else path.replace('\\', '/').replace('C:', '/mnt/c')
path = getPath('C:\\Users\\Benjamin\\Desktop\\BensFolder\\DEV\\StackOverflow\\YouTube\\')
os.chdir(path)
def exec(cmd):
return subprocess.check_output(cmd, shell=True).decode('utf-8')
with open('curlCommand.txt') as f:
line = f.readline()
needle = 'isHearted'
#print(line)
'''
two approaches:
1. block manually some useless pattern
2. automatically remove some patterns while keeping needle retrieved
'''
# beautify: echo '{JSON}' | python -m json.tool
headersToRemove = ['Accept-Encoding', 'User-Agent', 'Accept', 'Accept-Language', 'X-Goog-Visitor-Id', 'Sec-Fetch-Dest', 'DNT', 'Connection', 'Origin', 'X-Youtube-Client-Version', 'X-Youtube-Client-Name', 'Cookie', 'Sec-Fetch-Mode', 'Sec-Fetch-Site', 'Pragma', 'Cache-Control', 'TE'] # likewise more general # 'Referer' required for youtube music
# or could make a whitelist instead
toRemoves = [' -X POST']
# could also make one big but doing like currently we give some semantical structure and can then for instance make bruteforce
toReplaces = [['curl', 'curl -s'], ['2.20220119.05.00', '2.2022011'], ['1.20220125.01.00', '1.2022012'], ['%3D', '=']] # 2.20220201.05.00 -> 2.20220201
#dataRawNeedle = ' --data-raw \''
contextToRemoves = ['adSignalsInfo', 'user', 'request', 'clickTracking', 'clientScreenNonce']
clientToRemoves = ['hl', 'gl', 'remoteHost', 'deviceMake', 'deviceModel', 'userAgent', 'osName', 'osVersion', 'originalUrl', 'platform', 'clientFormFactor', 'configInfo', 'browserName', 'browserVersion', 'visitorData', 'screenWidthPoints', 'screenHeightPoints', 'screenPixelDensity', 'screenDensityFloat', 'utcOffsetMinutes', 'userInterfaceTheme', 'mainAppWebInfo', 'timeZone', 'playerType', 'tvAppInfo', 'clientScreen']
generalToRemoves = ['webSearchboxStatsUrl', 'playbackContext', 'cpn', 'captionParams', 'playlistId']
def delete(variable, sub):
if sub in variable:
del(variable[sub])
def treat(line):
for headerToRemove in headersToRemove:
#line = re.sub(r"-H '" + headerToRemove + ": [^']'", '', line)
line = re.sub(f' -H\s+\'{headerToRemove}(.+?)\'', '', line) # can starts with r"XYZ"
for toRemove in toRemoves:
line = line.replace(toRemove, '')
for toReplace in toReplaces:
needle, replaceWith = toReplace
line = line.replace(needle, replaceWith)
#if dataRawNeedle in line:
regex = '--data-raw\s+\'(.+?)\''
search = re.search(regex, line)
if search:
dataRaw = search.group(1)
#print(dataRaw)
#lineParts = line.split(dataRawNeedle)
#linePartsParts = lineParts[1].split('\'')
#dataRaw = linePartsParts[0] # could also use a regex
dataRawJson = json.loads(dataRaw)
for contextToRemove in contextToRemoves:
delete(dataRawJson['context'], contextToRemove)
for clientToRemove in clientToRemoves:
delete(dataRawJson['context']['client'], clientToRemove) # could generalize with n arguments with ... notation
#del(dataRawJson['webSearchboxStatsUrl'])
for generalToRemove in generalToRemoves:
delete(dataRawJson, generalToRemove)
newDataRaw = json.dumps(dataRawJson, separators=(',', ':'))
#print(json.dumps(dataRawJson, separators=(',', ':'), indent = 4))
line = re.sub(regex, '--data-raw \'{newDataRaw}\'', line)
#line = lineParts[0] + dataRawNeedle + newDataRaw + '\''# + linePartsParts[1]
return line
cmd = treat(line)
print(cmd)
res = exec(cmd)
if needle in res:
print('working')
else:
print('not working:')
print(res)
#print(res)
================================================
FILE: videos.php
================================================
[
'method' => 'POST',
'header' => $headers,
'content' => $rawData,
]
];
return getJSON('https://' . ($music ? 'music' : 'www') . '.youtube.com/youtubei/v1/player?key=' . UI_KEY, $opts);
}
function getItem($id)
{
global $options;
$result = '';
if ($options['status'] || $options['contentDetails']) {
$rawData = [
'videoId' => $id,
'context' => [
'client' => [
'clientName' => 'WEB_EMBEDDED_PLAYER',
'clientVersion' => CLIENT_VERSION
]
]
];
$result = getJSONFunc(json_encode($rawData));
}
$item = [
'kind' => 'youtube#video',
'etag' => 'NotImplemented',
'id' => $id
];
if ($options['status']) {
$status = [
'embeddable' => $result['playabilityStatus']['status'] === 'OK',
'removedByTheUploader' => $result['playabilityStatus']['errorScreen']['playerErrorMessageRenderer']['subreason']['runs'][0]['text'] === 'This video has been removed by the uploader'
];
$item['status'] = $status;
}
if ($options['contentDetails']) {
$contentDetails = [
'duration' => intval($result['videoDetails']['lengthSeconds'])
];
$item['contentDetails'] = $contentDetails;
}
if ($options['music']) {
// music request doesn't provide embeddable info - could not make a request if only music and contentDetails
$rawData = [
'videoId' => $id,
'context' => [
'client' => [
'clientName' => 'WEB_REMIX',
'clientVersion' => CLIENT_VERSION
]
]
];
$resultMusic = getJSONFunc(json_encode($rawData), true);
$music = [
'available' => $resultMusic['playabilityStatus']['status'] === 'OK'
];
$item['music'] = $music;
}
if ($options['short']) {
$short = [
'available' => !isRedirection("https://www.youtube.com/shorts/$id")
];
$item['short'] = $short;
}
if ($options['impressions']) {
$headers = [
'x-origin: https://studio.youtube.com',
"authorization: SAPISIDHASH {$_GET['SAPISIDHASH']}",
'Content-Type:',
'Cookie: HSID=A4BqSu4moNA0Be1N9; SSID=AA0tycmNyGWo-Z_5v; APISID=a; SAPISID=zRbK-_14V7wIAieP/Ab_wY1sjLVrKQUM2c; SID=HwhYm6rJKOn_3R9oOrTNDJjpHIiq9Uos0F5fv4LPdMRSqyVHA1EDZwbLXo0kuUYAIN_MUQ.'
];
$rawData = [
'screenConfig' => [
'entity' => [
'videoId' => $id
]
],
'desktopState' => [
'tabId' => 'ANALYTICS_TAB_ID_REACH'
]
];
$opts = [
'http' => [
'method' => 'POST',
'header' => $headers,
'content' => json_encode($rawData),
]
];
$json = getJSON('https://studio.youtube.com/youtubei/v1/analytics_data/get_screen?key=' . UI_KEY, $opts);
$impressions = $json['cards'][0]['keyMetricCardData']['keyMetricTabs'][0]['primaryContent']['total'];
$item['impressions'] = $impressions;
}
if ($options['musics']) {
$opts = [
'http' => [
'header' => [
'Accept-Language: en',
]
]
];
$json = getJSONFromHTML("https://www.youtube.com/watch?v=$id", $opts);
$musics = [];
$engagementPanels = $json['engagementPanels'];
$cardsPath = 'engagementPanelSectionListRenderer/content/structuredDescriptionContentRenderer/items';
$engagementPanel = getFirstNodeContainingPath($engagementPanels, $cardsPath);
$cards = getValue($engagementPanel, $cardsPath);
$cardsPath = 'horizontalCardListRenderer/cards';
$structuredDescriptionContentRendererItem = getFirstNodeContainingPath($cards, $cardsPath);
$cards = getValue($structuredDescriptionContentRendererItem, $cardsPath);
foreach ($cards as $card) {
$videoAttributeViewModel = $card['videoAttributeViewModel'];
$music = [
'image' => $videoAttributeViewModel['image']['sources'][0]['url'],
'videoId' => $videoAttributeViewModel['onTap']['innertubeCommand']['watchEndpoint']['videoId'],
];
$runs = $videoAttributeViewModel['overflowMenuOnTap']['innertubeCommand']['confirmDialogEndpoint']['content']['confirmDialogRenderer']['dialogMessages'][0]['runs'];
for($runIndex = 0; $runIndex < count($runs); $runIndex += 4)
{
$field = strtolower($runs[$runIndex]['text']);
$value = $runs[$runIndex + 2]['text'];
$music[$field] = $value;
}
array_push($musics, $music);
}
$item['musics'] = $musics;
}
if(isset($_GET['clipId'])) {
$json = getJSONFromHTML("https://www.youtube.com/clip/$id", forceLanguage: true);
if ($options['id']) {
$videoId = $json['currentVideoEndpoint']['watchEndpoint']['videoId'];
$item['videoId'] = $videoId;
}
if ($options['clip']) {
$engagementPanels = $json['engagementPanels'];
$path = 'engagementPanelSectionListRenderer/onShowCommands/0/showEngagementPanelScrimAction/onClickCommands/0/commandExecutorCommand/commands/3/openPopupAction/popup/notificationActionRenderer/actionButton/buttonRenderer/command/commandExecutorCommand/commands/1/loopCommand';
foreach ($engagementPanels as $engagementPanel) {
if (doesPathExist($engagementPanel, $path)) {
$loopCommand = getValue($engagementPanel, $path);
$clipAttributionRenderer = $engagementPanel['engagementPanelSectionListRenderer']['content']['clipSectionRenderer']['contents'][0]['clipAttributionRenderer'];
$createdText = explode(' · ', $clipAttributionRenderer['createdText']['simpleText']);
$clip = [
'title' => $engagementPanel['engagementPanelSectionListRenderer']['content']['clipSectionRenderer']['contents'][0]['clipAttributionRenderer']['title']['runs'][0]['text'],
'startTimeMs' => intval($loopCommand['startTimeMs']),
'endTimeMs' => intval($loopCommand['endTimeMs']),
'viewCount' => getIntValue($createdText[0], 'view'),
'publishedAt' => $createdText[1],
];
$item['clip'] = $clip;
break;
}
}
}
}
if ($options['isPaidPromotion']) {
$json = getJSONFromHTML("https://www.youtube.com/watch?v=$id", scriptVariable: 'ytInitialPlayerResponse');
$isPaidPromotion = array_key_exists('paidContentOverlay', $json);
$item['isPaidPromotion'] = $isPaidPromotion;
}
if ($options['isPremium']) {
$json = getJSONFromHTML("https://www.youtube.com/watch?v=$id");
$isPremium = array_key_exists('offerModule', $json['contents']['twoColumnWatchNextResults']['secondaryResults']['secondaryResults']);
$item['isPremium'] = $isPremium;
}
if ($options['isMemberOnly']) {
$json = getJSONFromHTML("https://www.youtube.com/watch?v=$id", $opts);
$isMemberOnly = array_key_exists('badges', $json['contents']['twoColumnWatchNextResults']['results']['results']['contents'][0]['videoPrimaryInfoRenderer']);
$item['isMemberOnly'] = $isMemberOnly;
}
if ($options['mostReplayed']) {
$json = getJSONFromHTML("https://www.youtube.com/watch?v=$id", forceLanguage: true);
$mutations = $json['frameworkUpdates']['entityBatchUpdate']['mutations'];
$commonJsonPath = 'payload/macroMarkersListEntity/markersList';
$jsonPath = "$commonJsonPath/markersDecoration";
foreach($mutations as $mutation)
{
if(doesPathExist($mutation, $jsonPath))
{
break;
}
}
if(doesPathExist($mutation, $jsonPath))
{
$mostReplayed = getValue($mutation, $commonJsonPath);
foreach(array_keys($mostReplayed['markers']) as $markerIndex)
{
unset($mostReplayed['markers'][$markerIndex]['durationMillis']);
$mostReplayed['markers'][$markerIndex]['startMillis'] = intval($mostReplayed['markers'][$markerIndex]['startMillis']);
}
$timedMarkerDecorations = $mostReplayed['markersDecoration']['timedMarkerDecorations'];
foreach(array_keys($timedMarkerDecorations) as $timedMarkerDecorationIndex)
{
foreach(['label', 'icon', 'decorationTimeMillis'] as $timedMarkerDecorationKey)
{
unset($timedMarkerDecorations[$timedMarkerDecorationIndex][$timedMarkerDecorationKey]);
}
}
$mostReplayed['timedMarkerDecorations'] = $timedMarkerDecorations;
foreach(['markerType', 'markersMetadata', 'markersDecoration'] as $mostReplayedKey)
{
unset($mostReplayed[$mostReplayedKey]);
}
}
else
{
$mostReplayed = null;
}
$item['mostReplayed'] = $mostReplayed;
}
if ($options['qualities']) {
$json = getJSONFromHTML("https://www.youtube.com/watch?v=$id", scriptVariable: 'ytInitialPlayerResponse');
$qualities = [];
foreach ($json['streamingData']['adaptiveFormats'] as $quality) {
if (array_key_exists('qualityLabel', $quality)) {
$quality = $quality['qualityLabel'];
if (!in_array($quality, $qualities)) {
array_push($qualities, $quality);
}
}
}
$item['qualities'] = $qualities;
}
if ($options['captions']) {
$json = getJSONFromHTML("https://www.youtube.com/watch?v=$id", scriptVariable: 'ytInitialPlayerResponse', forceLanguage: true);
$captions = [];
foreach ($json['captions']['playerCaptionsTracklistRenderer']['captionTracks'] as $caption) {
array_push($captions, [
'name' => $caption['name']['simpleText'],
'languageCode' => $caption['languageCode'],
'kind' => $caption['kind'],
]);
}
$item['captions'] = $captions;
}
if ($options['location']) {
$json = getJSONFromHTML("https://www.youtube.com/watch?v=$id");
$location = $json['contents']['twoColumnWatchNextResults']['results']['results']['contents'][0]['videoPrimaryInfoRenderer']['superTitleLink']['runs'][0]['text'];
$item['location'] = $location;
}
if ($options['chapters']) {
$json = getJSONFromHTML("https://www.youtube.com/watch?v=$id");
$chapters = [];
$areAutoGenerated = false;
foreach ($json['engagementPanels'] as $engagementPanel) {
if ($engagementPanel['engagementPanelSectionListRenderer']['panelIdentifier'] === 'engagement-panel-macro-markers-description-chapters')
break;
}
$contents = $engagementPanel['engagementPanelSectionListRenderer']['content']['macroMarkersListRenderer']['contents'];
if ($contents !== null) {
$areAutoGenerated = array_key_exists('macroMarkersInfoItemRenderer', $contents[0]);
$contents = array_slice($contents, $areAutoGenerated ? 1 : 0);
foreach ($contents as $chapter) {
$chapter = $chapter['macroMarkersListItemRenderer'];
$timeInt = getIntFromDuration($chapter['timeDescription']['simpleText']);
array_push($chapters, [
'title' => $chapter['title']['simpleText'],
'time' => $timeInt,
'thumbnails' => $chapter['thumbnail']['thumbnails']
]);
}
}
$chapters = [
'areAutoGenerated' => $areAutoGenerated,
'chapters' => $chapters
];
$item['chapters'] = $chapters;
}
if ($options['isOriginal']) {
$json = getJson("https://www.youtube.com/watch?v=$id");
$isOriginal = doesPathExist($json, 'contents/twoColumnWatchNextResults/results/results/contents/1/videoSecondaryInfoRenderer/metadataRowContainer/metadataRowContainerRenderer/rows/2/metadataRowRenderer/contents/0/simpleText') or str_contains($html, 'xtags=' . urlencode('acont=original'));
$item['isOriginal'] = $isOriginal;
}
if ($options['isRestricted']) {
$opts = [
'http' => [
'header' => ['Cookie: PREF=f2=8000000'],
]
];
$json = getJSONFromHTML("https://www.youtube.com/watch?v=$id", $opts, 'ytInitialPlayerResponse');
$playabilityStatus = $json['playabilityStatus'];
$isRestricted = array_key_exists('isBlockedInRestrictedMode', $playabilityStatus);
$item['isRestricted'] = $isRestricted;
}
if ($options['snippet']) {
$json = getJSONFromHTML("https://www.youtube.com/watch?v=$id", forceLanguage: true);
$contents = $json['contents']['twoColumnWatchNextResults']['results']['results']['contents'];
// Note that `publishedAt` has a day only precision.
$publishedAt = strtotime($contents[0]['videoPrimaryInfoRenderer']['dateText']['simpleText']);
$description = $contents[1]['videoSecondaryInfoRenderer']['attributedDescription']['content'];
$snippet = [
'publishedAt' => $publishedAt,
'description' => $description,
'title' => $contents[0]['videoPrimaryInfoRenderer']['title']['runs'][0]['text'],
];
$item['snippet'] = $snippet;
}
if ($options['statistics']) {
$json = getJSONFromHTML("https://www.youtube.com/watch?v=$id", forceLanguage: true);
preg_match('/like this video along with ([\d,]+) other people/', $json['contents']['twoColumnWatchNextResults']['results']['results']['contents'][0]['videoPrimaryInfoRenderer']['videoActions']['menuRenderer']['topLevelButtons'][0]['segmentedLikeDislikeButtonViewModel']['likeButtonViewModel']['likeButtonViewModel']['toggleButtonViewModel']['toggleButtonViewModel']['defaultButtonViewModel']['buttonViewModel']['accessibilityText'], $viewCount);
$statistics = [
'viewCount' => getIntValue($json['playerOverlays']['playerOverlayRenderer']['videoDetails']['playerOverlayVideoDetailsRenderer']['subtitle']['runs'][2]['text'], 'view'),
'likeCount' => getIntValue($viewCount[1]),
];
$item['statistics'] = $statistics;
}
if ($options['activity']) {
$json = getJSONFromHTML("https://www.youtube.com/watch?v=$id", forceLanguage: true);
$activity = $json['contents']['twoColumnWatchNextResults']['results']['results']['contents'][1]['videoSecondaryInfoRenderer']['metadataRowContainer']['metadataRowContainerRenderer']['rows'][0]['richMetadataRowRenderer']['contents'][0]['richMetadataRenderer'];
$name = $activity['title']['simpleText'];
$year = $activity['subtitle']['simpleText'];
$thumbnails = $activity['thumbnail']['thumbnails'];
$channelId = $activity['endpoint']['browseEndpoint']['browseId'];
$activity = [
'name' => $name,
'year' => $year,
'thumbnails' => $thumbnails,
'channelId' => $channelId
];
$item['activity'] = $activity;
}
if ($options['explicitLyrics']) {
$json = getJSONFromHTML("https://www.youtube.com/watch?v=$id");
$rows = $json['contents']['twoColumnWatchNextResults']['results']['results']['contents'][1]['videoSecondaryInfoRenderer']['metadataRowContainer']['metadataRowContainerRenderer']['rows'];
$item['explicitLyrics'] = $rows !== null && end($rows)['metadataRowRenderer']['contents'][0]['simpleText'] === 'Explicit lyrics';
}
return $item;
}
function getAPI($ids)
{
$items = [];
foreach ($ids as $id) {
array_push($items, getItem($id));
}
$answer = [
'kind' => 'youtube#videoListResponse',
'etag' => 'NotImplemented',
'items' => $items
];
return json_encode($answer, JSON_PRETTY_PRINT);
}
================================================
FILE: ytPrivate/.htaccess
================================================
Deny from all
================================================
FILE: ytPrivate/test.php
================================================