global search
This commit is contained in:
parent
23edce7dce
commit
2f4c35a797
@ -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<GlobalSearchRow> 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<GlobalSearchRow> 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) {
|
||||
}
|
||||
|
||||
@ -154,6 +154,16 @@ final class XtreamLibraryService {
|
||||
};
|
||||
}
|
||||
|
||||
List<LibraryRepository.GlobalSearchRow> 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<LibraryRepository.FavoriteRow> listFavorites(String search, int limit, int offset) {
|
||||
return repository.listFavorites(search, limit, offset);
|
||||
}
|
||||
|
||||
@ -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<String, String> 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<String, Object> 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 {
|
||||
|
||||
@ -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 = `<li class="card muted">${esc(emptyMessage)}</li>`;
|
||||
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 = `
|
||||
<div>
|
||||
<button type="button" class="stream-title stream-link" data-action="open-result">${esc(result.title || "Untitled")}</button>
|
||||
<div class="stream-meta"><span class="fav-type-badge">${esc(badge)}</span>${esc(globalResultMeta(result))}</div>
|
||||
</div>
|
||||
<div class="stream-actions">
|
||||
${hasFavorite
|
||||
? `<button type="button" class="favorite-toggle" data-action="toggle-favorite" aria-label="${isFavorite(favorite.key) ? "Remove from favorites" : "Add to favorites"}" title="${isFavorite(favorite.key) ? "Remove from favorites" : "Add to favorites"}">${favoriteIcon(isFavorite(favorite.key))}</button>`
|
||||
: ""}
|
||||
</div>
|
||||
`;
|
||||
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);
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
</header>
|
||||
|
||||
<nav class="tabs" id="tabs">
|
||||
<button class="tab" data-tab="search">Search</button>
|
||||
<button class="tab" data-tab="live">Live</button>
|
||||
<button class="tab" data-tab="vod">VOD</button>
|
||||
<button class="tab" data-tab="series">Series</button>
|
||||
@ -63,6 +64,20 @@
|
||||
<div id="settings-message" class="message"></div>
|
||||
</article>
|
||||
|
||||
<article class="tab-panel" data-panel="search">
|
||||
<h2>Global search</h2>
|
||||
<div class="card controls">
|
||||
<label>
|
||||
Search
|
||||
<input id="global-search" type="search" placeholder="Search categories and streams">
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button id="global-refresh" type="button">Search</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul id="global-list" class="stream-list"></ul>
|
||||
</article>
|
||||
|
||||
<article class="tab-panel" data-panel="live">
|
||||
<h2>Live channels</h2>
|
||||
<div class="card controls">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user