2026-03-10 15:59:08 +01:00

3207 lines
133 KiB
JavaScript

(() => {
// ============ AUTHENTICATION ============
const authTokenKey = "xtream_auth_token";
function getAuthToken() {
return localStorage.getItem(authTokenKey);
}
function setAuthToken(token) {
localStorage.setItem(authTokenKey, token);
}
function clearAuthToken() {
localStorage.removeItem(authTokenKey);
}
function isAuthenticated() {
return !!getAuthToken();
}
function getAuthHeader() {
const token = getAuthToken();
return token ? { "Authorization": `Bearer ${token}` } : {};
}
async function login(username, password) {
try {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ username, password })
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "Login failed");
}
const data = await response.json();
setAuthToken(data.token);
return true;
} catch (error) {
console.error("Login error:", error);
throw error;
}
}
async function logout() {
try {
const headers = getAuthHeader();
await fetch("/api/auth/logout", {
method: "POST",
headers
});
} catch (error) {
console.error("Logout error:", error);
} finally {
clearAuthToken();
location.reload();
}
}
function showLoginScreen() {
const loginModal = document.getElementById("login-modal");
const appContainer = document.getElementById("app-container");
if (loginModal) loginModal.classList.add("active");
if (appContainer) appContainer.classList.add("hidden");
}
function hideLoginScreen() {
const loginModal = document.getElementById("login-modal");
const appContainer = document.getElementById("app-container");
if (loginModal) loginModal.classList.remove("active");
if (appContainer) appContainer.classList.remove("hidden");
}
// Setup login form handler
const loginForm = document.getElementById("login-form");
if (loginForm) {
loginForm.addEventListener("submit", async (e) => {
e.preventDefault();
const username = document.getElementById("login-username").value;
const password = document.getElementById("login-password").value;
const messageEl = document.getElementById("login-message");
try {
messageEl.textContent = "Logging in...";
messageEl.className = "message info";
await login(username, password);
messageEl.textContent = "";
messageEl.className = "message";
hideLoginScreen();
initializeApp();
} catch (error) {
messageEl.textContent = error.message;
messageEl.className = "message error";
}
});
}
// Setup logout button
const logoutBtn = document.getElementById("logout-btn");
if (logoutBtn) {
logoutBtn.addEventListener("click", () => {
if (confirm("Are you sure you want to logout?")) {
logout();
}
});
}
// Check authentication on page load
if (!isAuthenticated()) {
showLoginScreen();
} else {
hideLoginScreen();
}
let hlsInstance = null;
let embeddedSubtitleScanTimer = null;
let hlsSubtitleTracks = [];
const state = {
config: null,
libraryStatus: null,
globalResults: [],
liveStreams: [],
liveCategories: [],
vodStreams: [],
vodCategories: [],
seriesItems: [],
seriesCategories: [],
expandedSeriesId: null,
seriesEpisodesById: {},
expandedSeasonBySeries: {},
customStreams: [],
favorites: [],
favoriteKeys: new Set(),
expandedFavoriteSeriesById: {},
favoriteSeriesEpisodesById: {},
expandedSeasonByFavoriteSeries: {},
expandedFavoriteLiveCategoryById: {},
favoriteLiveCategoryStreamsById: {},
expandedFavoriteVodCategoryById: {},
favoriteVodCategoryStreamsById: {},
expandedFavoriteSeriesCategoryById: {},
favoriteSeriesCategoryItemsById: {},
currentLiveEpgStreamId: null,
currentStreamInfo: null,
globalSearchTimer: null,
liveSearchTimer: null,
vodSearchTimer: null,
seriesSearchTimer: null,
favoritesSearchTimer: null,
favoritesLimit: 50,
favoritesOffset: 0,
favoritesTotal: 0
};
const customStorageKey = "xtream_custom_streams_v1";
const el = {
tabs: document.getElementById("tabs"),
panels: Array.from(document.querySelectorAll(".tab-panel")),
globalStatus: document.getElementById("global-status"),
configForm: document.getElementById("config-form"),
settingsMessage: document.getElementById("settings-message"),
testLogin: document.getElementById("test-login"),
loadSources: document.getElementById("load-sources"),
sourcesProgress: document.getElementById("sources-progress"),
sourcesProgressText: document.getElementById("sources-progress-text"),
serverUrl: document.getElementById("server-url"),
username: document.getElementById("username"),
password: document.getElementById("password"),
liveFormat: document.getElementById("live-format"),
globalSearch: document.getElementById("global-search"),
globalRefresh: document.getElementById("global-refresh"),
globalList: document.getElementById("global-list"),
liveCategory: document.getElementById("live-category"),
liveSearch: document.getElementById("live-search"),
liveRefresh: document.getElementById("live-refresh"),
liveFavoriteCategory: document.getElementById("live-favorite-category"),
liveList: document.getElementById("live-list"),
vodCategory: document.getElementById("vod-category"),
vodSearch: document.getElementById("vod-search"),
vodRefresh: document.getElementById("vod-refresh"),
vodFavoriteCategory: document.getElementById("vod-favorite-category"),
vodList: document.getElementById("vod-list"),
seriesCategory: document.getElementById("series-category"),
seriesSearch: document.getElementById("series-search"),
seriesRefresh: document.getElementById("series-refresh"),
seriesFavoriteCategory: document.getElementById("series-favorite-category"),
seriesList: document.getElementById("series-list"),
customForm: document.getElementById("custom-form"),
customName: document.getElementById("custom-name"),
customUrl: document.getElementById("custom-url"),
customSearch: document.getElementById("custom-search"),
customList: document.getElementById("custom-list"),
favoritesSearch: document.getElementById("favorites-search"),
favoritesList: document.getElementById("favorites-list"),
favoritesPrev: document.getElementById("favorites-prev"),
favoritesNext: document.getElementById("favorites-next"),
favoritesPageInfo: document.getElementById("favorites-page-info"),
playerTitle: document.getElementById("player-title"),
player: document.getElementById("player"),
openDirect: document.getElementById("open-direct"),
openSystemPlayer: document.getElementById("open-system-player"),
subtitleUrl: document.getElementById("subtitle-url"),
subtitleLang: document.getElementById("subtitle-lang"),
embeddedSubtitleTrack: document.getElementById("embedded-subtitle-track"),
loadSubtitle: document.getElementById("load-subtitle"),
clearSubtitle: document.getElementById("clear-subtitle"),
subtitleStatus: document.getElementById("subtitle-status"),
streamInfo: document.getElementById("stream-info"),
refreshEpg: document.getElementById("refresh-epg"),
epgList: document.getElementById("epg-list")
};
// Initialize app only if authenticated
function initializeApp() {
init().catch((error) => {
setSettingsMessage(error.message || String(error), "err");
});
}
// Try to initialize on page load if already authenticated
if (isAuthenticated()) {
initializeApp();
}
async function init() {
bindTabs();
bindConfigForm();
bindGlobalTab();
bindLiveTab();
bindVodTab();
bindSeriesTab();
bindCustomTab();
bindFavoritesTab();
bindSubtitleControls();
bindPlayerEvents();
bindPlayerActionButtons();
loadCustomStreams();
renderCustomStreams();
renderFavorites();
renderStreamInfo();
updateLiveCategoryFavoriteButton();
updateVodCategoryFavoriteButton();
switchTab("settings");
await loadConfig();
}
function bindPlayerEvents() {
el.player.addEventListener("error", () => {
setSettingsMessage("Playback failed in embedded player. Try Open stream directly.", "err");
});
el.player.addEventListener("loadedmetadata", updateStreamRuntimeInfo);
el.player.addEventListener("loadedmetadata", refreshEmbeddedSubtitleTracks);
el.player.addEventListener("loadeddata", refreshEmbeddedSubtitleTracks);
}
function bindPlayerActionButtons() {
el.openDirect.addEventListener("click", () => {
openPlayerActionUrl(el.openDirect);
});
el.openSystemPlayer.addEventListener("click", () => {
openPlayerActionUrl(el.openSystemPlayer);
});
}
function openPlayerActionUrl(button) {
const url = String(button?.dataset?.url || "").trim();
if (!url) {
return;
}
const opened = window.open(url, "_blank", "noopener");
if (!opened) {
setSettingsMessage("Popup was blocked by browser. Allow popups for this site.", "err");
}
}
function bindSubtitleControls() {
el.loadSubtitle.addEventListener("click", () => {
const url = String(el.subtitleUrl.value || "").trim();
const lang = String(el.subtitleLang.value || "en").trim().toLowerCase();
if (!url) {
setSubtitleStatus("Enter subtitle URL first.", true);
return;
}
if (!url.toLowerCase().includes(".vtt")) {
setSubtitleStatus("Only WebVTT (.vtt) is supported in browser player.", true);
return;
}
addSubtitleTrack(url, lang || "en");
});
el.clearSubtitle.addEventListener("click", () => {
clearSubtitleTracks();
setSubtitleStatus("No subtitle loaded.", false);
});
el.embeddedSubtitleTrack.addEventListener("change", () => {
selectEmbeddedSubtitleTrack(el.embeddedSubtitleTrack.value);
});
}
function bindTabs() {
el.tabs.addEventListener("click", (event) => {
const target = event.target.closest("button[data-tab]");
if (!target) {
return;
}
const tab = target.dataset.tab;
switchTab(tab);
});
}
function switchTab(tabName) {
const tabButtons = Array.from(el.tabs.querySelectorAll("button[data-tab]"));
tabButtons.forEach((button) => button.classList.toggle("active", button.dataset.tab === tabName));
el.panels.forEach((panel) => panel.classList.toggle("active", panel.dataset.panel === tabName));
if (tabName === "favorites") {
loadFavorites(String(el.favoritesSearch.value || "").trim())
.then(() => renderFavorites())
.catch(showError);
}
if (!state.config?.configured || !state.libraryStatus?.ready) {
return;
}
releaseInactiveTabItems(tabName);
if (tabName === "search") {
loadGlobalResults().catch(showError);
}
if (tabName === "live") {
if (state.liveCategories.length === 0) {
loadLiveData().catch(showError);
} else {
loadLiveStreams().catch(showError);
}
}
if (tabName === "vod") {
if (state.vodCategories.length === 0) {
loadVodData().catch(showError);
} else {
loadVodStreams().catch(showError);
}
}
if (tabName === "series") {
if (state.seriesCategories.length === 0) {
loadSeriesData().catch(showError);
} else {
loadSeriesList().catch(showError);
}
}
}
function bindGlobalTab() {
el.globalSearch.addEventListener("input", scheduleGlobalSearch);
el.globalRefresh.addEventListener("click", () => loadGlobalResults().catch(showError));
}
function bindConfigForm() {
el.configForm.addEventListener("submit", async (event) => {
event.preventDefault();
const form = new URLSearchParams(new FormData(el.configForm));
try {
const config = await apiJson("/api/config", {
method: "POST",
headers: {"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8"},
body: form.toString()
});
state.config = config;
clearLibraryState();
refreshConfigUi();
await refreshLibraryStatus();
if (state.libraryStatus?.ready) {
renderAllFromState();
setSettingsMessage("Settings saved. Using local H2 data.", "ok");
} else {
renderAllFromState();
setSettingsMessage("Settings saved. Load sources into the local DB.", "");
}
} catch (error) {
setSettingsMessage(error.message, "err");
}
});
el.testLogin.addEventListener("click", async () => {
try {
const data = await apiJson("/api/test-login");
const authValue = String(data?.user_info?.auth ?? "");
if (authValue === "1") {
setSettingsMessage("Login is valid (auth=1).", "ok");
} else {
setSettingsMessage("Server responded, but auth is not 1. Check your credentials.", "err");
}
} catch (error) {
setSettingsMessage(`Login failed: ${error.message}`, "err");
}
});
el.loadSources.addEventListener("click", () => {
preloadSourcesWithProgress().catch((error) => {
setSettingsMessage(`Source loading failed: ${error.message}`, "err");
updateProgress(0, "Source loading failed.");
});
});
}
function bindLiveTab() {
el.liveCategory.addEventListener("change", () => {
loadLiveStreams().catch(showError);
updateLiveCategoryFavoriteButton();
});
el.liveSearch.addEventListener("input", scheduleLiveSearch);
el.liveRefresh.addEventListener("click", () => loadLiveData().catch(showError));
el.liveFavoriteCategory.addEventListener("click", () => {
const favorite = selectedLiveCategoryFavorite();
if (!favorite) {
setSettingsMessage("Select a live category first.", "err");
return;
}
toggleFavorite(favorite).catch(showError);
});
el.refreshEpg.addEventListener("click", () => refreshEpg().catch(showError));
}
function bindVodTab() {
el.vodCategory.addEventListener("change", () => {
loadVodStreams().catch(showError);
updateVodCategoryFavoriteButton();
});
el.vodSearch.addEventListener("input", scheduleVodSearch);
el.vodRefresh.addEventListener("click", () => loadVodData().catch(showError));
el.vodFavoriteCategory.addEventListener("click", () => {
const favorite = selectedVodCategoryFavorite();
if (!favorite) {
setSettingsMessage("Select a VOD category first.", "err");
return;
}
toggleFavorite(favorite).catch(showError);
});
}
function bindSeriesTab() {
el.seriesCategory.addEventListener("change", () => {
loadSeriesList().catch(showError);
updateSeriesCategoryFavoriteButton();
});
el.seriesSearch.addEventListener("input", scheduleSeriesSearch);
el.seriesRefresh.addEventListener("click", () => loadSeriesData().catch(showError));
el.seriesFavoriteCategory.addEventListener("click", () => {
const favorite = selectedSeriesCategoryFavorite();
if (!favorite) {
setSettingsMessage("Select a series category first.", "err");
return;
}
toggleFavorite(favorite).catch(showError);
});
}
function bindCustomTab() {
el.customForm.addEventListener("submit", (event) => {
event.preventDefault();
const name = el.customName.value.trim();
const url = el.customUrl.value.trim();
if (!name || !url) {
return;
}
if (!isSupportedCustomUrl(url)) {
setSettingsMessage("Custom stream URL must start with http://, https://, rtsp://, or rtsps://", "err");
return;
}
state.customStreams.push({
id: String(Date.now()),
name,
url
});
persistCustomStreams();
renderCustomStreams();
el.customForm.reset();
});
el.customSearch.addEventListener("input", renderCustomStreams);
}
function bindFavoritesTab() {
el.favoritesSearch.addEventListener("input", scheduleFavoritesSearch);
el.favoritesPrev.addEventListener("click", () => {
if (state.favoritesOffset <= 0) {
return;
}
state.favoritesOffset = Math.max(0, state.favoritesOffset - state.favoritesLimit);
loadFavorites(String(el.favoritesSearch.value || "").trim())
.then(() => renderFavorites())
.catch(showError);
});
el.favoritesNext.addEventListener("click", () => {
if (state.favoritesOffset + state.favoritesLimit >= state.favoritesTotal) {
return;
}
state.favoritesOffset += state.favoritesLimit;
loadFavorites(String(el.favoritesSearch.value || "").trim())
.then(() => renderFavorites())
.catch(showError);
});
el.favoritesList.addEventListener("click", onFavoritesListClick);
}
async function loadConfig() {
const config = await apiJson("/api/config");
state.config = config;
refreshConfigUi();
if (!config.configured) {
updateProgress(0, "Fill in connection settings first.");
return;
}
await refreshLibraryStatus();
if (state.libraryStatus?.ready) {
renderAllFromState();
} else {
clearLibraryState();
renderAllFromState();
}
}
async function refreshLibraryStatus() {
if (!state.config?.configured) {
state.libraryStatus = null;
updateProgress(0, "Fill in connection settings first.");
return;
}
const status = await apiJson("/api/library/status");
state.libraryStatus = status;
if (status.ready) {
updateProgress(
100,
`Local H2 sources are ready (${formatLoadedAt(status.loadedAt)}). ${formatSourceCounts(status.counts)}`
);
} else {
const counts = status.counts || {};
updateProgress(
0,
`Sources are incomplete. Live:${counts.liveStreams || 0}, VOD:${counts.vodStreams || 0}, Series:${counts.seriesItems || 0}`
);
}
}
async function preloadSourcesWithProgress() {
ensureConfigured();
el.loadSources.disabled = true;
setSettingsMessage("Loading sources into local H2 DB...", "");
const steps = [
{id: "live_categories", value: 8, text: "Step 1/6: live categories"},
{id: "live_streams", value: 22, text: "Step 2/6: live streams"},
{id: "vod_categories", value: 38, text: "Step 3/6: VOD categories"},
{id: "vod_streams", value: 58, text: "Step 4/6: VOD streams"},
{id: "series_categories", value: 76, text: "Step 5/6: series categories"},
{id: "series_items", value: 92, text: "Step 6/6: series list"}
];
try {
clearLibraryState();
renderAllFromState();
for (const step of steps) {
updateProgress(step.value, step.text);
await apiJson(`/api/library/load?step=${encodeURIComponent(step.id)}`, {method: "POST"});
}
await refreshLibraryStatus();
renderAllFromState();
updateProgress(100, `Done. Sources saved in H2 (${new Date().toLocaleString("en-US")}).`);
setSettingsMessage(
`Sources were loaded and saved to the local H2 database. ${formatSourceCounts(state.libraryStatus?.counts)}`,
"ok"
);
} finally {
el.loadSources.disabled = false;
}
}
function formatSourceCounts(countsRaw) {
const counts = countsRaw || {};
const liveCategories = Number(counts.liveCategories || 0);
const liveStreams = Number(counts.liveStreams || 0);
const vodCategories = Number(counts.vodCategories || 0);
const vodStreams = Number(counts.vodStreams || 0);
const seriesCategories = Number(counts.seriesCategories || 0);
const seriesItems = Number(counts.seriesItems || 0);
return `Live: ${liveCategories} categories / ${liveStreams} streams, `
+ `VOD: ${vodCategories} categories / ${vodStreams} items, `
+ `Series: ${seriesCategories} categories / ${seriesItems} series.`;
}
function refreshConfigUi() {
const config = state.config || {};
el.serverUrl.value = config.serverUrl || "";
el.username.value = config.username || "";
el.password.value = config.password || "";
el.liveFormat.value = (config.liveFormat || "m3u8").toLowerCase();
el.globalStatus.textContent = config.configured ? "Connected" : "Not configured";
}
function clearLibraryState() {
state.globalResults = [];
state.liveCategories = [];
state.liveStreams = [];
state.vodCategories = [];
state.vodStreams = [];
state.seriesCategories = [];
state.seriesItems = [];
state.expandedSeriesId = null;
state.seriesEpisodesById = {};
state.expandedSeasonBySeries = {};
state.expandedFavoriteSeriesById = {};
state.favoriteSeriesEpisodesById = {};
state.expandedSeasonByFavoriteSeries = {};
state.expandedFavoriteLiveCategoryById = {};
state.favoriteLiveCategoryStreamsById = {};
state.expandedFavoriteVodCategoryById = {};
state.favoriteVodCategoryStreamsById = {};
state.expandedFavoriteSeriesCategoryById = {};
state.favoriteSeriesCategoryItemsById = {};
}
function releaseInactiveTabItems(activeTab) {
if (activeTab !== "search") {
state.globalResults = [];
}
if (activeTab !== "live") {
state.liveStreams = [];
}
if (activeTab !== "vod") {
state.vodStreams = [];
}
if (activeTab !== "series") {
state.seriesItems = [];
state.expandedSeriesId = null;
state.seriesEpisodesById = {};
state.expandedSeasonBySeries = {};
}
}
function activeTabName() {
const active = el.tabs.querySelector("button[data-tab].active");
return String(active?.dataset?.tab || "settings");
}
function scheduleLiveSearch() {
if (state.liveSearchTimer) {
clearTimeout(state.liveSearchTimer);
}
state.liveSearchTimer = setTimeout(() => {
loadLiveStreams().catch(showError);
}, 250);
}
function scheduleGlobalSearch() {
if (state.globalSearchTimer) {
clearTimeout(state.globalSearchTimer);
}
state.globalSearchTimer = setTimeout(() => {
loadGlobalResults().catch(showError);
}, 250);
}
function scheduleVodSearch() {
if (state.vodSearchTimer) {
clearTimeout(state.vodSearchTimer);
}
state.vodSearchTimer = setTimeout(() => {
loadVodStreams().catch(showError);
}, 250);
}
function scheduleSeriesSearch() {
if (state.seriesSearchTimer) {
clearTimeout(state.seriesSearchTimer);
}
state.seriesSearchTimer = setTimeout(() => {
loadSeriesList().catch(showError);
}, 250);
}
function scheduleFavoritesSearch() {
if (state.favoritesSearchTimer) {
clearTimeout(state.favoritesSearchTimer);
}
state.favoritesSearchTimer = setTimeout(() => {
state.favoritesOffset = 0;
loadFavorites(String(el.favoritesSearch.value || "").trim())
.then(() => renderFavorites())
.catch(showError);
}, 250);
}
async function loadGlobalResults() {
ensureLibraryReady();
const queryText = String(el.globalSearch.value || "").trim();
if (queryText.length < 2) {
state.globalResults = [];
renderGlobalResults("Type at least 2 characters to search globally.");
return;
}
const query = new URLSearchParams({
query: queryText,
limit: "300",
offset: "0"
});
const payload = await apiJson(`/api/library/search?${query.toString()}`);
state.globalResults = sanitizeGlobalSearchResults(payload.items);
renderGlobalResults();
}
function renderGlobalResults(emptyMessage = "No matching category or stream found.") {
const results = Array.isArray(state.globalResults) ? state.globalResults : [];
if (results.length === 0) {
el.globalList.innerHTML = `<li class="card muted">${esc(emptyMessage)}</li>`;
return;
}
el.globalList.innerHTML = "";
results.forEach((result) => {
const li = document.createElement("li");
li.className = "stream-item";
const favorite = favoriteForGlobalResult(result);
const hasFavorite = Boolean(favorite?.key);
const badge = globalResultBadge(result);
li.innerHTML = `
<div>
<button type="button" class="stream-title stream-link" data-action="open-result">${esc(result.title || "Untitled")}</button>
<div class="stream-meta"><span class="fav-type-badge">${esc(badge)}</span>${esc(globalResultMeta(result))}</div>
</div>
<div class="stream-actions">
${hasFavorite
? `<button type="button" class="favorite-toggle" data-action="toggle-favorite" aria-label="${isFavorite(favorite.key) ? "Remove from favorites" : "Add to favorites"}" title="${isFavorite(favorite.key) ? "Remove from favorites" : "Add to favorites"}">${favoriteIcon(isFavorite(favorite.key))}</button>`
: ""}
</div>
`;
li.querySelector("button[data-action='open-result']").addEventListener("click", () => {
openGlobalResult(result).catch(showError);
});
if (hasFavorite) {
li.querySelector("button[data-action='toggle-favorite']").addEventListener("click", () => {
toggleFavorite(favorite).then(() => loadGlobalResults()).catch(showError);
});
}
el.globalList.appendChild(li);
});
}
async function openGlobalResult(result) {
const kind = String(result?.kind || "");
if (kind === "live") {
const streamId = String(result?.id || "");
const epgStreamId = String(result?.epg_channel_id || streamId);
await playXtream(
"live",
streamId,
state.config?.liveFormat || "m3u8",
result?.title || "Live stream",
epgStreamId,
{categoryId: String(result?.category_id || "")}
);
return;
}
if (kind === "vod") {
await playXtream(
"vod",
String(result?.id || ""),
String(result?.ext || "mp4"),
result?.title || "VOD",
null,
{categoryId: String(result?.category_id || "")}
);
return;
}
if (kind === "series_item") {
await openSeriesItemFromGlobalResult(result);
return;
}
if (kind === "live_category" || kind === "vod_category" || kind === "series_category") {
await openCategoryFromGlobalResult(result);
}
}
async function openCategoryFromGlobalResult(result) {
const kind = String(result?.kind || "");
const categoryId = String(result?.id || "");
if (!categoryId) {
return;
}
if (kind === "live_category") {
switchTab("live");
if (state.liveCategories.length === 0) {
await loadLiveData();
}
el.liveCategory.value = categoryId;
el.liveSearch.value = "";
updateLiveCategoryFavoriteButton();
await loadLiveStreams();
return;
}
if (kind === "vod_category") {
switchTab("vod");
if (state.vodCategories.length === 0) {
await loadVodData();
}
el.vodCategory.value = categoryId;
el.vodSearch.value = "";
updateVodCategoryFavoriteButton();
await loadVodStreams();
return;
}
if (kind === "series_category") {
switchTab("series");
if (state.seriesCategories.length === 0) {
await loadSeriesData();
}
el.seriesCategory.value = categoryId;
el.seriesSearch.value = "";
updateSeriesCategoryFavoriteButton();
await loadSeriesList();
}
}
async function openSeriesItemFromGlobalResult(result) {
switchTab("series");
if (state.seriesCategories.length === 0) {
await loadSeriesData();
}
const categoryId = String(result?.category_id || "");
const title = String(result?.title || "");
if (categoryId) {
el.seriesCategory.value = categoryId;
}
el.seriesSearch.value = title;
updateSeriesCategoryFavoriteButton();
await loadSeriesList();
}
function favoriteForGlobalResult(result) {
const kind = String(result?.kind || "");
if (kind === "live") {
return makeFavoriteLive({
stream_id: String(result?.id || ""),
name: String(result?.title || "Untitled"),
category_id: String(result?.category_id || ""),
epg_channel_id: String(result?.epg_channel_id || "")
});
}
if (kind === "vod") {
return makeFavoriteVod({
stream_id: String(result?.id || ""),
name: String(result?.title || "Untitled"),
category_id: String(result?.category_id || ""),
container_extension: String(result?.ext || "mp4")
});
}
if (kind === "series_item") {
return makeFavoriteSeriesItem({
series_id: String(result?.id || ""),
name: String(result?.title || "Untitled"),
category_id: String(result?.category_id || "")
});
}
if (kind === "live_category") {
return makeFavoriteLiveCategory({
category_id: String(result?.id || ""),
category_name: String(result?.title || "")
});
}
if (kind === "vod_category") {
return makeFavoriteVodCategory({
category_id: String(result?.id || ""),
category_name: String(result?.title || "")
});
}
if (kind === "series_category") {
return makeFavoriteSeriesCategory({
category_id: String(result?.id || ""),
category_name: String(result?.title || "")
});
}
return null;
}
function globalResultBadge(result) {
const kind = String(result?.kind || "");
if (kind === "live") {
return "LIVE";
}
if (kind === "vod") {
return "VOD";
}
if (kind === "series_item") {
return "SERIES";
}
if (kind === "live_category") {
return "LIVE CATEGORY";
}
if (kind === "vod_category") {
return "VOD CATEGORY";
}
if (kind === "series_category") {
return "SERIES CATEGORY";
}
return "RESULT";
}
function globalResultMeta(result) {
const kind = String(result?.kind || "");
const id = String(result?.id || "");
const categoryId = String(result?.category_id || "");
const categoryName = String(result?.category_name || "");
if (kind === "live") {
return `ID: ${id || "-"} | Category: ${categoryName || categoryId || "-"} | Format: ${state.config?.liveFormat || "m3u8"}`;
}
if (kind === "vod") {
return `ID: ${id || "-"} | Category: ${categoryName || categoryId || "-"} | Ext: ${result?.ext || "mp4"}`;
}
if (kind === "series_item") {
return `Series ID: ${id || "-"} | Category: ${categoryName || categoryId || "-"}`;
}
if (kind === "live_category" || kind === "vod_category" || kind === "series_category") {
return `Category ID: ${id || "-"}`;
}
return id ? `ID: ${id}` : "Result";
}
async function loadLiveData() {
ensureLibraryReady();
const categoriesPayload = await apiJson("/api/library/categories?type=live");
state.liveCategories = sanitizeCategories(categoriesPayload.items);
fillCategorySelect(el.liveCategory, state.liveCategories, "All live");
updateLiveCategoryFavoriteButton();
await loadLiveStreams();
}
async function loadLiveStreams() {
ensureLibraryReady();
const categoryId = String(el.liveCategory.value || "").trim();
const search = String(el.liveSearch.value || "").trim();
if (!categoryId && search.length < 2) {
state.liveStreams = [];
renderLiveStreams("Select Live category or type at least 2 characters.");
return;
}
const query = new URLSearchParams({type: "live"});
if (categoryId) {
query.set("category_id", categoryId);
}
if (search) {
query.set("search", search);
}
query.set("limit", "300");
query.set("offset", "0");
const payload = await apiJson(`/api/library/items?${query.toString()}`);
state.liveStreams = sanitizeLiveStreams(payload.items);
renderLiveStreams();
}
function selectedLiveCategoryFavorite() {
const categoryId = String(el.liveCategory.value || "").trim();
if (!categoryId) {
return null;
}
const category = state.liveCategories.find((item) => String(item?.category_id || "") === categoryId);
if (!category) {
return null;
}
return makeFavoriteLiveCategory(category);
}
function updateLiveCategoryFavoriteButton() {
const favorite = selectedLiveCategoryFavorite();
if (!favorite) {
el.liveFavoriteCategory.disabled = true;
el.liveFavoriteCategory.textContent = "☆";
el.liveFavoriteCategory.title = "Select category";
el.liveFavoriteCategory.setAttribute("aria-label", "Select category");
return;
}
el.liveFavoriteCategory.disabled = false;
const active = isFavorite(favorite.key);
el.liveFavoriteCategory.textContent = favoriteIcon(active);
const label = active ? "Remove category from favorites" : "Add category to favorites";
el.liveFavoriteCategory.title = label;
el.liveFavoriteCategory.setAttribute("aria-label", label);
}
function renderLiveStreams(emptyMessage = "No live stream found.") {
const filtered = state.liveStreams;
if (filtered.length === 0) {
el.liveList.innerHTML = `<li class="card muted">${esc(emptyMessage)}</li>`;
return;
}
el.liveList.innerHTML = "";
filtered.forEach((item) => {
const li = document.createElement("li");
li.className = "stream-item";
const streamId = String(item.stream_id || "");
const epgStreamId = String(item.epg_channel_id || streamId);
const favorite = makeFavoriteLive(item);
li.innerHTML = `
<div>
<button type="button" class="stream-title stream-link" data-action="play-title">${esc(item.name || "Untitled")}</button>
<div class="stream-meta">ID: ${esc(streamId)} | Category: ${esc(item.category_id || "-")}</div>
</div>
<div class="stream-actions">
<button type="button" class="favorite-toggle" data-action="toggle-favorite" aria-label="${isFavorite(favorite.key) ? "Remove from favorites" : "Add to favorites"}" title="${isFavorite(favorite.key) ? "Remove from favorites" : "Add to favorites"}">${favoriteIcon(isFavorite(favorite.key))}</button>
</div>
`;
li.querySelector("button[data-action='play-title']").addEventListener("click", () => {
playXtream(
"live",
streamId,
state.config.liveFormat || "m3u8",
item.name || "Live stream",
epgStreamId,
{categoryId: item.category_id}
).catch(showError);
});
li.querySelector("button[data-action='toggle-favorite']").addEventListener("click", () => {
toggleFavorite(favorite).then(() => loadLiveStreams()).catch(showError);
});
el.liveList.appendChild(li);
});
}
async function loadVodData() {
ensureLibraryReady();
const categoriesPayload = await apiJson("/api/library/categories?type=vod");
state.vodCategories = sanitizeCategories(categoriesPayload.items);
fillCategorySelect(el.vodCategory, state.vodCategories, "All VOD");
updateVodCategoryFavoriteButton();
await loadVodStreams();
}
function selectedVodCategoryFavorite() {
const categoryId = String(el.vodCategory.value || "").trim();
if (!categoryId) {
return null;
}
const category = state.vodCategories.find((item) => String(item?.category_id || "") === categoryId);
if (!category) {
return null;
}
return makeFavoriteVodCategory(category);
}
function updateVodCategoryFavoriteButton() {
const favorite = selectedVodCategoryFavorite();
if (!favorite) {
el.vodFavoriteCategory.disabled = true;
el.vodFavoriteCategory.textContent = "☆";
el.vodFavoriteCategory.title = "Select category";
el.vodFavoriteCategory.setAttribute("aria-label", "Select category");
return;
}
el.vodFavoriteCategory.disabled = false;
const active = isFavorite(favorite.key);
el.vodFavoriteCategory.textContent = favoriteIcon(active);
const label = active ? "Remove category from favorites" : "Add category to favorites";
el.vodFavoriteCategory.title = label;
el.vodFavoriteCategory.setAttribute("aria-label", label);
}
async function loadVodStreams() {
ensureLibraryReady();
const categoryId = String(el.vodCategory.value || "").trim();
const search = String(el.vodSearch.value || "").trim();
if (!categoryId && search.length < 2) {
state.vodStreams = [];
renderVodStreams("Select VOD category or type at least 2 characters.");
return;
}
const query = new URLSearchParams({type: "vod"});
if (categoryId) {
query.set("category_id", categoryId);
}
if (search) {
query.set("search", search);
}
query.set("limit", "300");
query.set("offset", "0");
const payload = await apiJson(`/api/library/items?${query.toString()}`);
state.vodStreams = sanitizeVodStreams(payload.items);
renderVodStreams();
}
function renderVodStreams(emptyMessage = "No VOD stream found.") {
const filtered = state.vodStreams;
if (filtered.length === 0) {
el.vodList.innerHTML = `<li class="card muted">${esc(emptyMessage)}</li>`;
return;
}
el.vodList.innerHTML = "";
filtered.forEach((item) => {
const li = document.createElement("li");
li.className = "stream-item";
const streamId = String(item.stream_id || "");
const ext = String(item.container_extension || "mp4");
const favorite = makeFavoriteVod(item);
li.innerHTML = `
<div>
<button type="button" class="stream-title stream-link" data-action="play-title">${esc(item.name || "Untitled")}</button>
<div class="stream-meta">ID: ${esc(streamId)} | Ext: ${esc(ext)}</div>
</div>
<div class="stream-actions">
<button type="button" class="favorite-toggle" data-action="toggle-favorite" aria-label="${isFavorite(favorite.key) ? "Remove from favorites" : "Add to favorites"}" title="${isFavorite(favorite.key) ? "Remove from favorites" : "Add to favorites"}">${favoriteIcon(isFavorite(favorite.key))}</button>
</div>
`;
li.querySelector("button[data-action='play-title']").addEventListener("click", () => {
playXtream("vod", streamId, ext, item.name || "VOD", null, {categoryId: item.category_id})
.catch(showError);
});
li.querySelector("button[data-action='toggle-favorite']").addEventListener("click", () => {
toggleFavorite(favorite).then(() => loadVodStreams()).catch(showError);
});
el.vodList.appendChild(li);
});
}
async function loadSeriesData() {
ensureLibraryReady();
const categoriesPayload = await apiJson("/api/library/categories?type=series");
state.seriesCategories = sanitizeCategories(categoriesPayload.items);
fillCategorySelect(el.seriesCategory, state.seriesCategories, "All series");
updateSeriesCategoryFavoriteButton();
await loadSeriesList();
}
function selectedSeriesCategoryFavorite() {
const categoryId = String(el.seriesCategory.value || "").trim();
if (!categoryId) {
return null;
}
const category = state.seriesCategories.find((item) => String(item?.category_id || "") === categoryId);
if (!category) {
return null;
}
return makeFavoriteSeriesCategory(category);
}
function updateSeriesCategoryFavoriteButton() {
const favorite = selectedSeriesCategoryFavorite();
if (!favorite) {
el.seriesFavoriteCategory.disabled = true;
el.seriesFavoriteCategory.textContent = "☆";
el.seriesFavoriteCategory.title = "Select category";
el.seriesFavoriteCategory.setAttribute("aria-label", "Select category");
return;
}
el.seriesFavoriteCategory.disabled = false;
const active = isFavorite(favorite.key);
el.seriesFavoriteCategory.textContent = favoriteIcon(active);
const label = active ? "Remove category from favorites" : "Add category to favorites";
el.seriesFavoriteCategory.title = label;
el.seriesFavoriteCategory.setAttribute("aria-label", label);
}
async function loadSeriesList() {
ensureLibraryReady();
const categoryId = String(el.seriesCategory.value || "").trim();
const search = String(el.seriesSearch.value || "").trim();
if (!categoryId && search.length < 2) {
state.seriesItems = [];
state.expandedSeriesId = null;
state.expandedSeasonBySeries = {};
renderSeriesList("Select Series category or type at least 2 characters.");
return;
}
const query = new URLSearchParams({type: "series"});
if (categoryId) {
query.set("category_id", categoryId);
}
if (search) {
query.set("search", search);
}
query.set("limit", "300");
query.set("offset", "0");
const payload = await apiJson(`/api/library/items?${query.toString()}`);
state.seriesItems = sanitizeSeriesItems(payload.items);
if (state.expandedSeriesId
&& !state.seriesItems.some((item) => String(item.series_id || "") === state.expandedSeriesId)) {
state.expandedSeriesId = null;
state.expandedSeasonBySeries = {};
}
renderSeriesList();
}
function renderSeriesList(emptyMessage = "No series found.") {
const filtered = state.seriesItems;
if (filtered.length === 0) {
el.seriesList.innerHTML = `<li class="card muted">${esc(emptyMessage)}</li>`;
return;
}
el.seriesList.innerHTML = "";
filtered.forEach((item) => {
const li = document.createElement("li");
li.className = "stream-item";
const seriesId = String(item.series_id || "");
const favorite = makeFavoriteSeriesItem(item);
li.innerHTML = `
<div>
<div class="stream-title">${esc(item.name || "Untitled")}</div>
<div class="stream-meta">Series ID: ${esc(seriesId)}</div>
</div>
<div class="stream-actions">
<button type="button" data-action="episodes">${state.expandedSeriesId === seriesId ? "Hide episodes" : "Episodes"}</button>
<button type="button" class="favorite-toggle" data-action="toggle-favorite" aria-label="${isFavorite(favorite.key) ? "Remove from favorites" : "Add to favorites"}" title="${isFavorite(favorite.key) ? "Remove from favorites" : "Add to favorites"}">${favoriteIcon(isFavorite(favorite.key))}</button>
</div>
`;
li.querySelector("button[data-action='episodes']").addEventListener("click", () => {
toggleSeriesEpisodes(item).catch(showError);
});
li.querySelector("button[data-action='toggle-favorite']").addEventListener("click", () => {
toggleFavorite(favorite).then(() => loadSeriesList()).catch(showError);
});
el.seriesList.appendChild(li);
if (state.expandedSeriesId === seriesId) {
const episodesEntry = state.seriesEpisodesById[seriesId];
const episodesLi = document.createElement("li");
episodesLi.className = "card episodes series-inline-episodes";
if (!episodesEntry || episodesEntry.loading) {
episodesLi.innerHTML = `<div class="muted">Loading episodes...</div>`;
} else if (episodesEntry.error) {
episodesLi.innerHTML = `<div class="danger">Unable to load episodes: ${esc(episodesEntry.error)}</div>`;
} else if (!episodesEntry.episodes || episodesEntry.episodes.length === 0) {
episodesLi.innerHTML = `<div class="muted">No episodes available.</div>`;
} else {
const wrap = document.createElement("div");
wrap.className = "series-inline-episodes-wrap";
const groupedBySeason = groupEpisodesBySeason(episodesEntry.episodes);
groupedBySeason.forEach((group) => {
const isExpanded = Boolean(state.expandedSeasonBySeries?.[seriesId]?.[group.season]);
const seasonBlock = document.createElement("div");
seasonBlock.className = "season-block";
seasonBlock.innerHTML = `
<button type="button" class="season-title season-toggle" data-action="toggle-season">
${isExpanded ? "Hide" : "Show"} Season ${esc(group.season)} (${group.episodes.length})
</button>
`;
seasonBlock.querySelector("button[data-action='toggle-season']").addEventListener("click", () => {
toggleSeasonGroup(seriesId, group.season);
});
if (!isExpanded) {
wrap.appendChild(seasonBlock);
return;
}
const seasonList = document.createElement("div");
seasonList.className = "season-list";
group.episodes.forEach((episode) => {
const episodeFavorite = makeFavoriteSeriesEpisode(item, episode);
const row = document.createElement("div");
row.className = "stream-item";
row.innerHTML = `
<div>
<button type="button" class="stream-title stream-link" data-action="play-title">S${esc(episode.season)}E${esc(episode.episodeNum)} - ${esc(episode.title)}</button>
<div class="stream-meta">Episode ID: ${esc(episode.id)} | Ext: ${esc(episode.ext)}</div>
</div>
<div class="stream-actions">
<button type="button" class="favorite-toggle" data-action="toggle-favorite" aria-label="${isFavorite(episodeFavorite.key) ? "Remove from favorites" : "Add to favorites"}" title="${isFavorite(episodeFavorite.key) ? "Remove from favorites" : "Add to favorites"}">${favoriteIcon(isFavorite(episodeFavorite.key))}</button>
</div>
`;
row.querySelector("button[data-action='play-title']").addEventListener("click", () => {
playXtream("series", episode.id, episode.ext, `${item.name} - ${episode.title}`, null, {
seriesId,
season: episode.season,
episode: episode.episodeNum
}).catch(showError);
});
row.querySelector("button[data-action='toggle-favorite']").addEventListener("click", () => {
toggleFavorite(episodeFavorite).then(() => loadSeriesList()).catch(showError);
});
seasonList.appendChild(row);
});
seasonBlock.appendChild(seasonList);
wrap.appendChild(seasonBlock);
});
episodesLi.appendChild(wrap);
}
el.seriesList.appendChild(episodesLi);
}
});
}
async function toggleSeriesEpisodes(seriesItem) {
const seriesId = String(seriesItem.series_id || "");
if (!seriesId) {
return;
}
if (state.expandedSeriesId === seriesId) {
state.expandedSeriesId = null;
delete state.expandedSeasonBySeries[seriesId];
renderSeriesList();
return;
}
state.expandedSeriesId = seriesId;
state.expandedSeasonBySeries[seriesId] = {};
renderSeriesList();
if (state.seriesEpisodesById[seriesId]?.loaded) {
return;
}
state.seriesEpisodesById[seriesId] = {loading: true, loaded: false, episodes: []};
renderSeriesList();
try {
const payload = await apiJson(`/api/library/series-episodes?series_id=${encodeURIComponent(seriesId)}`);
const episodes = sanitizeSeriesEpisodes(payload.items);
state.seriesEpisodesById[seriesId] = {
loading: false,
loaded: true,
episodes
};
} catch (error) {
state.seriesEpisodesById[seriesId] = {
loading: false,
loaded: false,
error: error.message || String(error),
episodes: []
};
throw error;
} finally {
renderSeriesList();
}
}
function toggleSeasonGroup(seriesId, season) {
const current = state.expandedSeasonBySeries[seriesId] || {};
const seasonKey = String(season || "?");
current[seasonKey] = !current[seasonKey];
state.expandedSeasonBySeries[seriesId] = current;
renderSeriesList();
}
function loadCustomStreams() {
try {
const raw = localStorage.getItem(customStorageKey);
state.customStreams = raw ? JSON.parse(raw) : [];
if (!Array.isArray(state.customStreams)) {
state.customStreams = [];
}
} catch (error) {
state.customStreams = [];
}
}
function persistCustomStreams() {
localStorage.setItem(customStorageKey, JSON.stringify(state.customStreams));
}
async function loadFavorites(searchRaw = "") {
const search = String(searchRaw || "").trim();
const query = new URLSearchParams();
if (search) {
query.set("search", search);
}
query.set("limit", String(state.favoritesLimit));
query.set("offset", String(state.favoritesOffset));
const path = query.toString() ? `/api/favorites?${query.toString()}` : "/api/favorites";
const payload = await apiJson(path);
const list = sanitizeFavorites(payload.items);
state.favorites = list;
list.forEach((item) => {
const key = String(item?.key || "");
if (key) {
state.favoriteKeys.add(key);
}
});
state.favoritesTotal = Number(payload?.total || 0);
state.favoritesLimit = Math.max(1, Number(payload?.limit || state.favoritesLimit || 50));
state.favoritesOffset = Math.max(0, Number(payload?.offset || 0));
}
function isFavorite(key) {
return state.favoriteKeys.has(String(key || ""));
}
function findFavoriteByKey(keyRaw) {
const key = String(keyRaw || "").trim();
if (!key) {
return null;
}
return state.favorites.find((item) => String(item?.key || "") === key) || null;
}
function clearFavoriteCachesForItem(favorite) {
if (!favorite) {
return;
}
const mode = String(favorite?.mode || "");
const id = String(favorite?.id || "");
if (mode === "series_item") {
delete state.expandedFavoriteSeriesById[id];
delete state.favoriteSeriesEpisodesById[id];
delete state.expandedSeasonByFavoriteSeries[id];
}
if (mode === "live_category") {
delete state.expandedFavoriteLiveCategoryById[id];
delete state.favoriteLiveCategoryStreamsById[id];
}
if (mode === "vod_category") {
delete state.expandedFavoriteVodCategoryById[id];
delete state.favoriteVodCategoryStreamsById[id];
}
if (mode === "series_category") {
delete state.expandedFavoriteSeriesCategoryById[id];
delete state.favoriteSeriesCategoryItemsById[id];
}
}
async function reloadFavoritesCurrentPage() {
await loadFavorites(String(el.favoritesSearch.value || "").trim());
if (state.favorites.length === 0 && state.favoritesOffset > 0) {
state.favoritesOffset = Math.max(0, state.favoritesOffset - state.favoritesLimit);
await loadFavorites(String(el.favoritesSearch.value || "").trim());
}
renderFavorites();
}
async function toggleFavorite(favorite) {
if (!favorite || !favorite.key) {
return;
}
const deleted = await deleteFavoriteByKey(favorite.key);
if (!deleted) {
const payload = {
...favorite,
createdAt: Number(favorite?.createdAt || Date.now())
};
const response = await apiJson("/api/favorites", {
method: "POST",
headers: {"Content-Type": "application/json;charset=UTF-8"},
body: JSON.stringify(payload)
});
const savedItem = sanitizeFavorite(response?.item);
if (!savedItem) {
throw new Error("Favorite was not saved.");
}
state.favorites = state.favorites.filter((item) => item?.key !== savedItem.key);
state.favorites.unshift(savedItem);
state.favoriteKeys.add(savedItem.key);
}
await reloadFavoritesCurrentPage();
updateLiveCategoryFavoriteButton();
updateVodCategoryFavoriteButton();
updateSeriesCategoryFavoriteButton();
}
async function onFavoritesListClick(event) {
const actionNode = event.target.closest("[data-action]");
if (!actionNode || !el.favoritesList.contains(actionNode)) {
return;
}
const action = String(actionNode.dataset.action || "");
const key = String(actionNode.dataset.key || "");
const favorite = findFavoriteByKey(key);
if (!favorite && action !== "toggle-favorite-season" && action !== "play-title" && action !== "toggle-favorite") {
return;
}
try {
if (action === "toggle-favorite-series" && favorite) {
await toggleFavoriteSeriesItem(favorite);
return;
}
if (action === "toggle-favorite-live-category" && favorite) {
await toggleFavoriteLiveCategory(favorite);
return;
}
if (action === "toggle-favorite-vod-category" && favorite) {
await toggleFavoriteVodCategory(favorite);
return;
}
if (action === "toggle-favorite-series-category" && favorite) {
await toggleFavoriteSeriesCategory(favorite);
return;
}
if (action === "open-favorite" && favorite) {
await openFavorite(favorite);
return;
}
if (action === "remove-favorite" && favorite) {
await deleteFavoriteByKey(favorite.key);
clearFavoriteCachesForItem(favorite);
await reloadFavoritesCurrentPage();
if (activeTabName() === "live") {
await loadLiveStreams();
}
if (activeTabName() === "vod") {
await loadVodStreams();
}
if (activeTabName() === "series") {
await loadSeriesList();
}
renderCustomStreams();
}
} catch (error) {
showError(error);
}
}
function renderFavorites() {
const search = el.favoritesSearch.value.trim().toLowerCase();
const matchesText = (value) => String(value || "").toLowerCase().includes(search);
const filtered = state.favorites.filter((item) => {
const title = String(item?.title || "").toLowerCase();
const type = String(item?.mode || "").toLowerCase();
const url = String(item?.url || "").toLowerCase();
if (!search || title.includes(search) || type.includes(search) || url.includes(search)) {
return true;
}
const mode = String(item?.mode || "");
if (mode === "live_category") {
const categoryId = String(item?.id || "");
const entry = state.favoriteLiveCategoryStreamsById[categoryId];
const streams = Array.isArray(entry?.episodes) ? entry.episodes : [];
return streams.some((stream) => matchesText(stream?.name) || matchesText(stream?.stream_id));
}
if (mode === "vod_category") {
const categoryId = String(item?.id || "");
const entry = state.favoriteVodCategoryStreamsById[categoryId];
const streams = Array.isArray(entry?.episodes) ? entry.episodes : [];
return streams.some((stream) => matchesText(stream?.name) || matchesText(stream?.stream_id));
}
if (mode === "series_category") {
const categoryId = String(item?.id || "");
const entry = state.favoriteSeriesCategoryItemsById[categoryId];
const seriesItems = Array.isArray(entry?.episodes) ? entry.episodes : [];
return seriesItems.some((seriesItem) => matchesText(seriesItem?.name) || matchesText(seriesItem?.series_id));
}
return false;
});
const from = state.favoritesTotal === 0 ? 0 : state.favoritesOffset + 1;
const to = Math.min(state.favoritesOffset + state.favoritesLimit, state.favoritesTotal);
el.favoritesPageInfo.textContent = `Showing ${from}-${to} of ${state.favoritesTotal}`;
el.favoritesPrev.disabled = state.favoritesOffset <= 0;
el.favoritesNext.disabled = state.favoritesOffset + state.favoritesLimit >= state.favoritesTotal;
if (filtered.length === 0) {
el.favoritesList.innerHTML = `<li class="card muted">No favorites yet.</li>`;
return;
}
el.favoritesList.innerHTML = "";
filtered.forEach((favorite) => {
const li = document.createElement("li");
const favoriteTone = favoriteToneClass(favorite);
const favoriteBadge = favoriteBadgeLabel(favorite);
li.className = `stream-item favorite-item ${favoriteTone}`;
li.dataset.favoriteKey = String(favorite?.key || "");
li.dataset.favoriteMode = String(favorite?.mode || "");
li.dataset.favoriteId = String(favorite?.id || "");
const isSeriesItem = favorite?.mode === "series_item";
const isLiveCategory = favorite?.mode === "live_category";
const isVodCategory = favorite?.mode === "vod_category";
const isSeriesCategory = favorite?.mode === "series_category";
const seriesId = String(favorite?.id || "");
const isSeriesItemExpanded = isSeriesItem && Boolean(state.expandedFavoriteSeriesById[seriesId]);
const liveCategoryId = String(favorite?.id || "");
const isLiveCategoryExpanded = isLiveCategory && Boolean(state.expandedFavoriteLiveCategoryById[liveCategoryId]);
const vodCategoryId = String(favorite?.id || "");
const isVodCategoryExpanded = isVodCategory && Boolean(state.expandedFavoriteVodCategoryById[vodCategoryId]);
const seriesCategoryId = String(favorite?.id || "");
const isSeriesCategoryExpanded = isSeriesCategory
&& Boolean(state.expandedFavoriteSeriesCategoryById[seriesCategoryId]);
li.innerHTML = isSeriesItem
? `
<div>
<button type="button" class="stream-title stream-link" data-action="toggle-favorite-series" data-key="${esc(favorite.key)}">${isSeriesItemExpanded ? "Hide" : "Show"} ${esc(favorite.title || "Untitled")}</button>
<div class="stream-meta"><span class="fav-type-badge">${esc(favoriteBadge)}</span>${esc(favoriteSummary(favorite))}</div>
</div>
<div class="stream-actions">
<button type="button" data-action="remove-favorite" data-key="${esc(favorite.key)}" class="danger">Remove</button>
</div>
`
: isLiveCategory
? `
<div>
<button type="button" class="stream-title stream-link" data-action="toggle-favorite-live-category" data-key="${esc(favorite.key)}">${isLiveCategoryExpanded ? "Hide" : "Show"} ${esc(favorite.title || "Untitled")}</button>
<div class="stream-meta"><span class="fav-type-badge">${esc(favoriteBadge)}</span>${esc(favoriteSummary(favorite))}</div>
</div>
<div class="stream-actions">
<button type="button" data-action="remove-favorite" data-key="${esc(favorite.key)}" class="danger">Remove</button>
</div>
`
: isVodCategory
? `
<div>
<button type="button" class="stream-title stream-link" data-action="toggle-favorite-vod-category" data-key="${esc(favorite.key)}">${isVodCategoryExpanded ? "Hide" : "Show"} ${esc(favorite.title || "Untitled")}</button>
<div class="stream-meta"><span class="fav-type-badge">${esc(favoriteBadge)}</span>${esc(favoriteSummary(favorite))}</div>
</div>
<div class="stream-actions">
<button type="button" data-action="remove-favorite" data-key="${esc(favorite.key)}" class="danger">Remove</button>
</div>
`
: isSeriesCategory
? `
<div>
<button type="button" class="stream-title stream-link" data-action="toggle-favorite-series-category" data-key="${esc(favorite.key)}">${isSeriesCategoryExpanded ? "Hide" : "Show"} ${esc(favorite.title || "Untitled")}</button>
<div class="stream-meta"><span class="fav-type-badge">${esc(favoriteBadge)}</span>${esc(favoriteSummary(favorite))}</div>
</div>
<div class="stream-actions">
<button type="button" data-action="remove-favorite" data-key="${esc(favorite.key)}" class="danger">Remove</button>
</div>
`
: `
<div>
<button type="button" class="stream-title stream-link" data-action="open-favorite" data-key="${esc(favorite.key)}">${esc(favorite.title || "Untitled")}</button>
<div class="stream-meta"><span class="fav-type-badge">${esc(favoriteBadge)}</span>${esc(favoriteSummary(favorite))}</div>
</div>
<div class="stream-actions">
<button type="button" data-action="remove-favorite" data-key="${esc(favorite.key)}" class="danger">Remove</button>
</div>
`;
el.favoritesList.appendChild(li);
if (!isSeriesItem && !isLiveCategory && !isVodCategory && !isSeriesCategory) {
return;
}
if (isLiveCategory && !isLiveCategoryExpanded) {
return;
}
if (isVodCategory && !isVodCategoryExpanded) {
return;
}
if (isSeriesItem && !isSeriesItemExpanded) {
return;
}
if (isSeriesCategory && !isSeriesCategoryExpanded) {
return;
}
const episodesEntry = isLiveCategory
? state.favoriteLiveCategoryStreamsById[liveCategoryId]
: isVodCategory
? state.favoriteVodCategoryStreamsById[vodCategoryId]
: isSeriesCategory
? state.favoriteSeriesCategoryItemsById[seriesCategoryId]
: state.favoriteSeriesEpisodesById[seriesId];
const episodesLi = document.createElement("li");
episodesLi.className = "card episodes series-inline-episodes";
if (!episodesEntry || episodesEntry.loading) {
episodesLi.innerHTML = `<div class="muted">Loading episodes...</div>`;
} else if (episodesEntry.error) {
episodesLi.innerHTML = `<div class="danger">Unable to load episodes: ${esc(episodesEntry.error)}</div>`;
} else if (!episodesEntry.episodes || episodesEntry.episodes.length === 0) {
episodesLi.innerHTML = `<div class="muted">No episodes available.</div>`;
} else {
const wrap = document.createElement("div");
wrap.className = "series-inline-episodes-wrap";
if (isLiveCategory) {
const liveStreams = Array.isArray(episodesEntry.episodes) ? episodesEntry.episodes : [];
const visibleLiveStreams = search
? liveStreams.filter((stream) => matchesText(stream?.name) || matchesText(stream?.stream_id))
: liveStreams;
if (visibleLiveStreams.length === 0) {
episodesLi.innerHTML = `<div class="muted">No matching streams in this category.</div>`;
el.favoritesList.appendChild(episodesLi);
return;
}
visibleLiveStreams.forEach((stream) => {
const streamFavorite = makeFavoriteLive(stream);
const row = document.createElement("div");
row.className = "stream-item";
const streamId = String(stream?.stream_id || "");
const epgStreamId = String(stream?.epg_channel_id || streamId);
row.innerHTML = `
<div>
<button type="button" class="stream-title stream-link" data-action="play-title">${esc(stream?.name || "Untitled")}</button>
<div class="stream-meta">ID: ${esc(streamId)} | Category: ${esc(stream?.category_id || "-")}</div>
</div>
<div class="stream-actions">
<button type="button" class="favorite-toggle" data-action="toggle-favorite" aria-label="${isFavorite(streamFavorite.key) ? "Remove from favorites" : "Add to favorites"}" title="${isFavorite(streamFavorite.key) ? "Remove from favorites" : "Add to favorites"}">${favoriteIcon(isFavorite(streamFavorite.key))}</button>
</div>
`;
row.querySelector("button[data-action='play-title']").addEventListener("click", () => {
playXtream(
"live",
streamId,
state.config?.liveFormat || "m3u8",
stream?.name || "Live stream",
epgStreamId,
{categoryId: stream?.category_id || ""}
).catch(showError);
});
row.querySelector("button[data-action='toggle-favorite']").addEventListener("click", () => {
toggleFavorite(streamFavorite).then(() => renderFavorites()).catch(showError);
});
wrap.appendChild(row);
});
episodesLi.appendChild(wrap);
el.favoritesList.appendChild(episodesLi);
return;
}
if (isVodCategory) {
const vodStreams = Array.isArray(episodesEntry.episodes) ? episodesEntry.episodes : [];
const visibleVodStreams = search
? vodStreams.filter((stream) => matchesText(stream?.name) || matchesText(stream?.stream_id))
: vodStreams;
if (visibleVodStreams.length === 0) {
episodesLi.innerHTML = `<div class="muted">No matching streams in this category.</div>`;
el.favoritesList.appendChild(episodesLi);
return;
}
visibleVodStreams.forEach((stream) => {
const streamFavorite = makeFavoriteVod(stream);
const row = document.createElement("div");
row.className = "stream-item";
const streamId = String(stream?.stream_id || "");
const ext = String(stream?.container_extension || "mp4");
row.innerHTML = `
<div>
<button type="button" class="stream-title stream-link" data-action="play-title">${esc(stream?.name || "Untitled")}</button>
<div class="stream-meta">ID: ${esc(streamId)} | Ext: ${esc(ext)}</div>
</div>
<div class="stream-actions">
<button type="button" class="favorite-toggle" data-action="toggle-favorite" aria-label="${isFavorite(streamFavorite.key) ? "Remove from favorites" : "Add to favorites"}" title="${isFavorite(streamFavorite.key) ? "Remove from favorites" : "Add to favorites"}">${favoriteIcon(isFavorite(streamFavorite.key))}</button>
</div>
`;
row.querySelector("button[data-action='play-title']").addEventListener("click", () => {
playXtream("vod", streamId, ext, stream?.name || "VOD", null, {
categoryId: stream?.category_id || ""
}).catch(showError);
});
row.querySelector("button[data-action='toggle-favorite']").addEventListener("click", () => {
toggleFavorite(streamFavorite).then(() => renderFavorites()).catch(showError);
});
wrap.appendChild(row);
});
episodesLi.appendChild(wrap);
el.favoritesList.appendChild(episodesLi);
return;
}
if (isSeriesCategory) {
const seriesItems = Array.isArray(episodesEntry.episodes) ? episodesEntry.episodes : [];
const visibleSeriesItems = search
? seriesItems.filter((seriesItem) => matchesText(seriesItem?.name)
|| matchesText(seriesItem?.series_id))
: seriesItems;
if (visibleSeriesItems.length === 0) {
episodesLi.innerHTML = `<div class="muted">No matching streams in this category.</div>`;
el.favoritesList.appendChild(episodesLi);
return;
}
visibleSeriesItems.forEach((seriesItem) => {
const seriesItemFavorite = makeFavoriteSeriesItem(seriesItem);
const localSeriesId = String(seriesItem?.series_id || "");
const isLocalExpanded = Boolean(state.expandedFavoriteSeriesById[localSeriesId]);
const row = document.createElement("div");
row.className = "stream-item";
row.innerHTML = `
<div>
<button type="button" class="stream-title stream-link" data-action="toggle-favorite-series">${isLocalExpanded ? "Hide episodes" : "Episodes"} ${esc(seriesItem?.name || "Untitled")}</button>
<div class="stream-meta">Series ID: ${esc(localSeriesId)}</div>
</div>
<div class="stream-actions">
<button type="button" class="favorite-toggle" data-action="toggle-favorite" aria-label="${isFavorite(seriesItemFavorite.key) ? "Remove from favorites" : "Add to favorites"}" title="${isFavorite(seriesItemFavorite.key) ? "Remove from favorites" : "Add to favorites"}">${favoriteIcon(isFavorite(seriesItemFavorite.key))}</button>
</div>
`;
row.querySelector("button[data-action='toggle-favorite-series']").addEventListener("click", () => {
toggleFavoriteSeriesItem(seriesItemFavorite).catch(showError);
});
row.querySelector("button[data-action='toggle-favorite']").addEventListener("click", () => {
toggleFavorite(seriesItemFavorite).then(() => renderFavorites()).catch(showError);
});
wrap.appendChild(row);
if (!isLocalExpanded) {
return;
}
const seriesEntry = state.favoriteSeriesEpisodesById[localSeriesId];
const episodesBlock = document.createElement("div");
episodesBlock.className = "season-list";
if (!seriesEntry || seriesEntry.loading) {
episodesBlock.innerHTML = `<div class="muted">Loading episodes...</div>`;
wrap.appendChild(episodesBlock);
return;
}
if (seriesEntry.error) {
episodesBlock.innerHTML = `<div class="danger">Unable to load episodes: ${esc(seriesEntry.error)}</div>`;
wrap.appendChild(episodesBlock);
return;
}
const groupedBySeason = groupEpisodesBySeason(seriesEntry.episodes);
groupedBySeason.forEach((group) => {
const isSeasonExpanded = Boolean(state.expandedSeasonByFavoriteSeries?.[localSeriesId]?.[group.season]);
const seasonBlock = document.createElement("div");
seasonBlock.className = "season-block";
seasonBlock.innerHTML = `
<button type="button" class="season-title season-toggle" data-action="toggle-favorite-season">
${isSeasonExpanded ? "Hide" : "Show"} Season ${esc(group.season)} (${group.episodes.length})
</button>
`;
seasonBlock.querySelector("button[data-action='toggle-favorite-season']").addEventListener("click", () => {
toggleFavoriteSeasonGroup(localSeriesId, group.season);
});
if (!isSeasonExpanded) {
episodesBlock.appendChild(seasonBlock);
return;
}
const seasonList = document.createElement("div");
seasonList.className = "season-list";
group.episodes.forEach((episode) => {
const episodeFavorite = makeFavoriteSeriesEpisode(
{name: seriesItem?.name || "Series", series_id: localSeriesId},
episode
);
const episodeRow = document.createElement("div");
episodeRow.className = "stream-item";
episodeRow.innerHTML = `
<div>
<button type="button" class="stream-title stream-link" data-action="play-title">S${esc(episode.season)}E${esc(episode.episodeNum)} - ${esc(episode.title)}</button>
<div class="stream-meta">Episode ID: ${esc(episode.id)} | Ext: ${esc(episode.ext)}</div>
</div>
<div class="stream-actions">
<button type="button" class="favorite-toggle" data-action="toggle-favorite" aria-label="${isFavorite(episodeFavorite.key) ? "Remove from favorites" : "Add to favorites"}" title="${isFavorite(episodeFavorite.key) ? "Remove from favorites" : "Add to favorites"}">${favoriteIcon(isFavorite(episodeFavorite.key))}</button>
</div>
`;
episodeRow.querySelector("button[data-action='play-title']").addEventListener("click", () => {
playXtream("series", episode.id, episode.ext, `${seriesItem?.name || "Series"} - ${episode.title}`, null, {
seriesId: localSeriesId,
season: episode.season,
episode: episode.episodeNum
}).catch(showError);
});
episodeRow.querySelector("button[data-action='toggle-favorite']").addEventListener("click", () => {
toggleFavorite(episodeFavorite).then(() => renderFavorites()).catch(showError);
});
seasonList.appendChild(episodeRow);
});
seasonBlock.appendChild(seasonList);
episodesBlock.appendChild(seasonBlock);
});
wrap.appendChild(episodesBlock);
});
episodesLi.appendChild(wrap);
el.favoritesList.appendChild(episodesLi);
return;
}
const groupedBySeason = groupEpisodesBySeason(episodesEntry.episodes);
groupedBySeason.forEach((group) => {
const isSeasonExpanded = Boolean(state.expandedSeasonByFavoriteSeries?.[seriesId]?.[group.season]);
const seasonBlock = document.createElement("div");
seasonBlock.className = "season-block";
seasonBlock.innerHTML = `
<button type="button" class="season-title season-toggle" data-action="toggle-favorite-season">
${isSeasonExpanded ? "Hide" : "Show"} Season ${esc(group.season)} (${group.episodes.length})
</button>
`;
seasonBlock.querySelector("button[data-action='toggle-favorite-season']").addEventListener("click", () => {
toggleFavoriteSeasonGroup(seriesId, group.season);
});
if (!isSeasonExpanded) {
wrap.appendChild(seasonBlock);
return;
}
const seasonList = document.createElement("div");
seasonList.className = "season-list";
group.episodes.forEach((episode) => {
const episodeFavorite = makeFavoriteSeriesEpisode(
{name: favorite.title || "Series", series_id: seriesId},
episode
);
const row = document.createElement("div");
row.className = "stream-item";
row.innerHTML = `
<div>
<button type="button" class="stream-title stream-link" data-action="play-title">S${esc(episode.season)}E${esc(episode.episodeNum)} - ${esc(episode.title)}</button>
<div class="stream-meta">Episode ID: ${esc(episode.id)} | Ext: ${esc(episode.ext)}</div>
</div>
<div class="stream-actions">
<button type="button" class="favorite-toggle" data-action="toggle-favorite" aria-label="${isFavorite(episodeFavorite.key) ? "Remove from favorites" : "Add to favorites"}" title="${isFavorite(episodeFavorite.key) ? "Remove from favorites" : "Add to favorites"}">${favoriteIcon(isFavorite(episodeFavorite.key))}</button>
</div>
`;
row.querySelector("button[data-action='play-title']").addEventListener("click", () => {
playXtream("series", episode.id, episode.ext, `${favorite.title} - ${episode.title}`, null, {
seriesId,
season: episode.season,
episode: episode.episodeNum
}).catch(showError);
});
row.querySelector("button[data-action='toggle-favorite']").addEventListener("click", () => {
toggleFavorite(episodeFavorite).then(() => renderFavorites()).catch(showError);
});
seasonList.appendChild(row);
});
seasonBlock.appendChild(seasonList);
wrap.appendChild(seasonBlock);
});
episodesLi.appendChild(wrap);
}
el.favoritesList.appendChild(episodesLi);
});
}
async function openFavorite(favorite) {
const mode = String(favorite?.mode || "");
switch (mode) {
case "live":
await playXtream(
"live",
String(favorite.id || ""),
String(favorite.ext || state.config?.liveFormat || "m3u8"),
favorite.title || "Live stream",
String(favorite.id || ""),
{categoryId: favorite.categoryId || ""}
);
break;
case "vod":
await playXtream(
"vod",
String(favorite.id || ""),
String(favorite.ext || "mp4"),
favorite.title || "VOD",
null,
{categoryId: favorite.categoryId || ""}
);
break;
case "series_episode":
await playXtream(
"series",
String(favorite.id || ""),
String(favorite.ext || "mp4"),
favorite.title || "Episode",
null,
{
seriesId: favorite.seriesId || "",
season: favorite.season || "",
episode: favorite.episode || ""
}
);
break;
case "series_item":
await toggleFavoriteSeriesItem(favorite);
break;
case "live_category":
await toggleFavoriteLiveCategory(favorite);
break;
case "vod_category":
await toggleFavoriteVodCategory(favorite);
break;
case "series_category":
await toggleFavoriteSeriesCategory(favorite);
break;
case "custom":
playCustom(favorite.title || "Custom stream", String(favorite.url || ""));
break;
default:
throw new Error("Unsupported favorite type.");
}
}
async function toggleFavoriteSeriesItem(favorite) {
const seriesId = String(favorite?.id || "");
if (!seriesId) {
throw new Error("Missing series id.");
}
if (state.expandedFavoriteSeriesById[seriesId]) {
delete state.expandedFavoriteSeriesById[seriesId];
delete state.expandedSeasonByFavoriteSeries[seriesId];
renderFavorites();
return;
}
state.expandedFavoriteSeriesById[seriesId] = true;
state.expandedSeasonByFavoriteSeries[seriesId] = state.expandedSeasonByFavoriteSeries[seriesId] || {};
renderFavorites();
await ensureFavoriteSeriesEpisodesLoaded(seriesId);
}
async function ensureFavoriteSeriesEpisodesLoaded(seriesId) {
ensureLibraryReady();
if (state.favoriteSeriesEpisodesById[seriesId]?.loaded || state.favoriteSeriesEpisodesById[seriesId]?.loading) {
return;
}
state.favoriteSeriesEpisodesById[seriesId] = {loading: true, loaded: false, episodes: []};
renderFavorites();
try {
const payload = await apiJson(`/api/library/series-episodes?series_id=${encodeURIComponent(seriesId)}`);
const episodes = sanitizeSeriesEpisodes(payload.items);
state.favoriteSeriesEpisodesById[seriesId] = {
loading: false,
loaded: true,
episodes
};
} catch (error) {
state.favoriteSeriesEpisodesById[seriesId] = {
loading: false,
loaded: false,
error: error.message || String(error),
episodes: []
};
throw error;
} finally {
renderFavorites();
}
}
async function toggleFavoriteLiveCategory(favorite) {
const categoryId = String(favorite?.id || "");
if (!categoryId) {
throw new Error("Missing live category id.");
}
if (state.expandedFavoriteLiveCategoryById[categoryId]) {
delete state.expandedFavoriteLiveCategoryById[categoryId];
renderFavorites();
return;
}
state.expandedFavoriteLiveCategoryById[categoryId] = true;
renderFavorites();
await ensureFavoriteLiveCategoryStreamsLoaded(categoryId);
}
async function ensureFavoriteLiveCategoryStreamsLoaded(categoryId) {
ensureLibraryReady();
if (state.favoriteLiveCategoryStreamsById[categoryId]?.loaded
|| state.favoriteLiveCategoryStreamsById[categoryId]?.loading) {
return;
}
state.favoriteLiveCategoryStreamsById[categoryId] = {loading: true, loaded: false, episodes: []};
renderFavorites();
try {
const query = new URLSearchParams({type: "live", category_id: categoryId});
const payload = await apiJson(`/api/library/items?${query.toString()}`);
state.favoriteLiveCategoryStreamsById[categoryId] = {
loading: false,
loaded: true,
episodes: sanitizeLiveStreams(payload.items)
};
} catch (error) {
state.favoriteLiveCategoryStreamsById[categoryId] = {
loading: false,
loaded: false,
error: error.message || String(error),
episodes: []
};
throw error;
} finally {
renderFavorites();
}
}
async function toggleFavoriteVodCategory(favorite) {
const categoryId = String(favorite?.id || "");
if (!categoryId) {
throw new Error("Missing VOD category id.");
}
if (state.expandedFavoriteVodCategoryById[categoryId]) {
delete state.expandedFavoriteVodCategoryById[categoryId];
renderFavorites();
return;
}
state.expandedFavoriteVodCategoryById[categoryId] = true;
renderFavorites();
await ensureFavoriteVodCategoryStreamsLoaded(categoryId);
}
async function ensureFavoriteVodCategoryStreamsLoaded(categoryId) {
ensureLibraryReady();
if (state.favoriteVodCategoryStreamsById[categoryId]?.loaded
|| state.favoriteVodCategoryStreamsById[categoryId]?.loading) {
return;
}
state.favoriteVodCategoryStreamsById[categoryId] = {loading: true, loaded: false, episodes: []};
renderFavorites();
try {
const query = new URLSearchParams({type: "vod", category_id: categoryId});
const payload = await apiJson(`/api/library/items?${query.toString()}`);
state.favoriteVodCategoryStreamsById[categoryId] = {
loading: false,
loaded: true,
episodes: sanitizeVodStreams(payload.items)
};
} catch (error) {
state.favoriteVodCategoryStreamsById[categoryId] = {
loading: false,
loaded: false,
error: error.message || String(error),
episodes: []
};
throw error;
} finally {
renderFavorites();
}
}
async function toggleFavoriteSeriesCategory(favorite) {
const categoryId = String(favorite?.id || "");
if (!categoryId) {
throw new Error("Missing series category id.");
}
if (state.expandedFavoriteSeriesCategoryById[categoryId]) {
delete state.expandedFavoriteSeriesCategoryById[categoryId];
renderFavorites();
return;
}
state.expandedFavoriteSeriesCategoryById[categoryId] = true;
renderFavorites();
await ensureFavoriteSeriesCategoryItemsLoaded(categoryId);
}
async function ensureFavoriteSeriesCategoryItemsLoaded(categoryId) {
ensureLibraryReady();
if (state.favoriteSeriesCategoryItemsById[categoryId]?.loaded
|| state.favoriteSeriesCategoryItemsById[categoryId]?.loading) {
return;
}
state.favoriteSeriesCategoryItemsById[categoryId] = {loading: true, loaded: false, episodes: []};
renderFavorites();
try {
const query = new URLSearchParams({type: "series", category_id: categoryId});
const payload = await apiJson(`/api/library/items?${query.toString()}`);
state.favoriteSeriesCategoryItemsById[categoryId] = {
loading: false,
loaded: true,
episodes: sanitizeSeriesItems(payload.items)
};
} catch (error) {
state.favoriteSeriesCategoryItemsById[categoryId] = {
loading: false,
loaded: false,
error: error.message || String(error),
episodes: []
};
throw error;
} finally {
renderFavorites();
}
}
function toggleFavoriteSeasonGroup(seriesId, season) {
const current = state.expandedSeasonByFavoriteSeries[seriesId] || {};
const seasonKey = String(season || "?");
current[seasonKey] = !current[seasonKey];
state.expandedSeasonByFavoriteSeries[seriesId] = current;
renderFavorites();
}
function favoriteSummary(favorite) {
const mode = String(favorite?.mode || "unknown");
if (mode === "custom") {
return `Custom | ${favorite.url || ""}`;
}
if (mode === "series_item") {
return `Series | ID: ${favorite.id || "-"}`;
}
if (mode === "live_category") {
return `Live category | ID: ${favorite.id || "-"}`;
}
if (mode === "vod_category") {
return `VOD category | ID: ${favorite.id || "-"}`;
}
if (mode === "series_category") {
return `Series category | ID: ${favorite.id || "-"}`;
}
if (mode === "series_episode") {
return `Series episode | ID: ${favorite.id || "-"} | Ext: ${favorite.ext || "mp4"}`;
}
if (mode === "live") {
return `Live | ID: ${favorite.id || "-"} | Format: ${favorite.ext || "m3u8"}`;
}
if (mode === "vod") {
return `VOD | ID: ${favorite.id || "-"} | Ext: ${favorite.ext || "mp4"}`;
}
return mode;
}
function favoriteToneClass(favorite) {
const mode = String(favorite?.mode || "").toLowerCase();
if (mode === "live" || mode === "live_category") {
return "fav-tone-live";
}
if (mode === "vod" || mode === "vod_category") {
return "fav-tone-vod";
}
if (mode === "series_item" || mode === "series_episode" || mode === "series_category") {
return "fav-tone-series";
}
if (mode === "custom") {
return "fav-tone-custom";
}
return "fav-tone-other";
}
function favoriteBadgeLabel(favorite) {
const mode = String(favorite?.mode || "").toLowerCase();
if (mode === "live") {
return "LIVE";
}
if (mode === "live_category") {
return "LIVE CATEGORY";
}
if (mode === "vod") {
return "VOD";
}
if (mode === "vod_category") {
return "VOD CATEGORY";
}
if (mode === "series_item") {
return "SERIES";
}
if (mode === "series_episode") {
return "EPISODE";
}
if (mode === "series_category") {
return "SERIES CATEGORY";
}
if (mode === "custom") {
return "CUSTOM";
}
return "FAVORITE";
}
function makeFavoriteLive(item) {
const streamId = String(item?.stream_id || "");
return {
key: `live:${streamId}`,
mode: "live",
id: streamId,
ext: String(state.config?.liveFormat || "m3u8"),
title: String(item?.name || "Untitled"),
categoryId: String(item?.category_id || "")
};
}
function makeFavoriteVod(item) {
const streamId = String(item?.stream_id || "");
return {
key: `vod:${streamId}`,
mode: "vod",
id: streamId,
ext: String(item?.container_extension || "mp4"),
title: String(item?.name || "Untitled"),
categoryId: String(item?.category_id || "")
};
}
function makeFavoriteLiveCategory(category) {
const categoryId = String(category?.category_id || "");
return {
key: `live-category:${categoryId}`,
mode: "live_category",
id: categoryId,
title: String(category?.category_name || `Category ${categoryId}`)
};
}
function makeFavoriteVodCategory(category) {
const categoryId = String(category?.category_id || "");
return {
key: `vod-category:${categoryId}`,
mode: "vod_category",
id: categoryId,
title: String(category?.category_name || `Category ${categoryId}`)
};
}
function makeFavoriteSeriesCategory(category) {
const categoryId = String(category?.category_id || "");
return {
key: `series-category:${categoryId}`,
mode: "series_category",
id: categoryId,
title: String(category?.category_name || `Category ${categoryId}`)
};
}
function makeFavoriteSeriesItem(item) {
const seriesId = String(item?.series_id || "");
return {
key: `series-item:${seriesId}`,
mode: "series_item",
id: seriesId,
title: String(item?.name || "Untitled"),
categoryId: String(item?.category_id || "")
};
}
function makeFavoriteSeriesEpisode(seriesItem, episode) {
const episodeId = String(episode?.id || "");
return {
key: `series-episode:${episodeId}`,
mode: "series_episode",
id: episodeId,
ext: String(episode?.ext || "mp4"),
title: `${String(seriesItem?.name || "Series")} - ${String(episode?.title || "Episode")}`,
seriesId: String(seriesItem?.series_id || ""),
season: String(episode?.season || ""),
episode: String(episode?.episodeNum || "")
};
}
function makeFavoriteCustom(item) {
const url = String(item?.url || "");
const customId = String(item?.id || url);
return {
key: `custom:${customId}`,
mode: "custom",
id: customId,
title: String(item?.name || "Untitled"),
url
};
}
function favoriteIcon(active) {
return active ? "★" : "☆";
}
async function deleteFavoriteByKey(keyRaw) {
const key = String(keyRaw || "").trim();
if (!key) {
return false;
}
const response = await apiJson(`/api/favorites?key=${encodeURIComponent(key)}`, {method: "DELETE"});
state.favorites = state.favorites.filter((item) => item?.key !== key);
state.favoriteKeys.delete(key);
updateLiveCategoryFavoriteButton();
updateVodCategoryFavoriteButton();
updateSeriesCategoryFavoriteButton();
return Boolean(response?.deleted);
}
function renderCustomStreams() {
const search = el.customSearch.value.trim().toLowerCase();
const filtered = state.customStreams.filter((item) => {
return !search || item.name.toLowerCase().includes(search) || item.url.toLowerCase().includes(search);
});
if (filtered.length === 0) {
el.customList.innerHTML = `<li class="card muted">No custom streams saved yet.</li>`;
return;
}
el.customList.innerHTML = "";
filtered.forEach((item) => {
const li = document.createElement("li");
li.className = "stream-item";
const favorite = makeFavoriteCustom(item);
li.innerHTML = `
<div>
<button type="button" class="stream-title stream-link" data-action="play-title">${esc(item.name)}</button>
<div class="stream-meta">${esc(item.url)}</div>
</div>
<div class="stream-actions">
<button type="button" class="favorite-toggle" data-action="toggle-favorite" aria-label="${isFavorite(favorite.key) ? "Remove from favorites" : "Add to favorites"}" title="${isFavorite(favorite.key) ? "Remove from favorites" : "Add to favorites"}">${favoriteIcon(isFavorite(favorite.key))}</button>
<button type="button" data-action="delete" class="danger">Delete</button>
</div>
`;
li.querySelector("button[data-action='play-title']").addEventListener("click", () => {
playCustom(item.name, item.url);
});
li.querySelector("button[data-action='toggle-favorite']").addEventListener("click", () => {
toggleFavorite(favorite).then(() => renderCustomStreams()).catch(showError);
});
li.querySelector("button[data-action='delete']").addEventListener("click", () => {
state.customStreams = state.customStreams.filter((stream) => stream.id !== item.id);
persistCustomStreams();
renderCustomStreams();
});
el.customList.appendChild(li);
});
}
async function playXtream(type, id, ext, title, epgStreamId = null, extraMeta = {}) {
if (!id) {
throw new Error("Missing stream id.");
}
const params = new URLSearchParams({type, id});
if (ext) {
params.set("ext", ext);
}
const response = await apiJson(`/api/stream-url?${params.toString()}`);
const url = response.url;
if (!url) {
throw new Error("Server did not return a stream URL.");
}
setPlayer(title, url, {
type,
streamId: id,
format: ext || "",
source: "Xtream",
...extraMeta
});
if (type === "live") {
state.currentLiveEpgStreamId = epgStreamId || id;
await refreshEpg();
} else {
state.currentLiveEpgStreamId = null;
el.epgList.innerHTML = "EPG is available only for live channels.";
}
}
function playCustom(title, url) {
state.currentLiveEpgStreamId = null;
setPlayer(title, url, {
type: "custom",
streamId: "-",
format: guessFormatFromUrl(url),
source: "Custom URL"
});
el.epgList.innerHTML = "EPG is available only for live channels.";
}
function setPlayer(title, url, info = {}) {
el.playerTitle.textContent = title || "Player";
const systemPlayerUrl = buildSystemPlayerHref(title, url, info);
const playbackUrl = buildBrowserPlaybackUrl(url);
el.openDirect.dataset.url = url;
el.openDirect.disabled = !url;
el.openSystemPlayer.dataset.url = systemPlayerUrl;
el.openSystemPlayer.disabled = !systemPlayerUrl;
state.currentStreamInfo = {
title: title || "Player",
url,
playbackEngine: "native",
resolution: "loading...",
duration: "loading...",
...info
};
renderStreamInfo();
resetPlayerElement();
hlsSubtitleTracks = [];
clearSubtitleTracks();
setSubtitleStatus("No subtitle loaded.", false);
scheduleEmbeddedSubtitleScan();
if (isRtspUrl(playbackUrl)) {
state.currentStreamInfo.playbackEngine = "external player (RTSP)";
state.currentStreamInfo.resolution = "n/a";
state.currentStreamInfo.duration = "n/a";
renderStreamInfo();
setSettingsMessage("RTSP is not supported in browser player. Use Open in system player.", "err");
return;
}
if (isLikelyHls(playbackUrl) && shouldUseHlsJs()) {
state.currentStreamInfo.playbackEngine = "hls.js";
renderStreamInfo();
hlsInstance = new window.Hls({
enableWorker: true,
lowLatencyMode: true
});
hlsInstance.on(window.Hls.Events.MANIFEST_PARSED, () => {
hlsSubtitleTracks = Array.isArray(hlsInstance?.subtitleTracks)
? hlsInstance.subtitleTracks
: [];
refreshEmbeddedSubtitleTracks();
});
hlsInstance.on(window.Hls.Events.SUBTITLE_TRACKS_UPDATED, (_event, data) => {
hlsSubtitleTracks = Array.isArray(data?.subtitleTracks)
? data.subtitleTracks
: (Array.isArray(hlsInstance?.subtitleTracks) ? hlsInstance.subtitleTracks : []);
refreshEmbeddedSubtitleTracks();
});
hlsInstance.loadSource(playbackUrl);
hlsInstance.attachMedia(el.player);
hlsInstance.on(window.Hls.Events.ERROR, (_event, data) => {
if (!data?.fatal) {
return;
}
disposeHls();
setSettingsMessage("HLS playback failed in embedded player. Try Open stream directly.", "err");
if (state.currentStreamInfo) {
state.currentStreamInfo.playbackEngine = "native (HLS fallback failed)";
renderStreamInfo();
}
});
} else {
el.player.src = playbackUrl;
}
const playbackPromise = el.player.play();
if (playbackPromise && typeof playbackPromise.catch === "function") {
playbackPromise.catch((error) => {
if (error?.name === "NotAllowedError") {
return;
}
setSettingsMessage(
`Playback could not start in embedded player: ${error?.message || "unknown error"}`,
"err"
);
});
}
}
function buildSystemPlayerHref(title, url, info = {}) {
const params = new URLSearchParams();
params.set("title", String(title || "Stream"));
const type = String(info?.type || "").toLowerCase();
const streamId = String(info?.streamId || "").trim();
if ((type === "live" || type === "vod" || type === "series") && streamId) {
params.set("type", type);
params.set("id", streamId);
if (info?.format) {
params.set("ext", String(info.format));
}
return `/api/open-in-player?${params.toString()}`;
}
params.set("url", String(url || ""));
return `/api/open-in-player?${params.toString()}`;
}
function buildBrowserPlaybackUrl(urlRaw) {
const url = String(urlRaw || "").trim();
if (!url) {
return url;
}
if (isRtspUrl(url)) {
return url;
}
try {
const pageIsHttps = window.location.protocol === "https:";
const target = new URL(url, window.location.href);
if (pageIsHttps && target.protocol === "http:") {
const authToken = getAuthToken();
const tokenParam = authToken ? `&token=${encodeURIComponent(authToken)}` : "";
return `/api/stream-proxy?url=${encodeURIComponent(target.toString())}${tokenParam}`;
}
} catch (error) {
return url;
}
return url;
}
function isRtspUrl(urlRaw) {
const value = String(urlRaw || "").trim().toLowerCase();
return value.startsWith("rtsp://") || value.startsWith("rtsps://");
}
function isSupportedCustomUrl(urlRaw) {
const value = String(urlRaw || "").trim().toLowerCase();
return value.startsWith("http://")
|| value.startsWith("https://")
|| value.startsWith("rtsp://")
|| value.startsWith("rtsps://");
}
function resetPlayerElement() {
disposeHls();
el.player.pause();
el.player.removeAttribute("src");
el.player.load();
}
function addSubtitleTrack(url, lang) {
clearSubtitleTracks();
const track = document.createElement("track");
track.kind = "subtitles";
track.label = `Subtitles (${lang || "en"})`;
track.srclang = lang || "en";
track.src = url;
track.default = true;
track.setAttribute("data-custom-subtitle", "1");
track.addEventListener("load", () => {
try {
if (track.track) {
track.track.mode = "showing";
}
} catch (error) {
// Some browsers restrict track mode handling; ignore.
}
refreshEmbeddedSubtitleTracks();
setSubtitleStatus(`Subtitle loaded: ${url}`, false);
});
track.addEventListener("error", () => {
setSubtitleStatus("Subtitle failed to load. Check URL/CORS/WebVTT format.", true);
});
el.player.appendChild(track);
}
function clearSubtitleTracks() {
Array.from(el.player.querySelectorAll("track[data-custom-subtitle='1']")).forEach((track) => track.remove());
const tracks = el.player.textTracks;
for (let index = 0; index < tracks.length; index++) {
tracks[index].mode = "disabled";
}
if (hlsInstance) {
hlsInstance.subtitleTrack = -1;
hlsInstance.subtitleDisplay = false;
}
refreshEmbeddedSubtitleTracks();
}
function setSubtitleStatus(text, isError) {
el.subtitleStatus.textContent = text;
el.subtitleStatus.className = isError ? "danger" : "muted";
}
function scheduleEmbeddedSubtitleScan() {
if (embeddedSubtitleScanTimer) {
clearTimeout(embeddedSubtitleScanTimer);
embeddedSubtitleScanTimer = null;
}
refreshEmbeddedSubtitleTracks();
const checkpoints = [400, 1200, 2800];
checkpoints.forEach((delay) => {
embeddedSubtitleScanTimer = setTimeout(() => {
refreshEmbeddedSubtitleTracks();
}, delay);
});
}
function refreshEmbeddedSubtitleTracks() {
const options = [{value: "off", label: "Off"}];
const tracks = [];
const hlsTracks = [];
for (let index = 0; index < el.player.textTracks.length; index++) {
const track = el.player.textTracks[index];
if (!track) {
continue;
}
if (track.kind !== "subtitles" && track.kind !== "captions") {
continue;
}
const label = (track.label || track.language || `Track ${index + 1}`).trim();
tracks.push({index, label, mode: track.mode});
}
if (Array.isArray(hlsSubtitleTracks)) {
hlsSubtitleTracks.forEach((track, index) => {
const label = String(track?.name || track?.lang || `HLS Track ${index + 1}`).trim();
hlsTracks.push({
index,
label,
active: Number(hlsInstance?.subtitleTrack) === index
});
});
}
tracks.forEach((track) => {
options.push({
value: `native:${track.index}`,
label: `Embedded: ${track.label || `Track ${track.index + 1}`}`
});
});
hlsTracks.forEach((track) => {
options.push({
value: `hls:${track.index}`,
label: `HLS: ${track.label || `Track ${track.index + 1}`}`
});
});
const previous = el.embeddedSubtitleTrack.value;
const selected = resolveSelectedEmbeddedTrackValue(previous, tracks, hlsTracks);
el.embeddedSubtitleTrack.innerHTML = "";
options.forEach((optionData) => {
const option = document.createElement("option");
option.value = optionData.value;
option.textContent = optionData.label;
el.embeddedSubtitleTrack.appendChild(option);
});
el.embeddedSubtitleTrack.value = selected;
const hasAnyTracks = tracks.length > 0 || hlsTracks.length > 0;
el.embeddedSubtitleTrack.disabled = !hasAnyTracks;
if (!hasAnyTracks) {
if (String(el.subtitleStatus.textContent || "").startsWith("No subtitle")) {
const format = String(state.currentStreamInfo?.format || "").toLowerCase();
if (format === "mkv") {
setSubtitleStatus(
"No embedded subtitles exposed by browser for MKV. Try .vtt subtitle URL or Open stream directly.",
false
);
} else {
setSubtitleStatus("No embedded subtitles detected for this stream.", false);
}
}
return;
}
if (selected === "off") {
return;
}
const selectedTrack = tracks.find((track) => `native:${track.index}` === selected);
if (selectedTrack && selectedTrack.mode === "showing") {
setSubtitleStatus(`Embedded subtitles active: ${selectedTrack.label}`, false);
return;
}
const selectedHlsTrack = hlsTracks.find((track) => `hls:${track.index}` === selected);
if (selectedHlsTrack && selectedHlsTrack.active) {
setSubtitleStatus(`Embedded subtitles active: ${selectedHlsTrack.label}`, false);
}
}
function resolveSelectedEmbeddedTrackValue(previousValue, tracks, hlsTracks) {
const availableValues = new Set([
...tracks.map((track) => `native:${track.index}`),
...hlsTracks.map((track) => `hls:${track.index}`)
]);
if (!availableValues.size) {
return "off";
}
if (previousValue && availableValues.has(previousValue)) {
return previousValue;
}
const showingTrack = tracks.find((track) => track.mode === "showing");
if (showingTrack) {
return `native:${showingTrack.index}`;
}
const showingHlsTrack = hlsTracks.find((track) => track.active);
if (showingHlsTrack) {
return `hls:${showingHlsTrack.index}`;
}
return "off";
}
function selectEmbeddedSubtitleTrack(valueRaw) {
const value = String(valueRaw || "off");
const [source, indexRaw] = value.split(":");
const selectedIndex = Number(indexRaw);
const tracks = el.player.textTracks;
let selectedLabel = "";
for (let index = 0; index < tracks.length; index++) {
const track = tracks[index];
if (!track) {
continue;
}
if (track.kind !== "subtitles" && track.kind !== "captions") {
continue;
}
if (source === "native" && index === selectedIndex) {
track.mode = "showing";
selectedLabel = track.label || track.language || `Track ${index + 1}`;
} else {
track.mode = "disabled";
}
}
if (hlsInstance) {
if (source === "hls" && selectedIndex >= 0) {
hlsInstance.subtitleTrack = selectedIndex;
hlsInstance.subtitleDisplay = true;
const hlsTrack = hlsSubtitleTracks[selectedIndex];
selectedLabel = selectedLabel || hlsTrack?.name || hlsTrack?.lang || `Track ${selectedIndex + 1}`;
} else {
hlsInstance.subtitleTrack = -1;
hlsInstance.subtitleDisplay = false;
}
}
if ((source === "native" || source === "hls") && selectedIndex >= 0) {
setSubtitleStatus(`Embedded subtitles active: ${selectedLabel || `Track ${selectedIndex + 1}`}`, false);
} else {
setSubtitleStatus("Embedded subtitles disabled.", false);
}
}
function disposeHls() {
if (!hlsInstance) {
return;
}
hlsInstance.destroy();
hlsInstance = null;
hlsSubtitleTracks = [];
}
function shouldUseHlsJs() {
return Boolean(window.Hls && window.Hls.isSupported());
}
function isLikelyHls(url) {
const value = String(url || "").toLowerCase();
return value.includes(".m3u8")
|| value.includes("m3u8?")
|| value.includes("type=m3u8")
|| value.includes("output=m3u8");
}
function updateStreamRuntimeInfo() {
if (!state.currentStreamInfo) {
return;
}
const width = Number(el.player.videoWidth || 0);
const height = Number(el.player.videoHeight || 0);
state.currentStreamInfo.resolution = (width > 0 && height > 0) ? `${width}x${height}` : "unknown";
const duration = Number(el.player.duration);
if (Number.isFinite(duration) && duration > 0) {
state.currentStreamInfo.duration = formatDuration(duration);
} else {
state.currentStreamInfo.duration = "live/unknown";
}
renderStreamInfo();
}
function renderStreamInfo() {
if (!state.currentStreamInfo) {
el.streamInfo.textContent = "No stream selected.";
el.streamInfo.classList.add("muted");
return;
}
el.streamInfo.classList.remove("muted");
const info = state.currentStreamInfo;
const rows = [
["Title", info.title || "-"],
["Type", info.type || "-"],
["Format", info.format || guessFormatFromUrl(info.url)],
["Resolution", info.resolution || "unknown"],
["Duration", info.duration || "unknown"],
["Stream ID", info.streamId || "-"],
["Engine", info.playbackEngine || "native"],
["Source", info.source || "-"],
["URL", info.url || "-"]
];
if (info.seriesId) {
rows.splice(6, 0, ["Series ID", info.seriesId]);
}
if (info.season || info.episode) {
rows.splice(7, 0, ["Episode", `S${info.season || "?"}E${info.episode || "?"}`]);
}
if (info.categoryId) {
rows.splice(6, 0, ["Category ID", info.categoryId]);
}
el.streamInfo.innerHTML = rows
.map(([key, value]) => (
`<div class="stream-info-row"><div class="stream-info-key">${esc(key)}:</div><div class="stream-info-value">${esc(value)}</div></div>`
))
.join("");
}
function formatDuration(totalSeconds) {
const safe = Math.max(0, Math.floor(totalSeconds));
const h = Math.floor(safe / 3600);
const m = Math.floor((safe % 3600) / 60);
const s = safe % 60;
if (h > 0) {
return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
return `${m}:${String(s).padStart(2, "0")}`;
}
function guessFormatFromUrl(url) {
const raw = String(url || "");
const clean = raw.split("?")[0].split("#")[0];
const parts = clean.split(".");
if (parts.length < 2) {
return "unknown";
}
const ext = String(parts[parts.length - 1] || "").trim().toLowerCase();
return ext || "unknown";
}
async function refreshEpg() {
if (!state.currentLiveEpgStreamId) {
el.epgList.innerHTML = "Select a live channel for EPG.";
return;
}
el.epgList.innerHTML = "Loading EPG...";
const data = await apiJson(
`/api/library/epg?stream_id=${encodeURIComponent(String(state.currentLiveEpgStreamId))}&limit=20`
);
const list = Array.isArray(data?.epg_listings) ? data.epg_listings : [];
if (list.length === 0) {
el.epgList.innerHTML = "No EPG returned for this channel.";
return;
}
el.epgList.innerHTML = "";
list.forEach((item) => {
const title = decodeMaybeBase64(item.title || item.programme_title || "Untitled");
const description = decodeMaybeBase64(item.description || "");
const timeRange = buildEpgTime(item);
const row = document.createElement("div");
row.className = "epg-item";
row.innerHTML = `
<div class="epg-time">${esc(timeRange)}</div>
<div class="stream-title">${esc(title)}</div>
<div class="stream-meta">${esc(description)}</div>
`;
el.epgList.appendChild(row);
});
}
function buildEpgTime(item) {
const start = toDate(item.start_timestamp, item.start);
const stop = toDate(item.stop_timestamp, item.end);
if (!start && !stop) {
return "No time";
}
if (start && stop) {
return `${formatDate(start)} - ${formatDate(stop)}`;
}
return start ? formatDate(start) : formatDate(stop);
}
function toDate(unixSeconds, fallback) {
if (unixSeconds && !Number.isNaN(Number(unixSeconds))) {
return new Date(Number(unixSeconds) * 1000);
}
if (fallback) {
const d = new Date(fallback);
if (!Number.isNaN(d.getTime())) {
return d;
}
}
return null;
}
function formatDate(date) {
return date.toLocaleString("en-US", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
});
}
function fillCategorySelect(select, categories, allLabel) {
const list = Array.isArray(categories) ? categories : [];
const sorted = [...list].sort((a, b) => {
const left = String(a?.category_name || "");
const right = String(b?.category_name || "");
return left.localeCompare(right, "en", {sensitivity: "base", numeric: true});
});
select.innerHTML = "";
const all = document.createElement("option");
all.value = "";
all.textContent = allLabel;
select.appendChild(all);
sorted.forEach((category) => {
const option = document.createElement("option");
option.value = String(category.category_id || "");
option.textContent = String(category.category_name || `Category ${option.value}`);
select.appendChild(option);
});
}
function renderAllFromState() {
fillCategorySelect(el.liveCategory, state.liveCategories, "All live");
fillCategorySelect(el.vodCategory, state.vodCategories, "All VOD");
fillCategorySelect(el.seriesCategory, state.seriesCategories, "All series");
updateLiveCategoryFavoriteButton();
updateVodCategoryFavoriteButton();
updateSeriesCategoryFavoriteButton();
renderGlobalResults("Type at least 2 characters to search globally.");
renderLiveStreams();
renderVodStreams();
renderSeriesList();
renderFavorites();
}
function sanitizeCategories(input) {
const list = Array.isArray(input) ? input : [];
return list.map((item) => ({
category_id: String(item?.categoryId ?? item?.category_id ?? ""),
category_name: String(item?.categoryName ?? item?.category_name ?? "Unknown category")
}));
}
function sanitizeLiveStreams(input) {
const list = Array.isArray(input) ? input : [];
return list.map((item) => ({
stream_id: String(item?.streamId ?? item?.stream_id ?? ""),
name: String(item?.name ?? "Untitled"),
category_id: String(item?.categoryId ?? item?.category_id ?? ""),
epg_channel_id: String(item?.epgChannelId ?? item?.epg_channel_id ?? "")
}));
}
function sanitizeVodStreams(input) {
const list = Array.isArray(input) ? input : [];
return list.map((item) => ({
stream_id: String(item?.streamId ?? item?.stream_id ?? ""),
name: String(item?.name ?? "Untitled"),
category_id: String(item?.categoryId ?? item?.category_id ?? ""),
container_extension: String(item?.containerExtension ?? item?.container_extension ?? "mp4")
}));
}
function sanitizeSeriesItems(input) {
const list = Array.isArray(input) ? input : [];
return list.map((item) => ({
series_id: String(item?.seriesId ?? item?.series_id ?? ""),
name: String(item?.name ?? "Untitled"),
category_id: String(item?.categoryId ?? item?.category_id ?? "")
}));
}
function sanitizeSeriesEpisodes(input) {
const list = Array.isArray(input) ? input : [];
return list.map((item) => ({
id: String(item?.episodeId ?? item?.episode_id ?? ""),
season: String(item?.season ?? "?"),
episodeNum: String(item?.episodeNum ?? item?.episode_num ?? "?"),
title: String(item?.title ?? "Episode"),
ext: String(item?.containerExtension ?? item?.container_extension ?? "mp4")
}));
}
function sanitizeGlobalSearchResults(input) {
const list = Array.isArray(input) ? input : [];
return list.map((item) => ({
kind: String(item?.kind ?? ""),
id: String(item?.id ?? ""),
title: String(item?.title ?? "Untitled"),
category_id: String(item?.categoryId ?? item?.category_id ?? ""),
category_name: String(item?.categoryName ?? item?.category_name ?? ""),
ext: String(item?.ext ?? ""),
epg_channel_id: String(item?.epgChannelId ?? item?.epg_channel_id ?? ""),
score: Number(item?.score ?? 0)
}));
}
function sanitizeFavorites(input) {
const list = Array.isArray(input) ? input : [];
return list.map(sanitizeFavorite).filter(Boolean);
}
function sanitizeFavorite(item) {
if (!item) {
return null;
}
const key = String(item?.key ?? item?.favoriteKey ?? item?.favorite_key ?? "").trim();
if (!key) {
return null;
}
return {
key,
mode: String(item?.mode ?? ""),
id: String(item?.id ?? item?.refId ?? item?.ref_id ?? ""),
ext: String(item?.ext ?? ""),
title: String(item?.title ?? "Untitled"),
categoryId: String(item?.categoryId ?? item?.category_id ?? ""),
seriesId: String(item?.seriesId ?? item?.series_id ?? ""),
season: String(item?.season ?? ""),
episode: String(item?.episode ?? ""),
url: String(item?.url ?? ""),
createdAt: Number(item?.createdAt ?? item?.created_at ?? Date.now())
};
}
function groupEpisodesBySeason(episodes) {
const groups = new Map();
(Array.isArray(episodes) ? episodes : []).forEach((episode) => {
const seasonKey = String(episode?.season ?? "?").trim() || "?";
if (!groups.has(seasonKey)) {
groups.set(seasonKey, []);
}
groups.get(seasonKey).push(episode);
});
return Array.from(groups.entries())
.sort(([left], [right]) => compareSeason(left, right))
.map(([season, items]) => ({
season,
episodes: [...items].sort(compareEpisode)
}));
}
function compareSeason(left, right) {
const leftNum = extractFirstNumber(left);
const rightNum = extractFirstNumber(right);
if (!Number.isNaN(leftNum) && !Number.isNaN(rightNum) && leftNum !== rightNum) {
return leftNum - rightNum;
}
return String(left).localeCompare(String(right), "en", {numeric: true, sensitivity: "base"});
}
function compareEpisode(left, right) {
const leftNum = extractFirstNumber(left?.episodeNum);
const rightNum = extractFirstNumber(right?.episodeNum);
if (!Number.isNaN(leftNum) && !Number.isNaN(rightNum) && leftNum !== rightNum) {
return leftNum - rightNum;
}
return String(left?.title || "").localeCompare(String(right?.title || ""), "en", {
numeric: true,
sensitivity: "base"
});
}
function extractFirstNumber(value) {
const match = String(value ?? "").match(/\d+/);
return match ? Number(match[0]) : Number.NaN;
}
function ensureConfigured() {
if (!state.config?.configured) {
throw new Error("Set server, username, and password first.");
}
}
function ensureLibraryReady() {
ensureConfigured();
if (!state.libraryStatus?.ready) {
throw new Error("Sources are not loaded in local H2 DB. Click Load sources.");
}
}
async function apiJson(url, options = {}) {
const fetchOptions = {...options};
if (!fetchOptions.cache) {
fetchOptions.cache = "no-store";
}
const headers = new Headers(fetchOptions.headers || {});
if (!headers.has("Cache-Control")) {
headers.set("Cache-Control", "no-cache");
}
if (!headers.has("Pragma")) {
headers.set("Pragma", "no-cache");
}
// Add auth token if logged in
const authToken = getAuthToken();
if (authToken && !headers.has("Authorization")) {
headers.set("Authorization", `Bearer ${authToken}`);
}
fetchOptions.headers = headers;
const response = await fetch(url, fetchOptions);
const text = await response.text();
let parsed;
try {
parsed = JSON.parse(text);
} catch (error) {
parsed = {raw: text};
}
if (!response.ok) {
// If unauthorized, redirect to login
if (response.status === 401) {
clearAuthToken();
location.reload();
}
throw new Error(parsed.error || parsed.raw || `HTTP ${response.status}`);
}
return parsed;
}
function setSettingsMessage(text, type = "") {
el.settingsMessage.textContent = text || "";
el.settingsMessage.className = `message ${type}`.trim();
}
function showError(error) {
setSettingsMessage(error.message || String(error), "err");
}
function updateProgress(value, text) {
el.sourcesProgress.value = value;
el.sourcesProgressText.textContent = text;
}
function formatLoadedAt(value) {
if (!value) {
return "unknown time";
}
const epoch = Number(value);
if (!Number.isNaN(epoch) && epoch > 0) {
return new Date(epoch).toLocaleString("en-US");
}
const parsed = new Date(value);
if (!Number.isNaN(parsed.getTime())) {
return parsed.toLocaleString("en-US");
}
return String(value);
}
function esc(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;");
}
function decodeMaybeBase64(value) {
const raw = String(value || "");
if (!raw) {
return "";
}
try {
const binary = atob(raw);
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
return new TextDecoder().decode(bytes);
} catch (error) {
return raw;
}
}
})();