rewritten loading categories and items

This commit is contained in:
Radek Davidek 2026-03-04 16:55:08 +01:00
parent 9bae442352
commit 0c5d5e73da
6 changed files with 517 additions and 142 deletions

21
pom.xml
View File

@ -42,6 +42,27 @@
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.3</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>cz.kamma.xtreamplayer.XtreamPlayerApplication</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>

View File

@ -267,7 +267,7 @@ final class LibraryRepository {
}
}
List<LiveStreamRow> listLiveStreams(String categoryId, String search) {
List<LiveStreamRow> listLiveStreams(String categoryId, String search, Integer limit, Integer offset) {
StringBuilder sql = new StringBuilder(
"SELECT stream_id, name, category_id, epg_channel_id FROM live_streams WHERE 1=1");
List<String> args = new ArrayList<>();
@ -280,6 +280,12 @@ final class LibraryRepository {
args.add("%" + search.trim().toLowerCase(Locale.ROOT) + "%");
}
sql.append(" ORDER BY LOWER(name), name");
if (limit != null && limit > 0) {
sql.append(" LIMIT ").append(limit);
if (offset != null && offset > 0) {
sql.append(" OFFSET ").append(offset);
}
}
List<LiveStreamRow> rows = new ArrayList<>();
try (Connection connection = openConnection();
@ -301,7 +307,7 @@ final class LibraryRepository {
}
}
List<VodStreamRow> listVodStreams(String categoryId, String search) {
List<VodStreamRow> listVodStreams(String categoryId, String search, Integer limit, Integer offset) {
StringBuilder sql = new StringBuilder(
"SELECT stream_id, name, category_id, container_extension FROM vod_streams WHERE 1=1");
List<String> args = new ArrayList<>();
@ -314,6 +320,12 @@ final class LibraryRepository {
args.add("%" + search.trim().toLowerCase(Locale.ROOT) + "%");
}
sql.append(" ORDER BY LOWER(name), name");
if (limit != null && limit > 0) {
sql.append(" LIMIT ").append(limit);
if (offset != null && offset > 0) {
sql.append(" OFFSET ").append(offset);
}
}
List<VodStreamRow> rows = new ArrayList<>();
try (Connection connection = openConnection();
@ -335,7 +347,7 @@ final class LibraryRepository {
}
}
List<SeriesItemRow> listSeriesItems(String categoryId, String search) {
List<SeriesItemRow> listSeriesItems(String categoryId, String search, Integer limit, Integer offset) {
StringBuilder sql = new StringBuilder(
"SELECT series_id, name, category_id FROM series_items WHERE 1=1");
List<String> args = new ArrayList<>();
@ -348,6 +360,12 @@ final class LibraryRepository {
args.add("%" + search.trim().toLowerCase(Locale.ROOT) + "%");
}
sql.append(" ORDER BY LOWER(name), name");
if (limit != null && limit > 0) {
sql.append(" LIMIT ").append(limit);
if (offset != null && offset > 0) {
sql.append(" OFFSET ").append(offset);
}
}
List<SeriesItemRow> rows = new ArrayList<>();
try (Connection connection = openConnection();
@ -408,14 +426,42 @@ final class LibraryRepository {
}
}
List<FavoriteRow> listFavorites() {
List<FavoriteRow> listFavorites(String searchRaw, int limitRaw, int offsetRaw) {
String search = searchRaw == null ? "" : searchRaw.trim().toLowerCase(Locale.ROOT);
int limit = Math.max(1, limitRaw);
int offset = Math.max(0, offsetRaw);
List<FavoriteRow> rows = new ArrayList<>();
StringBuilder sql = new StringBuilder("""
SELECT favorite_key, mode, ref_id, ext, title, category_id, series_id, season, episode, url, created_at
FROM favorites
""");
List<Object> args = new ArrayList<>();
if (!search.isBlank()) {
sql.append("""
WHERE LOWER(title) LIKE ?
OR LOWER(mode) LIKE ?
OR LOWER(url) LIKE ?
OR LOWER(favorite_key) LIKE ?
""");
String value = "%" + search + "%";
args.add(value);
args.add(value);
args.add(value);
args.add(value);
}
sql.append(" ORDER BY created_at DESC, favorite_key LIMIT ? OFFSET ?");
try (Connection connection = openConnection();
PreparedStatement preparedStatement = connection.prepareStatement("""
SELECT favorite_key, mode, ref_id, ext, title, category_id, series_id, season, episode, url, created_at
FROM favorites
ORDER BY created_at DESC, favorite_key
""")) {
PreparedStatement preparedStatement = connection.prepareStatement(sql.toString())) {
args.add(limit);
args.add(offset);
for (int i = 0; i < args.size(); i++) {
Object arg = args.get(i);
if (arg instanceof Integer intArg) {
preparedStatement.setInt(i + 1, intArg);
} else {
preparedStatement.setString(i + 1, String.valueOf(arg));
}
}
try (ResultSet resultSet = preparedStatement.executeQuery()) {
while (resultSet.next()) {
rows.add(new FavoriteRow(
@ -439,6 +485,39 @@ final class LibraryRepository {
}
}
int countFavorites(String searchRaw) {
String search = searchRaw == null ? "" : searchRaw.trim().toLowerCase(Locale.ROOT);
StringBuilder sql = new StringBuilder("SELECT COUNT(*) FROM favorites");
List<String> args = new ArrayList<>();
if (!search.isBlank()) {
sql.append("""
WHERE LOWER(title) LIKE ?
OR LOWER(mode) LIKE ?
OR LOWER(url) LIKE ?
OR LOWER(favorite_key) LIKE ?
""");
String value = "%" + search + "%";
args.add(value);
args.add(value);
args.add(value);
args.add(value);
}
try (Connection connection = openConnection();
PreparedStatement preparedStatement = connection.prepareStatement(sql.toString())) {
for (int i = 0; i < args.size(); i++) {
preparedStatement.setString(i + 1, args.get(i));
}
try (ResultSet resultSet = preparedStatement.executeQuery()) {
if (resultSet.next()) {
return resultSet.getInt(1);
}
return 0;
}
} catch (SQLException exception) {
throw new IllegalStateException("Unable to count favorites.", exception);
}
}
void upsertFavorite(FavoriteRow row) {
try (Connection connection = openConnection();
PreparedStatement preparedStatement = connection.prepareStatement("""

View File

@ -144,18 +144,22 @@ final class XtreamLibraryService {
return repository.listCategories(type);
}
List<?> listItems(String type, String categoryId, String search) {
List<?> listItems(String type, String categoryId, String search, Integer limit, Integer offset) {
String normalizedType = type == null ? "" : type.trim().toLowerCase(Locale.ROOT);
return switch (normalizedType) {
case "live" -> repository.listLiveStreams(categoryId, search);
case "vod" -> repository.listVodStreams(categoryId, search);
case "series" -> repository.listSeriesItems(categoryId, search);
case "live" -> repository.listLiveStreams(categoryId, search, limit, offset);
case "vod" -> repository.listVodStreams(categoryId, search, limit, offset);
case "series" -> repository.listSeriesItems(categoryId, search, limit, offset);
default -> throw new IllegalArgumentException("Unsupported type: " + type);
};
}
List<LibraryRepository.FavoriteRow> listFavorites() {
return repository.listFavorites();
List<LibraryRepository.FavoriteRow> listFavorites(String search, int limit, int offset) {
return repository.listFavorites(search, limit, offset);
}
int countFavorites(String search) {
return repository.countFavorites(search);
}
LibraryRepository.FavoriteRow saveFavorite(

View File

@ -472,9 +472,17 @@ public final class XtreamPlayerApplication {
String type = query.getOrDefault("type", "");
String categoryId = query.getOrDefault("category_id", "");
String search = query.getOrDefault("search", "");
Integer limit = query.containsKey("limit") ? parseIntOrDefault(query.get("limit"), 0) : null;
Integer offset = query.containsKey("offset") ? parseIntOrDefault(query.get("offset"), 0) : null;
if (limit != null && limit <= 0) {
limit = null;
}
if (offset != null && offset < 0) {
offset = 0;
}
Map<String, Object> out = new LinkedHashMap<>();
out.put("items", libraryService.listItems(type, categoryId, search));
out.put("items", libraryService.listItems(type, categoryId, search, limit, offset));
writeJsonObject(exchange, 200, out);
} catch (IllegalArgumentException exception) {
writeJson(exchange, 400, errorJson(exception.getMessage()));
@ -546,8 +554,23 @@ public final class XtreamPlayerApplication {
try {
if ("GET".equalsIgnoreCase(method)) {
String search = query.getOrDefault("search", "");
int limit = parseIntOrDefault(query.get("limit"), 50);
int offset = parseIntOrDefault(query.get("offset"), 0);
if (limit < 1) {
limit = 1;
}
if (limit > 200) {
limit = 200;
}
if (offset < 0) {
offset = 0;
}
Map<String, Object> out = new LinkedHashMap<>();
out.put("items", libraryService.listFavorites());
out.put("items", libraryService.listFavorites(search, limit, offset));
out.put("total", libraryService.countFavorites(search));
out.put("limit", limit);
out.put("offset", offset);
writeJsonObject(exchange, 200, out);
return;
}
@ -609,7 +632,7 @@ public final class XtreamPlayerApplication {
if ("/".equals(path) || "/index.html".equals(path)) {
resourcePath = "/web/index.html";
} else if (path.startsWith("/assets/")) {
resourcePath = "/web" + path;
resourcePath = "/web" + normalizeAssetPath(path);
} else {
resourcePath = "/web/index.html";
}
@ -627,6 +650,9 @@ public final class XtreamPlayerApplication {
LOGGER.debug("Serving static resource={}", resourcePath);
byte[] body = inputStream.readAllBytes();
exchange.getResponseHeaders().set("Content-Type", contentType(resourcePath));
exchange.getResponseHeaders().set("Cache-Control", "no-store, no-cache, must-revalidate");
exchange.getResponseHeaders().set("Pragma", "no-cache");
exchange.getResponseHeaders().set("Expires", "0");
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
} finally {
@ -1099,6 +1125,17 @@ public final class XtreamPlayerApplication {
}
}
private static int parseIntOrDefault(String raw, int defaultValue) {
if (raw == null || raw.isBlank()) {
return defaultValue;
}
try {
return Integer.parseInt(raw.trim());
} catch (NumberFormatException ignored) {
return defaultValue;
}
}
private static String configToJson(XtreamConfig config) {
return "{"
+ "\"serverUrl\":\"" + jsonEscape(config.serverUrl()) + "\","
@ -1156,6 +1193,19 @@ public final class XtreamPlayerApplication {
return "application/octet-stream";
}
private static String normalizeAssetPath(String path) {
if (path == null || path.isBlank()) {
return "/assets/app.js";
}
if (path.matches("^/assets/app\\.[^.]+\\.js$")) {
return "/assets/app.js";
}
if (path.matches("^/assets/style\\.[^.]+\\.css$")) {
return "/assets/style.css";
}
return path;
}
private static String urlDecode(String value) {
return URLDecoder.decode(value, StandardCharsets.UTF_8);
}

View File

@ -17,6 +17,7 @@
expandedSeasonBySeries: {},
customStreams: [],
favorites: [],
favoriteKeys: new Set(),
expandedFavoriteSeriesById: {},
favoriteSeriesEpisodesById: {},
expandedSeasonByFavoriteSeries: {},
@ -27,7 +28,14 @@
expandedFavoriteSeriesCategoryById: {},
favoriteSeriesCategoryItemsById: {},
currentLiveEpgStreamId: null,
currentStreamInfo: null
currentStreamInfo: null,
liveSearchTimer: null,
vodSearchTimer: null,
seriesSearchTimer: null,
favoritesSearchTimer: null,
favoritesLimit: 50,
favoritesOffset: 0,
favoritesTotal: 0
};
const customStorageKey = "xtream_custom_streams_v1";
@ -72,6 +80,9 @@
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"),
@ -105,12 +116,12 @@
bindPlayerActionButtons();
loadCustomStreams();
await loadFavorites();
renderCustomStreams();
renderFavorites();
renderStreamInfo();
updateLiveCategoryFavoriteButton();
updateVodCategoryFavoriteButton();
switchTab("settings");
await loadConfig();
}
@ -184,20 +195,35 @@
el.panels.forEach((panel) => panel.classList.toggle("active", panel.dataset.panel === tabName));
if (tabName === "favorites") {
renderFavorites();
loadFavorites(String(el.favoritesSearch.value || "").trim())
.then(() => renderFavorites())
.catch(showError);
}
if (!state.config?.configured || !state.libraryStatus?.ready) {
return;
}
if (tabName === "live" && state.liveStreams.length === 0) {
loadLiveData().catch(showError);
releaseInactiveTabItems(tabName);
if (tabName === "live") {
if (state.liveCategories.length === 0) {
loadLiveData().catch(showError);
} else {
loadLiveStreams().catch(showError);
}
}
if (tabName === "vod" && state.vodStreams.length === 0) {
loadVodData().catch(showError);
if (tabName === "vod") {
if (state.vodCategories.length === 0) {
loadVodData().catch(showError);
} else {
loadVodStreams().catch(showError);
}
}
if (tabName === "series" && state.seriesItems.length === 0) {
loadSeriesData().catch(showError);
if (tabName === "series") {
if (state.seriesCategories.length === 0) {
loadSeriesData().catch(showError);
} else {
loadSeriesList().catch(showError);
}
}
}
@ -217,7 +243,7 @@
await refreshLibraryStatus();
if (state.libraryStatus?.ready) {
await loadAllLibraryData();
renderAllFromState();
setSettingsMessage("Settings saved. Using local H2 data.", "ok");
} else {
renderAllFromState();
@ -255,7 +281,7 @@
loadLiveStreams().catch(showError);
updateLiveCategoryFavoriteButton();
});
el.liveSearch.addEventListener("input", renderLiveStreams);
el.liveSearch.addEventListener("input", scheduleLiveSearch);
el.liveRefresh.addEventListener("click", () => loadLiveData().catch(showError));
el.liveFavoriteCategory.addEventListener("click", () => {
const favorite = selectedLiveCategoryFavorite();
@ -273,7 +299,7 @@
loadVodStreams().catch(showError);
updateVodCategoryFavoriteButton();
});
el.vodSearch.addEventListener("input", renderVodStreams);
el.vodSearch.addEventListener("input", scheduleVodSearch);
el.vodRefresh.addEventListener("click", () => loadVodData().catch(showError));
el.vodFavoriteCategory.addEventListener("click", () => {
const favorite = selectedVodCategoryFavorite();
@ -290,7 +316,7 @@
loadSeriesList().catch(showError);
updateSeriesCategoryFavoriteButton();
});
el.seriesSearch.addEventListener("input", renderSeriesList);
el.seriesSearch.addEventListener("input", scheduleSeriesSearch);
el.seriesRefresh.addEventListener("click", () => loadSeriesData().catch(showError));
el.seriesFavoriteCategory.addEventListener("click", () => {
const favorite = selectedSeriesCategoryFavorite();
@ -327,7 +353,26 @@
}
function bindFavoritesTab() {
el.favoritesSearch.addEventListener("input", renderFavorites);
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() {
@ -342,7 +387,7 @@
await refreshLibraryStatus();
if (state.libraryStatus?.ready) {
await loadAllLibraryData();
renderAllFromState();
} else {
clearLibraryState();
renderAllFromState();
@ -393,7 +438,7 @@
await apiJson(`/api/library/load?step=${encodeURIComponent(step.id)}`, {method: "POST"});
}
await refreshLibraryStatus();
await loadAllLibraryData();
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)}`,
@ -417,10 +462,6 @@
+ `Series: ${seriesCategories} categories / ${seriesItems} series.`;
}
async function loadAllLibraryData() {
await Promise.all([loadLiveData(), loadVodData(), loadSeriesData()]);
}
function refreshConfigUi() {
const config = state.config || {};
el.serverUrl.value = config.serverUrl || "";
@ -451,6 +492,65 @@
state.favoriteSeriesCategoryItemsById = {};
}
function releaseInactiveTabItems(activeTab) {
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 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 loadLiveData() {
ensureLibraryReady();
const categoriesPayload = await apiJson("/api/library/categories?type=live");
@ -462,10 +562,22 @@
async function loadLiveStreams() {
ensureLibraryReady();
const query = new URLSearchParams({type: "live"});
if (el.liveCategory.value) {
query.set("category_id", el.liveCategory.value);
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();
@ -500,15 +612,11 @@
el.liveFavoriteCategory.setAttribute("aria-label", label);
}
function renderLiveStreams() {
const search = el.liveSearch.value.trim().toLowerCase();
const filtered = state.liveStreams.filter((item) => {
const name = String(item.name || "").toLowerCase();
return !search || name.includes(search);
});
function renderLiveStreams(emptyMessage = "No live stream found.") {
const filtered = state.liveStreams;
if (filtered.length === 0) {
el.liveList.innerHTML = `<li class="card muted">No live stream found.</li>`;
el.liveList.innerHTML = `<li class="card muted">${esc(emptyMessage)}</li>`;
return;
}
@ -539,7 +647,7 @@
).catch(showError);
});
li.querySelector("button[data-action='toggle-favorite']").addEventListener("click", () => {
toggleFavorite(favorite).then(() => renderLiveStreams()).catch(showError);
toggleFavorite(favorite).then(() => loadLiveStreams()).catch(showError);
});
el.liveList.appendChild(li);
});
@ -585,24 +693,32 @@
async function loadVodStreams() {
ensureLibraryReady();
const query = new URLSearchParams({type: "vod"});
if (el.vodCategory.value) {
query.set("category_id", el.vodCategory.value);
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() {
const search = el.vodSearch.value.trim().toLowerCase();
const filtered = state.vodStreams.filter((item) => {
const name = String(item.name || "").toLowerCase();
return !search || name.includes(search);
});
function renderVodStreams(emptyMessage = "No VOD stream found.") {
const filtered = state.vodStreams;
if (filtered.length === 0) {
el.vodList.innerHTML = `<li class="card muted">No VOD stream found.</li>`;
el.vodList.innerHTML = `<li class="card muted">${esc(emptyMessage)}</li>`;
return;
}
@ -627,7 +743,7 @@
.catch(showError);
});
li.querySelector("button[data-action='toggle-favorite']").addEventListener("click", () => {
toggleFavorite(favorite).then(() => renderVodStreams()).catch(showError);
toggleFavorite(favorite).then(() => loadVodStreams()).catch(showError);
});
el.vodList.appendChild(li);
});
@ -673,10 +789,24 @@
async function loadSeriesList() {
ensureLibraryReady();
const query = new URLSearchParams({type: "series"});
if (el.seriesCategory.value) {
query.set("category_id", el.seriesCategory.value);
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
@ -687,15 +817,11 @@
renderSeriesList();
}
function renderSeriesList() {
const search = el.seriesSearch.value.trim().toLowerCase();
const filtered = state.seriesItems.filter((item) => {
const name = String(item.name || "").toLowerCase();
return !search || name.includes(search);
});
function renderSeriesList(emptyMessage = "No series found.") {
const filtered = state.seriesItems;
if (filtered.length === 0) {
el.seriesList.innerHTML = `<li class="card muted">No series found.</li>`;
el.seriesList.innerHTML = `<li class="card muted">${esc(emptyMessage)}</li>`;
return;
}
@ -719,7 +845,7 @@
toggleSeriesEpisodes(item).catch(showError);
});
li.querySelector("button[data-action='toggle-favorite']").addEventListener("click", () => {
toggleFavorite(favorite).then(() => renderSeriesList()).catch(showError);
toggleFavorite(favorite).then(() => loadSeriesList()).catch(showError);
});
el.seriesList.appendChild(li);
@ -778,7 +904,7 @@
}).catch(showError);
});
row.querySelector("button[data-action='toggle-favorite']").addEventListener("click", () => {
toggleFavorite(episodeFavorite).then(() => renderSeriesList()).catch(showError);
toggleFavorite(episodeFavorite).then(() => loadSeriesList()).catch(showError);
});
seasonList.appendChild(row);
});
@ -860,22 +986,81 @@
localStorage.setItem(customStorageKey, JSON.stringify(state.customStreams));
}
async function loadFavorites() {
const payload = await apiJson("/api/favorites");
state.favorites = sanitizeFavorites(payload.items);
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.favorites.some((item) => item?.key === 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;
}
if (isFavorite(favorite.key)) {
await deleteFavoriteByKey(favorite.key);
} else {
const deleted = await deleteFavoriteByKey(favorite.key);
if (!deleted) {
const payload = {
...favorite,
createdAt: Number(favorite?.createdAt || Date.now())
@ -891,13 +1076,66 @@
}
state.favorites = state.favorites.filter((item) => item?.key !== savedItem.key);
state.favorites.unshift(savedItem);
state.favoriteKeys.add(savedItem.key);
}
renderFavorites();
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);
@ -929,6 +1167,11 @@
}
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>`;
@ -941,6 +1184,9 @@
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";
@ -957,99 +1203,52 @@
li.innerHTML = isSeriesItem
? `
<div>
<button type="button" class="stream-title stream-link" data-action="toggle-favorite-series">${isSeriesItemExpanded ? "Hide" : "Show"} ${esc(favorite.title || "Untitled")}</button>
<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" class="danger">Remove</button>
<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">${isLiveCategoryExpanded ? "Hide" : "Show"} ${esc(favorite.title || "Untitled")}</button>
<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" class="danger">Remove</button>
<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">${isVodCategoryExpanded ? "Hide" : "Show"} ${esc(favorite.title || "Untitled")}</button>
<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" class="danger">Remove</button>
<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">${isSeriesCategoryExpanded ? "Hide" : "Show"} ${esc(favorite.title || "Untitled")}</button>
<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" class="danger">Remove</button>
<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">${esc(favorite.title || "Untitled")}</button>
<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" class="danger">Remove</button>
<button type="button" data-action="remove-favorite" data-key="${esc(favorite.key)}" class="danger">Remove</button>
</div>
`;
if (isSeriesItem) {
li.querySelector("button[data-action='toggle-favorite-series']").addEventListener("click", () => {
toggleFavoriteSeriesItem(favorite).catch(showError);
});
} else if (isLiveCategory) {
li.querySelector("button[data-action='toggle-favorite-live-category']").addEventListener("click", () => {
toggleFavoriteLiveCategory(favorite).catch(showError);
});
} else if (isVodCategory) {
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);
});
}
li.querySelector("button[data-action='remove-favorite']").addEventListener("click", () => {
deleteFavoriteByKey(favorite.key).then(() => {
if (favorite?.mode === "series_item") {
delete state.expandedFavoriteSeriesById[seriesId];
delete state.favoriteSeriesEpisodesById[seriesId];
delete state.expandedSeasonByFavoriteSeries[seriesId];
}
if (favorite?.mode === "live_category") {
delete state.expandedFavoriteLiveCategoryById[liveCategoryId];
delete state.favoriteLiveCategoryStreamsById[liveCategoryId];
}
if (favorite?.mode === "vod_category") {
delete state.expandedFavoriteVodCategoryById[vodCategoryId];
delete state.favoriteVodCategoryStreamsById[vodCategoryId];
}
if (favorite?.mode === "series_category") {
delete state.expandedFavoriteSeriesCategoryById[seriesCategoryId];
delete state.favoriteSeriesCategoryItemsById[seriesCategoryId];
}
renderFavorites();
renderLiveStreams();
renderVodStreams();
renderSeriesList();
renderCustomStreams();
}).catch(showError);
});
el.favoritesList.appendChild(li);
if (!isSeriesItem && !isLiveCategory && !isVodCategory && !isSeriesCategory) {
@ -1754,13 +1953,15 @@
async function deleteFavoriteByKey(keyRaw) {
const key = String(keyRaw || "").trim();
if (!key) {
return;
return false;
}
await apiJson(`/api/favorites?key=${encodeURIComponent(key)}`, {method: "DELETE"});
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() {
@ -2513,7 +2714,20 @@
}
async function apiJson(url, options = {}) {
const response = await fetch(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");
}
fetchOptions.headers = headers;
const response = await fetch(url, fetchOptions);
const text = await response.text();
let parsed;
try {

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Xtream Player</title>
<link rel="stylesheet" href="/assets/style.css">
<link rel="stylesheet" href="/assets/style.20260304b.css">
</head>
<body>
<div class="bg-glow"></div>
@ -148,6 +148,13 @@
Search
<input id="favorites-search" type="search" placeholder="Filter favorites">
</label>
<div class="card controls">
<div class="actions">
<button id="favorites-prev" type="button">Previous</button>
<button id="favorites-next" type="button">Next</button>
</div>
<div id="favorites-page-info" class="muted">Page 1</div>
</div>
<ul id="favorites-list" class="stream-list"></ul>
</article>
</section>
@ -198,6 +205,6 @@
</main>
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.18/dist/hls.min.js"></script>
<script src="/assets/app.js"></script>
<script src="/assets/app.20260304b.js"></script>
</body>
</html>