diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c23d97c --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/.claude diff --git a/README.md b/README.md index 2b62cc2..44d5a5d 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,15 @@ Diese Sammlung enthält verschiedene Bridge-Implementierungen für RSS-Bridge, u - **Title Only Mode**: Behält nur Titel und Link (entfernt alles andere) - **Item Limit**: Maximale Anzahl der zurückgegebenen Items (Standard: 20) +### RTL+ Bridge (Von Akamaru) +- **Beschreibung**: RSS-Feed für Serien von RTL+. Ruft automatisch alle Staffeln und Episoden einer Serie ab. Nutzt die offizielle RTL+ GraphQL API mit OAuth2-Authentifizierung (anonym). +- **Parameter**: + - **Serien-ID**: Die numerische ID der Serie (z.B. 948092 für "Die Nibelungen" aus der URL /video-tv/serien/die-nibelungen-kampf-der-koenigreiche-948092) +- **Hinweise**: + - Feed-Items enthalten Episodenbeschreibung, Thumbnail und direkten Link zur Episode + - Titel im Format "Serienname S01E01 - Episodentitel" + - Keine Datumsinformationen verfügbar (API liefert keine Veröffentlichungsdaten), aber jede Episode hat eine eindeutige ID + ### Snowbreak News Bridge (Von Akamaru) - **Beschreibung**: Zeigt die neuesten Nachrichten von Snowbreak: Containment Zone diff --git a/RTLPlusBridge.php b/RTLPlusBridge.php new file mode 100644 index 0000000..49d04c4 --- /dev/null +++ b/RTLPlusBridge.php @@ -0,0 +1,264 @@ + [ + 'name' => 'Serien-ID', + 'type' => 'text', + 'title' => 'ID der Serie (z.B. 948092 für Die Nibelungen)', + 'required' => true, + 'exampleValue' => '948092' + ] + ] + ]; + + private $feedName = null; + + private $authToken = null; + + private function getAuthToken() + { + if ($this->authToken !== null) { + return $this->authToken; + } + + // Hole anonymen/Guest Token von der Auth-API + $authUrl = 'https://auth.rtl.de/auth/realms/rtlplus/protocol/openid-connect/token'; + + $postData = http_build_query([ + 'grant_type' => 'client_credentials', + 'client_id' => 'anonymous-user', + 'client_secret' => '4bfeb73f-1c4a-4e9f-a7fa-96aa1ad3d94c' + ]); + + $headers = array('Content-Type: application/x-www-form-urlencoded'); + $opts = array( + CURLOPT_POST => 1, + CURLOPT_POSTFIELDS => $postData + ); + + $response = getContents($authUrl, $headers, $opts); + + if ($response === false || empty($response)) { + returnServerError('Could not get authentication token from RTL+'); + } + + $data = json_decode($response, true); + if (!$data || !isset($data['access_token'])) { + returnServerError('Invalid authentication response: ' . substr($response, 0, 200)); + } + + $this->authToken = $data['access_token']; + return $this->authToken; + } + + private function makeGraphQLRequest($operation, $hash, $variables) + { + $token = $this->getAuthToken(); + + $url = 'https://cdn.gateway.now-plus-prod.aws-cbc.cloud/graphql?' . + 'operationName=' . urlencode($operation) . + '&variables=' . urlencode(json_encode($variables)) . + '&extensions=' . urlencode(json_encode([ + 'persistedQuery' => [ + 'version' => 1, + 'sha256Hash' => $hash + ] + ])); + + $headers = array( + 'Authorization: Bearer ' . $token, + 'rtlplus-client-id: rci:rtlplus:web', + 'rtlplus-client-version: 2025.11.6.1', + 'Accept: application/json' + ); + + $response = getContents($url, $headers); + + if ($response === false || empty($response)) { + returnServerError('GraphQL request failed: ' . $operation); + } + + $data = json_decode($response, true); + + if (!$data) { + returnServerError('Invalid JSON response from RTL+ API: ' . substr($response, 0, 200)); + } + + if (isset($data['errors'])) { + $errorMsg = isset($data['errors'][0]['message']) ? $data['errors'][0]['message'] : 'Unknown GraphQL error'; + returnServerError('RTL+ API error: ' . $errorMsg); + } + + return $data['data'] ?? null; + } + public function collectData() + { + $seriesId = $this->getInput('series_id'); + if (empty($seriesId)) { + return; + } + + // Hole Format-Daten (Serie mit Staffeln) + $formatData = $this->makeGraphQLRequest( + 'Format', + 'd112638c0184ab5698af7b69532dfe2f12973f7af9cb137b9f70278130b1eafa', + ['id' => 'rrn:watch:videohub:format:' . $seriesId] + ); + + if (!$formatData || !isset($formatData['format'])) { + returnServerError('Could not load series data'); + } + + $format = $formatData['format']; + $seriesName = $format['title'] ?? 'Unknown Series'; + $seasons = $format['seasons'] ?? []; + + if (empty($seasons)) { + returnServerError('No seasons found for this series'); + } + + // Setze Feed-Namen + $this->feedName = $seriesName . ' | RTL+'; + + // Sammle alle Episoden von allen Staffeln + $allEpisodes = []; + + foreach ($seasons as $season) { + $seasonId = $season['id'] ?? null; + + if (!$seasonId) { + continue; + } + + // Hole Episoden dieser Staffel (mit limit 50) + $seasonData = $this->makeGraphQLRequest( + 'SeasonWithFormatAndEpisodes', + 'cc0fbbe17143f549a35efa6f8665ceb9b1cfae44b590f0b2381a9a304304c584', + [ + 'seasonId' => $seasonId, + 'offset' => 0, + 'limit' => 50 + ] + ); + + if (!$seasonData || !isset($seasonData['season']['episodes'])) { + continue; + } + + $episodes = $seasonData['season']['episodes']; + $seasonNumber = $seasonData['season']['ordinal'] ?? null; + + foreach ($episodes as $episode) { + $episode['_seasonNumber'] = $seasonNumber; + $allEpisodes[] = $episode; + } + } + + if (empty($allEpisodes)) { + returnServerError('No episodes found'); + } + + // Sortiere Episoden nach Broadcast-Datum (neueste zuerst), oder nach Episodennummer + usort($allEpisodes, function ($a, $b) { + $dateA = isset($a['recentBroadcastDate']) ? strtotime($a['recentBroadcastDate']) : 0; + $dateB = isset($b['recentBroadcastDate']) ? strtotime($b['recentBroadcastDate']) : 0; + + // Wenn beide Daten vorhanden sind, sortiere nach Datum + if ($dateA > 0 && $dateB > 0) { + return $dateB - $dateA; + } + + // Sonst nach Staffel und Episode sortieren + $seasonA = $a['_seasonNumber'] ?? 0; + $seasonB = $b['_seasonNumber'] ?? 0; + $epA = $a['number'] ?? 0; + $epB = $b['number'] ?? 0; + + if ($seasonA != $seasonB) { + return $seasonB - $seasonA; + } + return $epB - $epA; + }); + + // Erstelle RSS Items + foreach ($allEpisodes as $episode) { + $item = []; + + // Titel + $episodeTitle = $episode['title'] ?? ''; + $episodeNumber = $episode['number'] ?? null; + $seasonNumber = $episode['_seasonNumber'] ?? null; + + // Formatiere Titel mit S00E00 wenn möglich + if (is_numeric($seasonNumber) && is_numeric($episodeNumber)) { + $seasonNum = str_pad($seasonNumber, 2, '0', STR_PAD_LEFT); + $epNum = str_pad($episodeNumber, 2, '0', STR_PAD_LEFT); + $item['title'] = sprintf('%s S%sE%s - %s', $seriesName, $seasonNum, $epNum, $episodeTitle); + } else { + $item['title'] = sprintf('%s - %s', $seriesName, $episodeTitle); + if ($seasonNumber) { + $item['title'] = sprintf('%s [Staffel %s] - %s', $seriesName, $seasonNumber, $episodeTitle); + } + } + + // Beschreibung + $description = $episode['descriptionV2'] ?? ''; + $item['content'] = '

