serias categories favs

This commit is contained in:
Radek Davidek 2026-03-04 15:28:06 +01:00
parent b8df5d0997
commit 28c5cb8c0c
2 changed files with 252 additions and 13 deletions

View File

@ -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
? `
<div>
<button type="button" class="stream-title stream-link" data-action="toggle-favorite-series">${isExpanded ? "Hide" : "Show"} ${esc(favorite.title || "Untitled")}</button>
<button type="button" class="stream-title stream-link" data-action="toggle-favorite-series">${isSeriesItemExpanded ? "Hide" : "Show"} ${esc(favorite.title || "Untitled")}</button>
<div class="stream-meta">${esc(favoriteSummary(favorite))}</div>
</div>
<div class="stream-actions">
@ -882,9 +933,19 @@
<div class="stream-meta">${esc(favoriteSummary(favorite))}</div>
</div>
<div class="stream-actions">
<button type="button" data-action="remove-favorite" class="danger">Remove</button>
</div>
`
<button type="button" data-action="remove-favorite" class="danger">Remove</button>
</div>
`
: isSeriesCategory
? `
<div>
<button type="button" class="stream-title stream-link" data-action="toggle-favorite-series-category">${isSeriesCategoryExpanded ? "Hide" : "Show"} ${esc(favorite.title || "Untitled")}</button>
<div class="stream-meta">${esc(favoriteSummary(favorite))}</div>
</div>
<div class="stream-actions">
<button type="button" data-action="remove-favorite" class="danger">Remove</button>
</div>
`
: `
<div>
<button type="button" class="stream-title stream-link" data-action="open-favorite">${esc(favorite.title || "Untitled")}</button>
@ -894,7 +955,7 @@
<button type="button" data-action="remove-favorite" class="danger">Remove</button>
</div>
`;
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 = `
<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) => {
@ -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();

View File

@ -112,7 +112,10 @@
Search
<input id="series-search" type="search" placeholder="Series title">
</label>
<button id="series-refresh">Reload</button>
<div class="actions">
<button id="series-refresh">Reload</button>
<button id="series-favorite-category" class="favorite-toggle" type="button" title="Add selected category to favorites" aria-label="Add selected category to favorites"></button>
</div>
</div>
<ul id="series-list" class="stream-list"></ul>
</article>