global search

This commit is contained in:
Radek Davidek 2026-03-05 13:22:20 +01:00
parent 23edce7dce
commit 2f4c35a797
5 changed files with 594 additions and 0 deletions

View File

@ -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) {
}

View File

@ -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);
}

View File

@ -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 {

View File

@ -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);

View File

@ -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">