' . htmlspecialchars($description) . '

'; + + // Thumbnail hinzufügen + $imageUrl = null; + if (isset($episode['watchImages']['default']['absoluteUri'])) { + $imageUrl = $episode['watchImages']['default']['absoluteUri']; + } elseif (isset($episode['watchImages']['portrait']['absoluteUri'])) { + $imageUrl = $episode['watchImages']['portrait']['absoluteUri']; + } + + if ($imageUrl) { + $item['content'] .= '

' . htmlspecialchars($episodeTitle) . '

'; + $item['enclosures'] = [$imageUrl]; + } + + // URL konstruieren (zur Episode-Seite) + if (isset($episode['urlData']['watchPath'])) { + $item['uri'] = 'https://plus.rtl.de' . $episode['urlData']['watchPath']; + } else { + $item['uri'] = 'https://plus.rtl.de/video-tv/serien/s-' . $seriesId; + } + + // Datum + if (isset($episode['recentBroadcastDate']) && !empty($episode['recentBroadcastDate'])) { + $item['timestamp'] = strtotime($episode['recentBroadcastDate']); + } + + // UID + $item['uid'] = $episode['id'] ?? uniqid(); + + // Autor + $item['author'] = 'RTL+'; + + // Kategorien (Staffel) + if ($seasonNumber) { + $item['categories'] = ['Staffel ' . $seasonNumber]; + } + + $this->items[] = $item; + } + } + + public function getName() + { + if (!is_null($this->feedName)) { + return $this->feedName; + } + return parent::getName(); + } +}