From 0f863e18f288d7cc00690122d9620a78866afd58 Mon Sep 17 00:00:00 2001 From: Ivan Mirchev Date: Fri, 13 Feb 2026 15:14:18 +0200 Subject: [PATCH] Add progress bar, timeline and seeking for replay/catchup content - Implement GetStreamTimes() for both live TV (EPG-based progress) and replay (fixed programme duration) so Kodi displays a proper timeline - Force ffmpegdirect for replay content with catchup buffer times, programme start/end, and playback_as_live=false - Add StreamRedirectProxy: a local HTTP server that generates fresh AES-CBC encrypted URLs for each seek position, since the t= timestamp is inside the encrypted payload and cannot be appended as a plain query parameter - Use 302 redirects so FFmpeg resolves relative HLS segment URLs against the CDN, not the local proxy - Generate a new session ID (UUID) per seek to prevent the CDN from rejecting requests when the previous session is still considered active - Cache encrypted URLs for identical timestamp retries - Compute server time offset once at stream start for accurate ctime values without per-seek API calls - Track current playback state (live/replay, channel, start/end times) for GetStreamTimes() reporting Co-Authored-By: Claude Opus 4.6 --- CMakeLists.txt | 2 + src/PVREon.cpp | 208 ++++++++++++++++++-- src/PVREon.h | 13 +- src/StreamRedirectProxy.cpp | 374 ++++++++++++++++++++++++++++++++++++ src/StreamRedirectProxy.h | 65 +++++++ 5 files changed, 639 insertions(+), 23 deletions(-) create mode 100644 src/StreamRedirectProxy.cpp create mode 100644 src/StreamRedirectProxy.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 8923ea4..fbffce0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -19,6 +19,7 @@ set(EON_SOURCES src/pkcs7_padding.c src/Utils.cpp src/Settings.cpp + src/StreamRedirectProxy.cpp src/PVREon.cpp ) @@ -33,6 +34,7 @@ set(EON_HEADERS src/pkcs7_padding.h src/Utils.h src/Settings.h + src/StreamRedirectProxy.h src/PVREon.h src/Globals.h ) diff --git a/src/PVREon.cpp b/src/PVREon.cpp index 187852d..9c2172e 100644 --- a/src/PVREon.cpp +++ b/src/PVREon.cpp @@ -764,55 +764,72 @@ bool CPVREon::HandleSession(bool start, int cid, int epg_id) void CPVREon::SetStreamProperties(std::vector& properties, const std::string& url, - const bool& realtime, const bool& playTimeshiftBuffer, const bool& isLive/*, - time_t starttime, time_t endtime*/) + const bool& realtime, const bool& playTimeshiftBuffer, const bool& isLive, + time_t starttime, time_t endtime) { - kodi::Log(ADDON_LOG_DEBUG, "[PLAY STREAM] url: %s", url.c_str()); + kodi::Log(ADDON_LOG_DEBUG, "[PLAY STREAM] url: %s isLive: %s", url.c_str(), isLive ? "true" : "false"); properties.emplace_back(PVR_STREAM_PROPERTY_STREAMURL, url); properties.emplace_back(PVR_STREAM_PROPERTY_ISREALTIMESTREAM, realtime ? "true" : "false"); int inputstream = m_settings->GetInputstream(); - if (inputstream == INPUTSTREAM_ADAPTIVE) + // For replay/catchup: always use ffmpegdirect (it supports catchup buffer times + // and programme duration, which inputstream.adaptive does not). + // For live: use whichever the user selected. + bool useFFmpegDirect = !isLive || (inputstream == INPUTSTREAM_FFMPEGDIRECT); + bool useAdaptive = isLive && (inputstream == INPUTSTREAM_ADAPTIVE); + + if (useAdaptive) { if (!Utils::CheckInputstreamInstalledAndEnabled("inputstream.adaptive")) { kodi::Log(ADDON_LOG_DEBUG, "inputstream.adaptive selected but not installed or enabled"); return; } - kodi::Log(ADDON_LOG_DEBUG, "...using inputstream.adaptive"); + kodi::Log(ADDON_LOG_DEBUG, "...using inputstream.adaptive (live)"); properties.emplace_back(PVR_STREAM_PROPERTY_INPUTSTREAM, "inputstream.adaptive"); properties.emplace_back("inputstream.adaptive.manifest_type", "hls"); - // properties.emplace_back("inputstream.adaptive.original_audio_language", "bs"); - // properties.emplace_back("inputstream.adaptive.stream_selection_type", "adaptive"); - // properties.emplace_back("inputstream.adaptive.stream_selection_type", "manual-osd"); properties.emplace_back("inputstream.adaptive.stream_selection_type", "fixed-res"); properties.emplace_back("inputstream.adaptive.chooser_resolution_max", "4K"); - //properties.emplace_back("inputstream.adaptive.stream_selection_type", "fixed-res"); properties.emplace_back("inputstream.adaptive.manifest_headers", "User-Agent=" + EonParameters[m_platform].user_agent); - // properties.emplace_back("inputstream.adaptive.manifest_update_parameter", "full"); - } else if (inputstream == INPUTSTREAM_FFMPEGDIRECT) + } + else if (useFFmpegDirect) { if (!Utils::CheckInputstreamInstalledAndEnabled("inputstream.ffmpegdirect")) { - kodi::Log(ADDON_LOG_DEBUG, "inputstream.ffmpegdirect selected but not installed or enabled"); + kodi::Log(ADDON_LOG_DEBUG, "inputstream.ffmpegdirect not installed or enabled, falling back to adaptive"); + // Fallback to adaptive if ffmpegdirect not available + if (Utils::CheckInputstreamInstalledAndEnabled("inputstream.adaptive")) + { + properties.emplace_back(PVR_STREAM_PROPERTY_INPUTSTREAM, "inputstream.adaptive"); + properties.emplace_back("inputstream.adaptive.manifest_type", "hls"); + properties.emplace_back("inputstream.adaptive.stream_selection_type", "fixed-res"); + properties.emplace_back("inputstream.adaptive.chooser_resolution_max", "4K"); + properties.emplace_back("inputstream.adaptive.manifest_headers", "User-Agent=" + EonParameters[m_platform].user_agent); + } + properties.emplace_back(PVR_STREAM_PROPERTY_MIMETYPE, "application/x-mpegURL"); return; } - kodi::Log(ADDON_LOG_DEBUG, "...using inputstream.ffmpegdirect"); + kodi::Log(ADDON_LOG_DEBUG, "...using inputstream.ffmpegdirect (isLive=%s)", isLive ? "true" : "false"); properties.emplace_back(PVR_STREAM_PROPERTY_INPUTSTREAM, "inputstream.ffmpegdirect"); properties.emplace_back("inputstream.ffmpegdirect.manifest_type", "hls"); - properties.emplace_back("inputstream.ffmpegdirect.is_realtime_stream", "true"); + properties.emplace_back("inputstream.ffmpegdirect.is_realtime_stream", isLive ? "true" : "false"); properties.emplace_back("inputstream.ffmpegdirect.stream_mode", isLive ? "timeshift" : "catchup"); -/* - if (!isLive) { - properties.emplace_back("inputstream.ffmpegdirect.catchup_buffer_start_time", std::to_string(starttime)); - properties.emplace_back("inputstream.ffmpegdirect.catchup_buffer_end_time", std::to_string(endtime)); + + if (!isLive && starttime > 0 && endtime > starttime) + { + kodi::Log(ADDON_LOG_DEBUG, "Setting catchup times: start=%lld end=%lld duration=%lld", + (long long)starttime, (long long)endtime, (long long)(endtime - starttime)); properties.emplace_back("inputstream.ffmpegdirect.programme_start_time", std::to_string(starttime)); properties.emplace_back("inputstream.ffmpegdirect.programme_end_time", std::to_string(endtime)); + properties.emplace_back("inputstream.ffmpegdirect.catchup_buffer_start_time", std::to_string(starttime)); + properties.emplace_back("inputstream.ffmpegdirect.catchup_buffer_end_time", std::to_string(endtime)); + properties.emplace_back("inputstream.ffmpegdirect.playback_as_live", "false"); } -*/ - } else { + } + else + { kodi::Log(ADDON_LOG_DEBUG, "Unknown inputstream detected"); } properties.emplace_back(PVR_STREAM_PROPERTY_MIMETYPE, "application/x-mpegURL"); @@ -991,6 +1008,14 @@ PVR_ERROR CPVREon::GetEPGTagStreamProperties( { if (channel.iUniqueId == tag.GetUniqueChannelId()) { + // Track replay playback for GetStreamTimes() + m_stream_is_live = false; + m_stream_channel_id = channel.iUniqueId; + m_stream_start_time = tag.GetStartTime(); + m_stream_end_time = tag.GetEndTime(); + kodi::Log(ADDON_LOG_DEBUG, "EPG Tag playback: start=%lld end=%lld duration=%lld", + (long long)m_stream_start_time, (long long)m_stream_end_time, + (long long)(m_stream_end_time - m_stream_start_time)); return GetStreamProperties(channel, properties, tag.GetStartTime(), /*tag.GetEndTime(),*/ false); } } @@ -1150,7 +1175,66 @@ PVR_ERROR CPVREon::GetStreamProperties( kodi::Log(ADDON_LOG_DEBUG, "Encrypted Stream URL -> %s", enc_url.c_str()); - SetStreamProperties(properties, enc_url, true, false, isLive/*, starttime, endtime*/); + SetStreamProperties(properties, enc_url, isLive, false, isLive, starttime, m_stream_end_time); + + // For replay: start a local redirect proxy that generates fresh encrypted + // URLs for each seek position. The t= timestamp must be INSIDE the AES + // payload, so we can't just append it as a query parameter. + if (!isLive && m_stream_end_time > starttime) + { + // Compute server time offset so the proxy can generate accurate ctime + // values without making API calls for each seek. + std::string serverTimeStr = GetTime(); + int64_t serverTimeMs = 0; + int64_t localTimeMs = static_cast(time(nullptr)) * 1000; + if (!serverTimeStr.empty()) + { + try { serverTimeMs = std::stoll(serverTimeStr); } + catch (...) { serverTimeMs = localTimeMs; } + } + else + { + serverTimeMs = localTimeMs; + } + int64_t serverTimeOffsetMs = serverTimeMs - localTimeMs; + kodi::Log(ADDON_LOG_DEBUG, + "Server time offset: %lldms (server=%lld local=%lld)", + static_cast(serverTimeOffsetMs), + static_cast(serverTimeMs), + static_cast(localTimeMs)); + + StreamParams sp; + sp.publishingPoint = channel.publishingPoints[0].publishingPoint; + sp.streamingProfile = streaming_profile; + sp.serviceProvider = m_service_provider; + sp.streamUser = m_settings->GetEonStreamUser(); + sp.streamKey = m_settings->GetEonStreamKey(); + sp.serverIp = currentServer.ip; + sp.serverHostname = currentServer.hostname; + sp.deviceNumber = m_settings->GetEonDeviceNumber(); + sp.sig = channel.sig; + sp.sessionId = m_session_id; + sp.aaEnabled = channel.aaEnabled; + sp.platform = m_platform; + sp.maxBitrate = rndbitrate; + sp.serverTimeOffsetMs = serverTimeOffsetMs; + + m_redirectProxy.Stop(); + m_redirectProxy.SetStreamParams(sp); + if (m_redirectProxy.Start()) + { + std::string catchup_url = "http://127.0.0.1:" + + std::to_string(m_redirectProxy.GetPort()) + + "/stream?t={utc}"; + properties.emplace_back("inputstream.ffmpegdirect.catchup_url_format_string", catchup_url); + properties.emplace_back("inputstream.ffmpegdirect.default_url", enc_url); + kodi::Log(ADDON_LOG_DEBUG, "Catchup URL via local proxy: %s", catchup_url.c_str()); + } + else + { + kodi::Log(ADDON_LOG_ERROR, "Failed to start redirect proxy, seeking won't work"); + } + } for (auto& prop : properties) kodi::Log(ADDON_LOG_DEBUG, "Name: %s Value: %s", prop.GetName().c_str(), prop.GetValue().c_str()); @@ -1165,6 +1249,33 @@ PVR_ERROR CPVREon::GetChannelStreamProperties( EonChannel addonChannel; if (GetChannel(channel, addonChannel)) { if (addonChannel.subscribed) { + // Track current live playback for GetStreamTimes() + m_stream_is_live = true; + m_stream_channel_id = addonChannel.iUniqueId; + m_stream_start_time = 0; + m_stream_end_time = 0; + + // Fetch current EPG programme to get start/end times for progress bar + time_t now = time(nullptr); + std::string epgUrl = m_api + "v1/events/epg" + + "?cid=" + std::to_string(addonChannel.iUniqueId) + + "&fromTime=" + std::to_string(now) + "000" + + "&toTime=" + std::to_string(now + 1) + "000"; + rapidjson::Document epgDoc; + if (GetPostJson(epgUrl, "", epgDoc)) + { + std::string cid = std::to_string(addonChannel.iUniqueId); + if (epgDoc.HasMember(cid.c_str()) && epgDoc[cid.c_str()].IsArray() + && epgDoc[cid.c_str()].Size() > 0) + { + const rapidjson::Value& epgItem = epgDoc[cid.c_str()][0]; + m_stream_start_time = (time_t)(Utils::JsonInt64OrZero(epgItem, "startTime") / 1000); + m_stream_end_time = (time_t)(Utils::JsonInt64OrZero(epgItem, "endTime") / 1000); + kodi::Log(ADDON_LOG_DEBUG, "Live EPG: start=%lld end=%lld", + (long long)m_stream_start_time, (long long)m_stream_end_time); + } + } + return GetStreamProperties(addonChannel, properties, 0,/* 0,*/ true); } kodi::Log(ADDON_LOG_DEBUG, "Channel not subscribed"); @@ -1238,6 +1349,61 @@ PVR_ERROR CPVREon::GetSignalStatus(int channelUid, kodi::addon::PVRSignalStatus& return PVR_ERROR_NO_ERROR; } +PVR_ERROR CPVREon::GetStreamTimes(kodi::addon::PVRStreamTimes& times) +{ + // PTS values are in microseconds + static const int64_t PTS_PER_SECOND = 1000000; + + if (m_stream_is_live) + { + // For live TV: show progress within a timeshift-like window. + // startTime = current wall-clock reference, ptsEnd grows as time passes. + // If we don't have EPG times, use a 2-hour default window from "now". + time_t now = time(nullptr); + + if (m_stream_start_time > 0 && m_stream_end_time > m_stream_start_time) + { + // We have EPG programme bounds + times.SetStartTime(m_stream_start_time); + times.SetPTSStart(0); + times.SetPTSBegin(0); + int64_t duration = static_cast(now - m_stream_start_time); + if (duration < 0) + duration = 0; + times.SetPTSEnd(duration * PTS_PER_SECOND); + kodi::Log(ADDON_LOG_DEBUG, "GetStreamTimes LIVE: start=%lld now=%lld elapsed=%lld", + (long long)m_stream_start_time, (long long)now, (long long)duration); + } + else + { + // No EPG info yet - provide a basic window so progress bar appears + times.SetStartTime(now); + times.SetPTSStart(0); + times.SetPTSBegin(0); + times.SetPTSEnd(0); + } + } + else + { + // For replay/catchup: fixed programme duration + if (m_stream_start_time == 0 || m_stream_end_time == 0) + return PVR_ERROR_NOT_IMPLEMENTED; + + int64_t duration = static_cast(m_stream_end_time - m_stream_start_time); + if (duration <= 0) + return PVR_ERROR_NOT_IMPLEMENTED; + + times.SetStartTime(m_stream_start_time); + times.SetPTSStart(0); + times.SetPTSBegin(0); + times.SetPTSEnd(duration * PTS_PER_SECOND); + kodi::Log(ADDON_LOG_DEBUG, "GetStreamTimes REPLAY: start=%lld end=%lld duration=%lld sec", + (long long)m_stream_start_time, (long long)m_stream_end_time, (long long)duration); + } + + return PVR_ERROR_NO_ERROR; +} + PVR_ERROR CPVREon::GetRecordingsAmount(bool deleted, int& amount) { return PVR_ERROR_NO_ERROR; diff --git a/src/PVREon.h b/src/PVREon.h index 9d95d01..8b0854c 100644 --- a/src/PVREon.h +++ b/src/PVREon.h @@ -11,6 +11,7 @@ #include #include "Settings.h" +#include "StreamRedirectProxy.h" #include "http/HttpClient.h" #include "rapidjson/document.h" @@ -149,6 +150,7 @@ class ATTR_DLL_LOCAL CPVREon : public kodi::addon::CAddonBase, PVR_ERROR GetRecordingStreamProperties( const kodi::addon::PVRRecording& recording, std::vector& properties) override; + PVR_ERROR GetStreamTimes(kodi::addon::PVRStreamTimes& times) override; ADDON_STATUS SetSetting(const std::string& settingName, const std::string& settingValue); @@ -161,8 +163,8 @@ class ATTR_DLL_LOCAL CPVREon : public kodi::addon::CAddonBase, private: void SetStreamProperties(std::vector& properties, const std::string& url, - const bool& realtime, const bool& playTimeshiftBuffer, const bool& isLive /*, - time_t starttime, time_t endtime*/); + const bool& realtime, const bool& playTimeshiftBuffer, const bool& isLive, + time_t starttime, time_t endtime); PVR_ERROR GetStreamProperties( const EonChannel& channel, @@ -190,6 +192,12 @@ class ATTR_DLL_LOCAL CPVREon : public kodi::addon::CAddonBase, std::string m_session_id; std::string m_stream_key; std::string m_stream_un; + + // Current playback tracking for progress bar / timeline + time_t m_stream_start_time = 0; + time_t m_stream_end_time = 0; + bool m_stream_is_live = false; + int m_stream_channel_id = 0; std::string m_service_provider; std::string m_support_web; // std::string m_ss_access; @@ -205,6 +213,7 @@ class ATTR_DLL_LOCAL CPVREon : public kodi::addon::CAddonBase, HttpClient *m_httpClient; CSettings* m_settings; + StreamRedirectProxy m_redirectProxy; std::string GetTime(); int getBitrate(const bool isRadio, const int id); diff --git a/src/StreamRedirectProxy.cpp b/src/StreamRedirectProxy.cpp new file mode 100644 index 0000000..d482156 --- /dev/null +++ b/src/StreamRedirectProxy.cpp @@ -0,0 +1,374 @@ +/* + * Copyright (C) 2011-2021 Team Kodi (https://kodi.tv) + * + * SPDX-License-Identifier: GPL-2.0-or-later + * See LICENSE.md for more information. + */ + +#include "StreamRedirectProxy.h" +#include "Base64.h" + +#define CBC 1 +#include "aes.hpp" +#include "pkcs7_padding.hpp" + +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +static const uint8_t PROXY_BLOCK_SIZE = 16; +static const int PROXY_PLATFORM_ANDROIDTV = 1; +static const std::string PROXY_PLAYER = "m3u8"; +static const std::string PROXY_CONN_TYPE_ETHERNET = "ETHERNET"; +static const std::string PROXY_CONN_TYPE_BROWSER = "BROWSER"; + +static std::string proxy_generate_uuid() +{ + static const char* hex = "0123456789abcdef"; + unsigned seed = static_cast( + std::chrono::system_clock::now().time_since_epoch().count()); + std::mt19937 gen(seed); + std::uniform_int_distribution dis(0, 15); + + std::string uuid = "xxxxxxxx-xxxx-4xxx-8xxx-xxxxxxxxxxxx"; + for (char& c : uuid) + { + if (c == 'x') + c = hex[dis(gen)]; + } + return uuid; +} + +static std::string proxy_urlsafeencode(const std::string& s) +{ + std::string t = s; + std::replace(t.begin(), t.end(), '+', '-'); + std::replace(t.begin(), t.end(), '/', '_'); + t.erase(std::remove(t.begin(), t.end(), '='), t.end()); + return t; +} + +static std::string proxy_urlsafedecode(const std::string& s) +{ + std::string t = s; + std::replace(t.begin(), t.end(), '-', '+'); + std::replace(t.begin(), t.end(), '_', '/'); + return t; +} + +static std::string proxy_aes_encrypt_cbc(const std::string& iv_str, + const std::string& key, + const std::string& plaintext) +{ + int dlen = static_cast(plaintext.size()); + int klen = static_cast(key.size()); + int dlenu = dlen + PROXY_BLOCK_SIZE - (dlen % PROXY_BLOCK_SIZE); + + uint8_t hexarray[dlenu]; + uint8_t kexarray[klen]; + uint8_t iv[klen]; + + memset(hexarray, 0, dlenu); + memset(kexarray, 0, klen); + memset(iv, 0, klen); + + for (int i = 0; i < dlen; i++) + hexarray[i] = static_cast(plaintext[i]); + for (int i = 0; i < klen; i++) + { + kexarray[i] = static_cast(key[i]); + iv[i] = static_cast(iv_str[i]); + } + + pkcs7_padding_pad_buffer(hexarray, dlen, sizeof(hexarray), PROXY_BLOCK_SIZE); + + struct AES_ctx ctx; + AES_init_ctx_iv(&ctx, kexarray, iv); + AES_CBC_encrypt_buffer(&ctx, hexarray, dlenu); + + std::ostringstream convert; + for (int i = 0; i < dlenu; i++) + convert << hexarray[i]; + + return convert.str(); +} + +StreamRedirectProxy::StreamRedirectProxy() {} + +StreamRedirectProxy::~StreamRedirectProxy() +{ + Stop(); +} + +bool StreamRedirectProxy::Start() +{ + if (m_running) + return true; + + m_serverSocket = socket(AF_INET, SOCK_STREAM, 0); + if (m_serverSocket < 0) + { + kodi::Log(ADDON_LOG_ERROR, "StreamRedirectProxy: failed to create socket"); + return false; + } + + int opt = 1; + setsockopt(m_serverSocket, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); + + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = 0; // Let the OS pick a port + + if (bind(m_serverSocket, reinterpret_cast(&addr), sizeof(addr)) < 0) + { + kodi::Log(ADDON_LOG_ERROR, "StreamRedirectProxy: failed to bind"); + close(m_serverSocket); + m_serverSocket = -1; + return false; + } + + socklen_t addrLen = sizeof(addr); + getsockname(m_serverSocket, reinterpret_cast(&addr), &addrLen); + m_port = ntohs(addr.sin_port); + + if (listen(m_serverSocket, 5) < 0) + { + kodi::Log(ADDON_LOG_ERROR, "StreamRedirectProxy: failed to listen"); + close(m_serverSocket); + m_serverSocket = -1; + return false; + } + + m_running = true; + m_thread = std::thread(&StreamRedirectProxy::ServerThread, this); + + kodi::Log(ADDON_LOG_INFO, "StreamRedirectProxy: listening on 127.0.0.1:%d", m_port); + return true; +} + +void StreamRedirectProxy::Stop() +{ + if (!m_running) + return; + + m_running = false; + + if (m_serverSocket >= 0) + { + shutdown(m_serverSocket, SHUT_RDWR); + close(m_serverSocket); + m_serverSocket = -1; + } + + if (m_thread.joinable()) + m_thread.join(); + + kodi::Log(ADDON_LOG_INFO, "StreamRedirectProxy: stopped"); +} + +void StreamRedirectProxy::SetStreamParams(const StreamParams& params) +{ + std::lock_guard lock(m_paramsMutex); + m_params = params; +} + +void StreamRedirectProxy::ServerThread() +{ + while (m_running) + { + fd_set readfds; + FD_ZERO(&readfds); + FD_SET(m_serverSocket, &readfds); + + struct timeval tv; + tv.tv_sec = 1; + tv.tv_usec = 0; + + int ret = select(m_serverSocket + 1, &readfds, nullptr, nullptr, &tv); + if (ret <= 0) + continue; + + int clientSocket = accept(m_serverSocket, nullptr, nullptr); + if (clientSocket < 0) + continue; + + char buf[2048]; + int bytesRead = static_cast(recv(clientSocket, buf, sizeof(buf) - 1, 0)); + if (bytesRead <= 0) + { + close(clientSocket); + continue; + } + buf[bytesRead] = '\0'; + + // Parse the timestamp from "GET /stream?t= HTTP/..." + std::string request(buf); + time_t timestamp = 0; + + size_t tPos = request.find("t="); + if (tPos != std::string::npos) + { + tPos += 2; + size_t tEnd = request.find_first_of("& \r\n", tPos); + std::string tStr = request.substr(tPos, tEnd - tPos); + timestamp = static_cast(std::stoll(tStr)); + } + + if (timestamp <= 0) + { + std::string response = "HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n"; + send(clientSocket, response.c_str(), response.size(), 0); + close(clientSocket); + continue; + } + + // Check cache: return same URL for retries of the same timestamp + std::string streamUrl; + { + std::lock_guard cacheLock(m_cacheMutex); + auto now = std::chrono::steady_clock::now(); + auto elapsed = std::chrono::duration_cast(now - m_lastSeekTime).count(); + + if (timestamp == m_lastSeekTimestamp && elapsed < 10000 && !m_lastStreamUrl.empty()) + { + streamUrl = m_lastStreamUrl; + kodi::Log(ADDON_LOG_DEBUG, + "StreamRedirectProxy: cache hit for t=%lld (elapsed=%lldms)", + static_cast(timestamp), static_cast(elapsed)); + } + } + + if (streamUrl.empty()) + { + // Generate a fresh session ID for each new seek to avoid CDN + // rejecting the request due to the previous session still being + // considered "active" on the server side. + streamUrl = BuildEncryptedUrl(timestamp); + + { + std::lock_guard cacheLock(m_cacheMutex); + m_lastSeekTimestamp = timestamp; + m_lastStreamUrl = streamUrl; + m_lastSeekTime = std::chrono::steady_clock::now(); + } + + kodi::Log(ADDON_LOG_DEBUG, + "StreamRedirectProxy: seek to t=%lld -> new encrypted URL (new session)", + static_cast(timestamp)); + } + + // Use 302 redirect to the encrypted URL. FFmpeg follows the redirect + // and uses the redirect target as the base URL for resolving relative + // sub-playlist/segment URLs. This avoids 3-level playlist nesting issues + // that occurred with the master playlist approach. + std::string response = "HTTP/1.1 302 Found\r\n" + "Location: " + streamUrl + "\r\n" + "Content-Length: 0\r\n" + "Connection: close\r\n\r\n"; + send(clientSocket, response.c_str(), response.size(), 0); + close(clientSocket); + } +} + +std::string StreamRedirectProxy::BuildEncryptedUrl(time_t timestamp) +{ + StreamParams params; + { + std::lock_guard lock(m_paramsMutex); + params = m_params; + } + + // Use server time offset to produce accurate ctime (matches server clock) + int64_t localMs = static_cast(time(nullptr)) * 1000; + int64_t serverMs = localMs + params.serverTimeOffsetMs; + std::string ctime = std::to_string(serverMs); + + // Generate a fresh session ID for each seek so the CDN doesn't see + // a conflict with the previous stream that may still be "active". + std::string sessionId = proxy_generate_uuid(); + + kodi::Log(ADDON_LOG_DEBUG, + "StreamRedirectProxy: ctime=%s (offset=%lldms) t=%lld session=%s", + ctime.c_str(), static_cast(params.serverTimeOffsetMs), + static_cast(timestamp), sessionId.c_str()); + + std::string plain_aes; + + if (params.platform == PROXY_PLATFORM_ANDROIDTV) + { + plain_aes = "channel=" + params.publishingPoint + ";" + "stream=" + params.streamingProfile + ";" + "sp=" + params.serviceProvider + ";" + "u=" + params.streamUser + ";" + "m=" + params.serverIp + ";" + "device=" + params.deviceNumber + ";" + "ctime=" + ctime + ";" + "lang=eng;player=" + PROXY_PLAYER + ";" + "aa=" + (params.aaEnabled ? "true" : "false") + ";" + "conn=" + PROXY_CONN_TYPE_ETHERNET + ";" + "minvbr=100;" + "ss=" + params.streamKey + ";" + "session=" + sessionId + ";" + "maxvbr=" + std::to_string(params.maxBitrate) + + ";t=" + std::to_string(static_cast(timestamp)) + "000;"; + } + else + { + plain_aes = "channel=" + params.publishingPoint + ";" + "stream=" + params.streamingProfile + ";" + "sp=" + params.serviceProvider + ";" + "u=" + params.streamUser + ";" + "ss=" + params.streamKey + ";" + "minvbr=100;adaptive=true;player=" + PROXY_PLAYER + ";" + "sig=" + params.sig + ";" + "session=" + sessionId + ";" + "m=" + params.serverIp + ";" + "device=" + params.deviceNumber + ";" + "ctime=" + ctime + ";" + "conn=" + PROXY_CONN_TYPE_BROWSER + ";" + "t=" + std::to_string(static_cast(timestamp)) + "000;" + "aa=" + (params.aaEnabled ? "true" : "false"); + } + + kodi::Log(ADDON_LOG_DEBUG, "StreamRedirectProxy: plain_aes=%s", plain_aes.c_str()); + + std::string key = base64_decode(proxy_urlsafedecode(params.streamKey)); + + std::ostringstream ivConvert; + for (int i = 0; i < PROXY_BLOCK_SIZE; i++) + ivConvert << static_cast(rand()); + std::string iv_str = ivConvert.str(); + + std::string enc_str = proxy_aes_encrypt_cbc(iv_str, key, plain_aes); + + std::string enc_url = "https://" + params.serverHostname + + "/stream?i=" + proxy_urlsafeencode(base64_encode(iv_str.c_str(), iv_str.length())) + + "&a=" + proxy_urlsafeencode(base64_encode(enc_str.c_str(), enc_str.length())); + + if (params.platform == PROXY_PLATFORM_ANDROIDTV) + enc_url += "&lang=eng"; + + enc_url += "&sp=" + params.serviceProvider + + "&u=" + params.streamUser + + "&player=" + PROXY_PLAYER + + "&session=" + sessionId; + + if (params.platform != PROXY_PLATFORM_ANDROIDTV) + enc_url += "&sig=" + params.sig; + + kodi::Log(ADDON_LOG_DEBUG, "StreamRedirectProxy: enc_url=%s", enc_url.c_str()); + + return enc_url; +} diff --git a/src/StreamRedirectProxy.h b/src/StreamRedirectProxy.h new file mode 100644 index 0000000..ef447ed --- /dev/null +++ b/src/StreamRedirectProxy.h @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2011-2021 Team Kodi (https://kodi.tv) + * + * SPDX-License-Identifier: GPL-2.0-or-later + * See LICENSE.md for more information. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include + +struct StreamParams +{ + std::string publishingPoint; + std::string streamingProfile; + std::string serviceProvider; + std::string streamUser; + std::string streamKey; + std::string serverIp; + std::string serverHostname; + std::string deviceNumber; + std::string sig; + std::string sessionId; + bool aaEnabled = false; + int platform = 0; + unsigned int maxBitrate = 0; + // Server time offset: serverTimeMs - localTimeMs at the time params were created. + // Used to generate accurate ctime values without calling the server API. + int64_t serverTimeOffsetMs = 0; +}; + +class StreamRedirectProxy +{ +public: + StreamRedirectProxy(); + ~StreamRedirectProxy(); + + bool Start(); + void Stop(); + int GetPort() const { return m_port; } + + void SetStreamParams(const StreamParams& params); + +private: + void ServerThread(); + std::string BuildEncryptedUrl(time_t timestamp); + + int m_port = 0; + int m_serverSocket = -1; + std::atomic m_running{false}; + std::thread m_thread; + std::mutex m_paramsMutex; + StreamParams m_params; + + // Throttle: cache the last generated URL to reuse for rapid repeated seeks + std::mutex m_cacheMutex; + time_t m_lastSeekTimestamp = 0; + std::string m_lastStreamUrl; + std::chrono::steady_clock::time_point m_lastSeekTime; +};