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/README.md b/README.md index ed5ef09..3b21a86 100644 --- a/README.md +++ b/README.md @@ -28,12 +28,47 @@ 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. +- 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` + +### Docker builds + +1. Ensure the sibling `xbmc` checkout has `origin/Omega`. +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 +- 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 +- 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/pvr.eon/addon.xml.in b/pvr.eon/addon.xml.in index 8b10024..a3f5d88 100644 --- a/pvr.eon/addon.xml.in +++ b/pvr.eon/addon.xml.in @@ -1,12 +1,12 @@ @ADDON_DEPENDS@ - + 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/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..c1dc081 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,196 @@ 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 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); + +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; +} + +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 + /*********************************************************** * PVR Client AddOn specific public library functions ***********************************************************/ @@ -119,13 +315,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) @@ -144,10 +334,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"); @@ -228,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; @@ -235,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 ""; } @@ -340,6 +562,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 +769,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 +778,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 +818,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 +838,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 +847,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 +947,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 +961,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 +983,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 +1064,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 +1122,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 +1171,579 @@ 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::string iv_str; + iv_str.reserve(block_size); + for (int i = 0; i < block_size; i++) + iv_str.push_back(static_cast(rand() & 0xFF)); + + 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, + 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, 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.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; + 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(effectivePlaybackTime); + } + + kodi::Log(ADDON_LOG_INFO, + "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)); + + 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 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); + 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); + 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, + maxSeekablePosition); + } + + 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, 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 +{ + 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 +1760,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 +1915,43 @@ 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()) + { + 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 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.initialPlaybackTime)); + return PVR_ERROR_NO_ERROR; + } + + return GetStreamProperties(channel, properties, tag.GetStartTime(), tag.GetEndTime(), false); } } return PVR_ERROR_NO_ERROR; @@ -1048,109 +2008,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 +2035,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 +2139,207 @@ 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; + 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 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(initialPlaybackTime), + BoolState(liveEdge)); + + m_pendingPlayback = {}; + return OpenNativeStream(addonChannel, !usePendingArchive, archiveStart, archiveEnd, + initialPlaybackTime, liveEdge); +} + +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 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", + whence, + static_cast(position), + static_cast(targetPosition), + static_cast(currentPosition), + static_cast(targetTime)); + 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; + + if (!m_nativeStream.seekable) + return 0; + + if (m_nativeStream.liveEdge) + return TimeToStreamPosition(GetCurrentNativeSeekableEndTime()); + + return m_nativeStream.virtualLength; +} + +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); + const int64_t visibleDuration = + m_nativeStream.liveEdge + ? std::max(GetCurrentNativeSeekableEndTime() - m_nativeStream.programmeStartTime, + 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); + 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; +} + 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..e727ff0 100644 --- a/src/PVREon.h +++ b/src/PVREon.h @@ -7,6 +7,7 @@ */ #include +#include #include #include @@ -107,6 +108,43 @@ struct EonCDN bool isDefault; }; +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; +}; + +struct EonNativeStreamState +{ + bool open = false; + 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; + time_t sessionStartTime = 0; + 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; + 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 +187,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 +206,50 @@ 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, + time_t initialPlaybackTime = 0, + bool liveEdge = false); + 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 GetCurrentNativeSeekableEndTime() 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 +281,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 +307,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/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/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/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/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/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-aarch64.sh b/tools/docker/build-android-aarch64.sh new file mode 100755 index 0000000..0efd763 --- /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:-21.4.7075529}" + +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:-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-android-armv7.sh b/tools/docker/build-android-armv7.sh new file mode 100755 index 0000000..52ab701 --- /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:-21.4.7075529}" + +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-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/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/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-aarch64.sh b/tools/docker/container-build-android-aarch64.sh new file mode 100755 index 0000000..2814ebb --- /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:=21}" +: "${ANDROID_NDK_VERSION:=21.4.7075529}" +: "${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}" \ + "${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_SHARED_LINKER_FLAGS="-static-libstdc++" \ + -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 -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" + +( + 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}" \ + "${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-aarch64.Dockerfile b/tools/docker/linux-aarch64.Dockerfile new file mode 100644 index 0000000..bb80b74 --- /dev/null +++ b/tools/docker/linux-aarch64.Dockerfile @@ -0,0 +1,30 @@ +FROM ubuntu:22.04 + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + autoconf \ + automake \ + binutils-aarch64-linux-gnu \ + build-essential \ + ca-certificates \ + cmake \ + curl \ + flex \ + g++-aarch64-linux-gnu \ + gawk \ + gcc-aarch64-linux-gnu \ + git \ + libtool \ + libcurl4-openssl-dev \ + pkg-config \ + python3 \ + rapidjson-dev \ + zip \ + && rm -rf /var/lib/apt/lists/* + +COPY tools/docker/container-build-linux-aarch64.sh /usr/local/bin/container-build-linux-aarch64.sh +RUN chmod +x /usr/local/bin/container-build-linux-aarch64.sh + +ENTRYPOINT ["/usr/local/bin/container-build-linux-aarch64.sh"] 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"] diff --git a/tools/docker/linux-armv7.Dockerfile b/tools/docker/linux-armv7.Dockerfile new file mode 100644 index 0000000..5c0c4ef --- /dev/null +++ b/tools/docker/linux-armv7.Dockerfile @@ -0,0 +1,30 @@ +FROM ubuntu:22.04 + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + autoconf \ + automake \ + binutils-arm-linux-gnueabihf \ + build-essential \ + ca-certificates \ + cmake \ + curl \ + flex \ + g++-arm-linux-gnueabihf \ + gawk \ + gcc-arm-linux-gnueabihf \ + git \ + libtool \ + libcurl4-openssl-dev \ + pkg-config \ + python3 \ + rapidjson-dev \ + zip \ + && rm -rf /var/lib/apt/lists/* + +COPY tools/docker/container-build-linux-armv7.sh /usr/local/bin/container-build-linux-armv7.sh +RUN chmod +x /usr/local/bin/container-build-linux-armv7.sh + +ENTRYPOINT ["/usr/local/bin/container-build-linux-armv7.sh"]