1
0

Neu: Discovery+ Bridge

This commit is contained in:
Akamaru
2025-11-15 15:51:40 +01:00
parent 3d0d069309
commit 1806e106fa
2 changed files with 355 additions and 0 deletions

344
DiscoveryPlusBridge.php Normal file
View File

@@ -0,0 +1,344 @@
<?php
declare(strict_types=1);
class DiscoveryPlusBridge extends BridgeAbstract
{
const NAME = 'Discovery+ Bridge';
const URI = 'https://www.discoveryplus.com/';
const DESCRIPTION = 'RSS-Feed für Serien von Discovery+';
const CACHE_TIMEOUT = 21600; // 6h
const MAINTAINER = 'Akamaru';
const ICON = 'https://www.discoveryplus.com/favicon.ico';
const PARAMETERS = [
[
'show_id' => [
'name' => 'Show-ID',
'type' => 'text',
'title' => 'UUID der Serie (z.B. 91d2c345-d885-44b4-bb45-e696ea6c6f0d)',
'required' => true,
'exampleValue' => '91d2c345-d885-44b4-bb45-e696ea6c6f0d'
],
'season' => [
'name' => 'Staffel',
'type' => 'number',
'title' => 'Staffelnummer (leer = höchste gefundene Staffel)',
'required' => false,
'defaultValue' => ''
]
]
];
const TOKEN_URL = 'https://default.any-any.prd.api.discoveryplus.com/token?realm=bolt';
const API_BASE = 'https://default.any-any.prd.api.discoveryplus.com/cms/routes/show/';
private $showName = '';
private $token = null;
public function getName()
{
if (!empty($this->showName)) {
return $this->showName . ' - Discovery+';
}
return parent::getName();
}
/**
* Get an anonymous access token from Discovery+ API
*/
private function getToken()
{
// Check cache first
$cachedToken = $this->loadCacheValue('discovery_token', 3600); // Cache for 1 hour
if ($cachedToken !== null && is_string($cachedToken) && !empty($cachedToken)) {
return $cachedToken;
}
// Generate a random device ID
$deviceId = sprintf(
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000,
mt_rand(0, 0x3fff) | 0x8000,
mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0xffff)
);
$headers = [
'Content-Type: application/json',
'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'x-disco-client: WEB:NT 10.0:dplus:6.8.1',
'x-disco-params: realm=bolt,bid=dplus,features=ar',
'x-device-info: dplus/6.8.1 (desktop/desktop; Windows/NT 10.0; ' . $deviceId . '/unknown)',
'Origin: https://play.discoveryplus.com',
'Referer: https://play.discoveryplus.com/'
];
// Use CURLOPT_HEADER to get response headers
$opts = [
CURLOPT_HEADER => true,
CURLOPT_RETURNTRANSFER => true
];
try {
$fullResponse = getContents(self::TOKEN_URL, $headers, $opts);
} catch (Exception $e) {
returnServerError('Token request failed: ' . $e->getMessage());
}
if ($fullResponse === false || empty($fullResponse)) {
returnServerError('Empty response from Discovery+ token endpoint');
}
// Split headers and body
$parts = explode("\r\n\r\n", $fullResponse, 2);
if (count($parts) < 2) {
returnServerError('Invalid token response format - could not split headers and body');
}
$headerSection = $parts[0];
// Extract token from Set-Cookie header
if (preg_match('/Set-Cookie:.*?st=([^;]+)/i', $headerSection, $matches)) {
$token = $matches[1];
$this->saveCacheValue('discovery_token', $token);
return $token;
}
returnServerError('Could not find st token in response headers. Headers: ' . substr($headerSection, 0, 500));
}
/**
* Make an authenticated API request to Discovery+
*/
private function makeApiRequest($url)
{
if ($this->token === null) {
$this->token = $this->getToken();
}
if (empty($this->token)) {
returnServerError('Token is empty or invalid');
}
$headers = [
'Content-Type: application/json',
'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'x-disco-client: WEB:NT 10.0:dplus:6.8.1',
'x-disco-params: realm=bolt,bid=dplus,features=ar',
'x-device-info: dplus/6.8.1 (desktop/desktop; Windows/NT 10.0)',
'Cookie: st=' . $this->token,
'Origin: https://play.discoveryplus.com',
'Referer: https://play.discoveryplus.com/'
];
try {
$response = getContents($url, $headers);
} catch (Exception $e) {
// If we get a 400, try to get more details
$tokenInfo = is_string($this->token) ? 'Token length: ' . strlen($this->token) : 'Token type: ' . gettype($this->token);
returnServerError('API request failed: ' . $e->getMessage() . '. ' . $tokenInfo);
}
$data = json_decode($response, true);
if (!$data) {
returnServerError('Could not parse API response');
}
if (isset($data['errors'])) {
returnServerError('API Error: ' . json_encode($data['errors']));
}
return $data;
}
public function collectData()
{
$showId = $this->getInput('show_id');
if (empty($showId)) {
returnClientError('Show-ID ist erforderlich');
}
// Validate show ID format (should be a UUID)
if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $showId)) {
returnClientError('Invalid Show-ID format. Expected UUID (e.g., 91d2c345-d885-44b4-bb45-e696ea6c6f0d)');
}
// Get desired season (null = highest available)
$desiredSeason = $this->getInput('season');
if ($desiredSeason !== null && $desiredSeason !== '') {
$desiredSeason = (int)$desiredSeason;
} else {
$desiredSeason = null;
}
// Step 1: Get show data with filters to find available seasons
$showUrl = self::API_BASE . $showId . '?include=default&decorators=viewingHistory,isFavorite,contentAction,badges&page[items.size]=10';
$showData = $this->makeApiRequest($showUrl);
if (!isset($showData['data'])) {
returnServerError('Keine Show-Daten gefunden');
}
// Extract show name
foreach ($showData['included'] ?? [] as $item) {
if ($item['type'] === 'show' && $item['id'] === $showId) {
$this->showName = $item['attributes']['name'] ?? '';
break;
}
}
// Step 2: Find collection with season filters
$collectionId = null;
$availableSeasons = [];
foreach ($showData['included'] ?? [] as $item) {
if ($item['type'] === 'collection' && isset($item['attributes']['component']['filters'])) {
foreach ($item['attributes']['component']['filters'] as $filter) {
if ($filter['id'] === 'seasonNumber' && isset($filter['options'])) {
// Found the collection with season filters!
$collectionId = $item['id'];
foreach ($filter['options'] as $option) {
$seasonNum = (int)$option['id'];
$availableSeasons[$seasonNum] = $option;
}
break 2; // Break out of both loops
}
}
}
}
if (empty($availableSeasons)) {
returnServerError('Keine Staffeln für diese Serie gefunden');
}
if ($collectionId === null) {
returnServerError('Konnte Collection-ID nicht finden');
}
// Determine target season
if ($desiredSeason === null) {
$targetSeason = max(array_keys($availableSeasons));
} else {
$targetSeason = $desiredSeason;
}
if (!isset($availableSeasons[$targetSeason])) {
returnServerError("Staffel $targetSeason nicht gefunden. Verfügbare Staffeln: " . implode(', ', array_keys($availableSeasons)));
}
// Step 3: Get episodes for the selected season
$episodesUrl = "https://default.any-any.prd.api.discoveryplus.com/cms/collections/{$collectionId}?include=default&decorators=viewingHistory,isFavorite,contentAction,badges&pf[show.id]={$showId}&pf[seasonNumber]={$targetSeason}";
$episodesData = $this->makeApiRequest($episodesUrl);
if (!isset($episodesData['included'])) {
returnServerError('Keine Episoden in der API-Antwort gefunden');
}
// Extract episodes and build image lookup table
$episodes = [];
$images = [];
foreach ($episodesData['included'] as $item) {
if ($item['type'] === 'video') {
$episodes[] = $item;
} elseif ($item['type'] === 'image') {
$images[$item['id']] = $item['attributes'];
}
}
if (empty($episodes)) {
returnServerError("Keine Episoden für Staffel $targetSeason gefunden");
}
// Sort by episode number (descending - newest first)
usort($episodes, function ($a, $b) {
$epA = $a['attributes']['episodeNumber'] ?? 0;
$epB = $b['attributes']['episodeNumber'] ?? 0;
return $epB - $epA;
});
// Convert episodes to RSS items
foreach ($episodes as $episode) {
$item = [];
$attrs = $episode['attributes'];
// Build title
$seasonNum = $attrs['seasonNumber'] ?? 1;
$episodeNum = $attrs['episodeNumber'] ?? 0;
$episodeName = $attrs['name'] ?? '';
$item['title'] = sprintf(
'%s S%02dE%02d - %s',
$this->showName,
$seasonNum,
$episodeNum,
$episodeName
);
// Description
$description = $attrs['description'] ?? '';
$item['content'] = $description;
// Build episode URL
$alternateId = $attrs['alternateId'] ?? $episode['id'];
$item['uri'] = sprintf(
'https://www.discoveryplus.com/de/de/video/%s',
$alternateId
);
// Timestamp from air date
if (isset($attrs['airDate'])) {
$item['timestamp'] = strtotime($attrs['airDate']);
} elseif (isset($attrs['publishStart'])) {
$item['timestamp'] = strtotime($attrs['publishStart']);
}
// Author
$item['author'] = 'Discovery+';
// Extract image from relationships
$imageUrl = null;
if (isset($episode['relationships']['images']['data']) && is_array($episode['relationships']['images']['data'])) {
$imageRefs = $episode['relationships']['images']['data'];
// Try to find 'default' kind first
foreach ($imageRefs as $imageRef) {
$imageId = $imageRef['id'] ?? null;
if ($imageId && isset($images[$imageId])) {
$imageData = $images[$imageId];
if (isset($imageData['kind']) && $imageData['kind'] === 'default' && isset($imageData['src'])) {
$imageUrl = $imageData['src'];
break;
}
}
}
// If no 'default' image, take the first available
if (!$imageUrl && !empty($imageRefs)) {
$firstImageId = $imageRefs[0]['id'] ?? null;
if ($firstImageId && isset($images[$firstImageId]['src'])) {
$imageUrl = $images[$firstImageId]['src'];
}
}
}
if ($imageUrl) {
$item['enclosures'] = [$imageUrl];
$item['content'] = '<img src="' . htmlspecialchars($imageUrl) . '" /><br><br>' . $description;
}
$this->items[] = $item;
}
}
public function getIcon()
{
return self::ICON;
}
}

View File

@@ -34,6 +34,17 @@ Diese Sammlung enthält verschiedene Bridge-Implementierungen für RSS-Bridge, u
- **Parameter**:
- **Sender**: DMAX, TLC, HGTV
### Discovery+ Bridge (Von Akamaru)
- **Beschreibung**: RSS-Feed für Serien von Discovery+. Nutzt die offizielle Discovery+ API mit anonymer Authentifizierung.
- **Parameter**:
- **Show-ID**: Die UUID der Serie (z.B. 91d2c345-d885-44b4-bb45-e696ea6c6f0d aus der URL /show/91d2c345-d885-44b4-bb45-e696ea6c6f0d)
- **Staffel** (optional): Staffelnummer (z.B. 12). Wenn leer, wird die höchste verfügbare Staffel verwendet
- **Hinweise**:
- Feed-Items enthalten Episodenbeschreibung, Thumbnail (falls verfügbar) und direkten Link zur Episode
- Titel im Format "Serienname S01E01 - Episodentitel"
- Episoden sind nach Nummer sortiert (neueste zuerst)
- Autor ist auf "Discovery+" gesetzt
### Dubesor Bridge (Von Brawl, Claude)
- **Beschreibung**: First Impressions Blog von Dubesor zu LLM-Benchmarks