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
-
+
+
+
+