[ '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'] = '

' . $description; } $this->items[] = $item; } } public function getIcon() { return self::ICON; } }