Neu: Discovery+ Bridge
This commit is contained in:
344
DiscoveryPlusBridge.php
Normal file
344
DiscoveryPlusBridge.php
Normal 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;
|
||||
}
|
||||
}
|
||||
11
README.md
11
README.md
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user