From 2f4c35a7974e79e068156e152b69638a0bd0d25f Mon Sep 17 00:00:00 2001 From: Radek Davidek Date: Thu, 5 Mar 2026 13:22:20 +0100 Subject: [PATCH] global search --- .../kamma/xtreamplayer/LibraryRepository.java | 258 ++++++++++++++++ .../xtreamplayer/XtreamLibraryService.java | 10 + .../xtreamplayer/XtreamPlayerApplication.java | 35 +++ src/main/resources/web/assets/app.js | 276 ++++++++++++++++++ src/main/resources/web/index.html | 15 + 5 files changed, 594 insertions(+) diff --git a/src/main/java/cz/kamma/xtreamplayer/LibraryRepository.java b/src/main/java/cz/kamma/xtreamplayer/LibraryRepository.java index 8b641cb..699976b 100644 --- a/src/main/java/cz/kamma/xtreamplayer/LibraryRepository.java +++ b/src/main/java/cz/kamma/xtreamplayer/LibraryRepository.java @@ -12,6 +12,7 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; import java.util.Locale; @@ -105,8 +106,21 @@ final class LibraryRepository { statement.execute("CREATE INDEX IF NOT EXISTS idx_vod_streams_category ON vod_streams(category_id)"); statement.execute("CREATE INDEX IF NOT EXISTS idx_series_items_category ON series_items(category_id)"); statement.execute("CREATE INDEX IF NOT EXISTS idx_series_episodes_series ON series_episodes(series_id)"); + statement.execute( + "CREATE INDEX IF NOT EXISTS idx_series_episodes_series_sort " + + "ON series_episodes(series_id, season, episode_num, title)" + ); + statement.execute("CREATE INDEX IF NOT EXISTS idx_live_categories_name ON live_categories(category_name)"); + statement.execute("CREATE INDEX IF NOT EXISTS idx_vod_categories_name ON vod_categories(category_name)"); + statement.execute("CREATE INDEX IF NOT EXISTS idx_series_categories_name ON series_categories(category_name)"); + statement.execute("CREATE INDEX IF NOT EXISTS idx_live_streams_name ON live_streams(name)"); + statement.execute("CREATE INDEX IF NOT EXISTS idx_vod_streams_name ON vod_streams(name)"); + statement.execute("CREATE INDEX IF NOT EXISTS idx_series_items_name ON series_items(name)"); statement.execute("CREATE INDEX IF NOT EXISTS idx_favorites_created_at ON favorites(created_at DESC)"); } + try (Connection connection = openConnection()) { + initializeFullText(connection); + } LOGGER.info("H2 repository initialized at {}", dbPath); } catch (Exception exception) { throw new IllegalStateException("Unable to initialize H2 repository.", exception); @@ -414,6 +428,44 @@ final class LibraryRepository { } } + List globalSearch(String queryRaw, int limitRaw, int offsetRaw) { + String query = queryRaw == null ? "" : queryRaw.trim(); + if (query.isBlank()) { + return List.of(); + } + int limit = Math.max(1, Math.min(limitRaw, 500)); + int offset = Math.max(0, offsetRaw); + List rows = new ArrayList<>(); + try (Connection connection = openConnection(); + PreparedStatement preparedStatement = connection.prepareStatement( + "SELECT \"TABLE\", KEYS, SCORE FROM FT_SEARCH_DATA(?, ?, ?)")) { + preparedStatement.setString(1, query); + preparedStatement.setInt(2, limit); + preparedStatement.setInt(3, offset); + try (ResultSet resultSet = preparedStatement.executeQuery()) { + while (resultSet.next()) { + String tableName = resultSet.getString("TABLE"); + String id = firstKeyValue(resultSet); + double score = resultSet.getDouble("SCORE"); + if (tableName == null || tableName.isBlank() || id.isBlank()) { + continue; + } + GlobalSearchRow row = resolveGlobalSearchRow(connection, tableName, id, score); + if (row != null) { + rows.add(row); + } + } + } + rows.sort(Comparator + .comparing((GlobalSearchRow row) -> safeLower(row.title())) + .thenComparing(row -> safeLower(row.kind())) + .thenComparing(row -> safeLower(row.id()))); + return rows; + } catch (SQLException exception) { + throw new IllegalStateException("Unable to search library.", exception); + } + } + void setMeta(String key, String value) { try (Connection connection = openConnection(); PreparedStatement preparedStatement = connection.prepareStatement( @@ -666,10 +718,212 @@ final class LibraryRepository { } } + private void initializeFullText(Connection connection) throws SQLException { + try (Statement statement = connection.createStatement()) { + statement.execute("CREATE ALIAS IF NOT EXISTS FTL_INIT FOR 'org.h2.fulltext.FullText.init'"); + statement.execute("CALL FTL_INIT()"); + } + ensureFullTextIndex(connection, "LIVE_CATEGORIES", "CATEGORY_NAME"); + ensureFullTextIndex(connection, "VOD_CATEGORIES", "CATEGORY_NAME"); + ensureFullTextIndex(connection, "SERIES_CATEGORIES", "CATEGORY_NAME"); + ensureFullTextIndex(connection, "LIVE_STREAMS", "STREAM_ID,NAME"); + ensureFullTextIndex(connection, "VOD_STREAMS", "STREAM_ID,NAME"); + ensureFullTextIndex(connection, "SERIES_ITEMS", "SERIES_ID,NAME"); + } + + private void ensureFullTextIndex(Connection connection, String tableName, String columnsCsv) throws SQLException { + try (PreparedStatement preparedStatement = connection.prepareStatement(""" + SELECT COUNT(*) + FROM FT.INDEXES + WHERE SCHEMA = 'PUBLIC' + AND "TABLE" = ? + """)) { + preparedStatement.setString(1, tableName); + try (ResultSet resultSet = preparedStatement.executeQuery()) { + resultSet.next(); + if (resultSet.getInt(1) > 0) { + return; + } + } + } + try (PreparedStatement preparedStatement = connection.prepareStatement("CALL FT_CREATE_INDEX(?, ?, ?)")) { + preparedStatement.setString(1, "PUBLIC"); + preparedStatement.setString(2, tableName); + preparedStatement.setString(3, columnsCsv); + preparedStatement.execute(); + } + } + + private String firstKeyValue(ResultSet resultSet) throws SQLException { + java.sql.Array array = resultSet.getArray("KEYS"); + if (array == null) { + return ""; + } + try { + Object raw = array.getArray(); + if (raw instanceof Object[] values && values.length > 0 && values[0] != null) { + return String.valueOf(values[0]); + } + return ""; + } finally { + array.free(); + } + } + + private GlobalSearchRow resolveGlobalSearchRow(Connection connection, String tableNameRaw, String id, double score) + throws SQLException { + String tableName = tableNameRaw.trim().toUpperCase(Locale.ROOT); + return switch (tableName) { + case "LIVE_CATEGORIES" -> findLiveCategoryHit(connection, id, score); + case "VOD_CATEGORIES" -> findVodCategoryHit(connection, id, score); + case "SERIES_CATEGORIES" -> findSeriesCategoryHit(connection, id, score); + case "LIVE_STREAMS" -> findLiveStreamHit(connection, id, score); + case "VOD_STREAMS" -> findVodStreamHit(connection, id, score); + case "SERIES_ITEMS" -> findSeriesItemHit(connection, id, score); + default -> null; + }; + } + + private GlobalSearchRow findLiveCategoryHit(Connection connection, String categoryId, double score) throws SQLException { + try (PreparedStatement preparedStatement = connection.prepareStatement(""" + SELECT category_id, category_name + FROM live_categories + WHERE category_id = ? + """)) { + preparedStatement.setString(1, categoryId); + try (ResultSet resultSet = preparedStatement.executeQuery()) { + if (!resultSet.next()) { + return null; + } + String id = resultSet.getString("category_id"); + String name = resultSet.getString("category_name"); + return new GlobalSearchRow("live_category", id, name, id, name, "", "", score); + } + } + } + + private GlobalSearchRow findVodCategoryHit(Connection connection, String categoryId, double score) throws SQLException { + try (PreparedStatement preparedStatement = connection.prepareStatement(""" + SELECT category_id, category_name + FROM vod_categories + WHERE category_id = ? + """)) { + preparedStatement.setString(1, categoryId); + try (ResultSet resultSet = preparedStatement.executeQuery()) { + if (!resultSet.next()) { + return null; + } + String id = resultSet.getString("category_id"); + String name = resultSet.getString("category_name"); + return new GlobalSearchRow("vod_category", id, name, id, name, "", "", score); + } + } + } + + private GlobalSearchRow findSeriesCategoryHit(Connection connection, String categoryId, double score) throws SQLException { + try (PreparedStatement preparedStatement = connection.prepareStatement(""" + SELECT category_id, category_name + FROM series_categories + WHERE category_id = ? + """)) { + preparedStatement.setString(1, categoryId); + try (ResultSet resultSet = preparedStatement.executeQuery()) { + if (!resultSet.next()) { + return null; + } + String id = resultSet.getString("category_id"); + String name = resultSet.getString("category_name"); + return new GlobalSearchRow("series_category", id, name, id, name, "", "", score); + } + } + } + + private GlobalSearchRow findLiveStreamHit(Connection connection, String streamId, double score) throws SQLException { + try (PreparedStatement preparedStatement = connection.prepareStatement(""" + SELECT ls.stream_id, ls.name, ls.category_id, ls.epg_channel_id, lc.category_name + FROM live_streams ls + LEFT JOIN live_categories lc ON lc.category_id = ls.category_id + WHERE ls.stream_id = ? + """)) { + preparedStatement.setString(1, streamId); + try (ResultSet resultSet = preparedStatement.executeQuery()) { + if (!resultSet.next()) { + return null; + } + return new GlobalSearchRow( + "live", + resultSet.getString("stream_id"), + resultSet.getString("name"), + resultSet.getString("category_id"), + resultSet.getString("category_name"), + "", + resultSet.getString("epg_channel_id"), + score + ); + } + } + } + + private GlobalSearchRow findVodStreamHit(Connection connection, String streamId, double score) throws SQLException { + try (PreparedStatement preparedStatement = connection.prepareStatement(""" + SELECT vs.stream_id, vs.name, vs.category_id, vs.container_extension, vc.category_name + FROM vod_streams vs + LEFT JOIN vod_categories vc ON vc.category_id = vs.category_id + WHERE vs.stream_id = ? + """)) { + preparedStatement.setString(1, streamId); + try (ResultSet resultSet = preparedStatement.executeQuery()) { + if (!resultSet.next()) { + return null; + } + return new GlobalSearchRow( + "vod", + resultSet.getString("stream_id"), + resultSet.getString("name"), + resultSet.getString("category_id"), + resultSet.getString("category_name"), + resultSet.getString("container_extension"), + "", + score + ); + } + } + } + + private GlobalSearchRow findSeriesItemHit(Connection connection, String seriesId, double score) throws SQLException { + try (PreparedStatement preparedStatement = connection.prepareStatement(""" + SELECT si.series_id, si.name, si.category_id, sc.category_name + FROM series_items si + LEFT JOIN series_categories sc ON sc.category_id = si.category_id + WHERE si.series_id = ? + """)) { + preparedStatement.setString(1, seriesId); + try (ResultSet resultSet = preparedStatement.executeQuery()) { + if (!resultSet.next()) { + return null; + } + return new GlobalSearchRow( + "series_item", + resultSet.getString("series_id"), + resultSet.getString("name"), + resultSet.getString("category_id"), + resultSet.getString("category_name"), + "", + "", + score + ); + } + } + } + private String normalizeType(String type) { return type == null ? "" : type.trim().toLowerCase(Locale.ROOT); } + private String safeLower(String value) { + return value == null ? "" : value.toLowerCase(Locale.ROOT); + } + private Connection openConnection() throws SQLException { return DriverManager.getConnection(jdbcUrl, "sa", ""); } @@ -716,6 +970,10 @@ final class LibraryRepository { String season, String episode, String url, long createdAt) { } + record GlobalSearchRow(String kind, String id, String title, String categoryId, String categoryName, + String ext, String epgChannelId, double score) { + } + record LibraryCounts(int liveCategoryCount, int liveStreamCount, int vodCategoryCount, int vodStreamCount, int seriesCategoryCount, int seriesItemCount, int seriesEpisodeCount) { } diff --git a/src/main/java/cz/kamma/xtreamplayer/XtreamLibraryService.java b/src/main/java/cz/kamma/xtreamplayer/XtreamLibraryService.java index d82a08a..776ab08 100644 --- a/src/main/java/cz/kamma/xtreamplayer/XtreamLibraryService.java +++ b/src/main/java/cz/kamma/xtreamplayer/XtreamLibraryService.java @@ -154,6 +154,16 @@ final class XtreamLibraryService { }; } + List globalSearch(String query, int limitRaw, int offsetRaw) { + String normalizedQuery = nullSafe(query).trim(); + if (normalizedQuery.isBlank()) { + return List.of(); + } + int limit = Math.max(1, Math.min(limitRaw, 500)); + int offset = Math.max(0, offsetRaw); + return repository.globalSearch(normalizedQuery, limit, offset); + } + List listFavorites(String search, int limit, int offset) { return repository.listFavorites(search, limit, offset); } diff --git a/src/main/java/cz/kamma/xtreamplayer/XtreamPlayerApplication.java b/src/main/java/cz/kamma/xtreamplayer/XtreamPlayerApplication.java index f85ddae..a998796 100644 --- a/src/main/java/cz/kamma/xtreamplayer/XtreamPlayerApplication.java +++ b/src/main/java/cz/kamma/xtreamplayer/XtreamPlayerApplication.java @@ -76,6 +76,7 @@ public final class XtreamPlayerApplication { server.createContext("/api/library/status", new LibraryStatusHandler(libraryService)); server.createContext("/api/library/categories", new LibraryCategoriesHandler(libraryService)); server.createContext("/api/library/items", new LibraryItemsHandler(libraryService)); + server.createContext("/api/library/search", new LibrarySearchHandler(libraryService)); server.createContext("/api/library/series-episodes", new LibrarySeriesEpisodesHandler(libraryService)); server.createContext("/api/library/epg", new LibraryEpgHandler(libraryService)); server.createContext("/api/favorites", new FavoritesHandler(libraryService)); @@ -493,6 +494,40 @@ public final class XtreamPlayerApplication { } } + private record LibrarySearchHandler(XtreamLibraryService libraryService) implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + Map query = parseKeyValue(exchange.getRequestURI().getRawQuery()); + logApiRequest(exchange, "/api/library/search", query); + if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { + methodNotAllowed(exchange, "GET"); + return; + } + try { + String searchQuery = query.getOrDefault("query", ""); + int limit = parseIntOrDefault(query.get("limit"), 300); + int offset = parseIntOrDefault(query.get("offset"), 0); + if (limit < 1) { + limit = 1; + } + if (limit > 500) { + limit = 500; + } + if (offset < 0) { + offset = 0; + } + Map out = new LinkedHashMap<>(); + out.put("items", libraryService.globalSearch(searchQuery, limit, offset)); + out.put("limit", limit); + out.put("offset", offset); + writeJsonObject(exchange, 200, out); + } catch (Exception exception) { + LOGGER.error("Library global search failed", exception); + writeJson(exchange, 500, errorJson("Library search failed: " + exception.getMessage())); + } + } + } + private record LibrarySeriesEpisodesHandler(XtreamLibraryService libraryService) implements HttpHandler { @Override public void handle(HttpExchange exchange) throws IOException { diff --git a/src/main/resources/web/assets/app.js b/src/main/resources/web/assets/app.js index 37012c0..0bf7bbf 100644 --- a/src/main/resources/web/assets/app.js +++ b/src/main/resources/web/assets/app.js @@ -6,6 +6,7 @@ const state = { config: null, libraryStatus: null, + globalResults: [], liveStreams: [], liveCategories: [], vodStreams: [], @@ -29,6 +30,7 @@ favoriteSeriesCategoryItemsById: {}, currentLiveEpgStreamId: null, currentStreamInfo: null, + globalSearchTimer: null, liveSearchTimer: null, vodSearchTimer: null, seriesSearchTimer: null, @@ -54,6 +56,9 @@ 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"), @@ -106,6 +111,7 @@ async function init() { bindTabs(); bindConfigForm(); + bindGlobalTab(); bindLiveTab(); bindVodTab(); bindSeriesTab(); @@ -204,6 +210,9 @@ } releaseInactiveTabItems(tabName); + if (tabName === "search") { + loadGlobalResults().catch(showError); + } if (tabName === "live") { if (state.liveCategories.length === 0) { loadLiveData().catch(showError); @@ -227,6 +236,11 @@ } } + function bindGlobalTab() { + el.globalSearch.addEventListener("input", scheduleGlobalSearch); + el.globalRefresh.addEventListener("click", () => loadGlobalResults().catch(showError)); + } + function bindConfigForm() { el.configForm.addEventListener("submit", async (event) => { event.preventDefault(); @@ -472,6 +486,7 @@ } function clearLibraryState() { + state.globalResults = []; state.liveCategories = []; state.liveStreams = []; state.vodCategories = []; @@ -493,6 +508,9 @@ } function releaseInactiveTabItems(activeTab) { + if (activeTab !== "search") { + state.globalResults = []; + } if (activeTab !== "live") { state.liveStreams = []; } @@ -521,6 +539,15 @@ }, 250); } + function scheduleGlobalSearch() { + if (state.globalSearchTimer) { + clearTimeout(state.globalSearchTimer); + } + state.globalSearchTimer = setTimeout(() => { + loadGlobalResults().catch(showError); + }, 250); + } + function scheduleVodSearch() { if (state.vodSearchTimer) { clearTimeout(state.vodSearchTimer); @@ -551,6 +578,240 @@ }, 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 = `
  • ${esc(emptyMessage)}
  • `; + 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 = ` +
    + +
    ${esc(badge)}${esc(globalResultMeta(result))}
    +
    +
    + ${hasFavorite + ? `` + : ""} +
    + `; + 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"); @@ -2574,6 +2835,7 @@ updateLiveCategoryFavoriteButton(); updateVodCategoryFavoriteButton(); updateSeriesCategoryFavoriteButton(); + renderGlobalResults("Type at least 2 characters to search globally."); renderLiveStreams(); renderVodStreams(); renderSeriesList(); @@ -2628,6 +2890,20 @@ })); } + 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); diff --git a/src/main/resources/web/index.html b/src/main/resources/web/index.html index c42e0e4..8758e9b 100644 --- a/src/main/resources/web/index.html +++ b/src/main/resources/web/index.html @@ -17,6 +17,7 @@