3207 lines
133 KiB
JavaScript
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("&", "&")
|
|
.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;
|
|
}
|
|
}
|
|
})();
|