From 28c5cb8c0cdd0f1ac9fba17792d27a82ed05ef52 Mon Sep 17 00:00:00 2001 From: Radek Davidek Date: Wed, 4 Mar 2026 15:28:06 +0100 Subject: [PATCH] serias categories favs --- src/main/resources/web/assets/app.js | 260 +++++++++++++++++++++++++-- src/main/resources/web/index.html | 5 +- 2 files changed, 252 insertions(+), 13 deletions(-) diff --git a/src/main/resources/web/assets/app.js b/src/main/resources/web/assets/app.js index 7ab3264..4b1a985 100644 --- a/src/main/resources/web/assets/app.js +++ b/src/main/resources/web/assets/app.js @@ -24,6 +24,8 @@ favoriteLiveCategoryStreamsById: {}, expandedFavoriteVodCategoryById: {}, favoriteVodCategoryStreamsById: {}, + expandedFavoriteSeriesCategoryById: {}, + favoriteSeriesCategoryItemsById: {}, currentLiveEpgStreamId: null, currentStreamInfo: null }; @@ -60,6 +62,7 @@ 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"), @@ -283,9 +286,20 @@ } function bindSeriesTab() { - el.seriesCategory.addEventListener("change", () => loadSeriesList().catch(showError)); + el.seriesCategory.addEventListener("change", () => { + loadSeriesList().catch(showError); + updateSeriesCategoryFavoriteButton(); + }); el.seriesSearch.addEventListener("input", renderSeriesList); 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() { @@ -410,6 +424,8 @@ state.favoriteLiveCategoryStreamsById = {}; state.expandedFavoriteVodCategoryById = {}; state.favoriteVodCategoryStreamsById = {}; + state.expandedFavoriteSeriesCategoryById = {}; + state.favoriteSeriesCategoryItemsById = {}; } async function loadLiveData() { @@ -599,9 +615,39 @@ 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 query = new URLSearchParams({type: "series"}); @@ -826,6 +872,7 @@ renderFavorites(); updateLiveCategoryFavoriteButton(); updateVodCategoryFavoriteButton(); + updateSeriesCategoryFavoriteButton(); } function renderFavorites() { @@ -846,19 +893,23 @@ filtered.forEach((favorite) => { const li = document.createElement("li"); li.className = "stream-item"; - const isSeriesCategory = favorite?.mode === "series_item"; + 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 isExpanded = isSeriesCategory && Boolean(state.expandedFavoriteSeriesById[seriesId]); + 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]); - li.innerHTML = isSeriesCategory + const seriesCategoryId = String(favorite?.id || ""); + const isSeriesCategoryExpanded = isSeriesCategory + && Boolean(state.expandedFavoriteSeriesCategoryById[seriesCategoryId]); + li.innerHTML = isSeriesItem ? `
- +
${esc(favoriteSummary(favorite))}
@@ -882,9 +933,19 @@
${esc(favoriteSummary(favorite))}
- -
- ` + + + ` + : isSeriesCategory + ? ` +
+ +
${esc(favoriteSummary(favorite))}
+
+
+ +
+ ` : `
@@ -894,7 +955,7 @@
`; - if (isSeriesCategory) { + if (isSeriesItem) { li.querySelector("button[data-action='toggle-favorite-series']").addEventListener("click", () => { toggleFavoriteSeriesItem(favorite).catch(showError); }); @@ -906,6 +967,10 @@ li.querySelector("button[data-action='toggle-favorite-vod-category']").addEventListener("click", () => { toggleFavoriteVodCategory(favorite).catch(showError); }); + } else if (isSeriesCategory) { + li.querySelector("button[data-action='toggle-favorite-series-category']").addEventListener("click", () => { + toggleFavoriteSeriesCategory(favorite).catch(showError); + }); } else { li.querySelector("button[data-action='open-favorite']").addEventListener("click", () => { openFavorite(favorite).catch(showError); @@ -926,6 +991,10 @@ delete state.expandedFavoriteVodCategoryById[vodCategoryId]; delete state.favoriteVodCategoryStreamsById[vodCategoryId]; } + if (favorite?.mode === "series_category") { + delete state.expandedFavoriteSeriesCategoryById[seriesCategoryId]; + delete state.favoriteSeriesCategoryItemsById[seriesCategoryId]; + } renderFavorites(); renderLiveStreams(); renderVodStreams(); @@ -935,7 +1004,7 @@ }); el.favoritesList.appendChild(li); - if (!isSeriesCategory && !isLiveCategory && !isVodCategory) { + if (!isSeriesItem && !isLiveCategory && !isVodCategory && !isSeriesCategory) { return; } @@ -945,7 +1014,10 @@ if (isVodCategory && !isVodCategoryExpanded) { return; } - if (isSeriesCategory && !isExpanded) { + if (isSeriesItem && !isSeriesItemExpanded) { + return; + } + if (isSeriesCategory && !isSeriesCategoryExpanded) { return; } @@ -953,7 +1025,9 @@ ? state.favoriteLiveCategoryStreamsById[liveCategoryId] : isVodCategory ? state.favoriteVodCategoryStreamsById[vodCategoryId] - : state.favoriteSeriesEpisodesById[seriesId]; + : isSeriesCategory + ? state.favoriteSeriesCategoryItemsById[seriesCategoryId] + : state.favoriteSeriesEpisodesById[seriesId]; const episodesLi = document.createElement("li"); episodesLi.className = "card episodes series-inline-episodes"; @@ -1033,6 +1107,106 @@ el.favoritesList.appendChild(episodesLi); return; } + if (isSeriesCategory) { + const seriesItems = Array.isArray(episodesEntry.episodes) ? episodesEntry.episodes : []; + seriesItems.forEach((seriesItem) => { + const seriesItemFavorite = makeFavoriteSeriesItem(seriesItem); + const localSeriesId = String(seriesItem?.series_id || ""); + const isLocalExpanded = Boolean(state.expandedFavoriteSeriesById[localSeriesId]); + const row = document.createElement("div"); + row.className = "stream-item"; + row.innerHTML = ` +
+ +
Series ID: ${esc(localSeriesId)}
+
+
+ +
+ `; + row.querySelector("button[data-action='toggle-favorite-series']").addEventListener("click", () => { + toggleFavoriteSeriesItem(seriesItemFavorite).catch(showError); + }); + row.querySelector("button[data-action='toggle-favorite']").addEventListener("click", () => { + toggleFavorite(seriesItemFavorite).then(() => renderFavorites()).catch(showError); + }); + wrap.appendChild(row); + + if (!isLocalExpanded) { + return; + } + + const seriesEntry = state.favoriteSeriesEpisodesById[localSeriesId]; + const episodesBlock = document.createElement("div"); + episodesBlock.className = "season-list"; + if (!seriesEntry || seriesEntry.loading) { + episodesBlock.innerHTML = `
Loading episodes...
`; + wrap.appendChild(episodesBlock); + return; + } + if (seriesEntry.error) { + episodesBlock.innerHTML = `
Unable to load episodes: ${esc(seriesEntry.error)}
`; + wrap.appendChild(episodesBlock); + return; + } + const groupedBySeason = groupEpisodesBySeason(seriesEntry.episodes); + groupedBySeason.forEach((group) => { + const isSeasonExpanded = Boolean(state.expandedSeasonByFavoriteSeries?.[localSeriesId]?.[group.season]); + const seasonBlock = document.createElement("div"); + seasonBlock.className = "season-block"; + seasonBlock.innerHTML = ` + + `; + seasonBlock.querySelector("button[data-action='toggle-favorite-season']").addEventListener("click", () => { + toggleFavoriteSeasonGroup(localSeriesId, group.season); + }); + + if (!isSeasonExpanded) { + episodesBlock.appendChild(seasonBlock); + return; + } + + const seasonList = document.createElement("div"); + seasonList.className = "season-list"; + group.episodes.forEach((episode) => { + const episodeFavorite = makeFavoriteSeriesEpisode( + {name: seriesItem?.name || "Series", series_id: localSeriesId}, + episode + ); + const episodeRow = document.createElement("div"); + episodeRow.className = "stream-item"; + episodeRow.innerHTML = ` +
+ +
Episode ID: ${esc(episode.id)} | Ext: ${esc(episode.ext)}
+
+
+ +
+ `; + episodeRow.querySelector("button[data-action='play-title']").addEventListener("click", () => { + playXtream("series", episode.id, episode.ext, `${seriesItem?.name || "Series"} - ${episode.title}`, null, { + seriesId: localSeriesId, + season: episode.season, + episode: episode.episodeNum + }).catch(showError); + }); + episodeRow.querySelector("button[data-action='toggle-favorite']").addEventListener("click", () => { + toggleFavorite(episodeFavorite).then(() => renderFavorites()).catch(showError); + }); + seasonList.appendChild(episodeRow); + }); + seasonBlock.appendChild(seasonList); + episodesBlock.appendChild(seasonBlock); + }); + wrap.appendChild(episodesBlock); + }); + episodesLi.appendChild(wrap); + el.favoritesList.appendChild(episodesLi); + return; + } const groupedBySeason = groupEpisodesBySeason(episodesEntry.episodes); groupedBySeason.forEach((group) => { @@ -1138,6 +1312,9 @@ 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; @@ -1279,6 +1456,50 @@ } } + 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 || "?"); @@ -1301,6 +1522,9 @@ 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"}`; } @@ -1357,6 +1581,16 @@ }; } + 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 { @@ -1407,6 +1641,7 @@ state.favorites = state.favorites.filter((item) => item?.key !== key); updateLiveCategoryFavoriteButton(); updateVodCategoryFavoriteButton(); + updateSeriesCategoryFavoriteButton(); } function renderCustomStreams() { @@ -1993,6 +2228,7 @@ fillCategorySelect(el.seriesCategory, state.seriesCategories, "All series"); updateLiveCategoryFavoriteButton(); updateVodCategoryFavoriteButton(); + updateSeriesCategoryFavoriteButton(); renderLiveStreams(); renderVodStreams(); renderSeriesList(); diff --git a/src/main/resources/web/index.html b/src/main/resources/web/index.html index 19f80a3..e9bb6f6 100644 --- a/src/main/resources/web/index.html +++ b/src/main/resources/web/index.html @@ -112,7 +112,10 @@ Search - +
+ + +