From 64a9b7170e3997f41657050e9d46de8361e7fdcc Mon Sep 17 00:00:00 2001 From: Yordan Cheyrekov Date: Tue, 10 Mar 2026 19:03:12 +0200 Subject: [PATCH 1/4] modified: README.md modified: pvr.eon/resources/language/resource.language.bg_BG/strings.po modified: pvr.eon/resources/language/resource.language.de_de/strings.po modified: pvr.eon/resources/language/resource.language.en_gb/strings.po modified: pvr.eon/resources/language/resource.language.en_us/strings.po modified: pvr.eon/resources/settings.xml modified: src/PVREon.cpp modified: src/PVREon.h modified: src/Settings.cpp modified: src/Settings.h modified: src/http/Curl.cpp modified: src/http/HttpClient.cpp new file: tools/debug/analyze-eon-har.sh new file: tools/docker/android-aarch64.Dockerfile new file: tools/docker/build-android-aarch64.sh new file: tools/docker/build-linux-amd64.sh new file: tools/docker/container-build-android-aarch64.sh new file: tools/docker/container-build-linux.sh new file: tools/docker/linux-amd64.Dockerfile --- README.md | 25 + .../resource.language.bg_BG/strings.po | 4 + .../resource.language.de_de/strings.po | 4 + .../resource.language.en_gb/strings.po | 4 + .../resource.language.en_us/strings.po | 4 + pvr.eon/resources/settings.xml | 5 + src/PVREon.cpp | 1194 +++++++++++++++-- src/PVREon.h | 80 +- src/Settings.cpp | 14 + src/Settings.h | 2 + src/http/Curl.cpp | 6 +- src/http/HttpClient.cpp | 150 ++- tools/debug/analyze-eon-har.sh | 195 +++ tools/docker/android-aarch64.Dockerfile | 51 + tools/docker/build-android-aarch64.sh | 46 + tools/docker/build-linux-amd64.sh | 38 + .../docker/container-build-android-aarch64.sh | 119 ++ tools/docker/container-build-linux.sh | 70 + tools/docker/linux-amd64.Dockerfile | 20 + 19 files changed, 1870 insertions(+), 161 deletions(-) create mode 100755 tools/debug/analyze-eon-har.sh create mode 100644 tools/docker/android-aarch64.Dockerfile create mode 100755 tools/docker/build-android-aarch64.sh create mode 100755 tools/docker/build-linux-amd64.sh create mode 100755 tools/docker/container-build-android-aarch64.sh create mode 100755 tools/docker/container-build-linux.sh create mode 100644 tools/docker/linux-amd64.Dockerfile diff --git a/README.md b/README.md index ed5ef09..885e258 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,31 @@ This is the EON.tv PVR client addon for Kodi. It provides Kodi integration for t 4. `cmake -DADDONS_TO_BUILD=pvr.eon -DADDON_SRC_PREFIX=../.. -DCMAKE_BUILD_TYPE=Debug -DCMAKE_INSTALL_PREFIX=../../xbmc/addons -DPACKAGE_ZIP=1 ../../xbmc/cmake/addons` 5. `make` +### Local checkout note + +- `ADDON_SRC_PREFIX` only redirects the source path. Kodi still requires an addon definition for `pvr.eon` via `ADDONS_DEFINITION_DIR` or `xbmc/cmake/addons/addons/pvr.eon/pvr.eon.txt`. +- The current `xbmc` `master` branch tracks Kodi `Piers` (v22). The `Omega` branch of this addon should be built against an `xbmc` Omega checkout. +- A reproducible Docker build for Linux x86_64 is available at `tools/docker/build-linux-amd64.sh`. +- A reproducible Docker build for Android `aarch64` is available at `tools/docker/build-android-aarch64.sh`. + +### Android aarch64 via Docker + +1. Ensure the sibling `xbmc` checkout has `origin/Omega`. +2. Run `./tools/docker/build-android-aarch64.sh`. +3. Use the generated zip from `build/docker-android-aarch64/zips/pvr.eon+android-aarch64/`. + +The Android Docker image installs: +- Android SDK command-line tools +- Android platform `android-36` +- Android build-tools `36.0.0` +- Android NDK `28.2.13676358` + +The build uses Kodi's `tools/depends` step only to generate the Android binary-addon toolchain, then cross-builds `pvr.eon` through `xbmc/cmake/addons`. + +For older Android Kodi builds, you can match the Kodi tag and Android NDK used by that Kodi release. Example for Kodi 21.2: + +`XBMC_REF=21.2-Omega ANDROID_NDK_VERSION=21.4.7075529 ANDROID_NDK_API=21 ./tools/docker/build-android-aarch64.sh` + ## Notes - Tested building it for Linux and Android / x86 and aarch64 diff --git a/pvr.eon/resources/language/resource.language.bg_BG/strings.po b/pvr.eon/resources/language/resource.language.bg_BG/strings.po index 3a6433b..68e49df 100644 --- a/pvr.eon/resources/language/resource.language.bg_BG/strings.po +++ b/pvr.eon/resources/language/resource.language.bg_BG/strings.po @@ -236,6 +236,10 @@ msgctxt "#30056" msgid "Възрастово ограничение" msgstr "" +msgctxt "#30057" +msgid "Експериментално собствено архивно стриймване" +msgstr "" + msgctxt "#30500" msgid "Грешка при входящия поток" msgstr "" diff --git a/pvr.eon/resources/language/resource.language.de_de/strings.po b/pvr.eon/resources/language/resource.language.de_de/strings.po index 551bc48..96ef9d4 100644 --- a/pvr.eon/resources/language/resource.language.de_de/strings.po +++ b/pvr.eon/resources/language/resource.language.de_de/strings.po @@ -244,6 +244,10 @@ msgctxt "#30056" msgid "Age Rating" msgstr "Altersfreigabe" +msgctxt "#30057" +msgid "Experimental native archive streaming" +msgstr "Experimentelles natives Archiv-Streaming" + msgctxt "#30500" msgid "Inputstream error" msgstr "Inputstream Fehler" diff --git a/pvr.eon/resources/language/resource.language.en_gb/strings.po b/pvr.eon/resources/language/resource.language.en_gb/strings.po index b556150..a4d07e4 100644 --- a/pvr.eon/resources/language/resource.language.en_gb/strings.po +++ b/pvr.eon/resources/language/resource.language.en_gb/strings.po @@ -232,6 +232,10 @@ msgctxt "#30056" msgid "Age Rating" msgstr "" +msgctxt "#30057" +msgid "Experimental native archive streaming" +msgstr "" + msgctxt "#30500" msgid "Inputstream error" diff --git a/pvr.eon/resources/language/resource.language.en_us/strings.po b/pvr.eon/resources/language/resource.language.en_us/strings.po index 929c875..4fb35da 100644 --- a/pvr.eon/resources/language/resource.language.en_us/strings.po +++ b/pvr.eon/resources/language/resource.language.en_us/strings.po @@ -236,6 +236,10 @@ msgctxt "#30056" msgid "Age Rating" msgstr "" +msgctxt "#30057" +msgid "Experimental native archive streaming" +msgstr "" + msgctxt "#30500" msgid "Inputstream error" msgstr "" diff --git a/pvr.eon/resources/settings.xml b/pvr.eon/resources/settings.xml index 8c4dd16..a46b01d 100644 --- a/pvr.eon/resources/settings.xml +++ b/pvr.eon/resources/settings.xml @@ -207,6 +207,11 @@ + + 0 + false + + diff --git a/src/PVREon.cpp b/src/PVREon.cpp index 187852d..dda0fce 100644 --- a/src/PVREon.cpp +++ b/src/PVREon.cpp @@ -10,7 +10,13 @@ #include "Globals.h" #include +#include +#include +#include +#include +#include +#include #include #include #include "Utils.h" @@ -26,6 +32,177 @@ static const uint8_t block_size = 16; +namespace +{ +constexpr int64_t PVR_TIME_BASE = 1000000; +constexpr time_t PENDING_PLAYBACK_TTL_SECONDS = 15; +constexpr int64_t NATIVE_VIRTUAL_UNITS_PER_SECOND = 1000; +constexpr time_t NATIVE_SEEK_RESTART_EPSILON_SECONDS = 2; +constexpr int NATIVE_POLL_RETRY_COUNT = 10; +constexpr auto NATIVE_POLL_RETRY_DELAY = std::chrono::milliseconds(200); + +int64_t MonotonicNowMs() +{ + return std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); +} + +const char* BoolState(bool value) +{ + return value ? "true" : "false"; +} + +const char* PlatformName(int platform) +{ + switch (platform) + { + case PLATFORM_WEB: + return "web"; + case PLATFORM_ANDROIDTV: + return "androidtv"; + default: + return "unknown"; + } +} + +const char* InputstreamName(int inputstream) +{ + switch (inputstream) + { + case INPUTSTREAM_ADAPTIVE: + return "inputstream.adaptive"; + case INPUTSTREAM_FFMPEGDIRECT: + return "inputstream.ffmpegdirect"; + default: + return "unknown"; + } +} + +std::string DescribeValue(const std::string& value) +{ + return value.empty() ? "empty" : "set(len=" + std::to_string(value.size()) + ")"; +} + +std::string PreviewForLog(std::string value) +{ + std::replace(value.begin(), value.end(), '\n', ' '); + std::replace(value.begin(), value.end(), '\r', ' '); + if (value.size() > 200) + value = value.substr(0, 200) + "..."; + return value; +} + +size_t CountOccurrences(const std::string& haystack, const std::string& needle) +{ + if (needle.empty()) + return 0; + + size_t count = 0; + size_t pos = 0; + while ((pos = haystack.find(needle, pos)) != std::string::npos) + { + ++count; + pos += needle.size(); + } + return count; +} + +std::string Trim(std::string value) +{ + while (!value.empty() && (value.back() == '\r' || value.back() == '\n' || value.back() == ' ' || value.back() == '\t')) + value.pop_back(); + + size_t start = 0; + while (start < value.size() && (value[start] == ' ' || value[start] == '\t')) + ++start; + + return value.substr(start); +} + +std::string ResolvePlaylistUrl(const std::string& baseUrl, const std::string& childUrl) +{ + if (childUrl.empty()) + return ""; + + if (childUrl.rfind("https://", 0) == 0 || childUrl.rfind("http://", 0) == 0) + return childUrl; + + const size_t schemePos = baseUrl.find("://"); + if (schemePos == std::string::npos) + return childUrl; + + const std::string scheme = baseUrl.substr(0, schemePos); + const size_t authorityStart = schemePos + 3; + const size_t pathStart = baseUrl.find('/', authorityStart); + const std::string authority = pathStart == std::string::npos + ? baseUrl.substr(authorityStart) + : baseUrl.substr(authorityStart, pathStart - authorityStart); + + if (childUrl.rfind("//", 0) == 0) + return scheme + ":" + childUrl; + + if (childUrl.front() == '/') + return scheme + "://" + authority + childUrl; + + const size_t lastSlash = baseUrl.rfind('/'); + if (lastSlash == std::string::npos || lastSlash < authorityStart) + return scheme + "://" + authority + "/" + childUrl; + + return baseUrl.substr(0, lastSlash + 1) + childUrl; +} + +std::string FirstVariantPlaylistUrl(const std::string& manifestBody, const std::string& baseUrl) +{ + size_t pos = 0; + while (pos < manifestBody.size()) + { + const size_t lineEnd = manifestBody.find('\n', pos); + const std::string line = Trim(manifestBody.substr(pos, lineEnd == std::string::npos ? std::string::npos : lineEnd - pos)); + pos = lineEnd == std::string::npos ? manifestBody.size() : lineEnd + 1; + + if (line.rfind("#EXT-X-STREAM-INF", 0) != 0) + continue; + + while (pos < manifestBody.size()) + { + const size_t uriEnd = manifestBody.find('\n', pos); + const std::string uri = Trim(manifestBody.substr(pos, uriEnd == std::string::npos ? std::string::npos : uriEnd - pos)); + pos = uriEnd == std::string::npos ? manifestBody.size() : uriEnd + 1; + if (uri.empty() || uri[0] == '#') + continue; + + return ResolvePlaylistUrl(baseUrl, uri); + } + } + + return ""; +} + +std::vector ExtractMediaSegmentUrls(const std::string& playlistBody, + const std::string& baseUrl) +{ + std::vector urls; + + size_t pos = 0; + while (pos < playlistBody.size()) + { + const size_t lineEnd = playlistBody.find('\n', pos); + const std::string line = + Trim(playlistBody.substr(pos, lineEnd == std::string::npos ? std::string::npos : lineEnd - pos)); + pos = lineEnd == std::string::npos ? playlistBody.size() : lineEnd + 1; + + if (line.empty() || line[0] == '#') + continue; + + urls.emplace_back(ResolvePlaylistUrl(baseUrl, line)); + } + + return urls; +} + +} // namespace + /*********************************************************** * PVR Client AddOn specific public library functions ***********************************************************/ @@ -144,10 +321,13 @@ bool CPVREon::GetPostJson(const std::string& url, const std::string& body, rapid doc.Parse(result.c_str()); if ((doc.GetParseError()) || (statusCode != 200 && statusCode != 206)) { - kodi::Log(ADDON_LOG_ERROR, "Failed to get JSON for URL %s and body %s. Status code: %i", url.c_str(), body.c_str(), statusCode); + kodi::Log(ADDON_LOG_ERROR, + "Failed to get JSON for URL %s. requestBodyLen=%zu responseLen=%zu parseError=%u status=%i", + url.c_str(), body.size(), result.size(), doc.GetParseError(), statusCode); + if (!result.empty()) + kodi::Log(ADDON_LOG_DEBUG, "JSON failure response preview: %s", PreviewForLog(result).c_str()); if (!doc.GetParseError()) { - kodi::Log(ADDON_LOG_ERROR, "Result is: %s", result.c_str()); if (doc.HasMember("error") && doc.HasMember("errorMessage")) { std::string title = Utils::JsonStringOrEmpty(doc, "error"); @@ -340,6 +520,44 @@ bool CPVREon::GetDeviceFromSerial() return true; } +bool CPVREon::RefreshDeviceRegistration() +{ + kodi::Log(ADDON_LOG_INFO, "Refreshing stored device registration."); + + m_settings->SetSetting("accesstoken", ""); + m_settings->SetSetting("refreshtoken", ""); + m_settings->SetSetting("subscriberid", ""); + m_settings->SetSetting("streamkey", ""); + m_settings->SetSetting("streamuser", ""); + m_settings->SetSetting("deviceid", ""); + m_settings->SetSetting("devicenumber", ""); + + m_device_id.clear(); + m_device_number.clear(); + m_subscriber_id.clear(); + + m_device_serial = m_settings->GetEonDeviceSerial(); + if (m_device_serial.empty()) + { + m_device_serial = m_httpClient->GetUUID(); + m_settings->SetSetting("deviceserial", m_device_serial); + kodi::Log(ADDON_LOG_DEBUG, "Generated replacement device serial: %s", m_device_serial.c_str()); + } + + if (!GetDeviceFromSerial()) + { + kodi::Log(ADDON_LOG_ERROR, "Failed to refresh stored device registration."); + return false; + } + + m_settings->SetSetting("deviceid", m_device_id); + m_settings->SetSetting("devicenumber", m_device_number); + kodi::Log(ADDON_LOG_INFO, "Device registration refreshed. deviceId=%s deviceNumber=%s", + DescribeValue(m_device_id).c_str(), + DescribeValue(m_device_number).c_str()); + return true; +} + bool CPVREon::GetServers() { std::string url = m_api + "v1/servers"; @@ -509,7 +727,7 @@ bool CPVREon::GetCategories(const bool isRadio) CPVREon::CPVREon() : m_settings(new CSettings()) { - m_settings->Load(); + const bool settings_loaded = m_settings->Load(); m_httpClient = new HttpClient(m_settings); m_platform = m_settings->GetPlatform(); @@ -518,6 +736,31 @@ CPVREon::CPVREon() : srand(time(nullptr)); + kodi::Log(ADDON_LOG_INFO, + "Starting pvr.eon. platform=%s provider=%i tv=%s radio=%s groups=%s hideUnsubscribed=%s shortNames=%s ageRating=%i inputstream=%i settingsLoaded=%s settingsValid=%s", + PlatformName(m_platform), + m_settings->GetEonServiceProvider(), + BoolState(m_settings->IsTVenabled()), + BoolState(m_settings->IsRadioenabled()), + BoolState(m_settings->IsGroupsenabled()), + BoolState(m_settings->HideUnsubscribed()), + BoolState(m_settings->UseShortNames()), + m_settings->GetAgeRating(), + m_settings->GetInputstream(), + BoolState(settings_loaded), + BoolState(m_settings->VerifySettings())); + kodi::Log(ADDON_LOG_DEBUG, + "Startup settings state. username=%s password=%s access=%s refresh=%s generic=%s deviceId=%s deviceNumber=%s deviceSerial=%s subscriberId=%s", + DescribeValue(m_settings->GetEonUsername()).c_str(), + DescribeValue(m_settings->GetEonPassword()).c_str(), + DescribeValue(m_settings->GetEonAccessToken()).c_str(), + DescribeValue(m_settings->GetEonRefreshToken()).c_str(), + DescribeValue(m_settings->GetGenericAccessToken()).c_str(), + DescribeValue(m_settings->GetEonDeviceID()).c_str(), + DescribeValue(m_settings->GetEonDeviceNumber()).c_str(), + DescribeValue(m_settings->GetEonDeviceSerial()).c_str(), + DescribeValue(m_settings->GetEonSubscriberID()).c_str()); + if (GetCDNInfo()) { std::string cdn_identifier = GetBrandIdentifier(); kodi::Log(ADDON_LOG_DEBUG, "CDN Identifier: %s", cdn_identifier.c_str()); @@ -533,6 +776,11 @@ CPVREon::CPVREon() : m_device_id = m_settings->GetEonDeviceID(); m_device_number = m_settings->GetEonDeviceNumber(); + m_device_serial = m_settings->GetEonDeviceSerial(); + kodi::Log(ADDON_LOG_DEBUG, "Stored device state before init. deviceId=%s deviceNumber=%s deviceSerial=%s", + DescribeValue(m_device_id).c_str(), + DescribeValue(m_device_number).c_str(), + DescribeValue(m_device_serial).c_str()); /* m_ss_identity = m_settings->GetSSIdentity(); if (m_ss_identity.empty()) { @@ -548,7 +796,6 @@ CPVREon::CPVREon() : m_settings->SetSetting("deviceserial", m_device_serial); } */ - m_device_serial = m_settings->GetEonDeviceSerial(); if (m_device_serial.empty()) { m_device_serial = m_httpClient->GetUUID(); m_settings->SetSetting("deviceserial", m_device_serial); @@ -558,37 +805,88 @@ CPVREon::CPVREon() : m_settings->SetSetting("deviceid", m_device_id); m_settings->SetSetting("devicenumber", m_device_number); } + } else { + kodi::Log(ADDON_LOG_INFO, "Using stored device registration. deviceId=%s deviceNumber=%s", + DescribeValue(m_device_id).c_str(), + DescribeValue(m_device_number).c_str()); } bool allgood = true; + bool retried_with_fresh_device = false; m_subscriber_id = m_settings->GetEonSubscriberID(); if (m_subscriber_id.empty()) { - if (GetHouseholds()) { + const bool households_ok = GetHouseholds(); + kodi::Log(ADDON_LOG_INFO, "Startup step GetHouseholds=%s subscriberId=%s", + BoolState(households_ok), DescribeValue(m_subscriber_id).c_str()); + if (households_ok) { m_settings->SetSetting("subscriberid", m_subscriber_id); + } else if (m_platform == PLATFORM_WEB && !m_device_number.empty()) { + kodi::Log(ADDON_LOG_INFO, "Retrying startup after refreshing stored web device registration."); + retried_with_fresh_device = RefreshDeviceRegistration(); + if (retried_with_fresh_device && GetHouseholds()) { + kodi::Log(ADDON_LOG_INFO, "Startup retry GetHouseholds=true subscriberId=%s", + DescribeValue(m_subscriber_id).c_str()); + m_settings->SetSetting("subscriberid", m_subscriber_id); + } else { + allgood = false; + } } else { allgood = false; } + } else { + kodi::Log(ADDON_LOG_INFO, "Using stored subscriber ID. subscriberId=%s", + DescribeValue(m_subscriber_id).c_str()); } if (m_service_provider.empty() || m_support_web.empty()) { allgood = GetServiceProvider(); + kodi::Log(ADDON_LOG_INFO, "Startup step GetServiceProvider=%s serviceProvider=%s supportApi=%s", + BoolState(allgood), DescribeValue(m_service_provider).c_str(), + DescribeValue(m_support_web).c_str()); + if (!allgood && m_platform == PLATFORM_WEB && !retried_with_fresh_device && !m_device_number.empty()) { + kodi::Log(ADDON_LOG_INFO, "Retrying service provider lookup after refreshing stored web device registration."); + retried_with_fresh_device = RefreshDeviceRegistration(); + if (retried_with_fresh_device) + { + allgood = GetServiceProvider(); + kodi::Log(ADDON_LOG_INFO, "Startup retry GetServiceProvider=%s serviceProvider=%s supportApi=%s", + BoolState(allgood), DescribeValue(m_service_provider).c_str(), + DescribeValue(m_support_web).c_str()); + } + } } if (allgood) { allgood = GetRenderingProfiles(); + kodi::Log(ADDON_LOG_INFO, "Startup step GetRenderingProfiles=%s totalProfiles=%zu", + BoolState(allgood), m_rendering_profiles.size()); } if (allgood) { allgood = GetServers(); + kodi::Log(ADDON_LOG_INFO, "Startup step GetServers=%s liveServers=%zu timeshiftServers=%zu", + BoolState(allgood), m_live_servers.size(), m_timeshift_servers.size()); } if (m_settings->IsTVenabled() && (allgood)) { allgood = GetCategories(false); + kodi::Log(ADDON_LOG_INFO, "Startup step GetCategories(TV)=%s totalCategories=%zu", + BoolState(allgood), m_categories.size()); allgood = LoadChannels(false); } if (m_settings->IsRadioenabled() && (allgood)) { allgood = GetCategories(true); + kodi::Log(ADDON_LOG_INFO, "Startup step GetCategories(Radio)=%s totalCategories=%zu", + BoolState(allgood), m_categories.size()); allgood = LoadChannels(true); } + + const size_t tv_channels = std::count_if(m_channels.begin(), m_channels.end(), + [](const EonChannel& channel) { return !channel.bRadio; }); + const size_t radio_channels = std::count_if(m_channels.begin(), m_channels.end(), + [](const EonChannel& channel) { return channel.bRadio; }); + kodi::Log(ADDON_LOG_INFO, + "Startup finished. allgood=%s totalChannels=%zu tvChannels=%zu radioChannels=%zu categories=%zu", + BoolState(allgood), m_channels.size(), tv_channels, radio_channels, m_categories.size()); } CPVREon::~CPVREon() @@ -607,7 +905,7 @@ ADDON_STATUS CPVREon::SetSetting(const std::string& settingName, const std::stri bool CPVREon::LoadChannels(const bool isRadio) { - kodi::Log(ADDON_LOG_DEBUG, "Load Eon Channels"); + kodi::Log(ADDON_LOG_DEBUG, "Load Eon Channels. type=%s", isRadio ? "radio" : "tv"); std::string url = m_api + "v3/channels?channelType=" + (isRadio ? "RADIO&channelSort=RECOMMENDED&sortDir=DESC" : "TV"); @@ -621,10 +919,15 @@ bool CPVREon::LoadChannels(const bool isRadio) int startnumber = m_settings->GetStartNum()-1; int lastnumber = startnumber; const rapidjson::Value& channels = doc; + size_t total_channels = 0; + size_t added_channels = 0; + size_t unsubscribed_skipped = 0; + size_t archive_channels = 0; for (rapidjson::Value::ConstValueIterator itr1 = channels.Begin(); itr1 != channels.End(); ++itr1) { + ++total_channels; const rapidjson::Value& channelItem = (*itr1); std::string channame; @@ -638,6 +941,8 @@ bool CPVREon::LoadChannels(const bool isRadio) eon_channel.bRadio = isRadio; eon_channel.bArchive = Utils::JsonBoolOrFalse(channelItem, "cutvEnabled"); + if (eon_channel.bArchive) + ++archive_channels; eon_channel.strChannelName = channame; int ref_id = Utils::JsonIntOrZero(channelItem, "id"); @@ -717,9 +1022,21 @@ bool CPVREon::LoadChannels(const bool isRadio) if (!m_settings->HideUnsubscribed() || eon_channel.subscribed) { kodi::Log(ADDON_LOG_DEBUG, "%i. Channel Name: %s ID: %i Sig: %s", lastnumber, channame.c_str(), ref_id, eon_channel.sig.c_str()); m_channels.emplace_back(eon_channel); + ++added_channels; + } else { + ++unsubscribed_skipped; } } + kodi::Log(ADDON_LOG_INFO, + "LoadChannels summary. type=%s total=%zu added=%zu skippedUnsubscribed=%zu archiveEnabled=%zu totalStored=%zu", + isRadio ? "radio" : "tv", + total_channels, + added_channels, + unsubscribed_skipped, + archive_channels, + m_channels.size()); + return true; } @@ -763,11 +1080,21 @@ 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 std::string& url, + 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 realtime=%s playTimeshiftBuffer=%s mode=%s start=%lld end=%lld", + url.c_str(), + BoolState(realtime), + BoolState(playTimeshiftBuffer), + isLive ? "live" : "replay", + static_cast(starttime), + static_cast(endtime)); properties.emplace_back(PVR_STREAM_PROPERTY_STREAMURL, url); properties.emplace_back(PVR_STREAM_PROPERTY_ISREALTIMESTREAM, realtime ? "true" : "false"); @@ -802,20 +1129,550 @@ void CPVREon::SetStreamProperties(std::vector& p kodi::Log(ADDON_LOG_DEBUG, "...using inputstream.ffmpegdirect"); 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.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)); - 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.is_realtime_stream", realtime ? "true" : "false"); + if (isLive) + { + properties.emplace_back("inputstream.ffmpegdirect.stream_mode", "timeshift"); + } + else + { + properties.emplace_back("inputstream.ffmpegdirect.open_mode", "ffmpeg"); + properties.emplace_back("inputstream.ffmpegdirect.playback_as_live", "false"); + kodi::Log(ADDON_LOG_INFO, + "Using simplified ffmpegdirect replay mode. start=%lld end=%lld", + static_cast(starttime), + static_cast(endtime)); } -*/ } else { kodi::Log(ADDON_LOG_DEBUG, "Unknown inputstream detected"); } properties.emplace_back(PVR_STREAM_PROPERTY_MIMETYPE, "application/x-mpegURL"); + + if (!isLive) + { + kodi::Log(ADDON_LOG_INFO, + "Replay properties prepared. inputstream=%s realtime=%s start=%lld end=%lld urlLen=%zu", + InputstreamName(inputstream), + BoolState(realtime), + static_cast(starttime), + static_cast(endtime), + url.size()); + } +} + +bool CPVREon::BuildPlaybackUrl(const EonChannel& channel, + time_t starttime, + time_t endtime, + const bool& isLive, + EonPlaybackUrlResult& result, + const bool includeDiagnostics) +{ + result = {}; + + if (channel.publishingPoints.empty()) + { + kodi::Log(ADDON_LOG_ERROR, "Channel uid=%i has no publishing points", channel.iUniqueId); + return false; + } + + std::string streaming_profile = "hp7000"; + + unsigned int rndbitrate = 0; + unsigned int current_bitrate = 0; + unsigned int current_id = 0; + for (unsigned int i = 0; i < channel.publishingPoints[0].profileIds.size(); i++) + { + current_bitrate = getBitrate(channel.bRadio, channel.publishingPoints[0].profileIds[i]); + kodi::Log(ADDON_LOG_DEBUG, "Bitrate is: %u for profile id: %u", current_bitrate, + channel.publishingPoints[0].profileIds[i]); + if (current_bitrate > rndbitrate) + { + current_id = channel.publishingPoints[0].profileIds[i]; + rndbitrate = current_bitrate; + } + } + + if (current_id == 0) + { + kodi::Log(ADDON_LOG_ERROR, "Failed to resolve rendering profile for channel uid=%i", + channel.iUniqueId); + return false; + } + + streaming_profile = getCoreStreamId(current_id); + result.streamProfile = streaming_profile; + result.bitrate = static_cast(rndbitrate); + kodi::Log(ADDON_LOG_DEBUG, "Channel Rendering Profile -> %u", current_id); + + m_session_id = Utils::CreateUUID(); + + EonServer currentServer; + if (!GetServer(isLive, currentServer)) + { + kodi::Log(ADDON_LOG_ERROR, "Failed to select %s server for channel uid=%i", + isLive ? "live" : "timeshift", channel.iUniqueId); + return false; + } + + std::string plain_aes; + const bool use_adaptive_stream_hint = + isLive || m_settings->GetInputstream() == INPUTSTREAM_ADAPTIVE; + + if (m_platform == PLATFORM_ANDROIDTV) + { + plain_aes = "channel=" + channel.publishingPoints[0].publishingPoint + ";" + + "stream=" + streaming_profile + ";" + "sp=" + m_service_provider + ";" + + "u=" + m_settings->GetEonStreamUser() + ";" + "m=" + currentServer.ip + ";" + + "device=" + m_settings->GetEonDeviceNumber() + ";" + "ctime=" + GetTime() + ";" + + "lang=eng;player=" + PLAYER + ";" + + "aa=" + (channel.aaEnabled ? "true" : "false") + ";" + + "conn=" + CONN_TYPE_ETHERNET + ";" + "minvbr=100;" + + "ss=" + m_settings->GetEonStreamKey() + ";" + "session=" + m_session_id + ";" + + "maxvbr=" + std::to_string(rndbitrate); + if (!isLive) + plain_aes = plain_aes + ";t=" + std::to_string(static_cast(starttime)) + "000;"; + } + else + { + plain_aes = "channel=" + channel.publishingPoints[0].publishingPoint + ";" + + "stream=" + streaming_profile + ";" + "sp=" + m_service_provider + ";" + + "u=" + m_settings->GetEonStreamUser() + ";" + + "ss=" + m_settings->GetEonStreamKey() + ";" + "minvbr=100;" + + "sig=" + channel.sig + ";" + "session=" + m_session_id + ";" + + "m=" + currentServer.ip + ";" + "device=" + m_settings->GetEonDeviceNumber() + + ";" + "ctime=" + GetTime() + ";" + "conn=" + CONN_TYPE_BROWSER + ";"; + if (use_adaptive_stream_hint) + plain_aes = plain_aes + "adaptive=true;"; + plain_aes = plain_aes + "player=" + PLAYER + ";"; + if (!isLive) + plain_aes = plain_aes + "t=" + std::to_string(static_cast(starttime)) + "000;"; + plain_aes = plain_aes + "aa=" + (channel.aaEnabled ? "true" : "false"); + } + + if (!isLive) + { + kodi::Log(ADDON_LOG_INFO, + "Replay URL payload prepared. inputstream=%s adaptiveHint=%s start=%lld end=%lld", + InputstreamName(m_settings->GetInputstream()), + BoolState(use_adaptive_stream_hint), + static_cast(starttime), + static_cast(endtime)); + } + + std::string key = base64_decode(urlsafedecode(m_settings->GetEonStreamKey())); + + std::ostringstream convert; + for (int i = 0; i < block_size; i++) + convert << static_cast(rand()); + std::string iv_str = convert.str(); + + std::string enc_str = aes_encrypt_cbc(iv_str, key, plain_aes); + + kodi::Log(ADDON_LOG_DEBUG, "IV -> %s", string_to_hex(iv_str).c_str()); + kodi::Log(ADDON_LOG_DEBUG, "IV (base64) -> %s", + urlsafeencode(base64_encode(iv_str.c_str(), iv_str.length())).c_str()); + kodi::Log(ADDON_LOG_DEBUG, "Encrypted -> %s", string_to_hex(enc_str).c_str()); + kodi::Log(ADDON_LOG_DEBUG, "Encrypted (base64) -> %s", + urlsafeencode(base64_encode(enc_str.c_str(), enc_str.length())).c_str()); + + result.url = "https://" + currentServer.hostname + + "/stream?i=" + urlsafeencode(base64_encode(iv_str.c_str(), iv_str.length())) + + "&a=" + urlsafeencode(base64_encode(enc_str.c_str(), enc_str.length())); + if (m_platform == PLATFORM_ANDROIDTV) + result.url = result.url + "&lang=eng"; + result.url = result.url + "&sp=" + m_service_provider + "&u=" + m_settings->GetEonStreamUser() + + "&player=" + PLAYER + "&session=" + m_session_id; + if (m_platform != PLATFORM_ANDROIDTV) + result.url = result.url + "&sig=" + channel.sig; + + kodi::Log(ADDON_LOG_DEBUG, "Encrypted Stream URL -> %s", result.url.c_str()); + + if (includeDiagnostics && !isLive) + { + Curl manifestCurl; + manifestCurl.AddHeader("User-Agent", EonParameters[m_platform].user_agent); + int manifestStatus = 0; + const std::string manifestBody = manifestCurl.Get(result.url, manifestStatus); + kodi::Log(ADDON_LOG_INFO, + "Replay manifest fetch. status=%i bodyLen=%zu extinf=%zu endlist=%s vod=%s event=%s preview=%s", + manifestStatus, manifestBody.size(), CountOccurrences(manifestBody, "#EXTINF"), + BoolState(manifestBody.find("#EXT-X-ENDLIST") != std::string::npos), + BoolState(manifestBody.find("#EXT-X-PLAYLIST-TYPE:VOD") != std::string::npos), + BoolState(manifestBody.find("#EXT-X-PLAYLIST-TYPE:EVENT") != std::string::npos), + PreviewForLog(manifestBody).c_str()); + + const std::string variantUrl = FirstVariantPlaylistUrl(manifestBody, result.url); + if (!variantUrl.empty()) + { + int variantStatus = 0; + const std::string variantBody = manifestCurl.Get(variantUrl, variantStatus); + kodi::Log(ADDON_LOG_INFO, + "Replay variant fetch. status=%i bodyLen=%zu extinf=%zu endlist=%s vod=%s event=%s preview=%s", + variantStatus, variantBody.size(), CountOccurrences(variantBody, "#EXTINF"), + BoolState(variantBody.find("#EXT-X-ENDLIST") != std::string::npos), + BoolState(variantBody.find("#EXT-X-PLAYLIST-TYPE:VOD") != std::string::npos), + BoolState(variantBody.find("#EXT-X-PLAYLIST-TYPE:EVENT") != std::string::npos), + PreviewForLog(variantBody).c_str()); + } + } + + return true; +} + +bool CPVREon::UseExperimentalNativeStream() const +{ + return m_settings->UseExperimentalNativeStream() && m_platform == PLATFORM_WEB; +} + +bool CPVREon::OpenNativeStream(const EonChannel& channel, + bool isLive, + time_t starttime, + time_t endtime) +{ + CloseNativeStreamInternal(); + + EonPlaybackUrlResult playback; + if (!BuildPlaybackUrl(channel, starttime, endtime, isLive, playback, false)) + return false; + + m_nativeStream.open = true; + m_nativeStream.isLive = isLive; + m_nativeStream.seekable = !isLive; + m_nativeStream.channel = channel; + m_nativeStream.programmeStartTime = isLive ? time(nullptr) : starttime; + m_nativeStream.programmeEndTime = isLive ? 0 : endtime; + m_nativeStream.sessionStartTime = isLive ? time(nullptr) : starttime; + m_nativeStream.sessionAnchorMonotonicMs = MonotonicNowMs(); + m_nativeStream.masterUrl = playback.url; + m_nativeStream.bitrate = playback.bitrate; + m_nativeStream.virtualUnitsPerSecond = NATIVE_VIRTUAL_UNITS_PER_SECOND; + + if (!isLive) + { + const int64_t duration = + std::max(m_nativeStream.programmeEndTime - m_nativeStream.programmeStartTime, 1); + m_nativeStream.virtualLength = duration * m_nativeStream.virtualUnitsPerSecond; + m_nativeStream.currentPosition = TimeToStreamPosition(starttime); + } + + kodi::Log(ADDON_LOG_INFO, + "Opening native %s stream. channel=%s uid=%i start=%lld end=%lld bitrate=%i unitsPerSecond=%lld", + isLive ? "live" : "archive", + channel.strChannelName.c_str(), + channel.iUniqueId, + static_cast(starttime), + static_cast(endtime), + playback.bitrate, + static_cast(m_nativeStream.virtualUnitsPerSecond)); + + if (!UpdateNativeVariantUrl(true) || !LoadNextNativeFragment()) + { + CloseNativeStreamInternal(); + return false; + } + + return true; +} + +void CPVREon::CloseNativeStreamInternal() +{ + if (m_nativeStream.open) + { + const int64_t visiblePosition = + m_nativeStream.isLive ? 0 : GetCurrentNativePosition(); + kodi::Log(ADDON_LOG_INFO, + "Closing native stream. live=%s currentPosition=%lld queuedFragments=%zu", + BoolState(m_nativeStream.isLive), + static_cast(visiblePosition), + m_nativeStream.pendingFragments.size()); + } + + m_nativeStream = {}; +} + +bool CPVREon::RestartNativeStreamAt(time_t starttime) +{ + if (!m_nativeStream.open || m_nativeStream.isLive) + return false; + + if (m_nativeStream.programmeEndTime <= m_nativeStream.programmeStartTime) + return false; + + const time_t clampedStart = + std::clamp(starttime, m_nativeStream.programmeStartTime, m_nativeStream.programmeEndTime - 1); + const time_t currentPlaybackTime = StreamPositionToTime(GetCurrentNativePosition()); + long long restartDelta = + static_cast(clampedStart) - static_cast(currentPlaybackTime); + if (restartDelta < 0) + restartDelta = -restartDelta; + if (restartDelta <= static_cast(NATIVE_SEEK_RESTART_EPSILON_SECONDS)) + { + m_nativeStream.currentPosition = TimeToStreamPosition(clampedStart); + kodi::Log(ADDON_LOG_DEBUG, + "Skipping native archive restart. currentTime=%lld targetTime=%lld delta=%lld", + static_cast(currentPlaybackTime), + static_cast(clampedStart), + restartDelta); + return true; + } + + EonPlaybackUrlResult playback; + if (!BuildPlaybackUrl(m_nativeStream.channel, clampedStart, m_nativeStream.programmeEndTime, + false, playback, false)) + { + return false; + } + + m_nativeStream.sessionStartTime = clampedStart; + m_nativeStream.masterUrl = playback.url; + m_nativeStream.variantUrl.clear(); + m_nativeStream.pendingFragments.clear(); + m_nativeStream.lastFragmentUrl.clear(); + m_nativeStream.currentFragmentData.clear(); + m_nativeStream.currentFragmentOffset = 0; + m_nativeStream.sessionAnchorMonotonicMs = MonotonicNowMs(); + m_nativeStream.bitrate = playback.bitrate; + m_nativeStream.currentPosition = TimeToStreamPosition(clampedStart); + + kodi::Log(ADDON_LOG_INFO, + "Restarting native archive stream. channel=%s uid=%i targetTime=%lld position=%lld", + m_nativeStream.channel.strChannelName.c_str(), + m_nativeStream.channel.iUniqueId, + static_cast(clampedStart), + static_cast(m_nativeStream.currentPosition)); + + return UpdateNativeVariantUrl(true) && LoadNextNativeFragment(); +} + +bool CPVREon::UpdateNativeVariantUrl(bool logErrors) +{ + if (!m_nativeStream.open || m_nativeStream.masterUrl.empty()) + return false; + + Curl manifestCurl; + manifestCurl.AddHeader("User-Agent", EonParameters[m_platform].user_agent); + int manifestStatus = 0; + const std::string manifestBody = manifestCurl.Get(m_nativeStream.masterUrl, manifestStatus); + if (manifestStatus != 200 && manifestStatus != 206) + { + if (logErrors) + { + kodi::Log(ADDON_LOG_ERROR, "Native stream manifest request failed. status=%i url=%s", + manifestStatus, m_nativeStream.masterUrl.c_str()); + } + return false; + } + + std::string variantUrl = FirstVariantPlaylistUrl(manifestBody, m_nativeStream.masterUrl); + if (variantUrl.empty()) + variantUrl = m_nativeStream.masterUrl; + + m_nativeStream.variantUrl = variantUrl; + kodi::Log(ADDON_LOG_INFO, "Native stream variant selected. url=%s", + m_nativeStream.variantUrl.c_str()); + return true; +} + +bool CPVREon::PollNativeFragmentQueue(bool forceRefresh) +{ + if (!m_nativeStream.open) + return false; + + if (!forceRefresh && !m_nativeStream.pendingFragments.empty()) + return true; + + if (m_nativeStream.variantUrl.empty() && !UpdateNativeVariantUrl(true)) + return false; + + Curl playlistCurl; + playlistCurl.AddHeader("User-Agent", EonParameters[m_platform].user_agent); + int playlistStatus = 0; + const std::string playlistBody = playlistCurl.Get(m_nativeStream.variantUrl, playlistStatus); + if (playlistStatus != 200 && playlistStatus != 206) + { + kodi::Log(ADDON_LOG_ERROR, "Native playlist request failed. status=%i url=%s", playlistStatus, + m_nativeStream.variantUrl.c_str()); + return false; + } + + if (CountOccurrences(playlistBody, "#EXTINF") == 0 && + playlistBody.find("#EXT-X-STREAM-INF") != std::string::npos) + { + const std::string nestedVariant = FirstVariantPlaylistUrl(playlistBody, m_nativeStream.variantUrl); + if (!nestedVariant.empty() && nestedVariant != m_nativeStream.variantUrl) + { + m_nativeStream.variantUrl = nestedVariant; + return PollNativeFragmentQueue(true); + } + } + + const std::vector fragmentUrls = + ExtractMediaSegmentUrls(playlistBody, m_nativeStream.variantUrl); + if (fragmentUrls.empty()) + { + kodi::Log(ADDON_LOG_ERROR, "Native playlist contained no fragments. url=%s preview=%s", + m_nativeStream.variantUrl.c_str(), PreviewForLog(playlistBody).c_str()); + return false; + } + + size_t startIndex = 0; + if (!m_nativeStream.lastFragmentUrl.empty()) + { + const auto lastIt = + std::find(fragmentUrls.begin(), fragmentUrls.end(), m_nativeStream.lastFragmentUrl); + if (lastIt != fragmentUrls.end()) + startIndex = static_cast(std::distance(fragmentUrls.begin(), lastIt) + 1); + } + + size_t added = 0; + for (size_t i = startIndex; i < fragmentUrls.size(); ++i) + { + if (std::find(m_nativeStream.pendingFragments.begin(), m_nativeStream.pendingFragments.end(), + fragmentUrls[i]) != m_nativeStream.pendingFragments.end()) + { + continue; + } + + m_nativeStream.pendingFragments.push_back(fragmentUrls[i]); + ++added; + } + + kodi::Log(ADDON_LOG_DEBUG, + "Native playlist poll. fragments=%zu added=%zu queued=%zu last=%s preview=%s", + fragmentUrls.size(), + added, + m_nativeStream.pendingFragments.size(), + m_nativeStream.lastFragmentUrl.empty() ? "" : m_nativeStream.lastFragmentUrl.c_str(), + PreviewForLog(playlistBody).c_str()); + + return !m_nativeStream.pendingFragments.empty(); +} + +bool CPVREon::LoadNextNativeFragment() +{ + m_nativeStream.currentFragmentData.clear(); + m_nativeStream.currentFragmentOffset = 0; + + for (int attempt = 0; attempt < NATIVE_POLL_RETRY_COUNT; ++attempt) + { + if (!m_nativeStream.pendingFragments.empty()) + break; + + if (PollNativeFragmentQueue(true)) + break; + + std::this_thread::sleep_for(NATIVE_POLL_RETRY_DELAY); + } + + while (!m_nativeStream.pendingFragments.empty()) + { + const std::string fragmentUrl = m_nativeStream.pendingFragments.front(); + m_nativeStream.pendingFragments.pop_front(); + + std::vector data; + int statusCode = 0; + if (!FetchBinaryUrl(fragmentUrl, data, statusCode) || data.empty()) + { + kodi::Log(ADDON_LOG_ERROR, + "Failed to fetch native fragment. status=%i bytes=%zu url=%s", + statusCode, + data.size(), + fragmentUrl.c_str()); + continue; + } + + m_nativeStream.lastFragmentUrl = fragmentUrl; + m_nativeStream.currentFragmentData = std::move(data); + kodi::Log(ADDON_LOG_DEBUG, "Loaded native fragment. bytes=%zu url=%s", + m_nativeStream.currentFragmentData.size(), fragmentUrl.c_str()); + return true; + } + + return false; +} + +bool CPVREon::FetchBinaryUrl(const std::string& url, std::vector& data, int& statusCode) +{ + data.clear(); + statusCode = -1; + + kodi::vfs::CFile file; + if (!file.CURLCreate(url)) + { + kodi::Log(ADDON_LOG_ERROR, "CURLCreate failed for native fragment %s", url.c_str()); + return false; + } + + file.CURLAddOption(ADDON_CURL_OPTION_HEADER, "User-Agent", EonParameters[m_platform].user_agent); + file.CURLAddOption(ADDON_CURL_OPTION_PROTOCOL, "failonerror", "false"); + if (!file.CURLOpen(ADDON_READ_NO_CACHE)) + { + kodi::Log(ADDON_LOG_ERROR, "CURLOpen failed for native fragment %s", url.c_str()); + return false; + } + + const std::string proto = file.GetPropertyValue(ADDON_FILE_PROPERTY_RESPONSE_PROTOCOL, ""); + const std::string::size_type posResponseCode = proto.find(' '); + if (posResponseCode != std::string::npos) + statusCode = atoi(proto.c_str() + (posResponseCode + 1)); + + std::array buffer{}; + ssize_t bytesRead = 0; + while ((bytesRead = file.Read(buffer.data(), buffer.size())) > 0) + { + data.insert(data.end(), buffer.begin(), buffer.begin() + bytesRead); + } + + return statusCode == 200 || statusCode == 206; +} + +int64_t CPVREon::GetCurrentNativePosition() const +{ + if (!m_nativeStream.open || m_nativeStream.isLive || m_nativeStream.virtualLength <= 0) + return 0; + + const int64_t basePosition = TimeToStreamPosition(m_nativeStream.sessionStartTime); + if (m_nativeStream.sessionAnchorMonotonicMs <= 0 || m_nativeStream.virtualUnitsPerSecond <= 0) + { + return std::clamp(std::max(m_nativeStream.currentPosition, basePosition), 0, + m_nativeStream.virtualLength); + } + + const int64_t elapsedMs = + std::max(MonotonicNowMs() - m_nativeStream.sessionAnchorMonotonicMs, 0); + const int64_t elapsedUnits = + (elapsedMs * m_nativeStream.virtualUnitsPerSecond) / 1000; + return std::clamp( + std::max(m_nativeStream.currentPosition, basePosition + elapsedUnits), 0, + m_nativeStream.virtualLength); +} + +time_t CPVREon::StreamPositionToTime(int64_t position) const +{ + if (!m_nativeStream.open || m_nativeStream.isLive || m_nativeStream.virtualLength <= 0 || + m_nativeStream.virtualUnitsPerSecond <= 0) + { + return m_nativeStream.sessionStartTime; + } + + const int64_t clampedPosition = std::clamp(position, 0, m_nativeStream.virtualLength); + const int64_t offsetSeconds = clampedPosition / m_nativeStream.virtualUnitsPerSecond; + return m_nativeStream.programmeStartTime + offsetSeconds; +} + +int64_t CPVREon::TimeToStreamPosition(time_t timeValue) const +{ + if (!m_nativeStream.open || m_nativeStream.isLive || m_nativeStream.virtualLength <= 0 || + m_nativeStream.virtualUnitsPerSecond <= 0) + { + return 0; + } + + const time_t clampedTime = + std::clamp(timeValue, m_nativeStream.programmeStartTime, m_nativeStream.programmeEndTime); + const int64_t offsetSeconds = clampedTime - m_nativeStream.programmeStartTime; + return std::clamp(offsetSeconds * m_nativeStream.virtualUnitsPerSecond, 0, + m_nativeStream.virtualLength); } PVR_ERROR CPVREon::GetCapabilities(kodi::addon::PVRCapabilities& capabilities) @@ -832,6 +1689,7 @@ PVR_ERROR CPVREon::GetCapabilities(kodi::addon::PVRCapabilities& capabilities) capabilities.SetSupportsRecordingsLifetimeChange(false); capabilities.SetSupportsDescrambleInfo(false); capabilities.SetSupportsProviders(false); + capabilities.SetHandlesInputStream(UseExperimentalNativeStream()); return PVR_ERROR_NO_ERROR; } @@ -986,12 +1844,34 @@ PVR_ERROR CPVREon::IsEPGTagPlayable(const kodi::addon::PVREPGTag& tag, bool& bIs PVR_ERROR CPVREon::GetEPGTagStreamProperties( const kodi::addon::PVREPGTag& tag, std::vector& properties) { - kodi::Log(ADDON_LOG_DEBUG, "function call: [%s]", __FUNCTION__); + kodi::Log(ADDON_LOG_INFO, + "function call: [%s] channelUid=%u start=%lld end=%lld", + __FUNCTION__, + tag.GetUniqueChannelId(), + static_cast(tag.GetStartTime()), + static_cast(tag.GetEndTime())); for (const auto& channel : m_channels) { if (channel.iUniqueId == tag.GetUniqueChannelId()) { - return GetStreamProperties(channel, properties, tag.GetStartTime(), /*tag.GetEndTime(),*/ false); + if (UseExperimentalNativeStream()) + { + m_pendingPlayback.active = true; + m_pendingPlayback.channelUid = channel.iUniqueId; + m_pendingPlayback.startTime = tag.GetStartTime(); + m_pendingPlayback.endTime = tag.GetEndTime(); + m_pendingPlayback.requestTime = time(nullptr); + properties.emplace_back(PVR_STREAM_PROPERTY_EPGPLAYBACKASLIVE, "true"); + kodi::Log(ADDON_LOG_INFO, + "Queued archive playback for native stream mode. channel=%s uid=%i start=%lld end=%lld", + channel.strChannelName.c_str(), + channel.iUniqueId, + static_cast(m_pendingPlayback.startTime), + static_cast(m_pendingPlayback.endTime)); + return PVR_ERROR_NO_ERROR; + } + + return GetStreamProperties(channel, properties, tag.GetStartTime(), tag.GetEndTime(), false); } } return PVR_ERROR_NO_ERROR; @@ -1048,109 +1928,25 @@ PVR_ERROR CPVREon::GetChannels(bool bRadio, kodi::addon::PVRChannelsResultSet& r } PVR_ERROR CPVREon::GetStreamProperties( - const EonChannel& channel, std::vector& properties, time_t starttime,/* time_t endtime,*/ const bool& isLive) -{ - kodi::Log(ADDON_LOG_DEBUG, "function call: [%s]", __FUNCTION__); - std::string streaming_profile = "hp7000"; - - unsigned int rndbitrate = 0; - unsigned int current_bitrate = 0; - unsigned int current_id = 0; - for (unsigned int i = 0; i < channel.publishingPoints[0].profileIds.size(); i++) { - current_bitrate = getBitrate(channel.bRadio, channel.publishingPoints[0].profileIds[i]); - kodi::Log(ADDON_LOG_DEBUG, "Bitrate is: %u for profile id: %u", current_bitrate, channel.publishingPoints[0].profileIds[i]); - if (current_bitrate > rndbitrate) { - current_id = channel.publishingPoints[0].profileIds[i]; - rndbitrate = current_bitrate; - } - } - if (current_id == 0) { - return PVR_ERROR_NO_ERROR; - } else { - streaming_profile = getCoreStreamId(current_id); - } - kodi::Log(ADDON_LOG_DEBUG, "Channel Rendering Profile -> %u", current_id); - - m_session_id = Utils::CreateUUID(); - - EonServer currentServer; - - GetServer(isLive, currentServer); - - std::string plain_aes; - - if (m_platform == PLATFORM_ANDROIDTV) { - plain_aes = "channel=" + channel.publishingPoints[0].publishingPoint + ";" + - "stream=" + streaming_profile + ";" + - "sp=" + m_service_provider + ";" + - "u=" + m_settings->GetEonStreamUser() + ";" + - "m=" + currentServer.ip + ";" + - "device=" + m_settings->GetEonDeviceNumber() + ";" + - "ctime=" + GetTime() + ";" + -// "lang=eng;minvbr=100;adaptive=true;player=" + PLAYER + ";" + - "lang=eng;player=" + PLAYER + ";" + - "aa=" + (channel.aaEnabled ? "true" : "false") + ";" + - "conn=" + CONN_TYPE_ETHERNET + ";" + - "minvbr=100;" + -// "sig=" + channel.sig + ";" + - "ss=" + m_settings->GetEonStreamKey() + ";" + - "session=" + m_session_id + ";" + - "maxvbr=" + std::to_string(rndbitrate); - if (!isLive) { - plain_aes = plain_aes + ";t=" + std::to_string((int) starttime) + "000;"; - } - } else { - plain_aes = "channel=" + channel.publishingPoints[0].publishingPoint + ";" + - "stream=" + streaming_profile + ";" + "sp=" + m_service_provider + ";" + - "u=" + m_settings->GetEonStreamUser() + ";" + - "ss=" + m_settings->GetEonStreamKey() + ";" + - "minvbr=100;adaptive=true;player=" + PLAYER + ";" + - "sig=" + channel.sig + ";" + - "session=" + m_session_id + ";" + - "m=" + currentServer.ip + ";" + - "device=" + m_settings->GetEonDeviceNumber() + ";" + - "ctime=" + GetTime() + ";" + - "conn=" + CONN_TYPE_BROWSER + ";"; - if (!isLive) { - plain_aes = plain_aes + "t=" + std::to_string((int) starttime) + "000;"; - } - plain_aes = plain_aes + "aa=" + (channel.aaEnabled ? "true" : "false"); - } -// kodi::Log(ADDON_LOG_DEBUG, "Plain AES -> %s", plain_aes.c_str()); - - std::string key = base64_decode(urlsafedecode(m_settings->GetEonStreamKey())); - - std::ostringstream convert; - for (int i = 0; i < block_size; i++) { - convert << (uint8_t) rand(); - } - std::string iv_str = convert.str(); - - std::string enc_str = aes_encrypt_cbc(iv_str, key, plain_aes); - - kodi::Log(ADDON_LOG_DEBUG, "IV -> %s", string_to_hex(iv_str).c_str()); - kodi::Log(ADDON_LOG_DEBUG, "IV (base64) -> %s", urlsafeencode(base64_encode(iv_str.c_str(), iv_str.length())).c_str()); -// kodi::Log(ADDON_LOG_DEBUG, "Key -> %s", string_to_hex(key).c_str()); - kodi::Log(ADDON_LOG_DEBUG, "Encrypted -> %s", string_to_hex(enc_str).c_str()); - kodi::Log(ADDON_LOG_DEBUG, "Encrypted (base64) -> %s", urlsafeencode(base64_encode(enc_str.c_str(), enc_str.length())).c_str()); - - std::string enc_url = "https://" + currentServer.hostname + - "/stream?i=" + urlsafeencode(base64_encode(iv_str.c_str(), iv_str.length())) + - "&a=" + urlsafeencode(base64_encode(enc_str.c_str(), enc_str.length())); - if (m_platform == PLATFORM_ANDROIDTV) { - enc_url = enc_url + "&lang=eng"; - } - enc_url = enc_url + "&sp=" + m_service_provider + - "&u=" + m_settings->GetEonStreamUser() + - "&player=" + PLAYER + - "&session=" + m_session_id; - if (m_platform != PLATFORM_ANDROIDTV) { - enc_url = enc_url + "&sig=" + channel.sig; - } - - kodi::Log(ADDON_LOG_DEBUG, "Encrypted Stream URL -> %s", enc_url.c_str()); + const EonChannel& channel, + std::vector& properties, + time_t starttime, + time_t endtime, + const bool& isLive) +{ + kodi::Log(ADDON_LOG_DEBUG, + "function call: [%s] channel=%s uid=%i mode=%s start=%lld end=%lld", + __FUNCTION__, + channel.strChannelName.c_str(), + channel.iUniqueId, + isLive ? "live" : "replay", + static_cast(starttime), + static_cast(endtime)); + EonPlaybackUrlResult playback; + if (!BuildPlaybackUrl(channel, starttime, endtime, isLive, playback, true)) + return PVR_ERROR_SERVER_ERROR; - SetStreamProperties(properties, enc_url, true, false, isLive/*, starttime, endtime*/); + SetStreamProperties(properties, playback.url, isLive, false, isLive, starttime, endtime); for (auto& prop : properties) kodi::Log(ADDON_LOG_DEBUG, "Name: %s Value: %s", prop.GetName().c_str(), prop.GetValue().c_str()); @@ -1159,13 +1955,21 @@ PVR_ERROR CPVREon::GetStreamProperties( } PVR_ERROR CPVREon::GetChannelStreamProperties( - const kodi::addon::PVRChannel& channel, std::vector& properties) + const kodi::addon::PVRChannel& channel, + std::vector& properties) { - kodi::Log(ADDON_LOG_DEBUG, "function call: [%s]", __FUNCTION__); + kodi::Log(ADDON_LOG_DEBUG, "function call: [%s] channelUid=%u", __FUNCTION__, + channel.GetUniqueId()); + + if (UseExperimentalNativeStream()) + { + return PVR_ERROR_NO_ERROR; + } + EonChannel addonChannel; if (GetChannel(channel, addonChannel)) { if (addonChannel.subscribed) { - return GetStreamProperties(addonChannel, properties, 0,/* 0,*/ true); + return GetStreamProperties(addonChannel, properties, 0, 0, true); } kodi::Log(ADDON_LOG_DEBUG, "Channel not subscribed"); return PVR_ERROR_SERVER_ERROR; @@ -1255,6 +2059,144 @@ PVR_ERROR CPVREon::GetRecordingStreamProperties( return PVR_ERROR_NO_ERROR; } +bool CPVREon::OpenLiveStream(const kodi::addon::PVRChannel& channel) +{ + if (!UseExperimentalNativeStream()) + return false; + + EonChannel addonChannel; + if (!GetChannel(channel, addonChannel) || !addonChannel.subscribed) + { + kodi::Log(ADDON_LOG_ERROR, "Failed to resolve native stream channel uid=%u", channel.GetUniqueId()); + return false; + } + + const time_t now = time(nullptr); + const bool usePendingArchive = + m_pendingPlayback.active && m_pendingPlayback.channelUid == addonChannel.iUniqueId && + now - m_pendingPlayback.requestTime <= PENDING_PLAYBACK_TTL_SECONDS; + + const time_t archiveStart = usePendingArchive ? m_pendingPlayback.startTime : 0; + const time_t archiveEnd = usePendingArchive ? m_pendingPlayback.endTime : 0; + + kodi::Log(ADDON_LOG_INFO, + "OpenLiveStream in native mode. channel=%s uid=%i mode=%s start=%lld end=%lld", + addonChannel.strChannelName.c_str(), + addonChannel.iUniqueId, + usePendingArchive ? "archive" : "live", + static_cast(archiveStart), + static_cast(archiveEnd)); + + m_pendingPlayback = {}; + return OpenNativeStream(addonChannel, !usePendingArchive, archiveStart, archiveEnd); +} + +void CPVREon::CloseLiveStream() +{ + if (UseExperimentalNativeStream()) + CloseNativeStreamInternal(); +} + +int CPVREon::ReadLiveStream(unsigned char* buffer, unsigned int size) +{ + if (!UseExperimentalNativeStream() || !m_nativeStream.open) + return -1; + + unsigned int written = 0; + while (written < size) + { + if (m_nativeStream.currentFragmentOffset >= m_nativeStream.currentFragmentData.size()) + { + if (!LoadNextNativeFragment()) + break; + } + + const size_t remainingFragment = + m_nativeStream.currentFragmentData.size() - m_nativeStream.currentFragmentOffset; + const size_t toCopy = std::min(size - written, remainingFragment); + memcpy(buffer + written, + m_nativeStream.currentFragmentData.data() + m_nativeStream.currentFragmentOffset, + toCopy); + m_nativeStream.currentFragmentOffset += toCopy; + written += static_cast(toCopy); + } + + if (!m_nativeStream.isLive) + m_nativeStream.currentPosition = GetCurrentNativePosition(); + + return static_cast(written); +} + +int64_t CPVREon::SeekLiveStream(int64_t position, int whence) +{ + if (!UseExperimentalNativeStream() || !m_nativeStream.open || !m_nativeStream.seekable) + return -1; + + int64_t targetPosition = position; + if (whence == SEEK_CUR) + targetPosition = GetCurrentNativePosition() + position; + else if (whence == SEEK_END) + targetPosition = m_nativeStream.virtualLength + position; + + targetPosition = std::clamp(targetPosition, 0, m_nativeStream.virtualLength); + const time_t targetTime = StreamPositionToTime(targetPosition); + if (!RestartNativeStreamAt(targetTime)) + return -1; + + m_nativeStream.currentPosition = TimeToStreamPosition(targetTime); + return m_nativeStream.currentPosition; +} + +int64_t CPVREon::LengthLiveStream() +{ + if (!UseExperimentalNativeStream() || !m_nativeStream.open) + return 0; + + return m_nativeStream.seekable ? m_nativeStream.virtualLength : 0; +} + +bool CPVREon::CanPauseStream() +{ + return UseExperimentalNativeStream() && m_nativeStream.open && !m_nativeStream.isLive; +} + +bool CPVREon::CanSeekStream() +{ + return UseExperimentalNativeStream() && m_nativeStream.open && m_nativeStream.seekable; +} + +bool CPVREon::IsRealTimeStream() +{ + if (!UseExperimentalNativeStream()) + return true; + + return !m_nativeStream.open || m_nativeStream.isLive; +} + +PVR_ERROR CPVREon::GetStreamTimes(kodi::addon::PVRStreamTimes& times) +{ + if (!UseExperimentalNativeStream() || !m_nativeStream.open) + return PVR_ERROR_NOT_IMPLEMENTED; + + if (m_nativeStream.isLive) + { + times.SetStartTime(time(nullptr)); + times.SetPTSStart(0); + times.SetPTSBegin(0); + times.SetPTSEnd(0); + return PVR_ERROR_NO_ERROR; + } + + const int64_t duration = + std::max(m_nativeStream.programmeEndTime - m_nativeStream.programmeStartTime, 1); + + times.SetStartTime(m_nativeStream.programmeStartTime); + times.SetPTSStart(0); + times.SetPTSBegin(0); + times.SetPTSEnd(duration * PVR_TIME_BASE); + return PVR_ERROR_NO_ERROR; +} + PVR_ERROR CPVREon::GetTimerTypes(std::vector& types) { /* TODO: Implement this to get support for the timer features introduced with PVR API 1.9.7 */ diff --git a/src/PVREon.h b/src/PVREon.h index 9d95d01..2a8874a 100644 --- a/src/PVREon.h +++ b/src/PVREon.h @@ -7,6 +7,7 @@ */ #include +#include #include #include @@ -107,6 +108,37 @@ struct EonCDN bool isDefault; }; +struct EonPendingPlayback +{ + bool active = false; + int channelUid = 0; + time_t startTime = 0; + time_t endTime = 0; + time_t requestTime = 0; +}; + +struct EonNativeStreamState +{ + bool open = false; + bool isLive = true; + bool seekable = false; + EonChannel channel; + time_t programmeStartTime = 0; + time_t programmeEndTime = 0; + time_t sessionStartTime = 0; + int64_t virtualUnitsPerSecond = 1000; + int64_t virtualLength = 0; + int64_t currentPosition = 0; + int64_t sessionAnchorMonotonicMs = 0; + int bitrate = 0; + std::string masterUrl; + std::string variantUrl; + std::deque pendingFragments; + std::string lastFragmentUrl; + std::vector currentFragmentData; + size_t currentFragmentOffset = 0; +}; + class ATTR_DLL_LOCAL CPVREon : public kodi::addon::CAddonBase, public kodi::addon::CInstancePVRClient { @@ -149,6 +181,15 @@ class ATTR_DLL_LOCAL CPVREon : public kodi::addon::CAddonBase, PVR_ERROR GetRecordingStreamProperties( const kodi::addon::PVRRecording& recording, std::vector& properties) override; + bool OpenLiveStream(const kodi::addon::PVRChannel& channel) override; + void CloseLiveStream() override; + int ReadLiveStream(unsigned char* buffer, unsigned int size) override; + int64_t SeekLiveStream(int64_t position, int whence) override; + int64_t LengthLiveStream() override; + bool CanPauseStream() override; + bool CanSeekStream() override; + bool IsRealTimeStream() override; + PVR_ERROR GetStreamTimes(kodi::addon::PVRStreamTimes& times) override; ADDON_STATUS SetSetting(const std::string& settingName, const std::string& settingValue); @@ -159,14 +200,44 @@ class ATTR_DLL_LOCAL CPVREon : public kodi::addon::CAddonBase, bool GetServer(bool isLive, EonServer& myServer); private: + struct EonPlaybackUrlResult + { + std::string url; + std::string streamProfile; + int bitrate = 0; + }; + 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); + bool BuildPlaybackUrl(const EonChannel& channel, + time_t starttime, + time_t endtime, + const bool& isLive, + EonPlaybackUrlResult& result, + const bool includeDiagnostics = true); + bool UseExperimentalNativeStream() const; + bool OpenNativeStream(const EonChannel& channel, bool isLive, time_t starttime, time_t endtime); + void CloseNativeStreamInternal(); + bool RestartNativeStreamAt(time_t starttime); + bool UpdateNativeVariantUrl(bool logErrors = true); + bool PollNativeFragmentQueue(bool forceRefresh = false); + bool LoadNextNativeFragment(); + bool FetchBinaryUrl(const std::string& url, std::vector& data, int& statusCode); + int64_t GetCurrentNativePosition() const; + time_t StreamPositionToTime(int64_t position) const; + int64_t TimeToStreamPosition(time_t timeValue) const; PVR_ERROR GetStreamProperties( const EonChannel& channel, - std::vector& properties, time_t starttime,/* time_t endtime, */const bool& isLive); + std::vector& properties, + time_t starttime, + time_t endtime, + const bool& isLive); std::vector m_channels; std::vector m_live_servers; @@ -198,6 +269,8 @@ class ATTR_DLL_LOCAL CPVREon : public kodi::addon::CAddonBase, std::string m_api; std::string m_images_api; int m_platform; + EonPendingPlayback m_pendingPlayback; + EonNativeStreamState m_nativeStream; // std::string m_ss_refresh; // int m_active_profile_id; @@ -222,6 +295,7 @@ class ATTR_DLL_LOCAL CPVREon : public kodi::addon::CAddonBase, bool GetRenderingProfiles(); bool LoadChannels(const bool isRadio); bool GetCategories(const bool isRadio); + bool RefreshDeviceRegistration(); int GetDefaultNumber(const bool isRadio, int id); bool HandleSession(bool start, int cid, int epg_id); }; diff --git a/src/Settings.cpp b/src/Settings.cpp index beffff8..a3eef9c 100644 --- a/src/Settings.cpp +++ b/src/Settings.cpp @@ -115,6 +115,12 @@ bool CSettings::Load() return false; } + if (!kodi::addon::CheckSettingBoolean("experimentalnativestream", m_experimentalNativeStream)) + { + kodi::Log(ADDON_LOG_ERROR, "Couldn't get 'experimentalnativestream' setting"); + return false; + } + if (!kodi::addon::CheckSettingString("genericaccesstoken", m_Generic_AccessToken)) { /* If setting is unknown fallback to defaults */ @@ -346,6 +352,14 @@ ADDON_STATUS CSettings::SetSetting(const std::string& settingName, // return ADDON_STATUS_NEED_RESTART; } } + else if (settingName == "experimentalnativestream") + { + const bool previous = m_experimentalNativeStream; + kodi::Log(ADDON_LOG_DEBUG, "Changed Setting 'experimentalnativestream'"); + m_experimentalNativeStream = settingValue == "true"; + if (previous != m_experimentalNativeStream) + return ADDON_STATUS_NEED_RESTART; + } return ADDON_STATUS_OK; } diff --git a/src/Settings.h b/src/Settings.h index 95b25a0..45cb1c7 100644 --- a/src/Settings.h +++ b/src/Settings.h @@ -42,6 +42,7 @@ class ATTR_DLL_LOCAL CSettings const bool IsRadioenabled() const { return m_enableradio; } const bool IsGroupsenabled() const { return m_enablegroups; } const bool UseShortNames() const { return m_shortnames; } + const bool UseExperimentalNativeStream() const { return m_experimentalNativeStream; } private: int m_eonServiceProvider; @@ -68,4 +69,5 @@ class ATTR_DLL_LOCAL CSettings bool m_enableradio; bool m_enablegroups; bool m_shortnames; + bool m_experimentalNativeStream; }; diff --git a/src/http/Curl.cpp b/src/http/Curl.cpp index 9146559..4919486 100644 --- a/src/http/Curl.cpp +++ b/src/http/Curl.cpp @@ -54,6 +54,7 @@ std::string Curl::Request(const std::string& action, const std::string& url, con kodi::vfs::CFile file; if (!file.CURLCreate(url)) { + kodi::Log(ADDON_LOG_ERROR, "CURLCreate failed for %s %s.", action.c_str(), url.c_str()); statusCode = -1; return ""; } @@ -80,6 +81,7 @@ std::string Curl::Request(const std::string& action, const std::string& url, con if (!file.CURLOpen(ADDON_READ_NO_CACHE)) { + kodi::Log(ADDON_LOG_ERROR, "CURLOpen failed for %s %s.", action.c_str(), url.c_str()); statusCode = -2; return ""; } @@ -88,10 +90,6 @@ std::string Curl::Request(const std::string& action, const std::string& url, con std::string::size_type posResponseCode = proto.find(' '); if (posResponseCode != std::string::npos) statusCode = atoi(proto.c_str() + (posResponseCode + 1)); - - if (statusCode >= 400) { - return ""; - } const std::vector values = file.GetPropertyValues(ADDON_FILE_PROPERTY_RESPONSE_HEADER, "set-cookie"); for (const auto& value : values) diff --git a/src/http/HttpClient.cpp b/src/http/HttpClient.cpp index 4b026d2..393c253 100644 --- a/src/http/HttpClient.cpp +++ b/src/http/HttpClient.cpp @@ -9,6 +9,29 @@ #include "../SHA256.h" #include "../Base64.h" #include +#include + +namespace +{ +const char* BoolState(bool value) +{ + return value ? "true" : "false"; +} + +std::string DescribeSecret(const std::string& value) +{ + return value.empty() ? "empty" : "set(len=" + std::to_string(value.size()) + ")"; +} + +std::string PreviewForLog(std::string value) +{ + std::replace(value.begin(), value.end(), '\n', ' '); + std::replace(value.begin(), value.end(), '\r', ' '); + if (value.size() > 200) + value = value.substr(0, 200) + "..."; + return value; +} +} // namespace void HttpClient::SetApi(const std::string& api) { @@ -41,19 +64,21 @@ bool HttpClient::RefreshGenericToken() rapidjson::Document doc; doc.Parse(content_auth.c_str()); - if (doc.GetParseError()) + std::string access_token = Utils::JsonStringOrEmpty(doc, "access_token"); + if (doc.GetParseError() || statusCode != 200 || access_token.empty()) { - kodi::Log(ADDON_LOG_ERROR, "Failed to refresh generic access token"); + kodi::Log(ADDON_LOG_ERROR, "Failed to refresh generic access token. status=%i token=%s responseLen=%zu preview=%s", + statusCode, DescribeSecret(access_token).c_str(), + content_auth.size(), PreviewForLog(content_auth).c_str()); return false; } - std::string access_token = Utils::JsonStringOrEmpty(doc, "access_token"); - if (!access_token.empty()) { m_settings->SetSetting("genericaccesstoken", access_token); } - kodi::Log(ADDON_LOG_DEBUG, "Got Generic Access Token: %s", access_token.c_str()); + kodi::Log(ADDON_LOG_INFO, "Generic access token refresh succeeded. token=%s", + DescribeSecret(access_token).c_str()); return true; } @@ -70,11 +95,14 @@ bool HttpClient::RefreshSSToken() std::string postData = "{\"domainId\":\"" + SS_DOMAIN + "\",\"applicationId\":\"vpb\"" ",\"grantType\":\""; + std::string grant_flow; if (!refresh_token.empty()) { + grant_flow = "refresh"; postData = postData + "refresh\"" + ",\"refreshToken\":\"" + refresh_token + "\"}"; } else if (!username.empty() && !password.empty()) { + grant_flow = "password"; postData = postData + "password\"" + ",\"password\":\"" + password + //TODO: Fix Password: Salted AES encrypted hash - Passphrase is SS_PASS "\",\"username\":\"" + username + "\"}"; @@ -94,18 +122,26 @@ bool HttpClient::RefreshSSToken() rapidjson::Document doc; doc.Parse(ss_auth.c_str()); - if (doc.GetParseError()) + std::string ss_identity; + if (!doc.GetParseError() && doc.IsObject() && doc.HasMember("UserTokenAuthenticate")) { - kodi::Log(ADDON_LOG_ERROR, "Failed to refresh self service token"); + const rapidjson::Value& credentials = doc["UserTokenAuthenticate"]; + ss_identity = Utils::JsonStringOrEmpty(credentials, "identity"); + access_token = Utils::JsonStringOrEmpty(credentials, "accessToken"); + refresh_token = Utils::JsonStringOrEmpty(credentials, "refreshToken"); + } + if (doc.GetParseError() || statusCode != 200 || access_token.empty()) + { + kodi::Log(ADDON_LOG_ERROR, + "Failed to refresh self service token. flow=%s status=%i identity=%s access=%s refresh=%s responseLen=%zu preview=%s", + grant_flow.c_str(), statusCode, + DescribeSecret(ss_identity).c_str(), + DescribeSecret(access_token).c_str(), + DescribeSecret(refresh_token).c_str(), + ss_auth.size(), PreviewForLog(ss_auth).c_str()); return false; } - const rapidjson::Value& credentials = doc["UserTokenAuthenticate"]; - - std::string ss_identity = Utils::JsonStringOrEmpty(credentials, "identity"); - access_token = Utils::JsonStringOrEmpty(credentials, "accessToken"); - refresh_token = Utils::JsonStringOrEmpty(credentials, "refreshToken"); - if (!refresh_token.empty()) { m_settings->SetSetting("ssrefreshtoken", refresh_token); } @@ -115,7 +151,12 @@ bool HttpClient::RefreshSSToken() if (!ss_identity.empty()) { m_settings->SetSetting("ssidentity", ss_identity); } - kodi::Log(ADDON_LOG_DEBUG, "Got Identity: %s, Access Token: %s, Refresh Token: %s", ss_identity.c_str(), access_token.c_str(), refresh_token.c_str()); + kodi::Log(ADDON_LOG_INFO, + "Self service token refresh succeeded. flow=%s identity=%s access=%s refresh=%s", + grant_flow.c_str(), + DescribeSecret(ss_identity).c_str(), + DescribeSecret(access_token).c_str(), + DescribeSecret(refresh_token).c_str()); return true; } @@ -127,24 +168,29 @@ bool HttpClient::RefreshToken() std::string url = m_api + "oauth/token?grant_type="; std::string refresh_token = m_settings->GetEonRefreshToken(); std::string postData = "{}"; + std::string grant_flow; if (!refresh_token.empty()) { + grant_flow = "refresh_token"; url += "refresh_token&refresh_token=" + refresh_token; } else if ((!m_settings->GetEonUsername().empty()) && (!m_settings->GetEonPassword().empty()) && (!m_settings->GetEonDeviceNumber().empty()) && (m_settings->GetPlatform() != 1)) { + grant_flow = "password_device_number"; SHA256 sha; sha.update(m_settings->GetEonUsername()); uint8_t * digest = sha.digest(); std::string user_hash = SHA256::toString(digest); std::transform(user_hash.begin(), user_hash.end(), user_hash.begin(), ::toupper); - kodi::Log(ADDON_LOG_DEBUG, "SHA256 %s", user_hash.c_str()); delete[] digest; std::string password = m_settings->GetEonPassword(); std::string device_number = m_settings->GetEonDeviceNumber(); - kodi::Log(ADDON_LOG_DEBUG, "Using Device Number %s to login", device_number.c_str()); + kodi::Log(ADDON_LOG_DEBUG, + "Refreshing main token using password flow. deviceNumber=%s username=%s", + DescribeSecret(device_number).c_str(), + DescribeSecret(m_settings->GetEonUsername()).c_str()); std::string boundary = "----WebKitFormBoundary2VHeBtQPpnSo3SjK"; curl_auth.AddHeader("Content-Type", "multipart/form-data; boundary=" + boundary); @@ -160,6 +206,7 @@ bool HttpClient::RefreshToken() //Try to login with OTP std::string generic_access_token = m_settings->GetGenericAccessToken(); if ((m_settings->GetPlatform() == 1) && (!generic_access_token.empty()) && (!m_settings->GetEonDeviceNumber().empty())) { + grant_flow = "otp"; Curl curl_otp; int statusCode_otp; std::string url_otp = m_api + "v1/otp?deviceNumber=" + m_settings->GetEonDeviceNumber(); @@ -170,7 +217,8 @@ bool HttpClient::RefreshToken() doc.Parse(otp_response.c_str()); if (doc.GetParseError()) { - kodi::Log(ADDON_LOG_ERROR, "Failed to get OTP"); + kodi::Log(ADDON_LOG_ERROR, "Failed to get OTP. status=%i responseLen=%zu preview=%s", + statusCode_otp, otp_response.size(), PreviewForLog(otp_response).c_str()); return false; } std::string otp = Utils::JsonStringOrEmpty(doc, "otp"); @@ -180,7 +228,14 @@ bool HttpClient::RefreshToken() url += "otp&otp=" + otp + "&device_number=" + m_settings->GetEonDeviceNumber(); } else { kodi::gui::dialogs::OK::ShowAndGetInput("PVR EON: " + kodi::addon::GetLocalizedString(30054), kodi::addon::GetLocalizedString(30055)); - kodi::Log(ADDON_LOG_ERROR, "Failed to refresh token"); + kodi::Log(ADDON_LOG_ERROR, + "Failed to refresh token. platform=%i username=%s password=%s refresh=%s generic=%s deviceNumber=%s", + m_settings->GetPlatform(), + DescribeSecret(m_settings->GetEonUsername()).c_str(), + DescribeSecret(m_settings->GetEonPassword()).c_str(), + DescribeSecret(m_settings->GetEonRefreshToken()).c_str(), + DescribeSecret(m_settings->GetGenericAccessToken()).c_str(), + DescribeSecret(m_settings->GetEonDeviceNumber()).c_str()); return false; } } @@ -194,17 +249,26 @@ bool HttpClient::RefreshToken() rapidjson::Document doc; doc.Parse(content_auth.c_str()); - if (doc.GetParseError()) - { - kodi::Log(ADDON_LOG_ERROR, "Failed to refresh token"); - return false; - } - std::string access_token = Utils::JsonStringOrEmpty(doc, "access_token"); refresh_token = Utils::JsonStringOrEmpty(doc, "refresh_token"); std::string stream_key = Utils::JsonStringOrEmpty(doc, "stream_key"); std::string stream_un = Utils::JsonStringOrEmpty(doc, "stream_un"); + if (doc.GetParseError() || statusCode != 200 || access_token.empty()) + { + kodi::Log(ADDON_LOG_ERROR, + "Failed to refresh token. flow=%s status=%i access=%s refresh=%s streamKey=%s streamUser=%s responseLen=%zu preview=%s", + grant_flow.c_str(), + statusCode, + DescribeSecret(access_token).c_str(), + DescribeSecret(refresh_token).c_str(), + DescribeSecret(stream_key).c_str(), + DescribeSecret(stream_un).c_str(), + content_auth.size(), + PreviewForLog(content_auth).c_str()); + return false; + } + m_settings->SetSetting("accesstoken", access_token); if (!refresh_token.empty()) { m_settings->SetSetting("refreshtoken", refresh_token); @@ -216,6 +280,14 @@ bool HttpClient::RefreshToken() m_settings->SetSetting("streamuser", stream_un); } + kodi::Log(ADDON_LOG_INFO, + "Main token refresh succeeded. flow=%s access=%s refresh=%s streamKey=%s streamUser=%s", + grant_flow.c_str(), + DescribeSecret(access_token).c_str(), + DescribeSecret(refresh_token).c_str(), + DescribeSecret(stream_key).c_str(), + DescribeSecret(stream_un).c_str()); + return true; } @@ -314,6 +386,7 @@ std::string HttpClient::HttpRequest(const std::string& action, const std::string { Curl curl; std::string access_token; + std::string auth_mode; curl.AddHeader("User-Agent", EON_USER_AGENT); @@ -322,14 +395,19 @@ std::string HttpClient::HttpRequest(const std::string& action, const std::string access_token = m_settings->GetSSAccessToken(); if (!access_token.empty()) { curl.AddHeader("accesstoken", access_token); + auth_mode = "ss-access-token"; } std::string basic_token = SS_USER + ":" + SS_SECRET; curl.AddHeader("Authorization", "Basic " + base64_encode(basic_token.c_str(), basic_token.length())); + if (auth_mode.empty()) + auth_mode = "ss-basic"; } else { if (url.find(BROKER_URL) != std::string::npos || url.find("v1/devices") != std::string::npos) { access_token = m_settings->GetGenericAccessToken(); + auth_mode = access_token.empty() ? "generic-basic" : "generic-bearer"; } else { access_token = m_settings->GetEonAccessToken(); + auth_mode = access_token.empty() ? "main-basic" : "main-bearer"; } if (!access_token.empty()) { curl.AddHeader("Authorization", "bearer " + access_token); @@ -348,28 +426,40 @@ std::string HttpClient::HttpRequest(const std::string& action, const std::string std::string content = HttpRequestToCurl(curl, action, url, postData, statusCode); if (statusCode == 401) { + kodi::Log(ADDON_LOG_INFO, + "HTTP 401 for %s %s. auth=%s payloadLen=%zu, attempting token refresh.", + action.c_str(), url.c_str(), auth_mode.c_str(), postData.size()); Curl curl_reauth; size_t found = url.find(m_supportApi); bool refresh_successful = true; + std::string retry_auth_mode; if (found != std::string::npos) { if (RefreshSSToken()) { access_token = m_settings->GetSSAccessToken(); curl_reauth.AddHeader("accesstoken", access_token); std::string basic_token = SS_USER + ":" + SS_SECRET; curl_reauth.AddHeader("Authorization", "Basic " + base64_encode(basic_token.c_str(), basic_token.length())); + retry_auth_mode = "ss-access-token"; + } else { + refresh_successful = false; } } else { if (url.find(BROKER_URL) != std::string::npos || url.find("v1/devices") != std::string::npos) { refresh_successful = RefreshGenericToken(); access_token = m_settings->GetGenericAccessToken(); + retry_auth_mode = "generic-bearer"; } else { refresh_successful = RefreshToken(); access_token = m_settings->GetEonAccessToken(); + retry_auth_mode = "main-bearer"; } - curl_reauth.AddHeader("Authorization", "bearer " + access_token); + if (refresh_successful && !access_token.empty()) + curl_reauth.AddHeader("Authorization", "bearer " + access_token); } if (refresh_successful) { content = HttpRequestToCurl(curl_reauth, action, url, postData, statusCode); + kodi::Log(ADDON_LOG_INFO, "HTTP retry after refresh completed. auth=%s status=%i responseLen=%zu", + retry_auth_mode.c_str(), statusCode, content.size()); } else { std::string refresh_token = m_settings->GetEonRefreshToken(); if (!refresh_token.empty() && !(url.find(BROKER_URL) != std::string::npos || url.find("v1/devices") != std::string::npos)) { @@ -377,20 +467,24 @@ std::string HttpClient::HttpRequest(const std::string& action, const std::string m_settings->SetSetting("refreshtoken", ""); refresh_successful = RefreshToken(); access_token = m_settings->GetEonAccessToken(); - curl_reauth.AddHeader("Authorization", "bearer " + access_token); + if (!access_token.empty()) + curl_reauth.AddHeader("Authorization", "bearer " + access_token); if (refresh_successful) { content = HttpRequestToCurl(curl_reauth, action, url, postData, statusCode); + kodi::Log(ADDON_LOG_INFO, "HTTP retry after last-resort refresh completed. status=%i responseLen=%zu", + statusCode, content.size()); } } } } if (statusCode >= 400 || statusCode < 200) { - kodi::Log(ADDON_LOG_ERROR, "Open URL failed with %i.", statusCode); + kodi::Log(ADDON_LOG_ERROR, "Open URL failed with %i. method=%s url=%s auth=%s responseLen=%zu preview=%s", + statusCode, action.c_str(), url.c_str(), auth_mode.c_str(), content.size(), + PreviewForLog(content).c_str()); if (m_statusCodeHandler != nullptr) { m_statusCodeHandler->ErrorStatusCode(statusCode); } - return ""; } return content; diff --git a/tools/debug/analyze-eon-har.sh b/tools/debug/analyze-eon-har.sh new file mode 100755 index 0000000..d3a7882 --- /dev/null +++ b/tools/debug/analyze-eon-har.sh @@ -0,0 +1,195 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "Usage: $0 /path/to/eon.har" >&2 + exit 1 +fi + +har_path=$1 + +if [[ ! -f "$har_path" ]]; then + echo "HAR file not found: $har_path" >&2 + exit 1 +fi + +if ! command -v jq >/dev/null 2>&1; then + echo "jq is required but not installed" >&2 + exit 1 +fi + +echo "HAR: $har_path" +echo + +echo "Sessions" +jq -r ' + .log.entries[] + | select(.request.url | contains("/stream")) + | .request.url + | select(test("session=[^&]+")) + | capture("session=(?[^&]+)").session +' "$har_path" \ + | sort -u \ + | sed '/^$/d' \ + | sed 's/^/ /' +echo + +echo "Bootstrap requests" +jq -r ' + .log.entries[] + | select(.request.url | contains("/stream?")) + | [.startedDateTime, .response.status, (if (.request.url | contains("info=true")) then "info" else "bootstrap" end), .request.url] + | @tsv +' "$har_path" | while IFS=$'\t' read -r started status kind url; do + printf ' %s status=%s kind=%s\n' "$started" "$status" "$kind" +done +echo + +echo "Info responses" +jq -r ' + .log.entries[] + | select(.request.url | contains("info=true")) + | (.response.content.text // "" | try fromjson catch {}) as $body + | [ + .startedDateTime, + .response.status, + ($body.location // ""), + ($body.machine // ""), + ($body.source // ""), + ($body.target // "") + ] + | @tsv +' "$har_path" | while IFS=$'\t' read -r started status location machine source target; do + printf ' %s status=%s location=%s machine=%s source=%s target=%s\n' \ + "$started" "$status" "${location:-?}" "${machine:-?}" "${source:-?}" "${target:-?}" +done +echo + +playlist_rows=$( + jq -r ' + .log.entries[] + | select(.request.url | contains("/stream/playlist/hls/")) + | (.response.content.text // "") as $text + | select($text | contains("MEDIA-SEQUENCE:")) + | [ + .startedDateTime, + (.request.url | capture("stream=(?[^&]+)").stream), + ($text | capture("MEDIA-SEQUENCE:(?[0-9]+)").seq), + ([ $text | match("offset=([0-9]+)&pointeroffset=([0-9]+)"; "g") | .captures[0].string ] | first), + ([ $text | match("offset=([0-9]+)&pointeroffset=([0-9]+)"; "g") | .captures[0].string ] | last), + ([ $text | match("offset=([0-9]+)&pointeroffset=([0-9]+)"; "g") | .captures[1].string ] | first), + ([ $text | match("offset=([0-9]+)&pointeroffset=([0-9]+)"; "g") | .captures[1].string ] | last), + ($text | capture("TARGETDURATION:(?[0-9]+)").target), + ([ $text | match("#EXTINF:(?[0-9.]+),"; "g") | .captures[0].string ] | first) + ] + | @tsv + ' "$har_path" +) + +if [[ -z "$playlist_rows" ]]; then + echo "Playlist polling" + echo " No /stream/playlist/hls/ entries found" + exit 0 +fi + +echo "Playlist polling" +printf '%s\n' "$playlist_rows" | while IFS=$'\t' read -r started stream seq first_off last_off first_ptr last_ptr target segment; do + printf ' %s stream=%s seq=%s offsets=%s..%s pointers=%s..%s target=%ss segment=%ss\n' \ + "$started" "$stream" "$seq" "$first_off" "$last_off" "$first_ptr" "$last_ptr" "$target" "$segment" +done +echo + +playlist_count=$(printf '%s\n' "$playlist_rows" | wc -l | tr -d ' ') +first_playlist=$(printf '%s\n' "$playlist_rows" | head -n 1) +last_playlist=$(printf '%s\n' "$playlist_rows" | tail -n 1) + +echo "Playlist summary" +echo " playlist_count=$playlist_count" +printf '%s\n' "$first_playlist" | awk -F '\t' '{printf " first: %s stream=%s seq=%s offsets=%s..%s\n", $1, $2, $3, $4, $5}' +printf '%s\n' "$last_playlist" | awk -F '\t' '{printf " last: %s stream=%s seq=%s offsets=%s..%s\n", $1, $2, $3, $4, $5}' +echo + +echo "Detected jumps" +printf '%s\n' "$playlist_rows" | awk -F '\t' ' + NR == 1 { + prev_started = $1 + prev_stream = $2 + prev_seq = $3 + 0 + prev_first = $4 + 0 + prev_last = $5 + 0 + jumps = 0 + next + } + { + stream = $2 + seq = $3 + 0 + first = $4 + 0 + last = $5 + 0 + seq_delta = seq - prev_seq + offset_delta = first - prev_last + if (stream != prev_stream) { + prev_started = $1 + prev_stream = stream + prev_seq = seq + prev_first = first + prev_last = last + next + } + if (seq_delta != 1 || (offset_delta != -1 && offset_delta != 0 && offset_delta != 1)) { + printf " %s stream=%s seq=%d prev_seq=%d first_offset=%d prev_last_offset=%d seq_delta=%d offset_delta=%d\n", + $1, stream, seq, prev_seq, first, prev_last, seq_delta, offset_delta + jumps++ + } + prev_started = $1 + prev_stream = stream + prev_seq = seq + prev_first = first + prev_last = last + } + END { + if (jumps == 0) { + print " none" + } + } +' +echo + +echo "Interpretation" +printf '%s\n' "$playlist_rows" | awk -F '\t' ' + NR == 1 { + prev_stream = $2 + prev_seq = $3 + 0 + prev_last = $5 + 0 + total = 1 + jumps = 0 + next + } + { + stream = $2 + seq = $3 + 0 + first = $4 + 0 + if (stream != prev_stream) { + prev_stream = stream + prev_seq = seq + prev_last = $5 + 0 + total++ + next + } + seq_delta = seq - prev_seq + offset_delta = first - prev_last + if (seq_delta != 1 || (offset_delta != -1 && offset_delta != 0 && offset_delta != 1)) { + jumps++ + } + prev_stream = stream + prev_seq = seq + prev_last = $5 + 0 + total++ + } + END { + if (jumps == 0) { + print " Continuous rolling playlist. This HAR did not capture a seek/restart transition." + } else { + printf " Found %d discontinuity event(s). This HAR likely includes a seek or session restart.\n", jumps + } + } +' diff --git a/tools/docker/android-aarch64.Dockerfile b/tools/docker/android-aarch64.Dockerfile new file mode 100644 index 0000000..1adff6b --- /dev/null +++ b/tools/docker/android-aarch64.Dockerfile @@ -0,0 +1,51 @@ +FROM ubuntu:22.04 + +ARG ANDROID_CMDLINE_TOOLS_URL=https://dl.google.com/android/repository/commandlinetools-linux-14742923_latest.zip +ARG ANDROID_PLATFORM=android-36 +ARG ANDROID_BUILD_TOOLS=36.0.0 +ARG ANDROID_NDK_VERSION=28.2.13676358 + +ENV DEBIAN_FRONTEND=noninteractive +ENV ANDROID_SDK_ROOT=/opt/android-sdk +ENV ANDROID_HOME=/opt/android-sdk +ENV PATH=/opt/android-sdk/cmdline-tools/latest/bin:/opt/android-sdk/platform-tools:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + autoconf \ + bison \ + build-essential \ + ca-certificates \ + cmake \ + curl \ + flex \ + gawk \ + git \ + gperf \ + lib32stdc++6 \ + lib32z1 \ + lib32z1-dev \ + pkg-config \ + openjdk-17-jdk-headless \ + unzip \ + zip \ + zlib1g-dev \ + && rm -rf /var/lib/apt/lists/* + +RUN mkdir -p /opt/android-sdk/cmdline-tools /tmp/android-sdk-download \ + && curl -fsSL "${ANDROID_CMDLINE_TOOLS_URL}" -o /tmp/android-sdk-download/commandlinetools.zip \ + && unzip -q /tmp/android-sdk-download/commandlinetools.zip -d /tmp/android-sdk-download \ + && mkdir -p /opt/android-sdk/cmdline-tools/latest \ + && cp -a /tmp/android-sdk-download/cmdline-tools/. /opt/android-sdk/cmdline-tools/latest/ \ + && yes | sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" --licenses >/dev/null \ + && sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" \ + "platform-tools" \ + "platforms;${ANDROID_PLATFORM}" \ + "build-tools;${ANDROID_BUILD_TOOLS}" \ + "ndk;${ANDROID_NDK_VERSION}" \ + && rm -rf /tmp/android-sdk-download + +COPY tools/docker/container-build-android-aarch64.sh /usr/local/bin/container-build-android-aarch64.sh +RUN chmod +x /usr/local/bin/container-build-android-aarch64.sh + +ENTRYPOINT ["/usr/local/bin/container-build-android-aarch64.sh"] diff --git a/tools/docker/build-android-aarch64.sh b/tools/docker/build-android-aarch64.sh new file mode 100755 index 0000000..9e39c1e --- /dev/null +++ b/tools/docker/build-android-aarch64.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +addon_root=$(cd "${script_dir}/../.." && pwd) +workspace_root=$(cd "${addon_root}/.." && pwd) +xbmc_root="${workspace_root}/xbmc" +output_root="${addon_root}/build/docker-android-aarch64" +image_tag="pvr-eon-builder:android-aarch64" +android_platform="${ANDROID_PLATFORM:-android-36}" +android_build_tools="${ANDROID_BUILD_TOOLS:-36.0.0}" +android_ndk_version="${ANDROID_NDK_VERSION:-28.2.13676358}" + +if [[ ! -d "${xbmc_root}/.git" ]]; then + echo "Expected xbmc checkout at ${xbmc_root}" >&2 + exit 1 +fi + +if ! git -C "${xbmc_root}" rev-parse --verify origin/Omega >/dev/null 2>&1; then + echo "The local xbmc checkout does not have origin/Omega." >&2 + echo "Fetch it first, then rerun this script." >&2 + exit 1 +fi + +mkdir -p "${output_root}" + +docker build \ + --platform linux/amd64 \ + --build-arg "ANDROID_PLATFORM=${android_platform}" \ + --build-arg "ANDROID_BUILD_TOOLS=${android_build_tools}" \ + --build-arg "ANDROID_NDK_VERSION=${android_ndk_version}" \ + -t "${image_tag}" \ + -f "${script_dir}/android-aarch64.Dockerfile" \ + "${addon_root}" + +docker run --rm \ + --platform linux/amd64 \ + --user "$(id -u):$(id -g)" \ + -e BUILD_TYPE="${BUILD_TYPE:-Release}" \ + -e XBMC_REF="${XBMC_REF:-origin/Omega}" \ + -e ANDROID_NDK_API="${ANDROID_NDK_API:-24}" \ + -e ANDROID_NDK_VERSION="${android_ndk_version}" \ + -v "${xbmc_root}:/src/xbmc:ro" \ + -v "${addon_root}:/src/pvr.eon:ro" \ + -v "${output_root}:/out" \ + "${image_tag}" diff --git a/tools/docker/build-linux-amd64.sh b/tools/docker/build-linux-amd64.sh new file mode 100755 index 0000000..4d42f07 --- /dev/null +++ b/tools/docker/build-linux-amd64.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +addon_root=$(cd "${script_dir}/../.." && pwd) +workspace_root=$(cd "${addon_root}/.." && pwd) +xbmc_root="${workspace_root}/xbmc" +output_root="${addon_root}/build/docker-linux-amd64" +image_tag="pvr-eon-builder:linux-amd64" + +if [[ ! -d "${xbmc_root}/.git" ]]; then + echo "Expected xbmc checkout at ${xbmc_root}" >&2 + exit 1 +fi + +if ! git -C "${xbmc_root}" rev-parse --verify origin/Omega >/dev/null 2>&1; then + echo "The local xbmc checkout does not have origin/Omega." >&2 + echo "Fetch it first, then rerun this script." >&2 + exit 1 +fi + +mkdir -p "${output_root}" + +docker build \ + --platform linux/amd64 \ + -t "${image_tag}" \ + -f "${script_dir}/linux-amd64.Dockerfile" \ + "${addon_root}" + +docker run --rm \ + --platform linux/amd64 \ + --user "$(id -u):$(id -g)" \ + -e BUILD_TYPE="${BUILD_TYPE:-Debug}" \ + -e XBMC_REF="${XBMC_REF:-origin/Omega}" \ + -v "${xbmc_root}:/src/xbmc:ro" \ + -v "${addon_root}:/src/pvr.eon:ro" \ + -v "${output_root}:/out" \ + "${image_tag}" diff --git a/tools/docker/container-build-android-aarch64.sh b/tools/docker/container-build-android-aarch64.sh new file mode 100755 index 0000000..5df2a97 --- /dev/null +++ b/tools/docker/container-build-android-aarch64.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${PVR_EON_ID:=pvr.eon}" +: "${XBMC_REF:=origin/Omega}" +: "${BUILD_TYPE:=Release}" +: "${ANDROID_HOST:=aarch64-linux-android}" +: "${ANDROID_NDK_API:=24}" +: "${ANDROID_NDK_VERSION:=28.2.13676358}" +: "${ANDROID_SDK_ROOT:=/opt/android-sdk}" + +src_root=/src +work_root=/tmp/pvr-eon-android-build +xbmc_src="${src_root}/xbmc" +addon_src="${src_root}/pvr.eon" +xbmc_work="${work_root}/xbmc" +addon_work="${work_root}/pvr.eon" +definition_dir="${work_root}/definition" +addon_depends="${work_root}/addon-depends" +depends_prefix="${work_root}/kodi-depends" +build_dir="${work_root}/build" +tarballs_dir="${work_root}/tarballs" +toolchain_template="${xbmc_work}/tools/depends/target/Toolchain_binaddons.cmake" +toolchain_file="${addon_depends}/share/Toolchain_binaddons.cmake" +output_root=/out +install_dir="${output_root}/install" +package_dir="${output_root}/zips" + +build_type_lc=$(printf '%s' "${BUILD_TYPE}" | tr '[:upper:]' '[:lower:]') +if [[ "${build_type_lc}" == "debug" ]]; then + depends_debug_flag="yes" +else + depends_debug_flag="no" +fi + +if [[ ! -d "${xbmc_src}/.git" ]]; then + echo "Expected a git checkout at ${xbmc_src}" >&2 + exit 1 +fi + +if [[ ! -f "${addon_src}/CMakeLists.txt" ]]; then + echo "Expected addon sources at ${addon_src}" >&2 + exit 1 +fi + +rm -rf "${work_root}" +rm -rf "${install_dir}" "${package_dir}" +mkdir -p \ + "${definition_dir}/${PVR_EON_ID}" \ + "${addon_depends}/share" \ + "${build_dir}" \ + "${install_dir}" \ + "${package_dir}" \ + "${tarballs_dir}" + +cp -a "${xbmc_src}" "${xbmc_work}" +cp -a "${addon_src}" "${addon_work}" + +# Keep local build output from leaking into the packaged addon. +rm -rf "${addon_work}/build" + +if ! git -C "${xbmc_work}" rev-parse --verify "${XBMC_REF}" >/dev/null 2>&1; then + echo "Missing xbmc ref '${XBMC_REF}' in the mounted repo." >&2 + echo "Fetch the Omega branch into the local xbmc clone first." >&2 + exit 1 +fi + +git -C "${xbmc_work}" checkout --detach "${XBMC_REF}" >/dev/null + +printf '%s . .\n' "${PVR_EON_ID}" > "${definition_dir}/${PVR_EON_ID}/${PVR_EON_ID}.txt" +printf 'android\n' > "${definition_dir}/${PVR_EON_ID}/platforms.txt" + +( + cd "${xbmc_work}/tools/depends" + ./bootstrap + ./configure \ + --with-tarballs="${tarballs_dir}" \ + --host="${ANDROID_HOST}" \ + --enable-debug="${depends_debug_flag}" \ + --with-sdk-path="${ANDROID_SDK_ROOT}" \ + --with-ndk-path="${ANDROID_SDK_ROOT}/ndk/${ANDROID_NDK_VERSION}" \ + --with-ndk-api="${ANDROID_NDK_API}" \ + --prefix="${depends_prefix}" +) + +if [[ ! -f "${toolchain_template}" ]]; then + echo "Kodi did not generate ${toolchain_template}" >&2 + exit 1 +fi + +sed "s|@CMAKE_FIND_ROOT_PATH@|${addon_depends}|g" \ + "${toolchain_template}" > "${toolchain_file}" + +cmake \ + -S "${xbmc_work}/cmake/addons" \ + -B "${build_dir}" \ + -DADDONS_TO_BUILD="${PVR_EON_ID}" \ + -DADDONS_DEFINITION_DIR="${definition_dir}" \ + -DADDON_SRC_PREFIX="${work_root}" \ + -DADDON_DEPENDS_PATH="${addon_depends}" \ + -DCORE_SOURCE_DIR="${xbmc_work}" \ + -DCMAKE_TOOLCHAIN_FILE="${toolchain_file}" \ + -DCMAKE_BUILD_TYPE="${BUILD_TYPE}" \ + -DCMAKE_INSTALL_PREFIX="${install_dir}" \ + -DPACKAGE_DIR="${package_dir}" \ + -DPACKAGE_ZIP=1 + +cmake --build "${build_dir}" --target "${PVR_EON_ID}" --parallel "$(nproc)" +cmake --build "${build_dir}" --target "package-${PVR_EON_ID}" --parallel "$(nproc)" + +git -C "${xbmc_work}" rev-parse HEAD > "${output_root}/xbmc-commit.txt" +git -C "${addon_work}" rev-parse HEAD > "${output_root}/pvr-eon-commit.txt" +cat > "${output_root}/android-build-info.txt" <&2 + exit 1 +fi + +if [[ ! -f "${addon_src}/CMakeLists.txt" ]]; then + echo "Expected addon sources at ${addon_src}" >&2 + exit 1 +fi + +rm -rf "${work_root}" +rm -rf "${install_dir}" "${package_dir}" +mkdir -p \ + "${definition_dir}/${PVR_EON_ID}" \ + "${build_dir}" \ + "${install_dir}" \ + "${package_dir}" + +cp -a "${xbmc_src}" "${xbmc_work}" +cp -a "${addon_src}" "${addon_work}" + +# Keep local build output from leaking into the packaged addon. +rm -rf "${addon_work}/build" + +if ! git -C "${xbmc_work}" rev-parse --verify "${XBMC_REF}" >/dev/null 2>&1; then + echo "Missing xbmc ref '${XBMC_REF}' in the mounted repo." >&2 + echo "Fetch the Omega branch into the local xbmc clone first." >&2 + exit 1 +fi + +git -C "${xbmc_work}" checkout --detach "${XBMC_REF}" >/dev/null + +printf '%s . .\n' "${PVR_EON_ID}" > "${definition_dir}/${PVR_EON_ID}/${PVR_EON_ID}.txt" +printf 'all\n' > "${definition_dir}/${PVR_EON_ID}/platforms.txt" + +cmake \ + -S "${xbmc_work}/cmake/addons" \ + -B "${build_dir}" \ + -DADDONS_TO_BUILD="${PVR_EON_ID}" \ + -DADDONS_DEFINITION_DIR="${definition_dir}" \ + -DADDON_SRC_PREFIX="${work_root}" \ + -DCMAKE_BUILD_TYPE="${BUILD_TYPE}" \ + -DCMAKE_INSTALL_PREFIX="${install_dir}" \ + -DPACKAGE_DIR="${package_dir}" \ + -DPACKAGE_ZIP=1 + +cmake --build "${build_dir}" --target "${PVR_EON_ID}" --parallel "$(nproc)" +cmake --build "${build_dir}" --target "package-${PVR_EON_ID}" --parallel "$(nproc)" + +git -C "${xbmc_work}" rev-parse HEAD > "${output_root}/xbmc-commit.txt" +git -C "${addon_work}" rev-parse HEAD > "${output_root}/pvr-eon-commit.txt" diff --git a/tools/docker/linux-amd64.Dockerfile b/tools/docker/linux-amd64.Dockerfile new file mode 100644 index 0000000..30dd6ae --- /dev/null +++ b/tools/docker/linux-amd64.Dockerfile @@ -0,0 +1,20 @@ +FROM ubuntu:22.04 + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + ca-certificates \ + cmake \ + git \ + libcurl4-openssl-dev \ + pkg-config \ + rapidjson-dev \ + zip \ + && rm -rf /var/lib/apt/lists/* + +COPY tools/docker/container-build-linux.sh /usr/local/bin/container-build-linux.sh +RUN chmod +x /usr/local/bin/container-build-linux.sh + +ENTRYPOINT ["/usr/local/bin/container-build-linux.sh"] From 8782028397a221eb66dfd2ac24a0e1ec306f0834 Mon Sep 17 00:00:00 2001 From: Yordan Cheyrekov Date: Wed, 11 Mar 2026 13:59:38 +0200 Subject: [PATCH 2/4] Add native archive streaming fixes and multi-arch Docker builds --- README.md | 24 ++-- src/PVREon.cpp | 103 ++++++++++++--- src/PVREon.h | 11 +- tools/docker/android-armv7.Dockerfile | 51 ++++++++ tools/docker/build-android-armv7.sh | 47 +++++++ tools/docker/build-linux-aarch64.sh | 41 ++++++ tools/docker/build-linux-armv7.sh | 41 ++++++ tools/docker/container-build-android-armv7.sh | 119 ++++++++++++++++++ tools/docker/container-build-linux-aarch64.sh | 116 +++++++++++++++++ tools/docker/container-build-linux-armv7.sh | 116 +++++++++++++++++ tools/docker/linux-aarch64.Dockerfile | 30 +++++ tools/docker/linux-armv7.Dockerfile | 30 +++++ 12 files changed, 704 insertions(+), 25 deletions(-) create mode 100644 tools/docker/android-armv7.Dockerfile create mode 100755 tools/docker/build-android-armv7.sh create mode 100755 tools/docker/build-linux-aarch64.sh create mode 100755 tools/docker/build-linux-armv7.sh create mode 100755 tools/docker/container-build-android-armv7.sh create mode 100755 tools/docker/container-build-linux-aarch64.sh create mode 100755 tools/docker/container-build-linux-armv7.sh create mode 100644 tools/docker/linux-aarch64.Dockerfile create mode 100644 tools/docker/linux-armv7.Dockerfile diff --git a/README.md b/README.md index 885e258..3b21a86 100644 --- a/README.md +++ b/README.md @@ -32,14 +32,22 @@ This is the EON.tv PVR client addon for Kodi. It provides Kodi integration for t - `ADDON_SRC_PREFIX` only redirects the source path. Kodi still requires an addon definition for `pvr.eon` via `ADDONS_DEFINITION_DIR` or `xbmc/cmake/addons/addons/pvr.eon/pvr.eon.txt`. - The current `xbmc` `master` branch tracks Kodi `Piers` (v22). The `Omega` branch of this addon should be built against an `xbmc` Omega checkout. -- A reproducible Docker build for Linux x86_64 is available at `tools/docker/build-linux-amd64.sh`. -- A reproducible Docker build for Android `aarch64` is available at `tools/docker/build-android-aarch64.sh`. +- Reproducible Docker builds are available under `tools/docker/` for: + - Linux `x86_64`: `build-linux-amd64.sh` + - Linux `armv7`: `build-linux-armv7.sh` + - Linux `aarch64`: `build-linux-aarch64.sh` + - Android `armv7`: `build-android-armv7.sh` + - Android `aarch64`: `build-android-aarch64.sh` -### Android aarch64 via Docker +### Docker builds 1. Ensure the sibling `xbmc` checkout has `origin/Omega`. -2. Run `./tools/docker/build-android-aarch64.sh`. -3. Use the generated zip from `build/docker-android-aarch64/zips/pvr.eon+android-aarch64/`. +2. Run the matching build script for your target from the addon root, for example `./tools/docker/build-linux-amd64.sh` or `./tools/docker/build-android-aarch64.sh`. +3. Use the generated zip from the corresponding `build/docker-*/zips/` directory. + +For ARM Linux targets, the Docker scripts default to `LINUX_RENDER_SYSTEM=gles`. If your target uses desktop OpenGL, override it, for example: + +`LINUX_RENDER_SYSTEM=gl ./tools/docker/build-linux-aarch64.sh` The Android Docker image installs: - Android SDK command-line tools @@ -55,10 +63,12 @@ For older Android Kodi builds, you can match the Kodi tag and Android NDK used b ## Notes -- Tested building it for Linux and Android / x86 and aarch64 +- Tested building it for Linux `x86_64`, Linux `armv7`, Linux `aarch64`, Android `armv7`, and Android `aarch64` - Only tested Telemach.ba, but other should work as well or should be easy to fix - Depends on inputstream addon -- Fast forward and rewind won't work in Replay TV because that is handled via specific servers which inputstream does not support +- Standard inputstream-based Replay TV still behaves like a short rolling live HLS window on EON/Vivacom, so full seek/rewind is limited there +- `Experimental native archive streaming` adds working archive seek support for finished replay programmes and for `EPG -> Play programme` on already-started events +- Direct live channel `Switch` still uses the standard live playback path and does not yet expose the native archive/timeshift behavior ##### Useful links diff --git a/src/PVREon.cpp b/src/PVREon.cpp index dda0fce..1e04128 100644 --- a/src/PVREon.cpp +++ b/src/PVREon.cpp @@ -38,6 +38,7 @@ constexpr int64_t PVR_TIME_BASE = 1000000; constexpr time_t PENDING_PLAYBACK_TTL_SECONDS = 15; constexpr int64_t NATIVE_VIRTUAL_UNITS_PER_SECOND = 1000; constexpr time_t NATIVE_SEEK_RESTART_EPSILON_SECONDS = 2; +constexpr time_t NATIVE_LIVE_EDGE_DELAY_SECONDS = 15; constexpr int NATIVE_POLL_RETRY_COUNT = 10; constexpr auto NATIVE_POLL_RETRY_DELAY = std::chrono::milliseconds(200); @@ -1327,21 +1328,29 @@ bool CPVREon::UseExperimentalNativeStream() const bool CPVREon::OpenNativeStream(const EonChannel& channel, bool isLive, time_t starttime, - time_t endtime) + time_t endtime, + time_t initialPlaybackTime, + bool liveEdge) { CloseNativeStreamInternal(); + const time_t effectivePlaybackTime = + isLive ? time(nullptr) + : std::clamp(initialPlaybackTime > 0 ? initialPlaybackTime : starttime, starttime, + std::max(starttime, endtime - 1)); + EonPlaybackUrlResult playback; - if (!BuildPlaybackUrl(channel, starttime, endtime, isLive, playback, false)) + if (!BuildPlaybackUrl(channel, effectivePlaybackTime, endtime, isLive, playback, false)) return false; m_nativeStream.open = true; m_nativeStream.isLive = isLive; m_nativeStream.seekable = !isLive; + m_nativeStream.liveEdge = liveEdge; m_nativeStream.channel = channel; m_nativeStream.programmeStartTime = isLive ? time(nullptr) : starttime; m_nativeStream.programmeEndTime = isLive ? 0 : endtime; - m_nativeStream.sessionStartTime = isLive ? time(nullptr) : starttime; + m_nativeStream.sessionStartTime = effectivePlaybackTime; m_nativeStream.sessionAnchorMonotonicMs = MonotonicNowMs(); m_nativeStream.masterUrl = playback.url; m_nativeStream.bitrate = playback.bitrate; @@ -1352,16 +1361,17 @@ bool CPVREon::OpenNativeStream(const EonChannel& channel, const int64_t duration = std::max(m_nativeStream.programmeEndTime - m_nativeStream.programmeStartTime, 1); m_nativeStream.virtualLength = duration * m_nativeStream.virtualUnitsPerSecond; - m_nativeStream.currentPosition = TimeToStreamPosition(starttime); + m_nativeStream.currentPosition = TimeToStreamPosition(effectivePlaybackTime); } kodi::Log(ADDON_LOG_INFO, - "Opening native %s stream. channel=%s uid=%i start=%lld end=%lld bitrate=%i unitsPerSecond=%lld", + "Opening native %s stream. channel=%s uid=%i programmeStart=%lld programmeEnd=%lld playbackStart=%lld bitrate=%i unitsPerSecond=%lld", isLive ? "live" : "archive", channel.strChannelName.c_str(), channel.iUniqueId, static_cast(starttime), static_cast(endtime), + static_cast(effectivePlaybackTime), playback.bitrate, static_cast(m_nativeStream.virtualUnitsPerSecond)); @@ -1398,8 +1408,10 @@ bool CPVREon::RestartNativeStreamAt(time_t starttime) if (m_nativeStream.programmeEndTime <= m_nativeStream.programmeStartTime) return false; - const time_t clampedStart = - std::clamp(starttime, m_nativeStream.programmeStartTime, m_nativeStream.programmeEndTime - 1); + const time_t seekableEndTime = GetCurrentNativeSeekableEndTime(); + const time_t clampedStart = std::clamp( + starttime, m_nativeStream.programmeStartTime, + std::max(m_nativeStream.programmeStartTime, seekableEndTime - 1)); const time_t currentPlaybackTime = StreamPositionToTime(GetCurrentNativePosition()); long long restartDelta = static_cast(clampedStart) - static_cast(currentPlaybackTime); @@ -1632,10 +1644,11 @@ int64_t CPVREon::GetCurrentNativePosition() const return 0; const int64_t basePosition = TimeToStreamPosition(m_nativeStream.sessionStartTime); + const int64_t maxSeekablePosition = TimeToStreamPosition(GetCurrentNativeSeekableEndTime()); if (m_nativeStream.sessionAnchorMonotonicMs <= 0 || m_nativeStream.virtualUnitsPerSecond <= 0) { return std::clamp(std::max(m_nativeStream.currentPosition, basePosition), 0, - m_nativeStream.virtualLength); + maxSeekablePosition); } const int64_t elapsedMs = @@ -1643,8 +1656,21 @@ int64_t CPVREon::GetCurrentNativePosition() const const int64_t elapsedUnits = (elapsedMs * m_nativeStream.virtualUnitsPerSecond) / 1000; return std::clamp( - std::max(m_nativeStream.currentPosition, basePosition + elapsedUnits), 0, - m_nativeStream.virtualLength); + std::max(m_nativeStream.currentPosition, basePosition + elapsedUnits), 0, maxSeekablePosition); +} + +time_t CPVREon::GetCurrentNativeSeekableEndTime() const +{ + if (!m_nativeStream.open || m_nativeStream.isLive) + return m_nativeStream.sessionStartTime; + + if (m_nativeStream.programmeEndTime <= m_nativeStream.programmeStartTime) + return m_nativeStream.programmeStartTime; + + const time_t now = time(nullptr); + const time_t delayedNow = + now > NATIVE_LIVE_EDGE_DELAY_SECONDS ? now - NATIVE_LIVE_EDGE_DELAY_SECONDS : now; + return std::clamp(delayedNow, m_nativeStream.programmeStartTime, m_nativeStream.programmeEndTime); } time_t CPVREon::StreamPositionToTime(int64_t position) const @@ -1856,18 +1882,27 @@ PVR_ERROR CPVREon::GetEPGTagStreamProperties( { if (UseExperimentalNativeStream()) { + const time_t now = time(nullptr); + const bool isInProgress = + now > tag.GetStartTime() && now < tag.GetEndTime(); + const time_t initialPlaybackTime = tag.GetStartTime(); + m_pendingPlayback.active = true; + m_pendingPlayback.liveEdge = isInProgress; m_pendingPlayback.channelUid = channel.iUniqueId; m_pendingPlayback.startTime = tag.GetStartTime(); m_pendingPlayback.endTime = tag.GetEndTime(); + m_pendingPlayback.initialPlaybackTime = initialPlaybackTime; m_pendingPlayback.requestTime = time(nullptr); properties.emplace_back(PVR_STREAM_PROPERTY_EPGPLAYBACKASLIVE, "true"); kodi::Log(ADDON_LOG_INFO, - "Queued archive playback for native stream mode. channel=%s uid=%i start=%lld end=%lld", + "Queued native EPG playback. channel=%s uid=%i mode=%s programmeStart=%lld programmeEnd=%lld initialPlayback=%lld", channel.strChannelName.c_str(), channel.iUniqueId, + isInProgress ? "archive-in-progress" : "archive", static_cast(m_pendingPlayback.startTime), - static_cast(m_pendingPlayback.endTime)); + static_cast(m_pendingPlayback.endTime), + static_cast(m_pendingPlayback.initialPlaybackTime)); return PVR_ERROR_NO_ERROR; } @@ -2078,17 +2113,23 @@ bool CPVREon::OpenLiveStream(const kodi::addon::PVRChannel& channel) const time_t archiveStart = usePendingArchive ? m_pendingPlayback.startTime : 0; const time_t archiveEnd = usePendingArchive ? m_pendingPlayback.endTime : 0; + const time_t initialPlaybackTime = + usePendingArchive ? m_pendingPlayback.initialPlaybackTime : 0; + const bool liveEdge = usePendingArchive ? m_pendingPlayback.liveEdge : false; kodi::Log(ADDON_LOG_INFO, - "OpenLiveStream in native mode. channel=%s uid=%i mode=%s start=%lld end=%lld", + "OpenLiveStream in native mode. channel=%s uid=%i mode=%s programmeStart=%lld programmeEnd=%lld initialPlayback=%lld liveEdge=%s", addonChannel.strChannelName.c_str(), addonChannel.iUniqueId, usePendingArchive ? "archive" : "live", static_cast(archiveStart), - static_cast(archiveEnd)); + static_cast(archiveEnd), + static_cast(initialPlaybackTime), + BoolState(liveEdge)); m_pendingPlayback = {}; - return OpenNativeStream(addonChannel, !usePendingArchive, archiveStart, archiveEnd); + return OpenNativeStream(addonChannel, !usePendingArchive, archiveStart, archiveEnd, + initialPlaybackTime, liveEdge); } void CPVREon::CloseLiveStream() @@ -2139,7 +2180,16 @@ int64_t CPVREon::SeekLiveStream(int64_t position, int whence) targetPosition = m_nativeStream.virtualLength + position; targetPosition = std::clamp(targetPosition, 0, m_nativeStream.virtualLength); + const int64_t currentPosition = GetCurrentNativePosition(); + const time_t targetTime = StreamPositionToTime(targetPosition); + kodi::Log(ADDON_LOG_INFO, + "SeekLiveStream request. whence=%d requested=%lld target=%lld current=%lld targetTime=%lld", + whence, + static_cast(position), + static_cast(targetPosition), + static_cast(currentPosition), + static_cast(targetTime)); if (!RestartNativeStreamAt(targetTime)) return -1; @@ -2152,7 +2202,13 @@ int64_t CPVREon::LengthLiveStream() if (!UseExperimentalNativeStream() || !m_nativeStream.open) return 0; - return m_nativeStream.seekable ? m_nativeStream.virtualLength : 0; + if (!m_nativeStream.seekable) + return 0; + + if (m_nativeStream.liveEdge) + return TimeToStreamPosition(GetCurrentNativeSeekableEndTime()); + + return m_nativeStream.virtualLength; } bool CPVREon::CanPauseStream() @@ -2189,11 +2245,24 @@ PVR_ERROR CPVREon::GetStreamTimes(kodi::addon::PVRStreamTimes& times) const int64_t duration = std::max(m_nativeStream.programmeEndTime - m_nativeStream.programmeStartTime, 1); + const int64_t visibleDuration = + m_nativeStream.liveEdge + ? std::max(GetCurrentNativeSeekableEndTime() - m_nativeStream.programmeStartTime, + 1) + : duration; times.SetStartTime(m_nativeStream.programmeStartTime); times.SetPTSStart(0); times.SetPTSBegin(0); - times.SetPTSEnd(duration * PVR_TIME_BASE); + times.SetPTSEnd(visibleDuration * PVR_TIME_BASE); + kodi::Log(ADDON_LOG_INFO, + "GetStreamTimes archive. programmeStart=%lld programmeEnd=%lld sessionStart=%lld seekableEnd=%lld ptsStart=%lld ptsEnd=%lld", + static_cast(m_nativeStream.programmeStartTime), + static_cast(m_nativeStream.programmeEndTime), + static_cast(m_nativeStream.sessionStartTime), + static_cast(GetCurrentNativeSeekableEndTime()), + 0LL, + static_cast(visibleDuration * PVR_TIME_BASE)); return PVR_ERROR_NO_ERROR; } diff --git a/src/PVREon.h b/src/PVREon.h index 2a8874a..9dc7798 100644 --- a/src/PVREon.h +++ b/src/PVREon.h @@ -111,9 +111,11 @@ struct EonCDN struct EonPendingPlayback { bool active = false; + bool liveEdge = false; int channelUid = 0; time_t startTime = 0; time_t endTime = 0; + time_t initialPlaybackTime = 0; time_t requestTime = 0; }; @@ -122,6 +124,7 @@ struct EonNativeStreamState bool open = false; bool isLive = true; bool seekable = false; + bool liveEdge = false; EonChannel channel; time_t programmeStartTime = 0; time_t programmeEndTime = 0; @@ -221,7 +224,12 @@ class ATTR_DLL_LOCAL CPVREon : public kodi::addon::CAddonBase, EonPlaybackUrlResult& result, const bool includeDiagnostics = true); bool UseExperimentalNativeStream() const; - bool OpenNativeStream(const EonChannel& channel, bool isLive, time_t starttime, time_t endtime); + bool OpenNativeStream(const EonChannel& channel, + bool isLive, + time_t starttime, + time_t endtime, + time_t initialPlaybackTime = 0, + bool liveEdge = false); void CloseNativeStreamInternal(); bool RestartNativeStreamAt(time_t starttime); bool UpdateNativeVariantUrl(bool logErrors = true); @@ -229,6 +237,7 @@ class ATTR_DLL_LOCAL CPVREon : public kodi::addon::CAddonBase, bool LoadNextNativeFragment(); bool FetchBinaryUrl(const std::string& url, std::vector& data, int& statusCode); int64_t GetCurrentNativePosition() const; + time_t GetCurrentNativeSeekableEndTime() const; time_t StreamPositionToTime(int64_t position) const; int64_t TimeToStreamPosition(time_t timeValue) const; diff --git a/tools/docker/android-armv7.Dockerfile b/tools/docker/android-armv7.Dockerfile new file mode 100644 index 0000000..6fe50dd --- /dev/null +++ b/tools/docker/android-armv7.Dockerfile @@ -0,0 +1,51 @@ +FROM ubuntu:22.04 + +ARG ANDROID_CMDLINE_TOOLS_URL=https://dl.google.com/android/repository/commandlinetools-linux-14742923_latest.zip +ARG ANDROID_PLATFORM=android-36 +ARG ANDROID_BUILD_TOOLS=36.0.0 +ARG ANDROID_NDK_VERSION=28.2.13676358 + +ENV DEBIAN_FRONTEND=noninteractive +ENV ANDROID_SDK_ROOT=/opt/android-sdk +ENV ANDROID_HOME=/opt/android-sdk +ENV PATH=/opt/android-sdk/cmdline-tools/latest/bin:/opt/android-sdk/platform-tools:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + autoconf \ + bison \ + build-essential \ + ca-certificates \ + cmake \ + curl \ + flex \ + gawk \ + git \ + gperf \ + lib32stdc++6 \ + lib32z1 \ + lib32z1-dev \ + pkg-config \ + openjdk-17-jdk-headless \ + unzip \ + zip \ + zlib1g-dev \ + && rm -rf /var/lib/apt/lists/* + +RUN mkdir -p /opt/android-sdk/cmdline-tools /tmp/android-sdk-download \ + && curl -fsSL "${ANDROID_CMDLINE_TOOLS_URL}" -o /tmp/android-sdk-download/commandlinetools.zip \ + && unzip -q /tmp/android-sdk-download/commandlinetools.zip -d /tmp/android-sdk-download \ + && mkdir -p /opt/android-sdk/cmdline-tools/latest \ + && cp -a /tmp/android-sdk-download/cmdline-tools/. /opt/android-sdk/cmdline-tools/latest/ \ + && yes | sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" --licenses >/dev/null \ + && sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" \ + "platform-tools" \ + "platforms;${ANDROID_PLATFORM}" \ + "build-tools;${ANDROID_BUILD_TOOLS}" \ + "ndk;${ANDROID_NDK_VERSION}" \ + && rm -rf /tmp/android-sdk-download + +COPY tools/docker/container-build-android-armv7.sh /usr/local/bin/container-build-android-armv7.sh +RUN chmod +x /usr/local/bin/container-build-android-armv7.sh + +ENTRYPOINT ["/usr/local/bin/container-build-android-armv7.sh"] diff --git a/tools/docker/build-android-armv7.sh b/tools/docker/build-android-armv7.sh new file mode 100755 index 0000000..48d04a8 --- /dev/null +++ b/tools/docker/build-android-armv7.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +addon_root=$(cd "${script_dir}/../.." && pwd) +workspace_root=$(cd "${addon_root}/.." && pwd) +xbmc_root="${workspace_root}/xbmc" +output_root="${addon_root}/build/docker-android-armv7" +image_tag="pvr-eon-builder:android-armv7" +android_platform="${ANDROID_PLATFORM:-android-36}" +android_build_tools="${ANDROID_BUILD_TOOLS:-36.0.0}" +android_ndk_version="${ANDROID_NDK_VERSION:-28.2.13676358}" + +if [[ ! -d "${xbmc_root}/.git" ]]; then + echo "Expected xbmc checkout at ${xbmc_root}" >&2 + exit 1 +fi + +if ! git -C "${xbmc_root}" rev-parse --verify origin/Omega >/dev/null 2>&1; then + echo "The local xbmc checkout does not have origin/Omega." >&2 + echo "Fetch it first, then rerun this script." >&2 + exit 1 +fi + +mkdir -p "${output_root}" + +docker build \ + --platform linux/amd64 \ + --build-arg "ANDROID_PLATFORM=${android_platform}" \ + --build-arg "ANDROID_BUILD_TOOLS=${android_build_tools}" \ + --build-arg "ANDROID_NDK_VERSION=${android_ndk_version}" \ + -t "${image_tag}" \ + -f "${script_dir}/android-armv7.Dockerfile" \ + "${addon_root}" + +docker run --rm \ + --platform linux/amd64 \ + --user "$(id -u):$(id -g)" \ + -e BUILD_TYPE="${BUILD_TYPE:-Release}" \ + -e XBMC_REF="${XBMC_REF:-origin/Omega}" \ + -e ANDROID_HOST="${ANDROID_HOST:-arm-linux-androideabi}" \ + -e ANDROID_NDK_API="${ANDROID_NDK_API:-21}" \ + -e ANDROID_NDK_VERSION="${android_ndk_version}" \ + -v "${xbmc_root}:/src/xbmc:ro" \ + -v "${addon_root}:/src/pvr.eon:ro" \ + -v "${output_root}:/out" \ + "${image_tag}" diff --git a/tools/docker/build-linux-aarch64.sh b/tools/docker/build-linux-aarch64.sh new file mode 100755 index 0000000..2750acb --- /dev/null +++ b/tools/docker/build-linux-aarch64.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +addon_root=$(cd "${script_dir}/../.." && pwd) +workspace_root=$(cd "${addon_root}/.." && pwd) +xbmc_root="${workspace_root}/xbmc" +output_root="${addon_root}/build/docker-linux-aarch64" +image_tag="pvr-eon-builder:linux-aarch64" + +if [[ ! -d "${xbmc_root}/.git" ]]; then + echo "Expected xbmc checkout at ${xbmc_root}" >&2 + exit 1 +fi + +if ! git -C "${xbmc_root}" rev-parse --verify origin/Omega >/dev/null 2>&1; then + echo "The local xbmc checkout does not have origin/Omega." >&2 + echo "Fetch it first, then rerun this script." >&2 + exit 1 +fi + +mkdir -p "${output_root}" + +docker build \ + --platform linux/amd64 \ + -t "${image_tag}" \ + -f "${script_dir}/linux-aarch64.Dockerfile" \ + "${addon_root}" + +docker run --rm \ + --platform linux/amd64 \ + --user "$(id -u):$(id -g)" \ + -e BUILD_TYPE="${BUILD_TYPE:-Release}" \ + -e XBMC_REF="${XBMC_REF:-origin/Omega}" \ + -e LINUX_HOST="${LINUX_HOST:-aarch64-linux-gnu}" \ + -e LINUX_RENDER_SYSTEM="${LINUX_RENDER_SYSTEM:-gles}" \ + -e LINUX_TOOLCHAIN="${LINUX_TOOLCHAIN:-/usr}" \ + -v "${xbmc_root}:/src/xbmc:ro" \ + -v "${addon_root}:/src/pvr.eon:ro" \ + -v "${output_root}:/out" \ + "${image_tag}" diff --git a/tools/docker/build-linux-armv7.sh b/tools/docker/build-linux-armv7.sh new file mode 100755 index 0000000..fecff7e --- /dev/null +++ b/tools/docker/build-linux-armv7.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +addon_root=$(cd "${script_dir}/../.." && pwd) +workspace_root=$(cd "${addon_root}/.." && pwd) +xbmc_root="${workspace_root}/xbmc" +output_root="${addon_root}/build/docker-linux-armv7" +image_tag="pvr-eon-builder:linux-armv7" + +if [[ ! -d "${xbmc_root}/.git" ]]; then + echo "Expected xbmc checkout at ${xbmc_root}" >&2 + exit 1 +fi + +if ! git -C "${xbmc_root}" rev-parse --verify origin/Omega >/dev/null 2>&1; then + echo "The local xbmc checkout does not have origin/Omega." >&2 + echo "Fetch it first, then rerun this script." >&2 + exit 1 +fi + +mkdir -p "${output_root}" + +docker build \ + --platform linux/amd64 \ + -t "${image_tag}" \ + -f "${script_dir}/linux-armv7.Dockerfile" \ + "${addon_root}" + +docker run --rm \ + --platform linux/amd64 \ + --user "$(id -u):$(id -g)" \ + -e BUILD_TYPE="${BUILD_TYPE:-Release}" \ + -e XBMC_REF="${XBMC_REF:-origin/Omega}" \ + -e LINUX_HOST="${LINUX_HOST:-arm-linux-gnueabihf}" \ + -e LINUX_RENDER_SYSTEM="${LINUX_RENDER_SYSTEM:-gles}" \ + -e LINUX_TOOLCHAIN="${LINUX_TOOLCHAIN:-/usr}" \ + -v "${xbmc_root}:/src/xbmc:ro" \ + -v "${addon_root}:/src/pvr.eon:ro" \ + -v "${output_root}:/out" \ + "${image_tag}" diff --git a/tools/docker/container-build-android-armv7.sh b/tools/docker/container-build-android-armv7.sh new file mode 100755 index 0000000..3e03283 --- /dev/null +++ b/tools/docker/container-build-android-armv7.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${PVR_EON_ID:=pvr.eon}" +: "${XBMC_REF:=origin/Omega}" +: "${BUILD_TYPE:=Release}" +: "${ANDROID_HOST:=arm-linux-androideabi}" +: "${ANDROID_NDK_API:=21}" +: "${ANDROID_NDK_VERSION:=28.2.13676358}" +: "${ANDROID_SDK_ROOT:=/opt/android-sdk}" + +src_root=/src +work_root=/tmp/pvr-eon-android-armv7-build +xbmc_src="${src_root}/xbmc" +addon_src="${src_root}/pvr.eon" +xbmc_work="${work_root}/xbmc" +addon_work="${work_root}/pvr.eon" +definition_dir="${work_root}/definition" +addon_depends="${work_root}/addon-depends" +depends_prefix="${work_root}/kodi-depends" +build_dir="${work_root}/build" +tarballs_dir="${work_root}/tarballs" +toolchain_template="${xbmc_work}/tools/depends/target/Toolchain_binaddons.cmake" +toolchain_file="${addon_depends}/share/Toolchain_binaddons.cmake" +output_root=/out +install_dir="${output_root}/install" +package_dir="${output_root}/zips" + +build_type_lc=$(printf '%s' "${BUILD_TYPE}" | tr '[:upper:]' '[:lower:]') +if [[ "${build_type_lc}" == "debug" ]]; then + depends_debug_flag="yes" +else + depends_debug_flag="no" +fi + +if [[ ! -d "${xbmc_src}/.git" ]]; then + echo "Expected a git checkout at ${xbmc_src}" >&2 + exit 1 +fi + +if [[ ! -f "${addon_src}/CMakeLists.txt" ]]; then + echo "Expected addon sources at ${addon_src}" >&2 + exit 1 +fi + +rm -rf "${work_root}" +rm -rf "${install_dir}" "${package_dir}" +mkdir -p \ + "${definition_dir}/${PVR_EON_ID}" \ + "${addon_depends}/share" \ + "${build_dir}" \ + "${install_dir}" \ + "${package_dir}" \ + "${tarballs_dir}" + +cp -a "${xbmc_src}" "${xbmc_work}" +cp -a "${addon_src}" "${addon_work}" + +# Keep local build output from leaking into the packaged addon. +rm -rf "${addon_work}/build" + +if ! git -C "${xbmc_work}" rev-parse --verify "${XBMC_REF}" >/dev/null 2>&1; then + echo "Missing xbmc ref '${XBMC_REF}' in the mounted repo." >&2 + echo "Fetch the Omega branch into the local xbmc clone first." >&2 + exit 1 +fi + +git -C "${xbmc_work}" checkout --detach "${XBMC_REF}" >/dev/null + +printf '%s . .\n' "${PVR_EON_ID}" > "${definition_dir}/${PVR_EON_ID}/${PVR_EON_ID}.txt" +printf 'android\n' > "${definition_dir}/${PVR_EON_ID}/platforms.txt" + +( + cd "${xbmc_work}/tools/depends" + ./bootstrap + ./configure \ + --with-tarballs="${tarballs_dir}" \ + --host="${ANDROID_HOST}" \ + --enable-debug="${depends_debug_flag}" \ + --with-sdk-path="${ANDROID_SDK_ROOT}" \ + --with-ndk-path="${ANDROID_SDK_ROOT}/ndk/${ANDROID_NDK_VERSION}" \ + --with-ndk-api="${ANDROID_NDK_API}" \ + --prefix="${depends_prefix}" +) + +if [[ ! -f "${toolchain_template}" ]]; then + echo "Kodi did not generate ${toolchain_template}" >&2 + exit 1 +fi + +sed "s|@CMAKE_FIND_ROOT_PATH@|${addon_depends}|g" \ + "${toolchain_template}" > "${toolchain_file}" + +cmake \ + -S "${xbmc_work}/cmake/addons" \ + -B "${build_dir}" \ + -DADDONS_TO_BUILD="${PVR_EON_ID}" \ + -DADDONS_DEFINITION_DIR="${definition_dir}" \ + -DADDON_SRC_PREFIX="${work_root}" \ + -DADDON_DEPENDS_PATH="${addon_depends}" \ + -DCORE_SOURCE_DIR="${xbmc_work}" \ + -DCMAKE_TOOLCHAIN_FILE="${toolchain_file}" \ + -DCMAKE_BUILD_TYPE="${BUILD_TYPE}" \ + -DCMAKE_INSTALL_PREFIX="${install_dir}" \ + -DPACKAGE_DIR="${package_dir}" \ + -DPACKAGE_ZIP=1 + +cmake --build "${build_dir}" --target "${PVR_EON_ID}" --parallel "$(nproc)" +cmake --build "${build_dir}" --target "package-${PVR_EON_ID}" --parallel "$(nproc)" + +git -C "${xbmc_work}" rev-parse HEAD > "${output_root}/xbmc-commit.txt" +git -C "${addon_work}" rev-parse HEAD > "${output_root}/pvr-eon-commit.txt" +cat > "${output_root}/android-build-info.txt" <&2 + exit 1 +fi + +if [[ ! -f "${addon_src}/CMakeLists.txt" ]]; then + echo "Expected addon sources at ${addon_src}" >&2 + exit 1 +fi + +rm -rf "${work_root}" +rm -rf "${install_dir}" "${package_dir}" +mkdir -p \ + "${definition_dir}/${PVR_EON_ID}" \ + "${addon_depends}/share" \ + "${build_dir}" \ + "${install_dir}" \ + "${package_dir}" \ + "${tarballs_dir}" + +cp -a "${xbmc_src}" "${xbmc_work}" +cp -a "${addon_src}" "${addon_work}" + +# Keep local build output from leaking into the packaged addon. +rm -rf "${addon_work}/build" + +if ! git -C "${xbmc_work}" rev-parse --verify "${XBMC_REF}" >/dev/null 2>&1; then + echo "Missing xbmc ref '${XBMC_REF}' in the mounted repo." >&2 + echo "Fetch the Omega branch into the local xbmc clone first." >&2 + exit 1 +fi + +git -C "${xbmc_work}" checkout --detach "${XBMC_REF}" >/dev/null + +printf '%s . .\n' "${PVR_EON_ID}" > "${definition_dir}/${PVR_EON_ID}/${PVR_EON_ID}.txt" +printf 'linux\n' > "${definition_dir}/${PVR_EON_ID}/platforms.txt" + +( + cd "${xbmc_work}/tools/depends" + ./bootstrap + ./configure \ + --with-tarballs="${tarballs_dir}" \ + --host="${LINUX_HOST}" \ + --enable-debug="${depends_debug_flag}" \ + --with-toolchain="${LINUX_TOOLCHAIN}" \ + --with-rendersystem="${LINUX_RENDER_SYSTEM}" \ + --prefix="${depends_prefix}" +) + +if [[ ! -f "${toolchain_template}" ]]; then + echo "Kodi did not generate ${toolchain_template}" >&2 + exit 1 +fi + +sed "s|@CMAKE_FIND_ROOT_PATH@|${addon_depends}|g" \ + "${toolchain_template}" > "${toolchain_file}" + +cmake \ + -S "${xbmc_work}/cmake/addons" \ + -B "${build_dir}" \ + -DADDONS_TO_BUILD="${PVR_EON_ID}" \ + -DADDONS_DEFINITION_DIR="${definition_dir}" \ + -DADDON_SRC_PREFIX="${work_root}" \ + -DADDON_DEPENDS_PATH="${addon_depends}" \ + -DCORE_SOURCE_DIR="${xbmc_work}" \ + -DCMAKE_TOOLCHAIN_FILE="${toolchain_file}" \ + -DCMAKE_BUILD_TYPE="${BUILD_TYPE}" \ + -DCMAKE_INSTALL_PREFIX="${install_dir}" \ + -DPACKAGE_DIR="${package_dir}" \ + -DPACKAGE_ZIP=1 + +cmake --build "${build_dir}" --target "${PVR_EON_ID}" --parallel "$(nproc)" +cmake --build "${build_dir}" --target "package-${PVR_EON_ID}" --parallel "$(nproc)" + +git -C "${xbmc_work}" rev-parse HEAD > "${output_root}/xbmc-commit.txt" +git -C "${addon_work}" rev-parse HEAD > "${output_root}/pvr-eon-commit.txt" +cat > "${output_root}/linux-build-info.txt" <&2 + exit 1 +fi + +if [[ ! -f "${addon_src}/CMakeLists.txt" ]]; then + echo "Expected addon sources at ${addon_src}" >&2 + exit 1 +fi + +rm -rf "${work_root}" +rm -rf "${install_dir}" "${package_dir}" +mkdir -p \ + "${definition_dir}/${PVR_EON_ID}" \ + "${addon_depends}/share" \ + "${build_dir}" \ + "${install_dir}" \ + "${package_dir}" \ + "${tarballs_dir}" + +cp -a "${xbmc_src}" "${xbmc_work}" +cp -a "${addon_src}" "${addon_work}" + +# Keep local build output from leaking into the packaged addon. +rm -rf "${addon_work}/build" + +if ! git -C "${xbmc_work}" rev-parse --verify "${XBMC_REF}" >/dev/null 2>&1; then + echo "Missing xbmc ref '${XBMC_REF}' in the mounted repo." >&2 + echo "Fetch the Omega branch into the local xbmc clone first." >&2 + exit 1 +fi + +git -C "${xbmc_work}" checkout --detach "${XBMC_REF}" >/dev/null + +printf '%s . .\n' "${PVR_EON_ID}" > "${definition_dir}/${PVR_EON_ID}/${PVR_EON_ID}.txt" +printf 'linux\n' > "${definition_dir}/${PVR_EON_ID}/platforms.txt" + +( + cd "${xbmc_work}/tools/depends" + ./bootstrap + ./configure \ + --with-tarballs="${tarballs_dir}" \ + --host="${LINUX_HOST}" \ + --enable-debug="${depends_debug_flag}" \ + --with-toolchain="${LINUX_TOOLCHAIN}" \ + --with-rendersystem="${LINUX_RENDER_SYSTEM}" \ + --prefix="${depends_prefix}" +) + +if [[ ! -f "${toolchain_template}" ]]; then + echo "Kodi did not generate ${toolchain_template}" >&2 + exit 1 +fi + +sed "s|@CMAKE_FIND_ROOT_PATH@|${addon_depends}|g" \ + "${toolchain_template}" > "${toolchain_file}" + +cmake \ + -S "${xbmc_work}/cmake/addons" \ + -B "${build_dir}" \ + -DADDONS_TO_BUILD="${PVR_EON_ID}" \ + -DADDONS_DEFINITION_DIR="${definition_dir}" \ + -DADDON_SRC_PREFIX="${work_root}" \ + -DADDON_DEPENDS_PATH="${addon_depends}" \ + -DCORE_SOURCE_DIR="${xbmc_work}" \ + -DCMAKE_TOOLCHAIN_FILE="${toolchain_file}" \ + -DCMAKE_BUILD_TYPE="${BUILD_TYPE}" \ + -DCMAKE_INSTALL_PREFIX="${install_dir}" \ + -DPACKAGE_DIR="${package_dir}" \ + -DPACKAGE_ZIP=1 + +cmake --build "${build_dir}" --target "${PVR_EON_ID}" --parallel "$(nproc)" +cmake --build "${build_dir}" --target "package-${PVR_EON_ID}" --parallel "$(nproc)" + +git -C "${xbmc_work}" rev-parse HEAD > "${output_root}/xbmc-commit.txt" +git -C "${addon_work}" rev-parse HEAD > "${output_root}/pvr-eon-commit.txt" +cat > "${output_root}/linux-build-info.txt" < Date: Thu, 12 Mar 2026 13:53:54 +0200 Subject: [PATCH 3/4] Fix Android ARMv7 loading and improve cross-version build compatibility --- CMakeLists.txt | 6 +++ pvr.eon/addon.xml.in | 2 +- src/PVREon.cpp | 48 +++++++++++++++---- src/PVREon.h | 3 ++ src/SHA256.cpp | 12 ++--- src/Utils.cpp | 28 ++++++----- src/Utils.h | 1 - tools/docker/build-android-aarch64.sh | 4 +- tools/docker/build-android-armv7.sh | 2 +- tools/docker/build-linux-armv7-lynx4k.sh | 42 ++++++++++++++++ .../docker/container-build-android-aarch64.sh | 4 +- tools/docker/container-build-android-armv7.sh | 3 +- tools/docker/container-build-linux-armv7.sh | 13 ++++- 13 files changed, 130 insertions(+), 38 deletions(-) create mode 100755 tools/docker/build-linux-armv7-lynx4k.sh diff --git a/CMakeLists.txt b/CMakeLists.txt index 8923ea4..e03155f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -40,6 +40,12 @@ set(EON_HEADERS addon_version(pvr.eon EON) add_definitions(-DEON_VERSION=${EON_VERSION}) +if(DEFINED APP_VERSION_MAJOR) + set(INPUTSTREAM_ADAPTIVE_MINVERSION "${APP_VERSION_MAJOR}.0.0") +else() + set(INPUTSTREAM_ADAPTIVE_MINVERSION "21.0.0") +endif() + build_addon(pvr.eon EON DEPLIBS) include(CPack) diff --git a/pvr.eon/addon.xml.in b/pvr.eon/addon.xml.in index 8b10024..95e8591 100644 --- a/pvr.eon/addon.xml.in +++ b/pvr.eon/addon.xml.in @@ -6,7 +6,7 @@ provider-name="Nirvana"> @ADDON_DEPENDS@ - + diff --git a/src/PVREon.cpp b/src/PVREon.cpp index 1e04128..88e23cd 100644 --- a/src/PVREon.cpp +++ b/src/PVREon.cpp @@ -39,6 +39,7 @@ constexpr time_t PENDING_PLAYBACK_TTL_SECONDS = 15; constexpr int64_t NATIVE_VIRTUAL_UNITS_PER_SECOND = 1000; constexpr time_t NATIVE_SEEK_RESTART_EPSILON_SECONDS = 2; constexpr time_t NATIVE_LIVE_EDGE_DELAY_SECONDS = 15; +constexpr int64_t NATIVE_INITIAL_SEEK_IGNORE_WINDOW_MS = 4000; constexpr int NATIVE_POLL_RETRY_COUNT = 10; constexpr auto NATIVE_POLL_RETRY_DELAY = std::chrono::milliseconds(200); @@ -297,13 +298,7 @@ std::string aes_encrypt_cbc(const std::string &iv_str, const std::string &key, c // encrypt AES_CBC_encrypt_buffer(&ctx, hexarray, dlenu); - std::ostringstream convert; - for (int i = 0; i < dlenu; i++) { - convert << hexarray[i]; - } - std::string output = convert.str(); - - return output; + return std::string(reinterpret_cast(hexarray), dlenu); } bool CPVREon::GetPostJson(const std::string& url, const std::string& body, rapidjson::Document& doc) @@ -1262,10 +1257,10 @@ bool CPVREon::BuildPlaybackUrl(const EonChannel& channel, std::string key = base64_decode(urlsafedecode(m_settings->GetEonStreamKey())); - std::ostringstream convert; + std::string iv_str; + iv_str.reserve(block_size); for (int i = 0; i < block_size; i++) - convert << static_cast(rand()); - std::string iv_str = convert.str(); + iv_str.push_back(static_cast(rand() & 0xFF)); std::string enc_str = aes_encrypt_cbc(iv_str, key, plain_aes); @@ -1347,10 +1342,14 @@ bool CPVREon::OpenNativeStream(const EonChannel& channel, m_nativeStream.isLive = isLive; m_nativeStream.seekable = !isLive; m_nativeStream.liveEdge = liveEdge; + m_nativeStream.startupTimelineReady = false; + m_nativeStream.ignoreInitialArchiveSeeks = + !isLive && !liveEdge && effectivePlaybackTime <= starttime; m_nativeStream.channel = channel; m_nativeStream.programmeStartTime = isLive ? time(nullptr) : starttime; m_nativeStream.programmeEndTime = isLive ? 0 : endtime; m_nativeStream.sessionStartTime = effectivePlaybackTime; + m_nativeStream.openMonotonicMs = MonotonicNowMs(); m_nativeStream.sessionAnchorMonotonicMs = MonotonicNowMs(); m_nativeStream.masterUrl = playback.url; m_nativeStream.bitrate = playback.bitrate; @@ -2182,6 +2181,24 @@ int64_t CPVREon::SeekLiveStream(int64_t position, int whence) targetPosition = std::clamp(targetPosition, 0, m_nativeStream.virtualLength); const int64_t currentPosition = GetCurrentNativePosition(); + if (m_nativeStream.ignoreInitialArchiveSeeks && whence == SEEK_SET && targetPosition > 0) + { + const int64_t startupAgeMs = + std::max(MonotonicNowMs() - m_nativeStream.openMonotonicMs, 0); + if (startupAgeMs <= NATIVE_INITIAL_SEEK_IGNORE_WINDOW_MS) + { + kodi::Log(ADDON_LOG_INFO, + "Ignoring initial Kodi archive seek. requested=%lld target=%lld current=%lld ageMs=%lld", + static_cast(position), + static_cast(targetPosition), + static_cast(currentPosition), + static_cast(startupAgeMs)); + return currentPosition; + } + + m_nativeStream.ignoreInitialArchiveSeeks = false; + } + const time_t targetTime = StreamPositionToTime(targetPosition); kodi::Log(ADDON_LOG_INFO, "SeekLiveStream request. whence=%d requested=%lld target=%lld current=%lld targetTime=%lld", @@ -2251,6 +2268,17 @@ PVR_ERROR CPVREon::GetStreamTimes(kodi::addon::PVRStreamTimes& times) 1) : duration; + if (!m_nativeStream.startupTimelineReady) + { + m_nativeStream.startupTimelineReady = true; + if (m_nativeStream.ignoreInitialArchiveSeeks) + { + m_nativeStream.ignoreInitialArchiveSeeks = false; + kodi::Log(ADDON_LOG_INFO, + "Native archive startup timeline ready. Enabling archive seeks."); + } + } + times.SetStartTime(m_nativeStream.programmeStartTime); times.SetPTSStart(0); times.SetPTSBegin(0); diff --git a/src/PVREon.h b/src/PVREon.h index 9dc7798..e727ff0 100644 --- a/src/PVREon.h +++ b/src/PVREon.h @@ -125,6 +125,8 @@ struct EonNativeStreamState bool isLive = true; bool seekable = false; bool liveEdge = false; + bool startupTimelineReady = false; + bool ignoreInitialArchiveSeeks = false; EonChannel channel; time_t programmeStartTime = 0; time_t programmeEndTime = 0; @@ -132,6 +134,7 @@ struct EonNativeStreamState int64_t virtualUnitsPerSecond = 1000; int64_t virtualLength = 0; int64_t currentPosition = 0; + int64_t openMonotonicMs = 0; int64_t sessionAnchorMonotonicMs = 0; int bitrate = 0; std::string masterUrl; diff --git a/src/SHA256.cpp b/src/SHA256.cpp index 9cc49c1..465d32b 100644 --- a/src/SHA256.cpp +++ b/src/SHA256.cpp @@ -1,7 +1,5 @@ #include "SHA256.h" #include -#include -#include SHA256::SHA256(): m_blocklen(0), m_bitlen(0) { m_state[0] = 0x6a09e667; @@ -142,12 +140,14 @@ void SHA256::revert(uint8_t * hash) { } std::string SHA256::toString(const uint8_t * digest) { - std::stringstream s; - s << std::setfill('0') << std::hex; + static const char hex_digits[] = "0123456789abcdef"; + std::string output; + output.reserve(64); for(uint8_t i = 0 ; i < 32 ; i++) { - s << std::setw(2) << (unsigned int) digest[i]; + output.push_back(hex_digits[(digest[i] >> 4) & 0x0F]); + output.push_back(hex_digits[digest[i] & 0x0F]); } - return s.str(); + return output; } diff --git a/src/Utils.cpp b/src/Utils.cpp index d9ad631..608771c 100644 --- a/src/Utils.cpp +++ b/src/Utils.cpp @@ -5,9 +5,9 @@ #include #include -#include #include -#include +#include +#include #include #include @@ -21,32 +21,34 @@ std::string Utils::GetFilePath(const std::string &strPath, bool bUserPath) // http://stackoverflow.com/a/17708801 std::string Utils::UrlEncode(const std::string &value) { - std::ostringstream escaped; - escaped.fill('0'); - escaped << std::hex; + static const char hex_digits[] = "0123456789abcdef"; + std::string escaped; + escaped.reserve(value.size() * 3); for (char c : value) { // Keep alphanumeric and other accepted characters intact if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~' || c == '!') // Exclamation mark should not be here but Zattoo does not correctly encode it { - escaped << c; + escaped.push_back(c); continue; } // Any other characters are percent-encoded - escaped << '%' << std::setw(2) << int((unsigned char) c); + escaped.push_back('%'); + escaped.push_back(hex_digits[(static_cast(c) >> 4) & 0x0F]); + escaped.push_back(hex_digits[static_cast(c) & 0x0F]); } - return escaped.str(); + return escaped; } double Utils::StringToDouble(const std::string &value) { - std::istringstream iss(value); - double result; - - iss >> result; - + errno = 0; + char* end = nullptr; + const double result = std::strtod(value.c_str(), &end); + if (end == value.c_str() || errno != 0) + return 0.0; return result; } diff --git a/src/Utils.h b/src/Utils.h index c370199..d925073 100644 --- a/src/Utils.h +++ b/src/Utils.h @@ -1,6 +1,5 @@ #pragma once -#include #include #include #include "rapidjson/document.h" diff --git a/tools/docker/build-android-aarch64.sh b/tools/docker/build-android-aarch64.sh index 9e39c1e..0efd763 100755 --- a/tools/docker/build-android-aarch64.sh +++ b/tools/docker/build-android-aarch64.sh @@ -9,7 +9,7 @@ output_root="${addon_root}/build/docker-android-aarch64" image_tag="pvr-eon-builder:android-aarch64" android_platform="${ANDROID_PLATFORM:-android-36}" android_build_tools="${ANDROID_BUILD_TOOLS:-36.0.0}" -android_ndk_version="${ANDROID_NDK_VERSION:-28.2.13676358}" +android_ndk_version="${ANDROID_NDK_VERSION:-21.4.7075529}" if [[ ! -d "${xbmc_root}/.git" ]]; then echo "Expected xbmc checkout at ${xbmc_root}" >&2 @@ -38,7 +38,7 @@ docker run --rm \ --user "$(id -u):$(id -g)" \ -e BUILD_TYPE="${BUILD_TYPE:-Release}" \ -e XBMC_REF="${XBMC_REF:-origin/Omega}" \ - -e ANDROID_NDK_API="${ANDROID_NDK_API:-24}" \ + -e ANDROID_NDK_API="${ANDROID_NDK_API:-21}" \ -e ANDROID_NDK_VERSION="${android_ndk_version}" \ -v "${xbmc_root}:/src/xbmc:ro" \ -v "${addon_root}:/src/pvr.eon:ro" \ diff --git a/tools/docker/build-android-armv7.sh b/tools/docker/build-android-armv7.sh index 48d04a8..52ab701 100755 --- a/tools/docker/build-android-armv7.sh +++ b/tools/docker/build-android-armv7.sh @@ -9,7 +9,7 @@ output_root="${addon_root}/build/docker-android-armv7" image_tag="pvr-eon-builder:android-armv7" android_platform="${ANDROID_PLATFORM:-android-36}" android_build_tools="${ANDROID_BUILD_TOOLS:-36.0.0}" -android_ndk_version="${ANDROID_NDK_VERSION:-28.2.13676358}" +android_ndk_version="${ANDROID_NDK_VERSION:-21.4.7075529}" if [[ ! -d "${xbmc_root}/.git" ]]; then echo "Expected xbmc checkout at ${xbmc_root}" >&2 diff --git a/tools/docker/build-linux-armv7-lynx4k.sh b/tools/docker/build-linux-armv7-lynx4k.sh new file mode 100755 index 0000000..680933e --- /dev/null +++ b/tools/docker/build-linux-armv7-lynx4k.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +addon_root=$(cd "${script_dir}/../.." && pwd) +workspace_root=$(cd "${addon_root}/.." && pwd) +xbmc_root="${workspace_root}/xbmc" +output_root="${addon_root}/build/Lynx4k" +image_tag="pvr-eon-builder:linux-armv7" + +if [[ ! -d "${xbmc_root}/.git" ]]; then + echo "Expected xbmc checkout at ${xbmc_root}" >&2 + exit 1 +fi + +if ! git -C "${xbmc_root}" rev-parse --verify origin/Nexus >/dev/null 2>&1; then + echo "The local xbmc checkout does not have origin/Nexus." >&2 + echo "Fetch it first, then rerun this script." >&2 + exit 1 +fi + +mkdir -p "${output_root}" + +docker build \ + --platform linux/amd64 \ + -t "${image_tag}" \ + -f "${script_dir}/linux-armv7.Dockerfile" \ + "${addon_root}" + +docker run --rm \ + --platform linux/amd64 \ + --user "$(id -u):$(id -g)" \ + -e BUILD_TYPE="${BUILD_TYPE:-Release}" \ + -e XBMC_REF="${XBMC_REF:-origin/Nexus}" \ + -e PVR_EON_REF="${PVR_EON_REF:-}" \ + -e LINUX_HOST="${LINUX_HOST:-arm-linux-gnueabihf}" \ + -e LINUX_RENDER_SYSTEM="${LINUX_RENDER_SYSTEM:-gles}" \ + -e LINUX_TOOLCHAIN="${LINUX_TOOLCHAIN:-/usr}" \ + -v "${xbmc_root}:/src/xbmc:ro" \ + -v "${addon_root}:/src/pvr.eon:ro" \ + -v "${output_root}:/out" \ + "${image_tag}" diff --git a/tools/docker/container-build-android-aarch64.sh b/tools/docker/container-build-android-aarch64.sh index 5df2a97..2814ebb 100755 --- a/tools/docker/container-build-android-aarch64.sh +++ b/tools/docker/container-build-android-aarch64.sh @@ -5,8 +5,8 @@ set -euo pipefail : "${XBMC_REF:=origin/Omega}" : "${BUILD_TYPE:=Release}" : "${ANDROID_HOST:=aarch64-linux-android}" -: "${ANDROID_NDK_API:=24}" -: "${ANDROID_NDK_VERSION:=28.2.13676358}" +: "${ANDROID_NDK_API:=21}" +: "${ANDROID_NDK_VERSION:=21.4.7075529}" : "${ANDROID_SDK_ROOT:=/opt/android-sdk}" src_root=/src diff --git a/tools/docker/container-build-android-armv7.sh b/tools/docker/container-build-android-armv7.sh index 3e03283..5b431e3 100755 --- a/tools/docker/container-build-android-armv7.sh +++ b/tools/docker/container-build-android-armv7.sh @@ -6,7 +6,7 @@ set -euo pipefail : "${BUILD_TYPE:=Release}" : "${ANDROID_HOST:=arm-linux-androideabi}" : "${ANDROID_NDK_API:=21}" -: "${ANDROID_NDK_VERSION:=28.2.13676358}" +: "${ANDROID_NDK_VERSION:=21.4.7075529}" : "${ANDROID_SDK_ROOT:=/opt/android-sdk}" src_root=/src @@ -101,6 +101,7 @@ cmake \ -DCORE_SOURCE_DIR="${xbmc_work}" \ -DCMAKE_TOOLCHAIN_FILE="${toolchain_file}" \ -DCMAKE_BUILD_TYPE="${BUILD_TYPE}" \ + -DCMAKE_SHARED_LINKER_FLAGS="-static-libstdc++" \ -DCMAKE_INSTALL_PREFIX="${install_dir}" \ -DPACKAGE_DIR="${package_dir}" \ -DPACKAGE_ZIP=1 diff --git a/tools/docker/container-build-linux-armv7.sh b/tools/docker/container-build-linux-armv7.sh index c9aaa0e..7f1a585 100755 --- a/tools/docker/container-build-linux-armv7.sh +++ b/tools/docker/container-build-linux-armv7.sh @@ -3,6 +3,7 @@ set -euo pipefail : "${PVR_EON_ID:=pvr.eon}" : "${XBMC_REF:=origin/Omega}" +: "${PVR_EON_REF:=}" : "${BUILD_TYPE:=Release}" : "${LINUX_HOST:=arm-linux-gnueabihf}" : "${LINUX_RENDER_SYSTEM:=gles}" @@ -64,7 +65,17 @@ if ! git -C "${xbmc_work}" rev-parse --verify "${XBMC_REF}" >/dev/null 2>&1; the exit 1 fi -git -C "${xbmc_work}" checkout --detach "${XBMC_REF}" >/dev/null +git -C "${xbmc_work}" checkout --detach -f "${XBMC_REF}" >/dev/null + +if [[ -n "${PVR_EON_REF}" ]]; then + if ! git -C "${addon_work}" rev-parse --verify "${PVR_EON_REF}" >/dev/null 2>&1; then + echo "Missing addon ref '${PVR_EON_REF}' in the mounted repo." >&2 + echo "Fetch the requested branch into the local pvr.eon clone first." >&2 + exit 1 + fi + + git -C "${addon_work}" checkout --detach -f "${PVR_EON_REF}" >/dev/null +fi printf '%s . .\n' "${PVR_EON_ID}" > "${definition_dir}/${PVR_EON_ID}/${PVR_EON_ID}.txt" printf 'linux\n' > "${definition_dir}/${PVR_EON_ID}/platforms.txt" From 99dd6ec36a8de634b786c81ac228cac139fb6f27 Mon Sep 17 00:00:00 2001 From: Yordan Cheyrekov Date: Fri, 10 Apr 2026 12:05:03 +0300 Subject: [PATCH 4/4] Fixed the auth regression. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root cause was not the password payload. Vivacom’s live broker brand list changed order, and the addon was still treating the provider setting as a raw array index. In current live data, Vivacom is brand index 2, but the addon setting for Vivacom is still 3, so the addon was resolving Nova’s forthnet API base instead of Vivacom’s. I patched PVREon.cpp (line 206) and PVREon.cpp (line 412) to resolve providers by stable brand identifier (vivacom) instead of current broker position. I also verified live auth against the real Vivacom base: device registration, password token exchange, v1/sp, and v1/households all succeed there, and fail on the wrongly resolved forthnet base. --- pvr.eon/addon.xml.in | 2 +- pvr.eon/changelog.txt | 2 ++ src/PVREon.cpp | 50 +++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/pvr.eon/addon.xml.in b/pvr.eon/addon.xml.in index 95e8591..a3f5d88 100644 --- a/pvr.eon/addon.xml.in +++ b/pvr.eon/addon.xml.in @@ -1,7 +1,7 @@ diff --git a/pvr.eon/changelog.txt b/pvr.eon/changelog.txt index 4093e8e..5585af6 100644 --- a/pvr.eon/changelog.txt +++ b/pvr.eon/changelog.txt @@ -1,3 +1,5 @@ +v21.7.11 + - Maintenance release v21.5.0 - Initial release v21.6.0 diff --git a/src/PVREon.cpp b/src/PVREon.cpp index 88e23cd..c1dc081 100644 --- a/src/PVREon.cpp +++ b/src/PVREon.cpp @@ -203,6 +203,23 @@ std::vector ExtractMediaSegmentUrls(const std::string& playlistBody return urls; } +std::string ExpectedBrandIdentifier(int providerSetting) +{ + switch (providerSetting) + { + case 0: // SBB + return "sbb-qa"; + case 1: // Telemach + return "telemach"; + case 3: // Vivacom + return "vivacom"; + case 5: // Nova + return "nova"; + default: + return ""; + } +} + } // namespace /*********************************************************** @@ -404,6 +421,7 @@ std::string CPVREon::GetBrandIdentifier() int i = 0; int sp_id = m_settings->GetEonServiceProvider(); + const std::string expected_identifier = ExpectedBrandIdentifier(sp_id); kodi::Log(ADDON_LOG_DEBUG, "Requested Service Provider ID:%u", sp_id); const rapidjson::Value& brands = doc; @@ -411,13 +429,41 @@ std::string CPVREon::GetBrandIdentifier() for (rapidjson::Value::ConstValueIterator itr1 = brands.Begin(); itr1 != brands.End(); ++itr1) { - if (i == sp_id) { - const rapidjson::Value& brandItem = (*itr1); + const rapidjson::Value& brandItem = (*itr1); + const std::string identifier = Utils::JsonStringOrEmpty(brandItem, "identifier"); + if (!expected_identifier.empty() && identifier == expected_identifier) + { + kodi::Log(ADDON_LOG_INFO, + "Resolved provider setting %i via stable brand identifier '%s'.", + sp_id, identifier.c_str()); return Utils::JsonStringOrEmpty(brandItem, "cdnIdentifier"); } + + if (expected_identifier.empty() && i == sp_id) + return Utils::JsonStringOrEmpty(brandItem, "cdnIdentifier"); + i++; } + if (!expected_identifier.empty()) + { + kodi::Log(ADDON_LOG_WARNING, + "Stable brand identifier '%s' was not found for provider setting %i. Falling back to legacy index mapping.", + expected_identifier.c_str(), sp_id); + + i = 0; + for (rapidjson::Value::ConstValueIterator itr1 = brands.Begin(); + itr1 != brands.End(); ++itr1) + { + if (i == sp_id) + { + const rapidjson::Value& brandItem = (*itr1); + return Utils::JsonStringOrEmpty(brandItem, "cdnIdentifier"); + } + i++; + } + } + return ""; }