(() => { // ============ 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 = `
  • ${esc(emptyMessage)}
  • `; 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 = `
    ${esc(badge)}${esc(globalResultMeta(result))}
    ${hasFavorite ? `` : ""}
    `; 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 = `
  • ${esc(emptyMessage)}
  • `; 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 = `
    ID: ${esc(streamId)} | Category: ${esc(item.category_id || "-")}
    `; 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 = `
  • ${esc(emptyMessage)}
  • `; 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 = `
    ID: ${esc(streamId)} | Ext: ${esc(ext)}
    `; 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 = `
  • ${esc(emptyMessage)}
  • `; 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 = `
    ${esc(item.name || "Untitled")}
    Series ID: ${esc(seriesId)}
    `; 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 = `
    Loading episodes...
    `; } else if (episodesEntry.error) { episodesLi.innerHTML = `
    Unable to load episodes: ${esc(episodesEntry.error)}
    `; } else if (!episodesEntry.episodes || episodesEntry.episodes.length === 0) { episodesLi.innerHTML = `
    No episodes available.
    `; } 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 = ` `; 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 = `
    Episode ID: ${esc(episode.id)} | Ext: ${esc(episode.ext)}
    `; 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 = `
  • No favorites yet.
  • `; 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 ? `
    ${esc(favoriteBadge)}${esc(favoriteSummary(favorite))}
    ` : isLiveCategory ? `
    ${esc(favoriteBadge)}${esc(favoriteSummary(favorite))}
    ` : isVodCategory ? `
    ${esc(favoriteBadge)}${esc(favoriteSummary(favorite))}
    ` : isSeriesCategory ? `
    ${esc(favoriteBadge)}${esc(favoriteSummary(favorite))}
    ` : `
    ${esc(favoriteBadge)}${esc(favoriteSummary(favorite))}
    `; 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 = `
    Loading episodes...
    `; } else if (episodesEntry.error) { episodesLi.innerHTML = `
    Unable to load episodes: ${esc(episodesEntry.error)}
    `; } else if (!episodesEntry.episodes || episodesEntry.episodes.length === 0) { episodesLi.innerHTML = `
    No episodes available.
    `; } 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 = `
    No matching streams in this category.
    `; 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 = `
    ID: ${esc(streamId)} | Category: ${esc(stream?.category_id || "-")}
    `; 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 = `
    No matching streams in this category.
    `; 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 = `
    ID: ${esc(streamId)} | Ext: ${esc(ext)}
    `; 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 = `
    No matching streams in this category.
    `; 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 = `
    Series ID: ${esc(localSeriesId)}
    `; 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 = `
    Loading episodes...
    `; wrap.appendChild(episodesBlock); return; } if (seriesEntry.error) { episodesBlock.innerHTML = `
    Unable to load episodes: ${esc(seriesEntry.error)}
    `; 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 = ` `; 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 = `
    Episode ID: ${esc(episode.id)} | Ext: ${esc(episode.ext)}
    `; 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 = ` `; 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 = `
    Episode ID: ${esc(episode.id)} | Ext: ${esc(episode.ext)}
    `; 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 = `
  • No custom streams saved yet.
  • `; return; } el.customList.innerHTML = ""; filtered.forEach((item) => { const li = document.createElement("li"); li.className = "stream-item"; const favorite = makeFavoriteCustom(item); li.innerHTML = `
    ${esc(item.url)}
    `; 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]) => ( `
    ${esc(key)}:
    ${esc(value)}
    ` )) .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 = `
    ${esc(timeRange)}
    ${esc(title)}
    ${esc(description)}
    `; 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("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll("\"", """); } 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; } } })();