diff --git a/DiscoveryPlusBridge.php b/DiscoveryPlusBridge.php new file mode 100644 index 0000000..57ae929 --- /dev/null +++ b/DiscoveryPlusBridge.php @@ -0,0 +1,344 @@ + [ + '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; + } +} diff --git a/README.md b/README.md index 44d5a5d..44df096 100644 --- a/README.md +++ b/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