From 0009920ac0d42b42b0035f5ad6a0a8a678fa40ea Mon Sep 17 00:00:00 2001 From: Radek Davidek Date: Wed, 4 Mar 2026 13:47:51 +0100 Subject: [PATCH] first commit --- .dockerignore | 7 + .gitignore | 24 + Dockerfile | 25 + README.md | 47 + pom.xml | 55 + .../cz/kamma/xtreamplayer/ConfigStore.java | 164 ++ .../kamma/xtreamplayer/LibraryRepository.java | 577 +++++ .../cz/kamma/xtreamplayer/XtreamConfig.java | 11 + .../xtreamplayer/XtreamLibraryService.java | 597 +++++ .../xtreamplayer/XtreamPlayerApplication.java | 847 +++++++ src/main/resources/log4j2.xml | 16 + src/main/resources/web/assets/app.js | 2182 +++++++++++++++++ src/main/resources/web/assets/style.css | 482 ++++ src/main/resources/web/index.html | 200 ++ 14 files changed, 5234 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 pom.xml create mode 100644 src/main/java/cz/kamma/xtreamplayer/ConfigStore.java create mode 100644 src/main/java/cz/kamma/xtreamplayer/LibraryRepository.java create mode 100644 src/main/java/cz/kamma/xtreamplayer/XtreamConfig.java create mode 100644 src/main/java/cz/kamma/xtreamplayer/XtreamLibraryService.java create mode 100644 src/main/java/cz/kamma/xtreamplayer/XtreamPlayerApplication.java create mode 100644 src/main/resources/log4j2.xml create mode 100644 src/main/resources/web/assets/app.js create mode 100644 src/main/resources/web/assets/style.css create mode 100644 src/main/resources/web/index.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..314f9a1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +.idea +.vscode +target +*.iml +*.log +README.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9d5e01c --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Maven / Java build outputs +target/ +*.class +*.jar +*.war +*.ear + +# Logs +*.log +logs/ + +# IDE +.idea/ +.vscode/ +*.iml + +# OS files +.DS_Store +Thumbs.db + +# Temporary files +*.tmp +*.swp +*.swo diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8040664 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM maven:3.9.9-eclipse-temurin-17 AS build +WORKDIR /workspace + +COPY pom.xml ./ +RUN mvn -q -DskipTests dependency:go-offline + +COPY src ./src +RUN mvn -q -DskipTests package dependency:copy-dependencies + +FROM eclipse-temurin:17-jre-alpine +WORKDIR /app + +RUN addgroup -S app && adduser -S app -G app + +COPY --from=build /workspace/target/xtream-player-1.0.0.jar /app/app.jar +COPY --from=build /workspace/target/dependency /app/libs + +ENV PORT=8080 +ENV HOME=/home/app + +USER app +EXPOSE 8080 +VOLUME ["/home/app/.xtream-player"] + +ENTRYPOINT ["java", "-cp", "/app/app.jar:/app/libs/*", "cz.kamma.xtreamplayer.XtreamPlayerApplication"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..ddd7af8 --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# Xtream Player (Java + com.sun.httpserver) + +Self-hosted HTML5 app for Xtream IPTV: +- provider settings/login +- manual source preload (button `Load sources`) with progress bar +- sources are stored in local H2 DB and the UI reads them only through local API +- tabs for `Live`, `VOD`, `Series`, and `Custom streams` +- search + filtering +- EPG (`get_short_epg`) for live channels +- playback of Xtream streams and custom URLs + +## Requirements +- Java 17+ +- Maven 3.9+ + +## Run +```bash +mvn -q compile exec:java +``` + +The app runs on: +- `http://localhost:8080` + +Optional: change port: +```bash +PORT=8090 mvn -q compile exec:java +``` + +## Docker (JRE 17) +Build image: +```bash +docker build -t xtream-player:latest . +``` + +Run container: +```bash +docker run --rm -p 8080:8080 \ + -e PORT=8080 \ + -v xtream-player-data:/home/app/.xtream-player \ + xtream-player:latest +``` + +## Notes +- Config is stored in `~/.xtream-player/config.properties`. +- Preloaded sources (live/vod/series categories + lists) are stored in `~/.xtream-player/library/xtream-sources.mv.db`. +- Custom streams are stored in browser `localStorage`. +- Some Xtream servers/streams may not be browser-friendly (for example `ts`). `m3u8` is usually better for browsers. diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..f9c81b9 --- /dev/null +++ b/pom.xml @@ -0,0 +1,55 @@ + + 4.0.0 + + cz.kamma + xtream-player + 1.0.0 + xtream-player + + + 17 + UTF-8 + + + + + com.h2database + h2 + 2.2.224 + + + com.fasterxml.jackson.core + jackson-databind + 2.18.3 + + + org.apache.logging.log4j + log4j-api + 2.24.3 + + + org.apache.logging.log4j + log4j-core + 2.24.3 + + + + + + + maven-compiler-plugin + 3.14.0 + + + org.codehaus.mojo + exec-maven-plugin + 3.5.1 + + cz.kamma.xtreamplayer.XtreamPlayerApplication + + + + + diff --git a/src/main/java/cz/kamma/xtreamplayer/ConfigStore.java b/src/main/java/cz/kamma/xtreamplayer/ConfigStore.java new file mode 100644 index 0000000..3064f59 --- /dev/null +++ b/src/main/java/cz/kamma/xtreamplayer/ConfigStore.java @@ -0,0 +1,164 @@ +package cz.kamma.xtreamplayer; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; +import java.util.Properties; + +final class ConfigStore { + private static final Logger LOGGER = LogManager.getLogger(ConfigStore.class); + private static final String KEY_SERVER_URL = "serverUrl"; + private static final String KEY_USERNAME = "username"; + private static final String KEY_PASSWORD = "password"; + private static final String KEY_LIVE_FORMAT = "liveFormat"; + + private final Path configPath; + private XtreamConfig current; + + ConfigStore(Path configPath) { + this.configPath = configPath; + this.current = loadFromDisk(); + LOGGER.info("Config store initialized at {}", configPath); + } + + synchronized XtreamConfig get() { + return current; + } + + synchronized XtreamConfig update(String serverUrl, String username, String password, String liveFormat) { + XtreamConfig next = new XtreamConfig( + normalize(serverUrl), + username == null ? "" : username.trim(), + password == null ? "" : password.trim(), + (liveFormat == null || liveFormat.isBlank()) ? "m3u8" : liveFormat.trim().toLowerCase() + ); + saveToDisk(next); + current = next; + LOGGER.info("Config persisted. Configured={}", next.isConfigured()); + return next; + } + + private XtreamConfig loadFromDisk() { + if (!Files.exists(configPath)) { + LOGGER.info("No config file found at {}. Using empty config.", configPath); + return XtreamConfig.empty(); + } + + Properties properties = new Properties(); + try (InputStream inputStream = Files.newInputStream(configPath)) { + properties.load(inputStream); + } catch (IOException exception) { + LOGGER.warn("Unable to load config from {}. Using empty config.", configPath, exception); + return XtreamConfig.empty(); + } + + return new XtreamConfig( + normalize(properties.getProperty(KEY_SERVER_URL, "")), + properties.getProperty(KEY_USERNAME, "").trim(), + properties.getProperty(KEY_PASSWORD, "").trim(), + properties.getProperty(KEY_LIVE_FORMAT, "m3u8").trim().toLowerCase() + ); + } + + private void saveToDisk(XtreamConfig config) { + Properties properties = new Properties(); + properties.setProperty(KEY_SERVER_URL, config.serverUrl()); + properties.setProperty(KEY_USERNAME, config.username()); + properties.setProperty(KEY_PASSWORD, config.password()); + properties.setProperty(KEY_LIVE_FORMAT, config.liveFormat()); + + try { + Files.createDirectories(configPath.getParent()); + try (OutputStream outputStream = Files.newOutputStream(configPath)) { + properties.store(outputStream, "Xtream Player config"); + } + LOGGER.debug("Config saved to {}", configPath); + } catch (IOException exception) { + throw new IllegalStateException("Unable to save config to " + configPath, exception); + } + } + + private static String normalize(String value) { + if (value == null || value.isBlank()) { + return ""; + } + String normalized = value.trim() + .replace(" ", "") + .replace("\\", "/"); + if (!normalized.toLowerCase(Locale.ROOT).startsWith("http://") + && !normalized.toLowerCase(Locale.ROOT).startsWith("https://")) { + normalized = "http://" + normalized; + } + + try { + URI uri = URI.create(normalized); + String scheme = uri.getScheme() == null ? "http" : uri.getScheme().toLowerCase(Locale.ROOT); + String host = uri.getHost(); + int port = uri.getPort(); + String path = uri.getPath() == null ? "" : uri.getPath().trim(); + if (host == null || host.isBlank()) { + return fallbackNormalize(normalized); + } + + path = stripKnownEndpoint(path); + if (path.endsWith("/")) { + path = path.substring(0, path.length() - 1); + } + + StringBuilder out = new StringBuilder(); + out.append(scheme).append("://"); + if (host.contains(":") && !host.startsWith("[") && !host.endsWith("]")) { + out.append("[").append(host).append("]"); + } else { + out.append(host); + } + if (port >= 0) { + out.append(":").append(port); + } + out.append(path); + return out.toString(); + } catch (Exception exception) { + return fallbackNormalize(normalized); + } + } + + private static String fallbackNormalize(String value) { + String normalized = value; + int query = normalized.indexOf('?'); + if (query >= 0) { + normalized = normalized.substring(0, query); + } + int fragment = normalized.indexOf('#'); + if (fragment >= 0) { + normalized = normalized.substring(0, fragment); + } + normalized = stripKnownEndpoint(normalized); + while (normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return normalized; + } + + private static String stripKnownEndpoint(String rawPath) { + if (rawPath == null || rawPath.isBlank()) { + return ""; + } + String path = rawPath; + String lower = path.toLowerCase(Locale.ROOT); + String[] endings = {"/player_api.php", "/get.php", "/xmltv.php"}; + for (String ending : endings) { + if (lower.endsWith(ending)) { + path = path.substring(0, path.length() - ending.length()); + break; + } + } + return path; + } +} diff --git a/src/main/java/cz/kamma/xtreamplayer/LibraryRepository.java b/src/main/java/cz/kamma/xtreamplayer/LibraryRepository.java new file mode 100644 index 0000000..f30dc30 --- /dev/null +++ b/src/main/java/cz/kamma/xtreamplayer/LibraryRepository.java @@ -0,0 +1,577 @@ +package cz.kamma.xtreamplayer; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +final class LibraryRepository { + private static final Logger LOGGER = LogManager.getLogger(LibraryRepository.class); + private final Path dbPath; + private final String jdbcUrl; + + LibraryRepository(Path dbPath) { + this.dbPath = dbPath; + this.jdbcUrl = "jdbc:h2:file:" + dbPath.toAbsolutePath().toString().replace("\\", "/") + ";AUTO_SERVER=TRUE"; + } + + void initialize() { + try { + Files.createDirectories(dbPath.getParent()); + try (Connection connection = openConnection(); Statement statement = connection.createStatement()) { + statement.execute(""" + CREATE TABLE IF NOT EXISTS source_meta ( + meta_key VARCHAR(120) PRIMARY KEY, + meta_value CLOB + ) + """); + statement.execute(""" + CREATE TABLE IF NOT EXISTS live_categories ( + category_id VARCHAR(80) PRIMARY KEY, + category_name VARCHAR(400) NOT NULL + ) + """); + statement.execute(""" + CREATE TABLE IF NOT EXISTS vod_categories ( + category_id VARCHAR(80) PRIMARY KEY, + category_name VARCHAR(400) NOT NULL + ) + """); + statement.execute(""" + CREATE TABLE IF NOT EXISTS series_categories ( + category_id VARCHAR(80) PRIMARY KEY, + category_name VARCHAR(400) NOT NULL + ) + """); + statement.execute(""" + CREATE TABLE IF NOT EXISTS live_streams ( + stream_id VARCHAR(120) PRIMARY KEY, + name VARCHAR(500) NOT NULL, + category_id VARCHAR(80), + epg_channel_id VARCHAR(180) + ) + """); + statement.execute(""" + CREATE TABLE IF NOT EXISTS vod_streams ( + stream_id VARCHAR(120) PRIMARY KEY, + name VARCHAR(500) NOT NULL, + category_id VARCHAR(80), + container_extension VARCHAR(40) + ) + """); + statement.execute(""" + CREATE TABLE IF NOT EXISTS series_items ( + series_id VARCHAR(120) PRIMARY KEY, + name VARCHAR(500) NOT NULL, + category_id VARCHAR(80) + ) + """); + statement.execute(""" + CREATE TABLE IF NOT EXISTS series_episodes ( + episode_id VARCHAR(120) PRIMARY KEY, + series_id VARCHAR(120) NOT NULL, + season VARCHAR(40), + episode_num VARCHAR(40), + title VARCHAR(500), + container_extension VARCHAR(40) + ) + """); + statement.execute(""" + CREATE TABLE IF NOT EXISTS favorites ( + favorite_key VARCHAR(260) PRIMARY KEY, + mode VARCHAR(80) NOT NULL, + ref_id VARCHAR(180), + ext VARCHAR(60), + title VARCHAR(500) NOT NULL, + category_id VARCHAR(120), + series_id VARCHAR(120), + season VARCHAR(40), + episode VARCHAR(40), + url CLOB, + created_at BIGINT NOT NULL + ) + """); + statement.execute("CREATE INDEX IF NOT EXISTS idx_live_streams_category ON live_streams(category_id)"); + 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_favorites_created_at ON favorites(created_at DESC)"); + } + LOGGER.info("H2 repository initialized at {}", dbPath); + } catch (Exception exception) { + throw new IllegalStateException("Unable to initialize H2 repository.", exception); + } + } + + void clearAllSources() { + LOGGER.info("Clearing all cached library data in H2"); + inTransaction(connection -> { + try (Statement statement = connection.createStatement()) { + statement.executeUpdate("DELETE FROM live_categories"); + statement.executeUpdate("DELETE FROM vod_categories"); + statement.executeUpdate("DELETE FROM series_categories"); + statement.executeUpdate("DELETE FROM live_streams"); + statement.executeUpdate("DELETE FROM vod_streams"); + statement.executeUpdate("DELETE FROM series_items"); + statement.executeUpdate("DELETE FROM series_episodes"); + } + }); + } + + void replaceLiveCategories(List rows) { + replaceCategories("live_categories", rows); + } + + void replaceVodCategories(List rows) { + replaceCategories("vod_categories", rows); + } + + void replaceSeriesCategories(List rows) { + replaceCategories("series_categories", rows); + } + + private void replaceCategories(String tableName, List rows) { + LOGGER.info("Replacing {} with {} rows", tableName, rows.size()); + inTransaction(connection -> { + try (Statement statement = connection.createStatement()) { + statement.executeUpdate("DELETE FROM " + tableName); + } + try (PreparedStatement preparedStatement = connection.prepareStatement( + "MERGE INTO " + tableName + " (category_id, category_name) KEY(category_id) VALUES (?, ?)")) { + for (CategoryRow row : rows) { + preparedStatement.setString(1, row.categoryId()); + preparedStatement.setString(2, row.categoryName()); + preparedStatement.addBatch(); + } + preparedStatement.executeBatch(); + } + }); + } + + void replaceLiveStreams(List rows) { + LOGGER.info("Replacing live streams with {} rows", rows.size()); + inTransaction(connection -> { + try (Statement statement = connection.createStatement()) { + statement.executeUpdate("DELETE FROM live_streams"); + } + try (PreparedStatement preparedStatement = connection.prepareStatement( + "MERGE INTO live_streams (stream_id, name, category_id, epg_channel_id) KEY(stream_id) VALUES (?, ?, ?, ?)")) { + for (LiveStreamRow row : rows) { + preparedStatement.setString(1, row.streamId()); + preparedStatement.setString(2, row.name()); + preparedStatement.setString(3, row.categoryId()); + preparedStatement.setString(4, row.epgChannelId()); + preparedStatement.addBatch(); + } + preparedStatement.executeBatch(); + } + }); + } + + void replaceVodStreams(List rows) { + LOGGER.info("Replacing VOD streams with {} rows", rows.size()); + inTransaction(connection -> { + try (Statement statement = connection.createStatement()) { + statement.executeUpdate("DELETE FROM vod_streams"); + } + try (PreparedStatement preparedStatement = connection.prepareStatement( + "MERGE INTO vod_streams (stream_id, name, category_id, container_extension) KEY(stream_id) VALUES (?, ?, ?, ?)")) { + for (VodStreamRow row : rows) { + preparedStatement.setString(1, row.streamId()); + preparedStatement.setString(2, row.name()); + preparedStatement.setString(3, row.categoryId()); + preparedStatement.setString(4, row.containerExtension()); + preparedStatement.addBatch(); + } + preparedStatement.executeBatch(); + } + }); + } + + void replaceSeriesItems(List rows) { + LOGGER.info("Replacing series items with {} rows", rows.size()); + inTransaction(connection -> { + try (Statement statement = connection.createStatement()) { + statement.executeUpdate("DELETE FROM series_items"); + } + try (PreparedStatement preparedStatement = connection.prepareStatement( + "MERGE INTO series_items (series_id, name, category_id) KEY(series_id) VALUES (?, ?, ?)")) { + for (SeriesItemRow row : rows) { + preparedStatement.setString(1, row.seriesId()); + preparedStatement.setString(2, row.name()); + preparedStatement.setString(3, row.categoryId()); + preparedStatement.addBatch(); + } + preparedStatement.executeBatch(); + } + }); + } + + void replaceSeriesEpisodes(String seriesId, List rows) { + LOGGER.info("Replacing series episodes for series_id={} with {} rows", seriesId, rows.size()); + inTransaction(connection -> { + try (PreparedStatement deleteStatement = connection.prepareStatement( + "DELETE FROM series_episodes WHERE series_id = ?")) { + deleteStatement.setString(1, seriesId); + deleteStatement.executeUpdate(); + } + try (PreparedStatement preparedStatement = connection.prepareStatement( + "MERGE INTO series_episodes (episode_id, series_id, season, episode_num, title, container_extension) KEY(episode_id) VALUES (?, ?, ?, ?, ?, ?)")) { + for (SeriesEpisodeRow row : rows) { + preparedStatement.setString(1, row.episodeId()); + preparedStatement.setString(2, row.seriesId()); + preparedStatement.setString(3, row.season()); + preparedStatement.setString(4, row.episodeNum()); + preparedStatement.setString(5, row.title()); + preparedStatement.setString(6, row.containerExtension()); + preparedStatement.addBatch(); + } + preparedStatement.executeBatch(); + } + }); + } + + List listCategories(String type) { + String table = switch (normalizeType(type)) { + case "live" -> "live_categories"; + case "vod" -> "vod_categories"; + case "series" -> "series_categories"; + default -> throw new IllegalArgumentException("Unsupported type: " + type); + }; + + List rows = new ArrayList<>(); + try (Connection connection = openConnection(); + PreparedStatement preparedStatement = connection.prepareStatement( + "SELECT category_id, category_name FROM " + table + " ORDER BY LOWER(category_name), category_name")) { + try (ResultSet resultSet = preparedStatement.executeQuery()) { + while (resultSet.next()) { + rows.add(new CategoryRow( + resultSet.getString("category_id"), + resultSet.getString("category_name") + )); + } + } + return rows; + } catch (SQLException exception) { + throw new IllegalStateException("Unable to list categories.", exception); + } + } + + List listLiveStreams(String categoryId, String search) { + StringBuilder sql = new StringBuilder( + "SELECT stream_id, name, category_id, epg_channel_id FROM live_streams WHERE 1=1"); + List args = new ArrayList<>(); + if (categoryId != null && !categoryId.isBlank()) { + sql.append(" AND category_id = ?"); + args.add(categoryId.trim()); + } + if (search != null && !search.isBlank()) { + sql.append(" AND LOWER(name) LIKE ?"); + args.add("%" + search.trim().toLowerCase(Locale.ROOT) + "%"); + } + sql.append(" ORDER BY LOWER(name), name"); + + List rows = new ArrayList<>(); + try (Connection connection = openConnection(); + PreparedStatement preparedStatement = connection.prepareStatement(sql.toString())) { + bindStringArgs(preparedStatement, args); + try (ResultSet resultSet = preparedStatement.executeQuery()) { + while (resultSet.next()) { + rows.add(new LiveStreamRow( + resultSet.getString("stream_id"), + resultSet.getString("name"), + resultSet.getString("category_id"), + resultSet.getString("epg_channel_id") + )); + } + } + return rows; + } catch (SQLException exception) { + throw new IllegalStateException("Unable to list live streams.", exception); + } + } + + List listVodStreams(String categoryId, String search) { + StringBuilder sql = new StringBuilder( + "SELECT stream_id, name, category_id, container_extension FROM vod_streams WHERE 1=1"); + List args = new ArrayList<>(); + if (categoryId != null && !categoryId.isBlank()) { + sql.append(" AND category_id = ?"); + args.add(categoryId.trim()); + } + if (search != null && !search.isBlank()) { + sql.append(" AND LOWER(name) LIKE ?"); + args.add("%" + search.trim().toLowerCase(Locale.ROOT) + "%"); + } + sql.append(" ORDER BY LOWER(name), name"); + + List rows = new ArrayList<>(); + try (Connection connection = openConnection(); + PreparedStatement preparedStatement = connection.prepareStatement(sql.toString())) { + bindStringArgs(preparedStatement, args); + try (ResultSet resultSet = preparedStatement.executeQuery()) { + while (resultSet.next()) { + rows.add(new VodStreamRow( + resultSet.getString("stream_id"), + resultSet.getString("name"), + resultSet.getString("category_id"), + resultSet.getString("container_extension") + )); + } + } + return rows; + } catch (SQLException exception) { + throw new IllegalStateException("Unable to list VOD streams.", exception); + } + } + + List listSeriesItems(String categoryId, String search) { + StringBuilder sql = new StringBuilder( + "SELECT series_id, name, category_id FROM series_items WHERE 1=1"); + List args = new ArrayList<>(); + if (categoryId != null && !categoryId.isBlank()) { + sql.append(" AND category_id = ?"); + args.add(categoryId.trim()); + } + if (search != null && !search.isBlank()) { + sql.append(" AND LOWER(name) LIKE ?"); + args.add("%" + search.trim().toLowerCase(Locale.ROOT) + "%"); + } + sql.append(" ORDER BY LOWER(name), name"); + + List rows = new ArrayList<>(); + try (Connection connection = openConnection(); + PreparedStatement preparedStatement = connection.prepareStatement(sql.toString())) { + bindStringArgs(preparedStatement, args); + try (ResultSet resultSet = preparedStatement.executeQuery()) { + while (resultSet.next()) { + rows.add(new SeriesItemRow( + resultSet.getString("series_id"), + resultSet.getString("name"), + resultSet.getString("category_id") + )); + } + } + return rows; + } catch (SQLException exception) { + throw new IllegalStateException("Unable to list series items.", exception); + } + } + + List listSeriesEpisodes(String seriesId) { + List rows = new ArrayList<>(); + try (Connection connection = openConnection(); + PreparedStatement preparedStatement = connection.prepareStatement(""" + SELECT episode_id, series_id, season, episode_num, title, container_extension + FROM series_episodes + WHERE series_id = ? + ORDER BY season, episode_num, title + """)) { + preparedStatement.setString(1, seriesId); + try (ResultSet resultSet = preparedStatement.executeQuery()) { + while (resultSet.next()) { + rows.add(new SeriesEpisodeRow( + resultSet.getString("episode_id"), + resultSet.getString("series_id"), + resultSet.getString("season"), + resultSet.getString("episode_num"), + resultSet.getString("title"), + resultSet.getString("container_extension") + )); + } + } + return rows; + } catch (SQLException exception) { + throw new IllegalStateException("Unable to list series episodes.", exception); + } + } + + void setMeta(String key, String value) { + try (Connection connection = openConnection(); + PreparedStatement preparedStatement = connection.prepareStatement( + "MERGE INTO source_meta (meta_key, meta_value) KEY(meta_key) VALUES (?, ?)")) { + preparedStatement.setString(1, key); + preparedStatement.setString(2, value); + preparedStatement.executeUpdate(); + } catch (SQLException exception) { + throw new IllegalStateException("Unable to set metadata.", exception); + } + } + + List listFavorites() { + List rows = new ArrayList<>(); + 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 + """)) { + try (ResultSet resultSet = preparedStatement.executeQuery()) { + while (resultSet.next()) { + rows.add(new FavoriteRow( + resultSet.getString("favorite_key"), + resultSet.getString("mode"), + resultSet.getString("ref_id"), + resultSet.getString("ext"), + resultSet.getString("title"), + resultSet.getString("category_id"), + resultSet.getString("series_id"), + resultSet.getString("season"), + resultSet.getString("episode"), + resultSet.getString("url"), + resultSet.getLong("created_at") + )); + } + } + return rows; + } catch (SQLException exception) { + throw new IllegalStateException("Unable to list favorites.", exception); + } + } + + void upsertFavorite(FavoriteRow row) { + try (Connection connection = openConnection(); + PreparedStatement preparedStatement = connection.prepareStatement(""" + MERGE INTO favorites + (favorite_key, mode, ref_id, ext, title, category_id, series_id, season, episode, url, created_at) + KEY(favorite_key) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """)) { + preparedStatement.setString(1, row.key()); + preparedStatement.setString(2, row.mode()); + preparedStatement.setString(3, row.id()); + preparedStatement.setString(4, row.ext()); + preparedStatement.setString(5, row.title()); + preparedStatement.setString(6, row.categoryId()); + preparedStatement.setString(7, row.seriesId()); + preparedStatement.setString(8, row.season()); + preparedStatement.setString(9, row.episode()); + preparedStatement.setString(10, row.url()); + preparedStatement.setLong(11, row.createdAt()); + preparedStatement.executeUpdate(); + } catch (SQLException exception) { + throw new IllegalStateException("Unable to save favorite.", exception); + } + } + + boolean deleteFavorite(String key) { + try (Connection connection = openConnection(); + PreparedStatement preparedStatement = connection.prepareStatement( + "DELETE FROM favorites WHERE favorite_key = ?")) { + preparedStatement.setString(1, key); + return preparedStatement.executeUpdate() > 0; + } catch (SQLException exception) { + throw new IllegalStateException("Unable to delete favorite.", exception); + } + } + + String getMeta(String key) { + try (Connection connection = openConnection(); + PreparedStatement preparedStatement = connection.prepareStatement( + "SELECT meta_value FROM source_meta WHERE meta_key = ?")) { + preparedStatement.setString(1, key); + try (ResultSet resultSet = preparedStatement.executeQuery()) { + if (resultSet.next()) { + return resultSet.getString("meta_value"); + } + return null; + } + } catch (SQLException exception) { + throw new IllegalStateException("Unable to read metadata.", exception); + } + } + + LibraryCounts countAll() { + try (Connection connection = openConnection()) { + return new LibraryCounts( + countTable(connection, "live_categories"), + countTable(connection, "live_streams"), + countTable(connection, "vod_categories"), + countTable(connection, "vod_streams"), + countTable(connection, "series_categories"), + countTable(connection, "series_items"), + countTable(connection, "series_episodes") + ); + } catch (SQLException exception) { + throw new IllegalStateException("Unable to count library rows.", exception); + } + } + + private int countTable(Connection connection, String tableName) throws SQLException { + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery("SELECT COUNT(*) AS c FROM " + tableName)) { + resultSet.next(); + return resultSet.getInt("c"); + } + } + + private void bindStringArgs(PreparedStatement preparedStatement, List args) throws SQLException { + for (int index = 0; index < args.size(); index++) { + preparedStatement.setString(index + 1, args.get(index)); + } + } + + private String normalizeType(String type) { + return type == null ? "" : type.trim().toLowerCase(Locale.ROOT); + } + + private Connection openConnection() throws SQLException { + return DriverManager.getConnection(jdbcUrl, "sa", ""); + } + + private void inTransaction(SqlWork work) { + try (Connection connection = openConnection()) { + connection.setAutoCommit(false); + try { + work.execute(connection); + connection.commit(); + } catch (Exception exception) { + connection.rollback(); + throw exception; + } finally { + connection.setAutoCommit(true); + } + } catch (Exception exception) { + throw new IllegalStateException("Database transaction failed.", exception); + } + } + + @FunctionalInterface + private interface SqlWork { + void execute(Connection connection) throws Exception; + } + + record CategoryRow(String categoryId, String categoryName) { + } + + record LiveStreamRow(String streamId, String name, String categoryId, String epgChannelId) { + } + + record VodStreamRow(String streamId, String name, String categoryId, String containerExtension) { + } + + record SeriesItemRow(String seriesId, String name, String categoryId) { + } + + record SeriesEpisodeRow(String episodeId, String seriesId, String season, String episodeNum, String title, + String containerExtension) { + } + + record FavoriteRow(String key, String mode, String id, String ext, String title, String categoryId, String seriesId, + String season, String episode, String url, long createdAt) { + } + + 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/XtreamConfig.java b/src/main/java/cz/kamma/xtreamplayer/XtreamConfig.java new file mode 100644 index 0000000..97b0e36 --- /dev/null +++ b/src/main/java/cz/kamma/xtreamplayer/XtreamConfig.java @@ -0,0 +1,11 @@ +package cz.kamma.xtreamplayer; + +record XtreamConfig(String serverUrl, String username, String password, String liveFormat) { + static XtreamConfig empty() { + return new XtreamConfig("", "", "", "m3u8"); + } + + boolean isConfigured() { + return !serverUrl.isBlank() && !username.isBlank() && !password.isBlank(); + } +} diff --git a/src/main/java/cz/kamma/xtreamplayer/XtreamLibraryService.java b/src/main/java/cz/kamma/xtreamplayer/XtreamLibraryService.java new file mode 100644 index 0000000..e572d98 --- /dev/null +++ b/src/main/java/cz/kamma/xtreamplayer/XtreamLibraryService.java @@ -0,0 +1,597 @@ +package cz.kamma.xtreamplayer; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +final class XtreamLibraryService { + private static final Logger LOGGER = LogManager.getLogger(XtreamLibraryService.class); + private static final Set SENSITIVE_KEYS = Set.of("password", "pass", "pwd", "token", "authorization"); + static final List LOAD_STEPS = List.of( + "live_categories", + "live_streams", + "vod_categories", + "vod_streams", + "series_categories", + "series_items" + ); + + private static final String META_FINGERPRINT = "source_fingerprint"; + private static final String META_LOADED_AT = "source_loaded_at"; + + private final ConfigStore configStore; + private final LibraryRepository repository; + private final ObjectMapper objectMapper; + private final HttpClient httpClient; + + XtreamLibraryService(ConfigStore configStore, LibraryRepository repository) { + this.configStore = configStore; + this.repository = repository; + this.objectMapper = new ObjectMapper(); + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(20)) + .followRedirects(HttpClient.Redirect.NORMAL) + .version(HttpClient.Version.HTTP_1_1) + .build(); + } + + Map loadAll() { + LOGGER.info("Library load started for all steps"); + for (String step : LOAD_STEPS) { + loadStep(step); + } + LOGGER.info("Library load finished for all steps"); + return status(); + } + + Map loadStep(String stepRaw) { + String step = normalizeStep(stepRaw); + if (!LOAD_STEPS.contains(step)) { + throw new IllegalArgumentException("Unsupported load step: " + stepRaw); + } + LOGGER.info("Library load step started: {}", step); + + XtreamConfig config = requireConfigured(); + ensureFingerprint(config); + if ("live_categories".equals(step)) { + repository.setMeta(META_LOADED_AT, ""); + } + + switch (step) { + case "live_categories" -> { + JsonNode response = fetchXtream(config, Map.of("action", "get_live_categories")); + repository.replaceLiveCategories(parseCategories(response)); + } + case "live_streams" -> { + JsonNode response = fetchXtream(config, Map.of("action", "get_live_streams")); + repository.replaceLiveStreams(parseLiveStreams(response)); + } + case "vod_categories" -> { + JsonNode response = fetchXtream(config, Map.of("action", "get_vod_categories")); + repository.replaceVodCategories(parseCategories(response)); + } + case "vod_streams" -> { + JsonNode response = fetchXtream(config, Map.of("action", "get_vod_streams")); + repository.replaceVodStreams(parseVodStreams(response)); + } + case "series_categories" -> { + JsonNode response = fetchXtream(config, Map.of("action", "get_series_categories")); + repository.replaceSeriesCategories(parseCategories(response)); + } + case "series_items" -> { + JsonNode response = fetchXtream(config, Map.of("action", "get_series")); + repository.replaceSeriesItems(parseSeriesItems(response)); + repository.setMeta(META_LOADED_AT, Long.toString(System.currentTimeMillis())); + } + default -> throw new IllegalArgumentException("Unsupported load step: " + step); + } + + Map out = new LinkedHashMap<>(); + out.put("step", step); + out.put("status", status()); + LOGGER.info("Library load step finished: {}", step); + return out; + } + + Map status() { + XtreamConfig config = configStore.get(); + LibraryRepository.LibraryCounts counts = repository.countAll(); + String storedFingerprint = nullSafe(repository.getMeta(META_FINGERPRINT)); + String loadedAt = nullSafe(repository.getMeta(META_LOADED_AT)); + String currentFingerprint = fingerprint(config); + + boolean ready = config.isConfigured() + && !loadedAt.isBlank() + && storedFingerprint.equals(currentFingerprint); + + Map out = new LinkedHashMap<>(); + out.put("configured", config.isConfigured()); + out.put("ready", ready); + out.put("fingerprintMatches", storedFingerprint.equals(currentFingerprint)); + out.put("loadedAt", loadedAt); + out.put("counts", Map.of( + "liveCategories", counts.liveCategoryCount(), + "liveStreams", counts.liveStreamCount(), + "vodCategories", counts.vodCategoryCount(), + "vodStreams", counts.vodStreamCount(), + "seriesCategories", counts.seriesCategoryCount(), + "seriesItems", counts.seriesItemCount(), + "seriesEpisodes", counts.seriesEpisodeCount() + )); + return out; + } + + List listCategories(String type) { + return repository.listCategories(type); + } + + List listItems(String type, String categoryId, String search) { + 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); + default -> throw new IllegalArgumentException("Unsupported type: " + type); + }; + } + + List listFavorites() { + return repository.listFavorites(); + } + + LibraryRepository.FavoriteRow saveFavorite( + String key, + String mode, + String id, + String ext, + String title, + String categoryId, + String seriesId, + String season, + String episode, + String url, + Long createdAt + ) { + String normalizedKey = nullSafe(key).trim(); + if (normalizedKey.isBlank()) { + throw new IllegalArgumentException("Missing favorite key."); + } + String normalizedMode = nullSafe(mode).trim(); + if (normalizedMode.isBlank()) { + throw new IllegalArgumentException("Missing favorite mode."); + } + String normalizedTitle = nullSafe(title).trim(); + if (normalizedTitle.isBlank()) { + throw new IllegalArgumentException("Missing favorite title."); + } + long safeCreatedAt = createdAt == null || createdAt <= 0 ? System.currentTimeMillis() : createdAt; + LibraryRepository.FavoriteRow row = new LibraryRepository.FavoriteRow( + normalizedKey, + normalizedMode, + nullSafe(id).trim(), + nullSafe(ext).trim(), + normalizedTitle, + nullSafe(categoryId).trim(), + nullSafe(seriesId).trim(), + nullSafe(season).trim(), + nullSafe(episode).trim(), + nullSafe(url).trim(), + safeCreatedAt + ); + repository.upsertFavorite(row); + return row; + } + + boolean deleteFavorite(String key) { + String normalizedKey = nullSafe(key).trim(); + if (normalizedKey.isBlank()) { + throw new IllegalArgumentException("Missing favorite key."); + } + return repository.deleteFavorite(normalizedKey); + } + + List listSeriesEpisodes(String seriesIdRaw) { + String seriesId = seriesIdRaw == null ? "" : seriesIdRaw.trim(); + if (seriesId.isBlank()) { + throw new IllegalArgumentException("Missing series_id."); + } + + List cached = repository.listSeriesEpisodes(seriesId); + if (!cached.isEmpty()) { + LOGGER.debug("Series episodes served from cache for series_id={}", seriesId); + return cached; + } + + XtreamConfig config = requireConfigured(); + LOGGER.info("Series episodes cache miss, loading from Xtream for series_id={}", seriesId); + JsonNode response = fetchXtream(config, Map.of( + "action", "get_series_info", + "series_id", seriesId + )); + List loaded = parseSeriesEpisodes(seriesId, response.path("episodes")); + repository.replaceSeriesEpisodes(seriesId, loaded); + return loaded; + } + + Object getShortEpg(String streamIdRaw, int limit) { + String streamId = streamIdRaw == null ? "" : streamIdRaw.trim(); + if (streamId.isBlank()) { + throw new IllegalArgumentException("Missing stream_id."); + } + XtreamConfig config = requireConfigured(); + LOGGER.debug("Loading short EPG for stream_id={} limit={}", streamId, limit); + JsonNode response = fetchXtream(config, Map.of( + "action", "get_short_epg", + "stream_id", streamId, + "limit", Integer.toString(Math.max(1, limit)) + )); + return objectMapper.convertValue(response, Object.class); + } + + private List parseCategories(JsonNode node) { + if (!node.isArray()) { + return List.of(); + } + Map deduplicated = new LinkedHashMap<>(); + for (JsonNode item : node) { + String id = text(item, "category_id"); + if (id.isBlank()) { + continue; + } + String name = text(item, "category_name"); + if (name.isBlank()) { + name = "Category " + id; + } + deduplicated.put(id, new LibraryRepository.CategoryRow(id, name)); + } + return new ArrayList<>(deduplicated.values()); + } + + private List parseLiveStreams(JsonNode node) { + if (!node.isArray()) { + return List.of(); + } + Map deduplicated = new LinkedHashMap<>(); + for (JsonNode item : node) { + String streamId = text(item, "stream_id"); + if (streamId.isBlank()) { + continue; + } + String name = text(item, "name"); + if (name.isBlank()) { + name = "Stream " + streamId; + } + deduplicated.put(streamId, new LibraryRepository.LiveStreamRow( + streamId, + name, + text(item, "category_id"), + text(item, "epg_channel_id") + )); + } + return new ArrayList<>(deduplicated.values()); + } + + private List parseVodStreams(JsonNode node) { + if (!node.isArray()) { + return List.of(); + } + Map deduplicated = new LinkedHashMap<>(); + for (JsonNode item : node) { + String streamId = text(item, "stream_id"); + if (streamId.isBlank()) { + continue; + } + String name = text(item, "name"); + if (name.isBlank()) { + name = "VOD " + streamId; + } + String ext = text(item, "container_extension"); + if (ext.isBlank()) { + ext = "mp4"; + } + deduplicated.put(streamId, new LibraryRepository.VodStreamRow( + streamId, + name, + text(item, "category_id"), + ext + )); + } + return new ArrayList<>(deduplicated.values()); + } + + private List parseSeriesItems(JsonNode node) { + if (!node.isArray()) { + return List.of(); + } + Map deduplicated = new LinkedHashMap<>(); + for (JsonNode item : node) { + String seriesId = text(item, "series_id"); + if (seriesId.isBlank()) { + continue; + } + String name = text(item, "name"); + if (name.isBlank()) { + name = "Series " + seriesId; + } + deduplicated.put(seriesId, new LibraryRepository.SeriesItemRow( + seriesId, + name, + text(item, "category_id") + )); + } + return new ArrayList<>(deduplicated.values()); + } + + private List parseSeriesEpisodes(String seriesId, JsonNode episodesBySeason) { + if (episodesBySeason == null || episodesBySeason.isMissingNode() || episodesBySeason.isNull()) { + return List.of(); + } + Map deduplicated = new LinkedHashMap<>(); + episodesBySeason.fields().forEachRemaining(entry -> { + String season = entry.getKey(); + JsonNode episodes = entry.getValue(); + if (!episodes.isArray()) { + return; + } + int index = 1; + for (JsonNode episode : episodes) { + String episodeId = text(episode, "id"); + if (episodeId.isBlank()) { + continue; + } + String title = text(episode, "title"); + if (title.isBlank()) { + title = "Episode " + index; + } + String ext = text(episode, "container_extension"); + if (ext.isBlank()) { + ext = "mp4"; + } + String episodeNum = text(episode, "episode_num"); + if (episodeNum.isBlank()) { + episodeNum = Integer.toString(index); + } + String seasonValue = text(episode, "season"); + if (seasonValue.isBlank()) { + seasonValue = season; + } + + deduplicated.put(episodeId, new LibraryRepository.SeriesEpisodeRow( + episodeId, + seriesId, + seasonValue, + episodeNum, + title, + ext + )); + index++; + } + }); + return new ArrayList<>(deduplicated.values()); + } + + private void ensureFingerprint(XtreamConfig config) { + String currentFingerprint = fingerprint(config); + String storedFingerprint = nullSafe(repository.getMeta(META_FINGERPRINT)); + if (!currentFingerprint.equals(storedFingerprint)) { + LOGGER.info("Source fingerprint changed. Clearing cached library data."); + repository.clearAllSources(); + repository.setMeta(META_FINGERPRINT, currentFingerprint); + repository.setMeta(META_LOADED_AT, ""); + } + } + + private XtreamConfig requireConfigured() { + XtreamConfig config = configStore.get(); + if (!config.isConfigured()) { + throw new IllegalStateException("Config is not complete."); + } + return config; + } + + private JsonNode fetchXtream(XtreamConfig config, Map params) { + URI uri = buildPlayerApiUri(config, params); + List attempts = candidateUris(uri); + List errors = new ArrayList<>(); + + for (URI candidate : attempts) { + long startedAt = System.nanoTime(); + try { + LOGGER.info("Xtream API request uri={} params={}", maskUri(candidate), maskSensitive(params)); + HttpRequest request = HttpRequest.newBuilder(candidate) + .GET() + .timeout(Duration.ofSeconds(30)) + .header("User-Agent", "XtreamPlayer/1.0") + .header("Accept", "application/json,text/plain,*/*") + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()); + String contentType = response.headers().firstValue("Content-Type").orElse("unknown"); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + LOGGER.warn( + "Xtream API response uri={} status={} bytes={} contentType={} latencyMs={}", + maskUri(candidate), + response.statusCode(), + response.body().length, + contentType, + elapsedMillis(startedAt) + ); + errors.add(maskUri(candidate) + " -> HTTP " + response.statusCode()); + continue; + } + LOGGER.info( + "Xtream API response uri={} status={} bytes={} contentType={} latencyMs={}", + maskUri(candidate), + response.statusCode(), + response.body().length, + contentType, + elapsedMillis(startedAt) + ); + return objectMapper.readTree(response.body()); + } catch (InterruptedException interruptedException) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Xtream request interrupted."); + } catch (Exception exception) { + LOGGER.warn("Xtream API call failed uri={} latencyMs={}", maskUri(candidate), elapsedMillis(startedAt), exception); + errors.add(maskUri(candidate) + " -> " + compactError(exception)); + } + } + LOGGER.error("Xtream fetch failed for all candidates. params={}", params.keySet()); + throw new IllegalStateException("Xtream fetch failed. Attempts: " + String.join(" | ", errors)); + } + + private URI buildPlayerApiUri(XtreamConfig config, Map params) { + StringBuilder query = new StringBuilder(); + query.append("username=").append(urlEncode(config.username())); + query.append("&password=").append(urlEncode(config.password())); + + for (Map.Entry entry : params.entrySet()) { + if (entry.getKey() == null || entry.getKey().isBlank()) { + continue; + } + query.append("&") + .append(urlEncode(entry.getKey())) + .append("=") + .append(urlEncode(entry.getValue() == null ? "" : entry.getValue())); + } + + return URI.create(config.serverUrl() + "/player_api.php?" + query); + } + + private List candidateUris(URI uri) { + LinkedHashSet candidates = new LinkedHashSet<>(); + candidates.add(uri); + URI switched = switchScheme(uri); + if (switched != null) { + candidates.add(switched); + } + return List.copyOf(candidates); + } + + private URI switchScheme(URI uri) { + String scheme = uri.getScheme(); + if (scheme == null) { + return null; + } + String replacement; + if ("http".equalsIgnoreCase(scheme)) { + replacement = "https"; + } else if ("https".equalsIgnoreCase(scheme)) { + replacement = "http"; + } else { + return null; + } + return URI.create(replacement + "://" + uri.getRawAuthority() + uri.getRawPath() + + (uri.getRawQuery() == null ? "" : "?" + uri.getRawQuery())); + } + + private String compactError(Throwable throwable) { + Throwable current = throwable; + StringBuilder builder = new StringBuilder(); + while (current != null) { + if (!builder.isEmpty()) { + builder.append(" <- "); + } + builder.append(current.getClass().getSimpleName()); + if (current.getMessage() != null && !current.getMessage().isBlank()) { + builder.append(": ").append(current.getMessage()); + } + current = current.getCause(); + } + return builder.toString(); + } + + private String text(JsonNode node, String field) { + JsonNode value = node == null ? null : node.get(field); + if (value == null || value.isNull()) { + return ""; + } + return value.asText("").trim(); + } + + private String normalizeStep(String stepRaw) { + return stepRaw == null ? "" : stepRaw.trim().toLowerCase(Locale.ROOT); + } + + private String fingerprint(XtreamConfig config) { + if (config == null) { + return ""; + } + return (nullSafe(config.serverUrl()) + "|" + nullSafe(config.username()) + "|" + nullSafe(config.liveFormat())) + .trim(); + } + + private String nullSafe(String value) { + return value == null ? "" : value; + } + + private String urlEncode(String value) { + return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8); + } + + private long elapsedMillis(long startedAtNanos) { + return (System.nanoTime() - startedAtNanos) / 1_000_000L; + } + + private String maskUri(URI uri) { + String rawQuery = uri.getRawQuery(); + if (rawQuery == null || rawQuery.isBlank()) { + return uri.getScheme() + "://" + uri.getRawAuthority() + uri.getRawPath(); + } + Map params = parseKeyValue(rawQuery); + Map masked = maskSensitive(params); + StringBuilder query = new StringBuilder(); + for (Map.Entry entry : masked.entrySet()) { + if (!query.isEmpty()) { + query.append("&"); + } + query.append(entry.getKey()).append("=").append(entry.getValue() == null ? "" : entry.getValue()); + } + return uri.getScheme() + "://" + uri.getRawAuthority() + uri.getRawPath() + "?" + query; + } + + private Map parseKeyValue(String raw) { + Map result = new LinkedHashMap<>(); + if (raw == null || raw.isBlank()) { + return result; + } + for (String part : raw.split("&")) { + if (part.isBlank()) { + continue; + } + String[] pieces = part.split("=", 2); + String key = java.net.URLDecoder.decode(pieces[0], StandardCharsets.UTF_8); + String value = pieces.length > 1 ? java.net.URLDecoder.decode(pieces[1], StandardCharsets.UTF_8) : ""; + result.put(key, value); + } + return result; + } + + private Map maskSensitive(Map params) { + Map masked = new LinkedHashMap<>(); + for (Map.Entry entry : params.entrySet()) { + String key = entry.getKey() == null ? "" : entry.getKey(); + if (SENSITIVE_KEYS.contains(key.toLowerCase(Locale.ROOT))) { + masked.put(key, "***"); + } else { + masked.put(key, entry.getValue()); + } + } + return masked; + } +} diff --git a/src/main/java/cz/kamma/xtreamplayer/XtreamPlayerApplication.java b/src/main/java/cz/kamma/xtreamplayer/XtreamPlayerApplication.java new file mode 100644 index 0000000..bc9c68a --- /dev/null +++ b/src/main/java/cz/kamma/xtreamplayer/XtreamPlayerApplication.java @@ -0,0 +1,847 @@ +package cz.kamma.xtreamplayer; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.io.IOException; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Set; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.Executors; + +public final class XtreamPlayerApplication { + private static final int DEFAULT_PORT = 8080; + private static final Set SENSITIVE_KEYS = Set.of("password", "pass", "pwd", "token", "authorization"); + private static final String ATTR_REQ_START_NANOS = "reqStartNanos"; + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final Logger LOGGER = LogManager.getLogger(XtreamPlayerApplication.class); + private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(20)) + .followRedirects(HttpClient.Redirect.NORMAL) + .version(HttpClient.Version.HTTP_1_1) + .build(); + + private XtreamPlayerApplication() { + } + + public static void main(String[] args) throws IOException { + int port = resolvePort(); + ConfigStore configStore = new ConfigStore( + Path.of(System.getProperty("user.home"), ".xtream-player", "config.properties") + ); + LibraryRepository libraryRepository = new LibraryRepository( + Path.of(System.getProperty("user.home"), ".xtream-player", "library", "xtream-sources") + ); + libraryRepository.initialize(); + XtreamLibraryService libraryService = new XtreamLibraryService(configStore, libraryRepository); + + HttpServer server = HttpServer.create(new InetSocketAddress(port), 0); + server.createContext("/api/config", new ConfigHandler(configStore)); + server.createContext("/api/test-login", new TestLoginHandler(configStore)); + server.createContext("/api/xtream", new XtreamProxyHandler(configStore)); + server.createContext("/api/stream-url", new StreamUrlHandler(configStore)); + server.createContext("/api/open-in-player", new OpenInPlayerHandler(configStore)); + server.createContext("/api/library/load", new LibraryLoadHandler(libraryService)); + 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/series-episodes", new LibrarySeriesEpisodesHandler(libraryService)); + server.createContext("/api/library/epg", new LibraryEpgHandler(libraryService)); + server.createContext("/api/favorites", new FavoritesHandler(libraryService)); + server.createContext("/", new StaticHandler()); + server.setExecutor(Executors.newFixedThreadPool(12)); + server.start(); + + LOGGER.info("Xtream Player started on http://localhost:{}", port); + } + + private static int resolvePort() { + String raw = System.getenv("PORT"); + if (raw == null || raw.isBlank()) { + return DEFAULT_PORT; + } + try { + return Integer.parseInt(raw.trim()); + } catch (NumberFormatException ignored) { + return DEFAULT_PORT; + } + } + + private record ConfigHandler(ConfigStore configStore) implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + try { + String method = exchange.getRequestMethod(); + if ("GET".equalsIgnoreCase(method)) { + logApiRequest(exchange, "/api/config", Map.of()); + writeJson(exchange, 200, configToJson(configStore.get())); + return; + } + if ("POST".equalsIgnoreCase(method)) { + String body = readBody(exchange); + Map form = parseKeyValue(body); + logApiRequest(exchange, "/api/config", form); + XtreamConfig current = configStore.get(); + XtreamConfig updated = configStore.update( + form.getOrDefault("serverUrl", current.serverUrl()), + form.getOrDefault("username", current.username()), + form.getOrDefault("password", current.password()), + form.getOrDefault("liveFormat", current.liveFormat()) + ); + LOGGER.info("Config updated. Configured={}", updated.isConfigured()); + writeJson(exchange, 200, configToJson(updated)); + return; + } + methodNotAllowed(exchange, "GET, POST"); + } catch (Exception exception) { + LOGGER.error("Config endpoint failed", exception); + writeJson(exchange, 500, errorJson("Config error: " + exception.getMessage())); + } + } + } + + private record TestLoginHandler(ConfigStore configStore) implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + logApiRequest(exchange, "/api/test-login", parseKeyValue(exchange.getRequestURI().getRawQuery())); + if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { + methodNotAllowed(exchange, "GET"); + return; + } + + XtreamConfig config = configStore.get(); + if (!config.isConfigured()) { + writeJson(exchange, 400, errorJson("Config is not complete.")); + return; + } + + URI uri = buildPlayerApiUri(config, Map.of()); + proxyRequest(exchange, uri); + } + } + + private record XtreamProxyHandler(ConfigStore configStore) implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + Map incoming = parseKeyValue(exchange.getRequestURI().getRawQuery()); + logApiRequest(exchange, "/api/xtream", incoming); + if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { + methodNotAllowed(exchange, "GET"); + return; + } + + XtreamConfig config = configStore.get(); + if (!config.isConfigured()) { + writeJson(exchange, 400, errorJson("Config is not complete.")); + return; + } + + incoming.remove("username"); + incoming.remove("password"); + LOGGER.debug("Proxy request for params={}", incoming.keySet()); + URI uri = buildPlayerApiUri(config, incoming); + proxyRequest(exchange, uri); + } + } + + private record StreamUrlHandler(ConfigStore configStore) implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + Map query = parseKeyValue(exchange.getRequestURI().getRawQuery()); + logApiRequest(exchange, "/api/stream-url", query); + if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { + methodNotAllowed(exchange, "GET"); + return; + } + XtreamConfig config = configStore.get(); + if (!config.isConfigured()) { + writeJson(exchange, 400, errorJson("Config is not complete.")); + return; + } + + String type = query.getOrDefault("type", "").toLowerCase(Locale.ROOT); + String id = query.getOrDefault("id", ""); + if (id.isBlank()) { + writeJson(exchange, 400, errorJson("Missing stream id.")); + return; + } + + String extension = query.getOrDefault("ext", ""); + String streamUrl = switch (type) { + case "live" -> buildLiveUrl(config, id, extension); + case "vod" -> buildVodUrl(config, id, extension); + case "series" -> buildSeriesUrl(config, id, extension); + default -> null; + }; + + if (streamUrl == null) { + writeJson(exchange, 400, errorJson("Unsupported stream type.")); + return; + } + LOGGER.debug("Resolved stream URL. type={} id={} ext={}", type, id, extension); + writeJson(exchange, 200, "{\"url\":\"" + jsonEscape(streamUrl) + "\"}"); + } + } + + private record OpenInPlayerHandler(ConfigStore configStore) implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + Map query = parseKeyValue(exchange.getRequestURI().getRawQuery()); + logApiRequest(exchange, "/api/open-in-player", query); + if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { + methodNotAllowed(exchange, "GET"); + return; + } + + String streamUrl; + String directUrl = query.getOrDefault("url", "").trim(); + if (!directUrl.isBlank()) { + if (!directUrl.startsWith("http://") && !directUrl.startsWith("https://")) { + writeJson(exchange, 400, errorJson("Unsupported URL protocol.")); + return; + } + streamUrl = directUrl; + } else { + XtreamConfig config = configStore.get(); + if (!config.isConfigured()) { + writeJson(exchange, 400, errorJson("Config is not complete.")); + return; + } + String type = query.getOrDefault("type", "").toLowerCase(Locale.ROOT); + String id = query.getOrDefault("id", "").trim(); + if (id.isBlank()) { + writeJson(exchange, 400, errorJson("Missing stream id.")); + return; + } + String extension = query.getOrDefault("ext", ""); + streamUrl = switch (type) { + case "live" -> buildLiveUrl(config, id, extension); + case "vod" -> buildVodUrl(config, id, extension); + case "series" -> buildSeriesUrl(config, id, extension); + default -> null; + }; + if (streamUrl == null) { + writeJson(exchange, 400, errorJson("Unsupported stream type.")); + return; + } + } + + String title = query.getOrDefault("title", "Xtream Stream") + .replace("\r", " ") + .replace("\n", " ") + .trim(); + if (title.isBlank()) { + title = "Xtream Stream"; + } + String safeFileName = title.replaceAll("[^A-Za-z0-9._-]+", "_"); + if (safeFileName.isBlank()) { + safeFileName = "xtream-stream"; + } + String m3u = "#EXTM3U\n#EXTINF:-1," + title + "\n" + streamUrl + "\n"; + exchange.getResponseHeaders().set("Content-Type", "application/x-mpegURL; charset=utf-8"); + exchange.getResponseHeaders().set("Content-Disposition", "attachment; filename=\"" + safeFileName + ".m3u\""); + writeBytes(exchange, 200, m3u.getBytes(StandardCharsets.UTF_8), "application/x-mpegURL; charset=utf-8"); + } + } + + private record LibraryLoadHandler(XtreamLibraryService libraryService) implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + Map query = parseKeyValue(exchange.getRequestURI().getRawQuery()); + logApiRequest(exchange, "/api/library/load", query); + if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) { + methodNotAllowed(exchange, "POST"); + return; + } + try { + String step = query.getOrDefault("step", "").trim(); + Object payload; + if (step.isBlank()) { + LOGGER.info("Library load started for all steps"); + payload = libraryService.loadAll(); + } else { + LOGGER.info("Library load started for step={}", step); + payload = libraryService.loadStep(step); + } + writeJsonObject(exchange, 200, payload); + } catch (IllegalArgumentException exception) { + writeJson(exchange, 400, errorJson(exception.getMessage())); + } catch (Exception exception) { + LOGGER.error("Library load failed", exception); + writeJson(exchange, 500, errorJson("Library load failed: " + exception.getMessage())); + } + } + } + + private record LibraryStatusHandler(XtreamLibraryService libraryService) implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + logApiRequest(exchange, "/api/library/status", parseKeyValue(exchange.getRequestURI().getRawQuery())); + if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { + methodNotAllowed(exchange, "GET"); + return; + } + try { + writeJsonObject(exchange, 200, libraryService.status()); + } catch (Exception exception) { + LOGGER.error("Library status failed", exception); + writeJson(exchange, 500, errorJson("Library status failed: " + exception.getMessage())); + } + } + } + + private record LibraryCategoriesHandler(XtreamLibraryService libraryService) implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + Map query = parseKeyValue(exchange.getRequestURI().getRawQuery()); + logApiRequest(exchange, "/api/library/categories", query); + if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { + methodNotAllowed(exchange, "GET"); + return; + } + try { + String type = query.getOrDefault("type", ""); + Map out = new LinkedHashMap<>(); + out.put("items", libraryService.listCategories(type)); + writeJsonObject(exchange, 200, out); + } catch (IllegalArgumentException exception) { + writeJson(exchange, 400, errorJson(exception.getMessage())); + } catch (Exception exception) { + LOGGER.error("Library categories failed", exception); + writeJson(exchange, 500, errorJson("Library categories failed: " + exception.getMessage())); + } + } + } + + private record LibraryItemsHandler(XtreamLibraryService libraryService) implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + Map query = parseKeyValue(exchange.getRequestURI().getRawQuery()); + logApiRequest(exchange, "/api/library/items", query); + if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { + methodNotAllowed(exchange, "GET"); + return; + } + try { + String type = query.getOrDefault("type", ""); + String categoryId = query.getOrDefault("category_id", ""); + String search = query.getOrDefault("search", ""); + + Map out = new LinkedHashMap<>(); + out.put("items", libraryService.listItems(type, categoryId, search)); + writeJsonObject(exchange, 200, out); + } catch (IllegalArgumentException exception) { + writeJson(exchange, 400, errorJson(exception.getMessage())); + } catch (Exception exception) { + LOGGER.error("Library items failed", exception); + writeJson(exchange, 500, errorJson("Library items failed: " + exception.getMessage())); + } + } + } + + private record LibrarySeriesEpisodesHandler(XtreamLibraryService libraryService) implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + Map query = parseKeyValue(exchange.getRequestURI().getRawQuery()); + logApiRequest(exchange, "/api/library/series-episodes", query); + if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { + methodNotAllowed(exchange, "GET"); + return; + } + try { + String seriesId = query.getOrDefault("series_id", ""); + Map out = new LinkedHashMap<>(); + out.put("items", libraryService.listSeriesEpisodes(seriesId)); + writeJsonObject(exchange, 200, out); + } catch (IllegalArgumentException exception) { + writeJson(exchange, 400, errorJson(exception.getMessage())); + } catch (Exception exception) { + LOGGER.error("Series episodes failed", exception); + writeJson(exchange, 500, errorJson("Series episodes failed: " + exception.getMessage())); + } + } + } + + private record LibraryEpgHandler(XtreamLibraryService libraryService) implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + Map query = parseKeyValue(exchange.getRequestURI().getRawQuery()); + logApiRequest(exchange, "/api/library/epg", query); + if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { + methodNotAllowed(exchange, "GET"); + return; + } + try { + String streamId = query.getOrDefault("stream_id", ""); + int limit = 20; + if (query.containsKey("limit")) { + try { + limit = Integer.parseInt(query.get("limit")); + } catch (NumberFormatException ignored) { + limit = 20; + } + } + writeJsonObject(exchange, 200, libraryService.getShortEpg(streamId, limit)); + } catch (IllegalArgumentException exception) { + writeJson(exchange, 400, errorJson(exception.getMessage())); + } catch (Exception exception) { + LOGGER.error("EPG request failed", exception); + writeJson(exchange, 500, errorJson("EPG failed: " + exception.getMessage())); + } + } + } + + private record FavoritesHandler(XtreamLibraryService libraryService) implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + Map query = parseKeyValue(exchange.getRequestURI().getRawQuery()); + String method = exchange.getRequestMethod(); + logApiRequest(exchange, "/api/favorites", query); + + try { + if ("GET".equalsIgnoreCase(method)) { + Map out = new LinkedHashMap<>(); + out.put("items", libraryService.listFavorites()); + writeJsonObject(exchange, 200, out); + return; + } + if ("POST".equalsIgnoreCase(method)) { + String body = readBody(exchange); + @SuppressWarnings("unchecked") + Map payload = body == null || body.isBlank() + ? Map.of() + : OBJECT_MAPPER.readValue(body, Map.class); + + LibraryRepository.FavoriteRow saved = libraryService.saveFavorite( + asString(payload.get("key")), + asString(payload.get("mode")), + asString(payload.get("id")), + asString(payload.get("ext")), + asString(payload.get("title")), + asString(payload.get("categoryId")), + asString(payload.get("seriesId")), + asString(payload.get("season")), + asString(payload.get("episode")), + asString(payload.get("url")), + asLong(payload.get("createdAt")) + ); + Map out = new LinkedHashMap<>(); + out.put("item", saved); + writeJsonObject(exchange, 200, out); + return; + } + if ("DELETE".equalsIgnoreCase(method)) { + String key = query.getOrDefault("key", ""); + boolean deleted = libraryService.deleteFavorite(key); + Map out = new LinkedHashMap<>(); + out.put("deleted", deleted); + out.put("key", key); + writeJsonObject(exchange, 200, out); + return; + } + methodNotAllowed(exchange, "GET, POST, DELETE"); + } catch (IllegalArgumentException exception) { + writeJson(exchange, 400, errorJson(exception.getMessage())); + } catch (Exception exception) { + LOGGER.error("Favorites endpoint failed", exception); + writeJson(exchange, 500, errorJson("Favorites API failed: " + exception.getMessage())); + } + } + } + + private static final class StaticHandler implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { + methodNotAllowed(exchange, "GET"); + return; + } + + String path = exchange.getRequestURI().getPath(); + String resourcePath; + + if ("/".equals(path) || "/index.html".equals(path)) { + resourcePath = "/web/index.html"; + } else if (path.startsWith("/assets/")) { + resourcePath = "/web" + path; + } else { + resourcePath = "/web/index.html"; + } + + if (resourcePath.contains("..")) { + writeJson(exchange, 400, errorJson("Invalid path")); + return; + } + + try (InputStream inputStream = XtreamPlayerApplication.class.getResourceAsStream(resourcePath)) { + if (inputStream == null) { + writeJson(exchange, 404, errorJson("Not found")); + return; + } + LOGGER.debug("Serving static resource={}", resourcePath); + byte[] body = inputStream.readAllBytes(); + exchange.getResponseHeaders().set("Content-Type", contentType(resourcePath)); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + } finally { + exchange.close(); + } + } + } + + private static void proxyRequest(HttpExchange exchange, URI uri) throws IOException { + List attempts = candidateUris(uri); + List errors = new ArrayList<>(); + + for (URI candidate : attempts) { + long startedAt = System.nanoTime(); + try { + LOGGER.info("Upstream request started uri={}", maskUri(candidate)); + HttpResponse response = sendUpstream(candidate); + String contentType = response.headers() + .firstValue("Content-Type") + .orElse("application/json; charset=utf-8"); + exchange.getResponseHeaders().set("Content-Type", contentType); + byte[] body = response.body(); + exchange.sendResponseHeaders(response.statusCode(), body.length); + exchange.getResponseBody().write(body); + exchange.close(); + LOGGER.info( + "Upstream response uri={} status={} bytes={} contentType={} latencyMs={}", + maskUri(candidate), + response.statusCode(), + body.length, + contentType, + elapsedMillis(startedAt) + ); + return; + } catch (InterruptedException interruptedException) { + Thread.currentThread().interrupt(); + LOGGER.warn("Upstream request interrupted uri={} latencyMs={}", maskUri(candidate), elapsedMillis(startedAt), interruptedException); + writeJson(exchange, 500, errorJson("Upstream request interrupted.")); + return; + } catch (Exception exception) { + LOGGER.warn("Upstream request failed uri={} latencyMs={}", maskUri(candidate), elapsedMillis(startedAt), exception); + errors.add(maskUri(candidate) + " -> " + compactError(exception)); + } + } + + LOGGER.error("All proxy attempts failed for uri={}. attempts={}", maskUri(uri), errors.size()); + writeJson(exchange, 502, errorJson("Upstream request failed. Attempts: " + String.join(" | ", errors))); + } + + private static HttpResponse sendUpstream(URI uri) throws IOException, InterruptedException { + HttpRequest request = HttpRequest.newBuilder(uri) + .GET() + .timeout(Duration.ofSeconds(25)) + .header("User-Agent", "XtreamPlayer/1.0") + .header("Accept", "application/json,text/plain,*/*") + .build(); + return HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofByteArray()); + } + + private static List candidateUris(URI uri) { + LinkedHashSet candidates = new LinkedHashSet<>(); + candidates.add(uri); + URI switched = switchScheme(uri); + if (switched != null) { + candidates.add(switched); + } + return List.copyOf(candidates); + } + + private static URI switchScheme(URI uri) { + String scheme = uri.getScheme(); + if (scheme == null) { + return null; + } + String replacement; + if ("http".equalsIgnoreCase(scheme)) { + replacement = "https"; + } else if ("https".equalsIgnoreCase(scheme)) { + replacement = "http"; + } else { + return null; + } + return URI.create(replacement + "://" + uri.getRawAuthority() + uri.getRawPath() + + (uri.getRawQuery() == null ? "" : "?" + uri.getRawQuery())); + } + + private static String compactError(Throwable throwable) { + Throwable current = throwable; + StringBuilder builder = new StringBuilder(); + while (current != null) { + if (!builder.isEmpty()) { + builder.append(" <- "); + } + builder.append(current.getClass().getSimpleName()); + if (current.getMessage() != null && !current.getMessage().isBlank()) { + builder.append(": ").append(current.getMessage()); + } + current = current.getCause(); + } + return builder.toString(); + } + + private static String buildLiveUrl(XtreamConfig config, String id, String extension) { + String ext = extension.isBlank() ? config.liveFormat() : extension; + if (ext.isBlank()) { + ext = "m3u8"; + } + return config.serverUrl() + "/live/" + urlEncode(config.username()) + "/" + urlEncode(config.password()) + "/" + + urlEncode(id) + "." + urlEncode(ext); + } + + private static String buildVodUrl(XtreamConfig config, String id, String extension) { + String ext = extension.isBlank() ? "mp4" : extension; + return config.serverUrl() + "/movie/" + urlEncode(config.username()) + "/" + urlEncode(config.password()) + "/" + + urlEncode(id) + "." + urlEncode(ext); + } + + private static String buildSeriesUrl(XtreamConfig config, String id, String extension) { + String ext = extension.isBlank() ? "mp4" : extension; + return config.serverUrl() + "/series/" + urlEncode(config.username()) + "/" + urlEncode(config.password()) + "/" + + urlEncode(id) + "." + urlEncode(ext); + } + + private static URI buildPlayerApiUri(XtreamConfig config, Map params) { + Objects.requireNonNull(config, "config must not be null"); + StringBuilder query = new StringBuilder(); + query.append("username=").append(urlEncode(config.username())); + query.append("&password=").append(urlEncode(config.password())); + for (Map.Entry entry : params.entrySet()) { + if (entry.getKey() == null || entry.getKey().isBlank()) { + continue; + } + query.append("&") + .append(urlEncode(entry.getKey())) + .append("=") + .append(urlEncode(entry.getValue() == null ? "" : entry.getValue())); + } + return URI.create(config.serverUrl() + "/player_api.php?" + query); + } + + private static Map parseKeyValue(String raw) { + Map result = new LinkedHashMap<>(); + if (raw == null || raw.isBlank()) { + return result; + } + for (String part : raw.split("&")) { + if (part.isBlank()) { + continue; + } + String[] pieces = part.split("=", 2); + String key = urlDecode(pieces[0]); + String value = pieces.length > 1 ? urlDecode(pieces[1]) : ""; + result.put(key, value); + } + return result; + } + + private static String readBody(HttpExchange exchange) throws IOException { + try (InputStream inputStream = exchange.getRequestBody()) { + return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + } + } + + private static String asString(Object value) { + return value == null ? "" : String.valueOf(value); + } + + private static Long asLong(Object value) { + if (value == null) { + return null; + } + if (value instanceof Number number) { + return number.longValue(); + } + try { + return Long.parseLong(String.valueOf(value).trim()); + } catch (NumberFormatException ignored) { + return null; + } + } + + private static String configToJson(XtreamConfig config) { + return "{" + + "\"serverUrl\":\"" + jsonEscape(config.serverUrl()) + "\"," + + "\"username\":\"" + jsonEscape(config.username()) + "\"," + + "\"password\":\"" + jsonEscape(config.password()) + "\"," + + "\"liveFormat\":\"" + jsonEscape(config.liveFormat()) + "\"," + + "\"configured\":" + config.isConfigured() + + "}"; + } + + private static String errorJson(String message) { + return "{\"error\":\"" + jsonEscape(message) + "\"}"; + } + + private static void methodNotAllowed(HttpExchange exchange, String allow) throws IOException { + exchange.getResponseHeaders().set("Allow", allow); + writeJson(exchange, 405, errorJson("Method not allowed.")); + } + + private static void writeJson(HttpExchange exchange, int statusCode, String json) throws IOException { + byte[] bytes = json.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8"); + writeBytes(exchange, statusCode, bytes, "application/json; charset=utf-8"); + } + + private static void writeJsonObject(HttpExchange exchange, int statusCode, Object payload) throws IOException { + byte[] bytes = OBJECT_MAPPER.writeValueAsBytes(payload); + exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8"); + writeBytes(exchange, statusCode, bytes, "application/json; charset=utf-8"); + } + + private static void writeBytes(HttpExchange exchange, int statusCode, byte[] bytes, String contentType) throws IOException { + exchange.sendResponseHeaders(statusCode, bytes.length); + exchange.getResponseBody().write(bytes); + logApiResponse(exchange, statusCode, bytes.length, contentType); + exchange.close(); + } + + private static String contentType(String resourcePath) { + if (resourcePath.endsWith(".html")) { + return "text/html; charset=utf-8"; + } + if (resourcePath.endsWith(".css")) { + return "text/css; charset=utf-8"; + } + if (resourcePath.endsWith(".js")) { + return "application/javascript; charset=utf-8"; + } + if (resourcePath.endsWith(".svg")) { + return "image/svg+xml"; + } + if (resourcePath.endsWith(".png")) { + return "image/png"; + } + return "application/octet-stream"; + } + + private static String urlDecode(String value) { + return URLDecoder.decode(value, StandardCharsets.UTF_8); + } + + private static String urlEncode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + private static void logApiRequest(HttpExchange exchange, String endpoint, Map params) { + exchange.setAttribute(ATTR_REQ_START_NANOS, System.nanoTime()); + Map merged = new LinkedHashMap<>(parseKeyValue(exchange.getRequestURI().getRawQuery())); + if (params != null && !params.isEmpty()) { + merged.putAll(params); + } + LOGGER.info( + "API request method={} endpoint={} params={}", + exchange.getRequestMethod(), + endpoint, + maskSensitive(merged) + ); + } + + private static void logApiResponse(HttpExchange exchange, int statusCode, int bytes, String contentType) { + String path = exchange.getRequestURI().getPath(); + if (path == null || !path.startsWith("/api/")) { + return; + } + Map query = parseKeyValue(exchange.getRequestURI().getRawQuery()); + Long startedAt = (Long) exchange.getAttribute(ATTR_REQ_START_NANOS); + long latencyMs = startedAt == null ? -1L : elapsedMillis(startedAt); + LOGGER.info( + "API response method={} endpoint={} status={} bytes={} contentType={} latencyMs={} params={}", + exchange.getRequestMethod(), + path, + statusCode, + bytes, + contentType, + latencyMs, + maskSensitive(query) + ); + } + + private static long elapsedMillis(long startedAtNanos) { + return (System.nanoTime() - startedAtNanos) / 1_000_000L; + } + + private static String maskUri(URI uri) { + Map params = parseKeyValue(uri.getRawQuery()); + String query = mapToCompactString(maskSensitive(params)); + String base = uri.getScheme() + "://" + uri.getRawAuthority() + uri.getRawPath(); + return query.isBlank() ? base : base + "?" + query; + } + + private static Map maskSensitive(Map params) { + Map masked = new LinkedHashMap<>(); + for (Map.Entry entry : params.entrySet()) { + String key = entry.getKey() == null ? "" : entry.getKey(); + if (SENSITIVE_KEYS.contains(key.toLowerCase(Locale.ROOT))) { + masked.put(key, "***"); + } else { + masked.put(key, entry.getValue()); + } + } + return masked; + } + + private static String mapToCompactString(Map map) { + if (map == null || map.isEmpty()) { + return ""; + } + StringBuilder out = new StringBuilder(); + for (Map.Entry entry : map.entrySet()) { + if (!out.isEmpty()) { + out.append("&"); + } + out.append(entry.getKey()).append("=").append(entry.getValue() == null ? "" : entry.getValue()); + } + return out.toString(); + } + + private static String jsonEscape(String value) { + if (value == null) { + return ""; + } + StringBuilder escaped = new StringBuilder(value.length() + 16); + for (char ch : value.toCharArray()) { + switch (ch) { + case '"' -> escaped.append("\\\""); + case '\\' -> escaped.append("\\\\"); + case '\b' -> escaped.append("\\b"); + case '\f' -> escaped.append("\\f"); + case '\n' -> escaped.append("\\n"); + case '\r' -> escaped.append("\\r"); + case '\t' -> escaped.append("\\t"); + default -> { + if (ch < 0x20) { + escaped.append(String.format("\\u%04x", (int) ch)); + } else { + escaped.append(ch); + } + } + } + } + return escaped.toString(); + } +} diff --git a/src/main/resources/log4j2.xml b/src/main/resources/log4j2.xml new file mode 100644 index 0000000..78e70ec --- /dev/null +++ b/src/main/resources/log4j2.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/main/resources/web/assets/app.js b/src/main/resources/web/assets/app.js new file mode 100644 index 0000000..f0d7987 --- /dev/null +++ b/src/main/resources/web/assets/app.js @@ -0,0 +1,2182 @@ +(() => { + let hlsInstance = null; + let embeddedSubtitleScanTimer = null; + let hlsSubtitleTracks = []; + + const state = { + config: null, + libraryStatus: null, + liveStreams: [], + liveCategories: [], + vodStreams: [], + vodCategories: [], + seriesItems: [], + seriesCategories: [], + expandedSeriesId: null, + seriesEpisodesById: {}, + expandedSeasonBySeries: {}, + customStreams: [], + favorites: [], + expandedFavoriteSeriesById: {}, + favoriteSeriesEpisodesById: {}, + expandedSeasonByFavoriteSeries: {}, + expandedFavoriteLiveCategoryById: {}, + favoriteLiveCategoryStreamsById: {}, + expandedFavoriteVodCategoryById: {}, + favoriteVodCategoryStreamsById: {}, + currentLiveEpgStreamId: null, + currentStreamInfo: null + }; + + const customStorageKey = "xtream_custom_streams_v1"; + + const el = { + tabs: document.getElementById("tabs"), + panels: Array.from(document.querySelectorAll(".tab-panel")), + globalStatus: document.getElementById("global-status"), + configForm: document.getElementById("config-form"), + settingsMessage: document.getElementById("settings-message"), + testLogin: document.getElementById("test-login"), + loadSources: document.getElementById("load-sources"), + sourcesProgress: document.getElementById("sources-progress"), + sourcesProgressText: document.getElementById("sources-progress-text"), + serverUrl: document.getElementById("server-url"), + username: document.getElementById("username"), + password: document.getElementById("password"), + liveFormat: document.getElementById("live-format"), + + liveCategory: document.getElementById("live-category"), + liveSearch: document.getElementById("live-search"), + liveRefresh: document.getElementById("live-refresh"), + liveFavoriteCategory: document.getElementById("live-favorite-category"), + liveList: document.getElementById("live-list"), + + vodCategory: document.getElementById("vod-category"), + vodSearch: document.getElementById("vod-search"), + vodRefresh: document.getElementById("vod-refresh"), + vodFavoriteCategory: document.getElementById("vod-favorite-category"), + vodList: document.getElementById("vod-list"), + + seriesCategory: document.getElementById("series-category"), + seriesSearch: document.getElementById("series-search"), + seriesRefresh: document.getElementById("series-refresh"), + seriesList: document.getElementById("series-list"), + + customForm: document.getElementById("custom-form"), + customName: document.getElementById("custom-name"), + customUrl: document.getElementById("custom-url"), + customSearch: document.getElementById("custom-search"), + customList: document.getElementById("custom-list"), + favoritesSearch: document.getElementById("favorites-search"), + favoritesList: document.getElementById("favorites-list"), + + playerTitle: document.getElementById("player-title"), + player: document.getElementById("player"), + openDirect: document.getElementById("open-direct"), + openSystemPlayer: document.getElementById("open-system-player"), + subtitleUrl: document.getElementById("subtitle-url"), + subtitleLang: document.getElementById("subtitle-lang"), + embeddedSubtitleTrack: document.getElementById("embedded-subtitle-track"), + loadSubtitle: document.getElementById("load-subtitle"), + clearSubtitle: document.getElementById("clear-subtitle"), + subtitleStatus: document.getElementById("subtitle-status"), + streamInfo: document.getElementById("stream-info"), + refreshEpg: document.getElementById("refresh-epg"), + epgList: document.getElementById("epg-list") + }; + + init().catch((error) => { + setSettingsMessage(error.message || String(error), "err"); + }); + + async function init() { + bindTabs(); + bindConfigForm(); + bindLiveTab(); + bindVodTab(); + bindSeriesTab(); + bindCustomTab(); + bindFavoritesTab(); + bindSubtitleControls(); + bindPlayerEvents(); + bindPlayerActionButtons(); + + loadCustomStreams(); + await loadFavorites(); + renderCustomStreams(); + renderFavorites(); + renderStreamInfo(); + updateLiveCategoryFavoriteButton(); + updateVodCategoryFavoriteButton(); + await loadConfig(); + } + + function bindPlayerEvents() { + el.player.addEventListener("error", () => { + setSettingsMessage("Playback failed in embedded player. Try Open stream directly.", "err"); + }); + el.player.addEventListener("loadedmetadata", updateStreamRuntimeInfo); + el.player.addEventListener("loadedmetadata", refreshEmbeddedSubtitleTracks); + el.player.addEventListener("loadeddata", refreshEmbeddedSubtitleTracks); + } + + function bindPlayerActionButtons() { + el.openDirect.addEventListener("click", () => { + openPlayerActionUrl(el.openDirect); + }); + el.openSystemPlayer.addEventListener("click", () => { + openPlayerActionUrl(el.openSystemPlayer); + }); + } + + function openPlayerActionUrl(button) { + const url = String(button?.dataset?.url || "").trim(); + if (!url) { + return; + } + const opened = window.open(url, "_blank", "noopener"); + if (!opened) { + setSettingsMessage("Popup was blocked by browser. Allow popups for this site.", "err"); + } + } + + function bindSubtitleControls() { + el.loadSubtitle.addEventListener("click", () => { + const url = String(el.subtitleUrl.value || "").trim(); + const lang = String(el.subtitleLang.value || "en").trim().toLowerCase(); + if (!url) { + setSubtitleStatus("Enter subtitle URL first.", true); + return; + } + if (!url.toLowerCase().includes(".vtt")) { + setSubtitleStatus("Only WebVTT (.vtt) is supported in browser player.", true); + return; + } + addSubtitleTrack(url, lang || "en"); + }); + + el.clearSubtitle.addEventListener("click", () => { + clearSubtitleTracks(); + setSubtitleStatus("No subtitle loaded.", false); + }); + el.embeddedSubtitleTrack.addEventListener("change", () => { + selectEmbeddedSubtitleTrack(el.embeddedSubtitleTrack.value); + }); + } + + function bindTabs() { + el.tabs.addEventListener("click", (event) => { + const target = event.target.closest("button[data-tab]"); + if (!target) { + return; + } + const tab = target.dataset.tab; + switchTab(tab); + }); + } + + function switchTab(tabName) { + const tabButtons = Array.from(el.tabs.querySelectorAll("button[data-tab]")); + tabButtons.forEach((button) => button.classList.toggle("active", button.dataset.tab === tabName)); + el.panels.forEach((panel) => panel.classList.toggle("active", panel.dataset.panel === tabName)); + + if (tabName === "favorites") { + renderFavorites(); + } + if (!state.config?.configured || !state.libraryStatus?.ready) { + return; + } + + if (tabName === "live" && state.liveStreams.length === 0) { + loadLiveData().catch(showError); + } + if (tabName === "vod" && state.vodStreams.length === 0) { + loadVodData().catch(showError); + } + if (tabName === "series" && state.seriesItems.length === 0) { + loadSeriesData().catch(showError); + } + } + + function bindConfigForm() { + el.configForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const form = new URLSearchParams(new FormData(el.configForm)); + try { + const config = await apiJson("/api/config", { + method: "POST", + headers: {"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8"}, + body: form.toString() + }); + state.config = config; + clearLibraryState(); + refreshConfigUi(); + await refreshLibraryStatus(); + + if (state.libraryStatus?.ready) { + await loadAllLibraryData(); + setSettingsMessage("Settings saved. Using local H2 data.", "ok"); + } else { + renderAllFromState(); + setSettingsMessage("Settings saved. Load sources into the local DB.", ""); + } + } catch (error) { + setSettingsMessage(error.message, "err"); + } + }); + + el.testLogin.addEventListener("click", async () => { + try { + const data = await apiJson("/api/test-login"); + const authValue = String(data?.user_info?.auth ?? ""); + if (authValue === "1") { + setSettingsMessage("Login is valid (auth=1).", "ok"); + } else { + setSettingsMessage("Server responded, but auth is not 1. Check your credentials.", "err"); + } + } catch (error) { + setSettingsMessage(`Login failed: ${error.message}`, "err"); + } + }); + + el.loadSources.addEventListener("click", () => { + preloadSourcesWithProgress().catch((error) => { + setSettingsMessage(`Source loading failed: ${error.message}`, "err"); + updateProgress(0, "Source loading failed."); + }); + }); + } + + function bindLiveTab() { + el.liveCategory.addEventListener("change", () => { + loadLiveStreams().catch(showError); + updateLiveCategoryFavoriteButton(); + }); + el.liveSearch.addEventListener("input", renderLiveStreams); + el.liveRefresh.addEventListener("click", () => loadLiveData().catch(showError)); + el.liveFavoriteCategory.addEventListener("click", () => { + const favorite = selectedLiveCategoryFavorite(); + if (!favorite) { + setSettingsMessage("Select a live category first.", "err"); + return; + } + toggleFavorite(favorite).catch(showError); + }); + el.refreshEpg.addEventListener("click", () => refreshEpg().catch(showError)); + } + + function bindVodTab() { + el.vodCategory.addEventListener("change", () => { + loadVodStreams().catch(showError); + updateVodCategoryFavoriteButton(); + }); + el.vodSearch.addEventListener("input", renderVodStreams); + el.vodRefresh.addEventListener("click", () => loadVodData().catch(showError)); + el.vodFavoriteCategory.addEventListener("click", () => { + const favorite = selectedVodCategoryFavorite(); + if (!favorite) { + setSettingsMessage("Select a VOD category first.", "err"); + return; + } + toggleFavorite(favorite).catch(showError); + }); + } + + function bindSeriesTab() { + el.seriesCategory.addEventListener("change", () => loadSeriesList().catch(showError)); + el.seriesSearch.addEventListener("input", renderSeriesList); + el.seriesRefresh.addEventListener("click", () => loadSeriesData().catch(showError)); + } + + function bindCustomTab() { + el.customForm.addEventListener("submit", (event) => { + event.preventDefault(); + const name = el.customName.value.trim(); + const url = el.customUrl.value.trim(); + if (!name || !url) { + return; + } + state.customStreams.push({ + id: String(Date.now()), + name, + url + }); + persistCustomStreams(); + renderCustomStreams(); + el.customForm.reset(); + }); + el.customSearch.addEventListener("input", renderCustomStreams); + } + + function bindFavoritesTab() { + el.favoritesSearch.addEventListener("input", renderFavorites); + } + + async function loadConfig() { + const config = await apiJson("/api/config"); + state.config = config; + refreshConfigUi(); + + if (!config.configured) { + updateProgress(0, "Fill in connection settings first."); + return; + } + + await refreshLibraryStatus(); + if (state.libraryStatus?.ready) { + await loadAllLibraryData(); + } else { + clearLibraryState(); + renderAllFromState(); + } + } + + async function refreshLibraryStatus() { + if (!state.config?.configured) { + state.libraryStatus = null; + updateProgress(0, "Fill in connection settings first."); + return; + } + const status = await apiJson("/api/library/status"); + state.libraryStatus = status; + if (status.ready) { + updateProgress(100, `Local H2 sources are ready (${formatLoadedAt(status.loadedAt)}).`); + } else { + const counts = status.counts || {}; + updateProgress( + 0, + `Sources are incomplete. Live:${counts.liveStreams || 0}, VOD:${counts.vodStreams || 0}, Series:${counts.seriesItems || 0}` + ); + } + } + + async function preloadSourcesWithProgress() { + ensureConfigured(); + el.loadSources.disabled = true; + setSettingsMessage("Loading sources into local H2 DB...", ""); + + const steps = [ + {id: "live_categories", value: 8, text: "Step 1/6: live categories"}, + {id: "live_streams", value: 22, text: "Step 2/6: live streams"}, + {id: "vod_categories", value: 38, text: "Step 3/6: VOD categories"}, + {id: "vod_streams", value: 58, text: "Step 4/6: VOD streams"}, + {id: "series_categories", value: 76, text: "Step 5/6: series categories"}, + {id: "series_items", value: 92, text: "Step 6/6: series list"} + ]; + + try { + clearLibraryState(); + renderAllFromState(); + for (const step of steps) { + updateProgress(step.value, step.text); + await apiJson(`/api/library/load?step=${encodeURIComponent(step.id)}`, {method: "POST"}); + } + await refreshLibraryStatus(); + await loadAllLibraryData(); + updateProgress(100, `Done. Sources saved in H2 (${new Date().toLocaleString("en-US")}).`); + setSettingsMessage("Sources were loaded and saved to the local H2 database.", "ok"); + } finally { + el.loadSources.disabled = false; + } + } + + async function loadAllLibraryData() { + await Promise.all([loadLiveData(), loadVodData(), loadSeriesData()]); + } + + function refreshConfigUi() { + const config = state.config || {}; + el.serverUrl.value = config.serverUrl || ""; + el.username.value = config.username || ""; + el.password.value = config.password || ""; + el.liveFormat.value = (config.liveFormat || "m3u8").toLowerCase(); + el.globalStatus.textContent = config.configured ? "Connected" : "Not configured"; + } + + function clearLibraryState() { + state.liveCategories = []; + state.liveStreams = []; + state.vodCategories = []; + state.vodStreams = []; + state.seriesCategories = []; + state.seriesItems = []; + state.expandedSeriesId = null; + state.seriesEpisodesById = {}; + state.expandedSeasonBySeries = {}; + state.expandedFavoriteSeriesById = {}; + state.favoriteSeriesEpisodesById = {}; + state.expandedSeasonByFavoriteSeries = {}; + state.expandedFavoriteLiveCategoryById = {}; + state.favoriteLiveCategoryStreamsById = {}; + state.expandedFavoriteVodCategoryById = {}; + state.favoriteVodCategoryStreamsById = {}; + } + + async function loadLiveData() { + ensureLibraryReady(); + const categoriesPayload = await apiJson("/api/library/categories?type=live"); + state.liveCategories = sanitizeCategories(categoriesPayload.items); + fillCategorySelect(el.liveCategory, state.liveCategories, "All live"); + updateLiveCategoryFavoriteButton(); + await loadLiveStreams(); + } + + async function loadLiveStreams() { + ensureLibraryReady(); + const query = new URLSearchParams({type: "live"}); + if (el.liveCategory.value) { + query.set("category_id", el.liveCategory.value); + } + const payload = await apiJson(`/api/library/items?${query.toString()}`); + state.liveStreams = sanitizeLiveStreams(payload.items); + renderLiveStreams(); + } + + function selectedLiveCategoryFavorite() { + const categoryId = String(el.liveCategory.value || "").trim(); + if (!categoryId) { + return null; + } + const category = state.liveCategories.find((item) => String(item?.category_id || "") === categoryId); + if (!category) { + return null; + } + return makeFavoriteLiveCategory(category); + } + + function updateLiveCategoryFavoriteButton() { + const favorite = selectedLiveCategoryFavorite(); + if (!favorite) { + el.liveFavoriteCategory.disabled = true; + el.liveFavoriteCategory.textContent = "☆"; + el.liveFavoriteCategory.title = "Select category"; + el.liveFavoriteCategory.setAttribute("aria-label", "Select category"); + return; + } + el.liveFavoriteCategory.disabled = false; + const active = isFavorite(favorite.key); + el.liveFavoriteCategory.textContent = favoriteIcon(active); + const label = active ? "Remove category from favorites" : "Add category to favorites"; + el.liveFavoriteCategory.title = label; + 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); + }); + + if (filtered.length === 0) { + el.liveList.innerHTML = `
  • No live stream found.
  • `; + return; + } + + el.liveList.innerHTML = ""; + filtered.forEach((item) => { + const li = document.createElement("li"); + li.className = "stream-item"; + const streamId = String(item.stream_id || ""); + const epgStreamId = String(item.epg_channel_id || streamId); + const favorite = makeFavoriteLive(item); + li.innerHTML = ` +
    + +
    ID: ${esc(streamId)} | Category: ${esc(item.category_id || "-")}
    +
    +
    + +
    + `; + li.querySelector("button[data-action='play-title']").addEventListener("click", () => { + playXtream( + "live", + streamId, + state.config.liveFormat || "m3u8", + item.name || "Live stream", + epgStreamId, + {categoryId: item.category_id} + ).catch(showError); + }); + li.querySelector("button[data-action='toggle-favorite']").addEventListener("click", () => { + toggleFavorite(favorite).then(() => renderLiveStreams()).catch(showError); + }); + el.liveList.appendChild(li); + }); + } + + async function loadVodData() { + ensureLibraryReady(); + const categoriesPayload = await apiJson("/api/library/categories?type=vod"); + state.vodCategories = sanitizeCategories(categoriesPayload.items); + fillCategorySelect(el.vodCategory, state.vodCategories, "All VOD"); + updateVodCategoryFavoriteButton(); + await loadVodStreams(); + } + + function selectedVodCategoryFavorite() { + const categoryId = String(el.vodCategory.value || "").trim(); + if (!categoryId) { + return null; + } + const category = state.vodCategories.find((item) => String(item?.category_id || "") === categoryId); + if (!category) { + return null; + } + return makeFavoriteVodCategory(category); + } + + function updateVodCategoryFavoriteButton() { + const favorite = selectedVodCategoryFavorite(); + if (!favorite) { + el.vodFavoriteCategory.disabled = true; + el.vodFavoriteCategory.textContent = "☆"; + el.vodFavoriteCategory.title = "Select category"; + el.vodFavoriteCategory.setAttribute("aria-label", "Select category"); + return; + } + el.vodFavoriteCategory.disabled = false; + const active = isFavorite(favorite.key); + el.vodFavoriteCategory.textContent = favoriteIcon(active); + const label = active ? "Remove category from favorites" : "Add category to favorites"; + el.vodFavoriteCategory.title = label; + el.vodFavoriteCategory.setAttribute("aria-label", label); + } + + async function loadVodStreams() { + ensureLibraryReady(); + const query = new URLSearchParams({type: "vod"}); + if (el.vodCategory.value) { + query.set("category_id", el.vodCategory.value); + } + 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); + }); + + if (filtered.length === 0) { + el.vodList.innerHTML = `
  • No VOD stream found.
  • `; + return; + } + + el.vodList.innerHTML = ""; + filtered.forEach((item) => { + const li = document.createElement("li"); + li.className = "stream-item"; + const streamId = String(item.stream_id || ""); + const ext = String(item.container_extension || "mp4"); + const favorite = makeFavoriteVod(item); + li.innerHTML = ` +
    + +
    ID: ${esc(streamId)} | Ext: ${esc(ext)}
    +
    +
    + +
    + `; + li.querySelector("button[data-action='play-title']").addEventListener("click", () => { + playXtream("vod", streamId, ext, item.name || "VOD", null, {categoryId: item.category_id}) + .catch(showError); + }); + li.querySelector("button[data-action='toggle-favorite']").addEventListener("click", () => { + toggleFavorite(favorite).then(() => renderVodStreams()).catch(showError); + }); + el.vodList.appendChild(li); + }); + } + + async function loadSeriesData() { + ensureLibraryReady(); + const categoriesPayload = await apiJson("/api/library/categories?type=series"); + state.seriesCategories = sanitizeCategories(categoriesPayload.items); + fillCategorySelect(el.seriesCategory, state.seriesCategories, "All series"); + await loadSeriesList(); + } + + async function loadSeriesList() { + ensureLibraryReady(); + const query = new URLSearchParams({type: "series"}); + if (el.seriesCategory.value) { + query.set("category_id", el.seriesCategory.value); + } + const payload = await apiJson(`/api/library/items?${query.toString()}`); + state.seriesItems = sanitizeSeriesItems(payload.items); + if (state.expandedSeriesId + && !state.seriesItems.some((item) => String(item.series_id || "") === state.expandedSeriesId)) { + state.expandedSeriesId = null; + state.expandedSeasonBySeries = {}; + } + 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); + }); + + if (filtered.length === 0) { + el.seriesList.innerHTML = `
  • No series found.
  • `; + return; + } + + el.seriesList.innerHTML = ""; + filtered.forEach((item) => { + const li = document.createElement("li"); + li.className = "stream-item"; + const seriesId = String(item.series_id || ""); + const favorite = makeFavoriteSeriesItem(item); + li.innerHTML = ` +
    +
    ${esc(item.name || "Untitled")}
    +
    Series ID: ${esc(seriesId)}
    +
    +
    + + +
    + `; + li.querySelector("button[data-action='episodes']").addEventListener("click", () => { + toggleSeriesEpisodes(item).catch(showError); + }); + li.querySelector("button[data-action='toggle-favorite']").addEventListener("click", () => { + toggleFavorite(favorite).then(() => renderSeriesList()).catch(showError); + }); + el.seriesList.appendChild(li); + + if (state.expandedSeriesId === seriesId) { + const episodesEntry = state.seriesEpisodesById[seriesId]; + const episodesLi = document.createElement("li"); + episodesLi.className = "card episodes series-inline-episodes"; + + if (!episodesEntry || episodesEntry.loading) { + episodesLi.innerHTML = `
    Loading episodes...
    `; + } else if (episodesEntry.error) { + episodesLi.innerHTML = `
    Unable to load episodes: ${esc(episodesEntry.error)}
    `; + } else if (!episodesEntry.episodes || episodesEntry.episodes.length === 0) { + episodesLi.innerHTML = `
    No episodes available.
    `; + } else { + const wrap = document.createElement("div"); + wrap.className = "series-inline-episodes-wrap"; + const groupedBySeason = groupEpisodesBySeason(episodesEntry.episodes); + groupedBySeason.forEach((group) => { + const isExpanded = Boolean(state.expandedSeasonBySeries?.[seriesId]?.[group.season]); + const seasonBlock = document.createElement("div"); + seasonBlock.className = "season-block"; + seasonBlock.innerHTML = ` + + `; + seasonBlock.querySelector("button[data-action='toggle-season']").addEventListener("click", () => { + toggleSeasonGroup(seriesId, group.season); + }); + + if (!isExpanded) { + wrap.appendChild(seasonBlock); + return; + } + const seasonList = document.createElement("div"); + seasonList.className = "season-list"; + group.episodes.forEach((episode) => { + const episodeFavorite = makeFavoriteSeriesEpisode(item, episode); + const row = document.createElement("div"); + row.className = "stream-item"; + row.innerHTML = ` +
    + +
    Episode ID: ${esc(episode.id)} | Ext: ${esc(episode.ext)}
    +
    +
    + +
    + `; + row.querySelector("button[data-action='play-title']").addEventListener("click", () => { + playXtream("series", episode.id, episode.ext, `${item.name} - ${episode.title}`, null, { + seriesId, + season: episode.season, + episode: episode.episodeNum + }).catch(showError); + }); + row.querySelector("button[data-action='toggle-favorite']").addEventListener("click", () => { + toggleFavorite(episodeFavorite).then(() => renderSeriesList()).catch(showError); + }); + seasonList.appendChild(row); + }); + seasonBlock.appendChild(seasonList); + wrap.appendChild(seasonBlock); + }); + episodesLi.appendChild(wrap); + } + + el.seriesList.appendChild(episodesLi); + } + }); + } + + async function toggleSeriesEpisodes(seriesItem) { + const seriesId = String(seriesItem.series_id || ""); + if (!seriesId) { + return; + } + if (state.expandedSeriesId === seriesId) { + state.expandedSeriesId = null; + delete state.expandedSeasonBySeries[seriesId]; + renderSeriesList(); + return; + } + + state.expandedSeriesId = seriesId; + state.expandedSeasonBySeries[seriesId] = {}; + renderSeriesList(); + + if (state.seriesEpisodesById[seriesId]?.loaded) { + return; + } + + state.seriesEpisodesById[seriesId] = {loading: true, loaded: false, episodes: []}; + renderSeriesList(); + try { + const payload = await apiJson(`/api/library/series-episodes?series_id=${encodeURIComponent(seriesId)}`); + const episodes = sanitizeSeriesEpisodes(payload.items); + state.seriesEpisodesById[seriesId] = { + loading: false, + loaded: true, + episodes + }; + } catch (error) { + state.seriesEpisodesById[seriesId] = { + loading: false, + loaded: false, + error: error.message || String(error), + episodes: [] + }; + throw error; + } finally { + renderSeriesList(); + } + } + + function toggleSeasonGroup(seriesId, season) { + const current = state.expandedSeasonBySeries[seriesId] || {}; + const seasonKey = String(season || "?"); + current[seasonKey] = !current[seasonKey]; + state.expandedSeasonBySeries[seriesId] = current; + renderSeriesList(); + } + + function loadCustomStreams() { + try { + const raw = localStorage.getItem(customStorageKey); + state.customStreams = raw ? JSON.parse(raw) : []; + if (!Array.isArray(state.customStreams)) { + state.customStreams = []; + } + } catch (error) { + state.customStreams = []; + } + } + + function persistCustomStreams() { + localStorage.setItem(customStorageKey, JSON.stringify(state.customStreams)); + } + + async function loadFavorites() { + const payload = await apiJson("/api/favorites"); + state.favorites = sanitizeFavorites(payload.items); + } + + function isFavorite(key) { + return state.favorites.some((item) => item?.key === key); + } + + async function toggleFavorite(favorite) { + if (!favorite || !favorite.key) { + return; + } + if (isFavorite(favorite.key)) { + await deleteFavoriteByKey(favorite.key); + } else { + const payload = { + ...favorite, + createdAt: Number(favorite?.createdAt || Date.now()) + }; + const response = await apiJson("/api/favorites", { + method: "POST", + headers: {"Content-Type": "application/json;charset=UTF-8"}, + body: JSON.stringify(payload) + }); + const savedItem = sanitizeFavorite(response?.item); + if (!savedItem) { + throw new Error("Favorite was not saved."); + } + state.favorites = state.favorites.filter((item) => item?.key !== savedItem.key); + state.favorites.unshift(savedItem); + } + renderFavorites(); + updateLiveCategoryFavoriteButton(); + updateVodCategoryFavoriteButton(); + } + + function renderFavorites() { + const search = el.favoritesSearch.value.trim().toLowerCase(); + const filtered = state.favorites.filter((item) => { + const title = String(item?.title || "").toLowerCase(); + const type = String(item?.mode || "").toLowerCase(); + const url = String(item?.url || "").toLowerCase(); + return !search || title.includes(search) || type.includes(search) || url.includes(search); + }); + + if (filtered.length === 0) { + el.favoritesList.innerHTML = `
  • No favorites yet.
  • `; + return; + } + + el.favoritesList.innerHTML = ""; + filtered.forEach((favorite) => { + const li = document.createElement("li"); + li.className = "stream-item"; + const isSeriesCategory = favorite?.mode === "series_item"; + const isLiveCategory = favorite?.mode === "live_category"; + const isVodCategory = favorite?.mode === "vod_category"; + const seriesId = String(favorite?.id || ""); + const isExpanded = isSeriesCategory && Boolean(state.expandedFavoriteSeriesById[seriesId]); + const liveCategoryId = String(favorite?.id || ""); + const isLiveCategoryExpanded = isLiveCategory && Boolean(state.expandedFavoriteLiveCategoryById[liveCategoryId]); + const vodCategoryId = String(favorite?.id || ""); + const isVodCategoryExpanded = isVodCategory && Boolean(state.expandedFavoriteVodCategoryById[vodCategoryId]); + li.innerHTML = isSeriesCategory + ? ` +
    + +
    ${esc(favoriteSummary(favorite))}
    +
    +
    + +
    + ` + : isLiveCategory + ? ` +
    + +
    ${esc(favoriteSummary(favorite))}
    +
    +
    + +
    + ` + : isVodCategory + ? ` +
    + +
    ${esc(favoriteSummary(favorite))}
    +
    +
    + +
    + ` + : ` +
    + +
    ${esc(favoriteSummary(favorite))}
    +
    +
    + +
    + `; + if (isSeriesCategory) { + 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 { + 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]; + } + renderFavorites(); + renderLiveStreams(); + renderVodStreams(); + renderSeriesList(); + renderCustomStreams(); + }).catch(showError); + }); + el.favoritesList.appendChild(li); + + if (!isSeriesCategory && !isLiveCategory && !isVodCategory) { + return; + } + + if (isLiveCategory && !isLiveCategoryExpanded) { + return; + } + if (isVodCategory && !isVodCategoryExpanded) { + return; + } + if (isSeriesCategory && !isExpanded) { + return; + } + + const episodesEntry = isLiveCategory + ? state.favoriteLiveCategoryStreamsById[liveCategoryId] + : isVodCategory + ? state.favoriteVodCategoryStreamsById[vodCategoryId] + : state.favoriteSeriesEpisodesById[seriesId]; + const episodesLi = document.createElement("li"); + episodesLi.className = "card episodes series-inline-episodes"; + + if (!episodesEntry || episodesEntry.loading) { + episodesLi.innerHTML = `
    Loading episodes...
    `; + } else if (episodesEntry.error) { + episodesLi.innerHTML = `
    Unable to load episodes: ${esc(episodesEntry.error)}
    `; + } else if (!episodesEntry.episodes || episodesEntry.episodes.length === 0) { + episodesLi.innerHTML = `
    No episodes available.
    `; + } else { + const wrap = document.createElement("div"); + wrap.className = "series-inline-episodes-wrap"; + if (isLiveCategory) { + const liveStreams = Array.isArray(episodesEntry.episodes) ? episodesEntry.episodes : []; + liveStreams.forEach((stream) => { + const streamFavorite = makeFavoriteLive(stream); + const row = document.createElement("div"); + row.className = "stream-item"; + const streamId = String(stream?.stream_id || ""); + const epgStreamId = String(stream?.epg_channel_id || streamId); + row.innerHTML = ` +
    + +
    ID: ${esc(streamId)} | Category: ${esc(stream?.category_id || "-")}
    +
    +
    + +
    + `; + row.querySelector("button[data-action='play-title']").addEventListener("click", () => { + playXtream( + "live", + streamId, + state.config?.liveFormat || "m3u8", + stream?.name || "Live stream", + epgStreamId, + {categoryId: stream?.category_id || ""} + ).catch(showError); + }); + row.querySelector("button[data-action='toggle-favorite']").addEventListener("click", () => { + toggleFavorite(streamFavorite).then(() => renderFavorites()).catch(showError); + }); + wrap.appendChild(row); + }); + episodesLi.appendChild(wrap); + el.favoritesList.appendChild(episodesLi); + return; + } + if (isVodCategory) { + const vodStreams = Array.isArray(episodesEntry.episodes) ? episodesEntry.episodes : []; + vodStreams.forEach((stream) => { + const streamFavorite = makeFavoriteVod(stream); + const row = document.createElement("div"); + row.className = "stream-item"; + const streamId = String(stream?.stream_id || ""); + const ext = String(stream?.container_extension || "mp4"); + row.innerHTML = ` +
    + +
    ID: ${esc(streamId)} | Ext: ${esc(ext)}
    +
    +
    + +
    + `; + row.querySelector("button[data-action='play-title']").addEventListener("click", () => { + playXtream("vod", streamId, ext, stream?.name || "VOD", null, { + categoryId: stream?.category_id || "" + }).catch(showError); + }); + row.querySelector("button[data-action='toggle-favorite']").addEventListener("click", () => { + toggleFavorite(streamFavorite).then(() => renderFavorites()).catch(showError); + }); + wrap.appendChild(row); + }); + episodesLi.appendChild(wrap); + el.favoritesList.appendChild(episodesLi); + return; + } + + const groupedBySeason = groupEpisodesBySeason(episodesEntry.episodes); + groupedBySeason.forEach((group) => { + const isSeasonExpanded = Boolean(state.expandedSeasonByFavoriteSeries?.[seriesId]?.[group.season]); + const seasonBlock = document.createElement("div"); + seasonBlock.className = "season-block"; + seasonBlock.innerHTML = ` + + `; + seasonBlock.querySelector("button[data-action='toggle-favorite-season']").addEventListener("click", () => { + toggleFavoriteSeasonGroup(seriesId, group.season); + }); + + if (!isSeasonExpanded) { + wrap.appendChild(seasonBlock); + return; + } + + const seasonList = document.createElement("div"); + seasonList.className = "season-list"; + group.episodes.forEach((episode) => { + const episodeFavorite = makeFavoriteSeriesEpisode( + {name: favorite.title || "Series", series_id: seriesId}, + episode + ); + const row = document.createElement("div"); + row.className = "stream-item"; + row.innerHTML = ` +
    + +
    Episode ID: ${esc(episode.id)} | Ext: ${esc(episode.ext)}
    +
    +
    + +
    + `; + row.querySelector("button[data-action='play-title']").addEventListener("click", () => { + playXtream("series", episode.id, episode.ext, `${favorite.title} - ${episode.title}`, null, { + seriesId, + season: episode.season, + episode: episode.episodeNum + }).catch(showError); + }); + row.querySelector("button[data-action='toggle-favorite']").addEventListener("click", () => { + toggleFavorite(episodeFavorite).then(() => renderFavorites()).catch(showError); + }); + seasonList.appendChild(row); + }); + seasonBlock.appendChild(seasonList); + wrap.appendChild(seasonBlock); + }); + episodesLi.appendChild(wrap); + } + el.favoritesList.appendChild(episodesLi); + }); + } + + async function openFavorite(favorite) { + const mode = String(favorite?.mode || ""); + switch (mode) { + case "live": + await playXtream( + "live", + String(favorite.id || ""), + String(favorite.ext || state.config?.liveFormat || "m3u8"), + favorite.title || "Live stream", + String(favorite.id || ""), + {categoryId: favorite.categoryId || ""} + ); + break; + case "vod": + await playXtream( + "vod", + String(favorite.id || ""), + String(favorite.ext || "mp4"), + favorite.title || "VOD", + null, + {categoryId: favorite.categoryId || ""} + ); + break; + case "series_episode": + await playXtream( + "series", + String(favorite.id || ""), + String(favorite.ext || "mp4"), + favorite.title || "Episode", + null, + { + seriesId: favorite.seriesId || "", + season: favorite.season || "", + episode: favorite.episode || "" + } + ); + break; + case "series_item": + await toggleFavoriteSeriesItem(favorite); + break; + case "live_category": + await toggleFavoriteLiveCategory(favorite); + break; + case "vod_category": + await toggleFavoriteVodCategory(favorite); + break; + case "custom": + playCustom(favorite.title || "Custom stream", String(favorite.url || "")); + break; + default: + throw new Error("Unsupported favorite type."); + } + } + + async function toggleFavoriteSeriesItem(favorite) { + const seriesId = String(favorite?.id || ""); + if (!seriesId) { + throw new Error("Missing series id."); + } + if (state.expandedFavoriteSeriesById[seriesId]) { + delete state.expandedFavoriteSeriesById[seriesId]; + delete state.expandedSeasonByFavoriteSeries[seriesId]; + renderFavorites(); + return; + } + state.expandedFavoriteSeriesById[seriesId] = true; + state.expandedSeasonByFavoriteSeries[seriesId] = state.expandedSeasonByFavoriteSeries[seriesId] || {}; + renderFavorites(); + await ensureFavoriteSeriesEpisodesLoaded(seriesId); + } + + async function ensureFavoriteSeriesEpisodesLoaded(seriesId) { + ensureLibraryReady(); + if (state.favoriteSeriesEpisodesById[seriesId]?.loaded || state.favoriteSeriesEpisodesById[seriesId]?.loading) { + return; + } + state.favoriteSeriesEpisodesById[seriesId] = {loading: true, loaded: false, episodes: []}; + renderFavorites(); + try { + const payload = await apiJson(`/api/library/series-episodes?series_id=${encodeURIComponent(seriesId)}`); + const episodes = sanitizeSeriesEpisodes(payload.items); + state.favoriteSeriesEpisodesById[seriesId] = { + loading: false, + loaded: true, + episodes + }; + } catch (error) { + state.favoriteSeriesEpisodesById[seriesId] = { + loading: false, + loaded: false, + error: error.message || String(error), + episodes: [] + }; + throw error; + } finally { + renderFavorites(); + } + } + + async function toggleFavoriteLiveCategory(favorite) { + const categoryId = String(favorite?.id || ""); + if (!categoryId) { + throw new Error("Missing live category id."); + } + if (state.expandedFavoriteLiveCategoryById[categoryId]) { + delete state.expandedFavoriteLiveCategoryById[categoryId]; + renderFavorites(); + return; + } + state.expandedFavoriteLiveCategoryById[categoryId] = true; + renderFavorites(); + await ensureFavoriteLiveCategoryStreamsLoaded(categoryId); + } + + async function ensureFavoriteLiveCategoryStreamsLoaded(categoryId) { + ensureLibraryReady(); + if (state.favoriteLiveCategoryStreamsById[categoryId]?.loaded + || state.favoriteLiveCategoryStreamsById[categoryId]?.loading) { + return; + } + state.favoriteLiveCategoryStreamsById[categoryId] = {loading: true, loaded: false, episodes: []}; + renderFavorites(); + try { + const query = new URLSearchParams({type: "live", category_id: categoryId}); + const payload = await apiJson(`/api/library/items?${query.toString()}`); + state.favoriteLiveCategoryStreamsById[categoryId] = { + loading: false, + loaded: true, + episodes: sanitizeLiveStreams(payload.items) + }; + } catch (error) { + state.favoriteLiveCategoryStreamsById[categoryId] = { + loading: false, + loaded: false, + error: error.message || String(error), + episodes: [] + }; + throw error; + } finally { + renderFavorites(); + } + } + + async function toggleFavoriteVodCategory(favorite) { + const categoryId = String(favorite?.id || ""); + if (!categoryId) { + throw new Error("Missing VOD category id."); + } + if (state.expandedFavoriteVodCategoryById[categoryId]) { + delete state.expandedFavoriteVodCategoryById[categoryId]; + renderFavorites(); + return; + } + state.expandedFavoriteVodCategoryById[categoryId] = true; + renderFavorites(); + await ensureFavoriteVodCategoryStreamsLoaded(categoryId); + } + + async function ensureFavoriteVodCategoryStreamsLoaded(categoryId) { + ensureLibraryReady(); + if (state.favoriteVodCategoryStreamsById[categoryId]?.loaded + || state.favoriteVodCategoryStreamsById[categoryId]?.loading) { + return; + } + state.favoriteVodCategoryStreamsById[categoryId] = {loading: true, loaded: false, episodes: []}; + renderFavorites(); + try { + const query = new URLSearchParams({type: "vod", category_id: categoryId}); + const payload = await apiJson(`/api/library/items?${query.toString()}`); + state.favoriteVodCategoryStreamsById[categoryId] = { + loading: false, + loaded: true, + episodes: sanitizeVodStreams(payload.items) + }; + } catch (error) { + state.favoriteVodCategoryStreamsById[categoryId] = { + loading: false, + loaded: false, + error: error.message || String(error), + episodes: [] + }; + throw error; + } finally { + renderFavorites(); + } + } + + function toggleFavoriteSeasonGroup(seriesId, season) { + const current = state.expandedSeasonByFavoriteSeries[seriesId] || {}; + const seasonKey = String(season || "?"); + current[seasonKey] = !current[seasonKey]; + state.expandedSeasonByFavoriteSeries[seriesId] = current; + renderFavorites(); + } + + function favoriteSummary(favorite) { + const mode = String(favorite?.mode || "unknown"); + if (mode === "custom") { + return `Custom | ${favorite.url || ""}`; + } + if (mode === "series_item") { + return `Series | ID: ${favorite.id || "-"}`; + } + if (mode === "live_category") { + return `Live category | ID: ${favorite.id || "-"}`; + } + if (mode === "vod_category") { + return `VOD category | ID: ${favorite.id || "-"}`; + } + if (mode === "series_episode") { + return `Series episode | ID: ${favorite.id || "-"} | Ext: ${favorite.ext || "mp4"}`; + } + if (mode === "live") { + return `Live | ID: ${favorite.id || "-"} | Format: ${favorite.ext || "m3u8"}`; + } + if (mode === "vod") { + return `VOD | ID: ${favorite.id || "-"} | Ext: ${favorite.ext || "mp4"}`; + } + return mode; + } + + function makeFavoriteLive(item) { + const streamId = String(item?.stream_id || ""); + return { + key: `live:${streamId}`, + mode: "live", + id: streamId, + ext: String(state.config?.liveFormat || "m3u8"), + title: String(item?.name || "Untitled"), + categoryId: String(item?.category_id || "") + }; + } + + function makeFavoriteVod(item) { + const streamId = String(item?.stream_id || ""); + return { + key: `vod:${streamId}`, + mode: "vod", + id: streamId, + ext: String(item?.container_extension || "mp4"), + title: String(item?.name || "Untitled"), + categoryId: String(item?.category_id || "") + }; + } + + function makeFavoriteLiveCategory(category) { + const categoryId = String(category?.category_id || ""); + return { + key: `live-category:${categoryId}`, + mode: "live_category", + id: categoryId, + title: String(category?.category_name || `Category ${categoryId}`) + }; + } + + function makeFavoriteVodCategory(category) { + const categoryId = String(category?.category_id || ""); + return { + key: `vod-category:${categoryId}`, + mode: "vod_category", + id: categoryId, + title: String(category?.category_name || `Category ${categoryId}`) + }; + } + + function makeFavoriteSeriesItem(item) { + const seriesId = String(item?.series_id || ""); + return { + key: `series-item:${seriesId}`, + mode: "series_item", + id: seriesId, + title: String(item?.name || "Untitled"), + categoryId: String(item?.category_id || "") + }; + } + + function makeFavoriteSeriesEpisode(seriesItem, episode) { + const episodeId = String(episode?.id || ""); + return { + key: `series-episode:${episodeId}`, + mode: "series_episode", + id: episodeId, + ext: String(episode?.ext || "mp4"), + title: `${String(seriesItem?.name || "Series")} - ${String(episode?.title || "Episode")}`, + seriesId: String(seriesItem?.series_id || ""), + season: String(episode?.season || ""), + episode: String(episode?.episodeNum || "") + }; + } + + function makeFavoriteCustom(item) { + const url = String(item?.url || ""); + const customId = String(item?.id || url); + return { + key: `custom:${customId}`, + mode: "custom", + id: customId, + title: String(item?.name || "Untitled"), + url + }; + } + + function favoriteIcon(active) { + return active ? "★" : "☆"; + } + + async function deleteFavoriteByKey(keyRaw) { + const key = String(keyRaw || "").trim(); + if (!key) { + return; + } + await apiJson(`/api/favorites?key=${encodeURIComponent(key)}`, {method: "DELETE"}); + state.favorites = state.favorites.filter((item) => item?.key !== key); + updateLiveCategoryFavoriteButton(); + updateVodCategoryFavoriteButton(); + } + + function renderCustomStreams() { + const search = el.customSearch.value.trim().toLowerCase(); + const filtered = state.customStreams.filter((item) => { + return !search || item.name.toLowerCase().includes(search) || item.url.toLowerCase().includes(search); + }); + + if (filtered.length === 0) { + el.customList.innerHTML = `
  • No custom streams saved yet.
  • `; + return; + } + + el.customList.innerHTML = ""; + filtered.forEach((item) => { + const li = document.createElement("li"); + li.className = "stream-item"; + const favorite = makeFavoriteCustom(item); + li.innerHTML = ` +
    + +
    ${esc(item.url)}
    +
    +
    + + +
    + `; + li.querySelector("button[data-action='play-title']").addEventListener("click", () => { + playCustom(item.name, item.url); + }); + li.querySelector("button[data-action='toggle-favorite']").addEventListener("click", () => { + toggleFavorite(favorite).then(() => renderCustomStreams()).catch(showError); + }); + li.querySelector("button[data-action='delete']").addEventListener("click", () => { + state.customStreams = state.customStreams.filter((stream) => stream.id !== item.id); + persistCustomStreams(); + renderCustomStreams(); + }); + el.customList.appendChild(li); + }); + } + + async function playXtream(type, id, ext, title, epgStreamId = null, extraMeta = {}) { + if (!id) { + throw new Error("Missing stream id."); + } + const params = new URLSearchParams({type, id}); + if (ext) { + params.set("ext", ext); + } + const response = await apiJson(`/api/stream-url?${params.toString()}`); + const url = response.url; + if (!url) { + throw new Error("Server did not return a stream URL."); + } + setPlayer(title, url, { + type, + streamId: id, + format: ext || "", + source: "Xtream", + ...extraMeta + }); + if (type === "live") { + state.currentLiveEpgStreamId = epgStreamId || id; + await refreshEpg(); + } else { + state.currentLiveEpgStreamId = null; + el.epgList.innerHTML = "EPG is available only for live channels."; + } + } + + function playCustom(title, url) { + state.currentLiveEpgStreamId = null; + setPlayer(title, url, { + type: "custom", + streamId: "-", + format: guessFormatFromUrl(url), + source: "Custom URL" + }); + el.epgList.innerHTML = "EPG is available only for live channels."; + } + + function setPlayer(title, url, info = {}) { + el.playerTitle.textContent = title || "Player"; + const systemPlayerUrl = buildSystemPlayerHref(title, url, info); + el.openDirect.dataset.url = url; + el.openDirect.disabled = !url; + el.openSystemPlayer.dataset.url = systemPlayerUrl; + el.openSystemPlayer.disabled = !systemPlayerUrl; + state.currentStreamInfo = { + title: title || "Player", + url, + playbackEngine: "native", + resolution: "loading...", + duration: "loading...", + ...info + }; + renderStreamInfo(); + resetPlayerElement(); + hlsSubtitleTracks = []; + clearSubtitleTracks(); + setSubtitleStatus("No subtitle loaded.", false); + scheduleEmbeddedSubtitleScan(); + + if (isLikelyHls(url) && shouldUseHlsJs()) { + state.currentStreamInfo.playbackEngine = "hls.js"; + renderStreamInfo(); + hlsInstance = new window.Hls({ + enableWorker: true, + lowLatencyMode: true + }); + hlsInstance.on(window.Hls.Events.MANIFEST_PARSED, () => { + hlsSubtitleTracks = Array.isArray(hlsInstance?.subtitleTracks) + ? hlsInstance.subtitleTracks + : []; + refreshEmbeddedSubtitleTracks(); + }); + hlsInstance.on(window.Hls.Events.SUBTITLE_TRACKS_UPDATED, (_event, data) => { + hlsSubtitleTracks = Array.isArray(data?.subtitleTracks) + ? data.subtitleTracks + : (Array.isArray(hlsInstance?.subtitleTracks) ? hlsInstance.subtitleTracks : []); + refreshEmbeddedSubtitleTracks(); + }); + hlsInstance.loadSource(url); + hlsInstance.attachMedia(el.player); + hlsInstance.on(window.Hls.Events.ERROR, (_event, data) => { + if (!data?.fatal) { + return; + } + disposeHls(); + setSettingsMessage("HLS playback failed in embedded player. Try Open stream directly.", "err"); + if (state.currentStreamInfo) { + state.currentStreamInfo.playbackEngine = "native (HLS fallback failed)"; + renderStreamInfo(); + } + }); + } else { + el.player.src = url; + } + + const playbackPromise = el.player.play(); + if (playbackPromise && typeof playbackPromise.catch === "function") { + playbackPromise.catch((error) => { + if (error?.name === "NotAllowedError") { + return; + } + setSettingsMessage( + `Playback could not start in embedded player: ${error?.message || "unknown error"}`, + "err" + ); + }); + } + } + + function buildSystemPlayerHref(title, url, info = {}) { + const params = new URLSearchParams(); + params.set("title", String(title || "Stream")); + const type = String(info?.type || "").toLowerCase(); + const streamId = String(info?.streamId || "").trim(); + if ((type === "live" || type === "vod" || type === "series") && streamId) { + params.set("type", type); + params.set("id", streamId); + if (info?.format) { + params.set("ext", String(info.format)); + } + return `/api/open-in-player?${params.toString()}`; + } + params.set("url", String(url || "")); + return `/api/open-in-player?${params.toString()}`; + } + + function resetPlayerElement() { + disposeHls(); + el.player.pause(); + el.player.removeAttribute("src"); + el.player.load(); + } + + function addSubtitleTrack(url, lang) { + clearSubtitleTracks(); + const track = document.createElement("track"); + track.kind = "subtitles"; + track.label = `Subtitles (${lang || "en"})`; + track.srclang = lang || "en"; + track.src = url; + track.default = true; + track.setAttribute("data-custom-subtitle", "1"); + + track.addEventListener("load", () => { + try { + if (track.track) { + track.track.mode = "showing"; + } + } catch (error) { + // Some browsers restrict track mode handling; ignore. + } + refreshEmbeddedSubtitleTracks(); + setSubtitleStatus(`Subtitle loaded: ${url}`, false); + }); + track.addEventListener("error", () => { + setSubtitleStatus("Subtitle failed to load. Check URL/CORS/WebVTT format.", true); + }); + + el.player.appendChild(track); + } + + function clearSubtitleTracks() { + Array.from(el.player.querySelectorAll("track[data-custom-subtitle='1']")).forEach((track) => track.remove()); + const tracks = el.player.textTracks; + for (let index = 0; index < tracks.length; index++) { + tracks[index].mode = "disabled"; + } + if (hlsInstance) { + hlsInstance.subtitleTrack = -1; + hlsInstance.subtitleDisplay = false; + } + refreshEmbeddedSubtitleTracks(); + } + + function setSubtitleStatus(text, isError) { + el.subtitleStatus.textContent = text; + el.subtitleStatus.className = isError ? "danger" : "muted"; + } + + function scheduleEmbeddedSubtitleScan() { + if (embeddedSubtitleScanTimer) { + clearTimeout(embeddedSubtitleScanTimer); + embeddedSubtitleScanTimer = null; + } + refreshEmbeddedSubtitleTracks(); + const checkpoints = [400, 1200, 2800]; + checkpoints.forEach((delay) => { + embeddedSubtitleScanTimer = setTimeout(() => { + refreshEmbeddedSubtitleTracks(); + }, delay); + }); + } + + function refreshEmbeddedSubtitleTracks() { + const options = [{value: "off", label: "Off"}]; + const tracks = []; + const hlsTracks = []; + for (let index = 0; index < el.player.textTracks.length; index++) { + const track = el.player.textTracks[index]; + if (!track) { + continue; + } + if (track.kind !== "subtitles" && track.kind !== "captions") { + continue; + } + const label = (track.label || track.language || `Track ${index + 1}`).trim(); + tracks.push({index, label, mode: track.mode}); + } + if (Array.isArray(hlsSubtitleTracks)) { + hlsSubtitleTracks.forEach((track, index) => { + const label = String(track?.name || track?.lang || `HLS Track ${index + 1}`).trim(); + hlsTracks.push({ + index, + label, + active: Number(hlsInstance?.subtitleTrack) === index + }); + }); + } + + tracks.forEach((track) => { + options.push({ + value: `native:${track.index}`, + label: `Embedded: ${track.label || `Track ${track.index + 1}`}` + }); + }); + hlsTracks.forEach((track) => { + options.push({ + value: `hls:${track.index}`, + label: `HLS: ${track.label || `Track ${track.index + 1}`}` + }); + }); + + const previous = el.embeddedSubtitleTrack.value; + const selected = resolveSelectedEmbeddedTrackValue(previous, tracks, hlsTracks); + el.embeddedSubtitleTrack.innerHTML = ""; + options.forEach((optionData) => { + const option = document.createElement("option"); + option.value = optionData.value; + option.textContent = optionData.label; + el.embeddedSubtitleTrack.appendChild(option); + }); + el.embeddedSubtitleTrack.value = selected; + const hasAnyTracks = tracks.length > 0 || hlsTracks.length > 0; + el.embeddedSubtitleTrack.disabled = !hasAnyTracks; + if (!hasAnyTracks) { + if (String(el.subtitleStatus.textContent || "").startsWith("No subtitle")) { + const format = String(state.currentStreamInfo?.format || "").toLowerCase(); + if (format === "mkv") { + setSubtitleStatus( + "No embedded subtitles exposed by browser for MKV. Try .vtt subtitle URL or Open stream directly.", + false + ); + } else { + setSubtitleStatus("No embedded subtitles detected for this stream.", false); + } + } + return; + } + if (selected === "off") { + return; + } + const selectedTrack = tracks.find((track) => `native:${track.index}` === selected); + if (selectedTrack && selectedTrack.mode === "showing") { + setSubtitleStatus(`Embedded subtitles active: ${selectedTrack.label}`, false); + return; + } + const selectedHlsTrack = hlsTracks.find((track) => `hls:${track.index}` === selected); + if (selectedHlsTrack && selectedHlsTrack.active) { + setSubtitleStatus(`Embedded subtitles active: ${selectedHlsTrack.label}`, false); + } + } + + function resolveSelectedEmbeddedTrackValue(previousValue, tracks, hlsTracks) { + const availableValues = new Set([ + ...tracks.map((track) => `native:${track.index}`), + ...hlsTracks.map((track) => `hls:${track.index}`) + ]); + if (!availableValues.size) { + return "off"; + } + if (previousValue && availableValues.has(previousValue)) { + return previousValue; + } + const showingTrack = tracks.find((track) => track.mode === "showing"); + if (showingTrack) { + return `native:${showingTrack.index}`; + } + const showingHlsTrack = hlsTracks.find((track) => track.active); + if (showingHlsTrack) { + return `hls:${showingHlsTrack.index}`; + } + return "off"; + } + + function selectEmbeddedSubtitleTrack(valueRaw) { + const value = String(valueRaw || "off"); + const [source, indexRaw] = value.split(":"); + const selectedIndex = Number(indexRaw); + const tracks = el.player.textTracks; + let selectedLabel = ""; + for (let index = 0; index < tracks.length; index++) { + const track = tracks[index]; + if (!track) { + continue; + } + if (track.kind !== "subtitles" && track.kind !== "captions") { + continue; + } + if (source === "native" && index === selectedIndex) { + track.mode = "showing"; + selectedLabel = track.label || track.language || `Track ${index + 1}`; + } else { + track.mode = "disabled"; + } + } + if (hlsInstance) { + if (source === "hls" && selectedIndex >= 0) { + hlsInstance.subtitleTrack = selectedIndex; + hlsInstance.subtitleDisplay = true; + const hlsTrack = hlsSubtitleTracks[selectedIndex]; + selectedLabel = selectedLabel || hlsTrack?.name || hlsTrack?.lang || `Track ${selectedIndex + 1}`; + } else { + hlsInstance.subtitleTrack = -1; + hlsInstance.subtitleDisplay = false; + } + } + if ((source === "native" || source === "hls") && selectedIndex >= 0) { + setSubtitleStatus(`Embedded subtitles active: ${selectedLabel || `Track ${selectedIndex + 1}`}`, false); + } else { + setSubtitleStatus("Embedded subtitles disabled.", false); + } + } + + function disposeHls() { + if (!hlsInstance) { + return; + } + hlsInstance.destroy(); + hlsInstance = null; + hlsSubtitleTracks = []; + } + + function shouldUseHlsJs() { + return Boolean(window.Hls && window.Hls.isSupported()); + } + + function isLikelyHls(url) { + const value = String(url || "").toLowerCase(); + return value.includes(".m3u8") + || value.includes("m3u8?") + || value.includes("type=m3u8") + || value.includes("output=m3u8"); + } + + function updateStreamRuntimeInfo() { + if (!state.currentStreamInfo) { + return; + } + const width = Number(el.player.videoWidth || 0); + const height = Number(el.player.videoHeight || 0); + state.currentStreamInfo.resolution = (width > 0 && height > 0) ? `${width}x${height}` : "unknown"; + + const duration = Number(el.player.duration); + if (Number.isFinite(duration) && duration > 0) { + state.currentStreamInfo.duration = formatDuration(duration); + } else { + state.currentStreamInfo.duration = "live/unknown"; + } + renderStreamInfo(); + } + + function renderStreamInfo() { + if (!state.currentStreamInfo) { + el.streamInfo.textContent = "No stream selected."; + el.streamInfo.classList.add("muted"); + return; + } + el.streamInfo.classList.remove("muted"); + const info = state.currentStreamInfo; + const rows = [ + ["Title", info.title || "-"], + ["Type", info.type || "-"], + ["Format", info.format || guessFormatFromUrl(info.url)], + ["Resolution", info.resolution || "unknown"], + ["Duration", info.duration || "unknown"], + ["Stream ID", info.streamId || "-"], + ["Engine", info.playbackEngine || "native"], + ["Source", info.source || "-"], + ["URL", info.url || "-"] + ]; + if (info.seriesId) { + rows.splice(6, 0, ["Series ID", info.seriesId]); + } + if (info.season || info.episode) { + rows.splice(7, 0, ["Episode", `S${info.season || "?"}E${info.episode || "?"}`]); + } + if (info.categoryId) { + rows.splice(6, 0, ["Category ID", info.categoryId]); + } + + el.streamInfo.innerHTML = rows + .map(([key, value]) => ( + `
    ${esc(key)}:
    ${esc(value)}
    ` + )) + .join(""); + } + + function formatDuration(totalSeconds) { + const safe = Math.max(0, Math.floor(totalSeconds)); + const h = Math.floor(safe / 3600); + const m = Math.floor((safe % 3600) / 60); + const s = safe % 60; + if (h > 0) { + return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; + } + return `${m}:${String(s).padStart(2, "0")}`; + } + + function guessFormatFromUrl(url) { + const raw = String(url || ""); + const clean = raw.split("?")[0].split("#")[0]; + const parts = clean.split("."); + if (parts.length < 2) { + return "unknown"; + } + const ext = String(parts[parts.length - 1] || "").trim().toLowerCase(); + return ext || "unknown"; + } + + async function refreshEpg() { + if (!state.currentLiveEpgStreamId) { + el.epgList.innerHTML = "Select a live channel for EPG."; + return; + } + el.epgList.innerHTML = "Loading EPG..."; + const data = await apiJson( + `/api/library/epg?stream_id=${encodeURIComponent(String(state.currentLiveEpgStreamId))}&limit=20` + ); + const list = Array.isArray(data?.epg_listings) ? data.epg_listings : []; + if (list.length === 0) { + el.epgList.innerHTML = "No EPG returned for this channel."; + return; + } + + el.epgList.innerHTML = ""; + list.forEach((item) => { + const title = decodeMaybeBase64(item.title || item.programme_title || "Untitled"); + const description = decodeMaybeBase64(item.description || ""); + const timeRange = buildEpgTime(item); + const row = document.createElement("div"); + row.className = "epg-item"; + row.innerHTML = ` +
    ${esc(timeRange)}
    +
    ${esc(title)}
    +
    ${esc(description)}
    + `; + el.epgList.appendChild(row); + }); + } + + function buildEpgTime(item) { + const start = toDate(item.start_timestamp, item.start); + const stop = toDate(item.stop_timestamp, item.end); + if (!start && !stop) { + return "No time"; + } + if (start && stop) { + return `${formatDate(start)} - ${formatDate(stop)}`; + } + return start ? formatDate(start) : formatDate(stop); + } + + function toDate(unixSeconds, fallback) { + if (unixSeconds && !Number.isNaN(Number(unixSeconds))) { + return new Date(Number(unixSeconds) * 1000); + } + if (fallback) { + const d = new Date(fallback); + if (!Number.isNaN(d.getTime())) { + return d; + } + } + return null; + } + + function formatDate(date) { + return date.toLocaleString("en-US", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit" + }); + } + + function fillCategorySelect(select, categories, allLabel) { + const list = Array.isArray(categories) ? categories : []; + const sorted = [...list].sort((a, b) => { + const left = String(a?.category_name || ""); + const right = String(b?.category_name || ""); + return left.localeCompare(right, "en", {sensitivity: "base", numeric: true}); + }); + select.innerHTML = ""; + const all = document.createElement("option"); + all.value = ""; + all.textContent = allLabel; + select.appendChild(all); + + sorted.forEach((category) => { + const option = document.createElement("option"); + option.value = String(category.category_id || ""); + option.textContent = String(category.category_name || `Category ${option.value}`); + select.appendChild(option); + }); + } + + function renderAllFromState() { + fillCategorySelect(el.liveCategory, state.liveCategories, "All live"); + fillCategorySelect(el.vodCategory, state.vodCategories, "All VOD"); + fillCategorySelect(el.seriesCategory, state.seriesCategories, "All series"); + updateLiveCategoryFavoriteButton(); + updateVodCategoryFavoriteButton(); + renderLiveStreams(); + renderVodStreams(); + renderSeriesList(); + renderFavorites(); + } + + function sanitizeCategories(input) { + const list = Array.isArray(input) ? input : []; + return list.map((item) => ({ + category_id: String(item?.categoryId ?? item?.category_id ?? ""), + category_name: String(item?.categoryName ?? item?.category_name ?? "Unknown category") + })); + } + + function sanitizeLiveStreams(input) { + const list = Array.isArray(input) ? input : []; + return list.map((item) => ({ + stream_id: String(item?.streamId ?? item?.stream_id ?? ""), + name: String(item?.name ?? "Untitled"), + category_id: String(item?.categoryId ?? item?.category_id ?? ""), + epg_channel_id: String(item?.epgChannelId ?? item?.epg_channel_id ?? "") + })); + } + + function sanitizeVodStreams(input) { + const list = Array.isArray(input) ? input : []; + return list.map((item) => ({ + stream_id: String(item?.streamId ?? item?.stream_id ?? ""), + name: String(item?.name ?? "Untitled"), + category_id: String(item?.categoryId ?? item?.category_id ?? ""), + container_extension: String(item?.containerExtension ?? item?.container_extension ?? "mp4") + })); + } + + function sanitizeSeriesItems(input) { + const list = Array.isArray(input) ? input : []; + return list.map((item) => ({ + series_id: String(item?.seriesId ?? item?.series_id ?? ""), + name: String(item?.name ?? "Untitled"), + category_id: String(item?.categoryId ?? item?.category_id ?? "") + })); + } + + function sanitizeSeriesEpisodes(input) { + const list = Array.isArray(input) ? input : []; + return list.map((item) => ({ + id: String(item?.episodeId ?? item?.episode_id ?? ""), + season: String(item?.season ?? "?"), + episodeNum: String(item?.episodeNum ?? item?.episode_num ?? "?"), + title: String(item?.title ?? "Episode"), + ext: String(item?.containerExtension ?? item?.container_extension ?? "mp4") + })); + } + + function sanitizeFavorites(input) { + const list = Array.isArray(input) ? input : []; + return list.map(sanitizeFavorite).filter(Boolean); + } + + function sanitizeFavorite(item) { + if (!item) { + return null; + } + const key = String(item?.key ?? item?.favoriteKey ?? item?.favorite_key ?? "").trim(); + if (!key) { + return null; + } + return { + key, + mode: String(item?.mode ?? ""), + id: String(item?.id ?? item?.refId ?? item?.ref_id ?? ""), + ext: String(item?.ext ?? ""), + title: String(item?.title ?? "Untitled"), + categoryId: String(item?.categoryId ?? item?.category_id ?? ""), + seriesId: String(item?.seriesId ?? item?.series_id ?? ""), + season: String(item?.season ?? ""), + episode: String(item?.episode ?? ""), + url: String(item?.url ?? ""), + createdAt: Number(item?.createdAt ?? item?.created_at ?? Date.now()) + }; + } + + function groupEpisodesBySeason(episodes) { + const groups = new Map(); + (Array.isArray(episodes) ? episodes : []).forEach((episode) => { + const seasonKey = String(episode?.season ?? "?").trim() || "?"; + if (!groups.has(seasonKey)) { + groups.set(seasonKey, []); + } + groups.get(seasonKey).push(episode); + }); + + return Array.from(groups.entries()) + .sort(([left], [right]) => compareSeason(left, right)) + .map(([season, items]) => ({ + season, + episodes: [...items].sort(compareEpisode) + })); + } + + function compareSeason(left, right) { + const leftNum = extractFirstNumber(left); + const rightNum = extractFirstNumber(right); + if (!Number.isNaN(leftNum) && !Number.isNaN(rightNum) && leftNum !== rightNum) { + return leftNum - rightNum; + } + return String(left).localeCompare(String(right), "en", {numeric: true, sensitivity: "base"}); + } + + function compareEpisode(left, right) { + const leftNum = extractFirstNumber(left?.episodeNum); + const rightNum = extractFirstNumber(right?.episodeNum); + if (!Number.isNaN(leftNum) && !Number.isNaN(rightNum) && leftNum !== rightNum) { + return leftNum - rightNum; + } + return String(left?.title || "").localeCompare(String(right?.title || ""), "en", { + numeric: true, + sensitivity: "base" + }); + } + + function extractFirstNumber(value) { + const match = String(value ?? "").match(/\d+/); + return match ? Number(match[0]) : Number.NaN; + } + + function ensureConfigured() { + if (!state.config?.configured) { + throw new Error("Set server, username, and password first."); + } + } + + function ensureLibraryReady() { + ensureConfigured(); + if (!state.libraryStatus?.ready) { + throw new Error("Sources are not loaded in local H2 DB. Click Load sources."); + } + } + + async function apiJson(url, options = {}) { + const response = await fetch(url, options); + const text = await response.text(); + let parsed; + try { + parsed = JSON.parse(text); + } catch (error) { + parsed = {raw: text}; + } + if (!response.ok) { + throw new Error(parsed.error || parsed.raw || `HTTP ${response.status}`); + } + return parsed; + } + + function setSettingsMessage(text, type = "") { + el.settingsMessage.textContent = text || ""; + el.settingsMessage.className = `message ${type}`.trim(); + } + + function showError(error) { + setSettingsMessage(error.message || String(error), "err"); + } + + function updateProgress(value, text) { + el.sourcesProgress.value = value; + el.sourcesProgressText.textContent = text; + } + + function formatLoadedAt(value) { + if (!value) { + return "unknown time"; + } + const epoch = Number(value); + if (!Number.isNaN(epoch) && epoch > 0) { + return new Date(epoch).toLocaleString("en-US"); + } + const parsed = new Date(value); + if (!Number.isNaN(parsed.getTime())) { + return parsed.toLocaleString("en-US"); + } + return String(value); + } + + function esc(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("\"", """); + } + + function decodeMaybeBase64(value) { + const raw = String(value || ""); + if (!raw) { + return ""; + } + try { + const binary = atob(raw); + const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)); + return new TextDecoder().decode(bytes); + } catch (error) { + return raw; + } + } +})(); diff --git a/src/main/resources/web/assets/style.css b/src/main/resources/web/assets/style.css new file mode 100644 index 0000000..b78314b --- /dev/null +++ b/src/main/resources/web/assets/style.css @@ -0,0 +1,482 @@ +:root { + --bg-0: #08141e; + --bg-1: #102233; + --bg-2: #1a3852; + --card: rgba(9, 22, 32, 0.84); + --line: rgba(151, 193, 224, 0.24); + --text: #e8f4ff; + --muted: #9ebdd3; + --accent: #ff7c43; + --accent-2: #ffd56a; + --ok: #59c38a; + --danger: #ff9a8b; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + color: var(--text); + font-family: "Trebuchet MS", "Gill Sans", sans-serif; + background: radial-gradient(circle at 15% 10%, #24455f 0%, var(--bg-0) 42%), + linear-gradient(130deg, var(--bg-0), var(--bg-1), var(--bg-2)); + padding: 1.1rem; +} + +.bg-glow { + pointer-events: none; + position: fixed; + inset: 0; + background: radial-gradient(circle at 90% 84%, rgba(255, 124, 67, 0.22), transparent 34%), + radial-gradient(circle at 24% 90%, rgba(255, 213, 106, 0.12), transparent 30%); + animation: pulse 8s ease-in-out infinite alternate; +} + +.app-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.8rem; + position: relative; + z-index: 1; +} + +.eyebrow { + letter-spacing: 0.2rem; + text-transform: uppercase; + font-size: 0.72rem; + color: var(--accent-2); + margin: 0 0 0.2rem; +} + +h1, h2, h3 { + margin: 0 0 0.6rem; + font-family: "Palatino Linotype", "Book Antiqua", serif; +} + +h1 { + font-size: clamp(1.5rem, 3vw, 2.2rem); +} + +.status-chip { + border: 1px solid var(--line); + border-radius: 999px; + padding: 0.45rem 0.8rem; + backdrop-filter: blur(8px); + color: var(--muted); +} + +.tabs { + display: flex; + gap: 0.4rem; + flex-wrap: wrap; + margin-bottom: 0.8rem; +} + +.tab { + border: 1px solid var(--line); + background: rgba(8, 24, 37, 0.8); + color: var(--text); + border-radius: 999px; + padding: 0.45rem 0.85rem; + cursor: pointer; +} + +.tab.active { + border-color: var(--accent); + background: rgba(255, 124, 67, 0.12); +} + +.layout { + display: grid; + grid-template-columns: 1.6fr 1fr; + gap: 0.85rem; +} + +.panels { + min-width: 0; +} + +.tab-panel { + display: none; + animation: rise 220ms ease; +} + +.tab-panel.active { + display: block; +} + +.card { + border: 1px solid var(--line); + border-radius: 14px; + background: var(--card); + backdrop-filter: blur(9px); + padding: 0.75rem; + margin-bottom: 0.7rem; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 0.6rem; +} + +label { + display: grid; + gap: 0.2rem; + color: var(--muted); + font-size: 0.95rem; +} + +input, select, button { + border-radius: 9px; + border: 1px solid rgba(158, 189, 211, 0.32); + background: rgba(7, 19, 29, 0.86); + color: var(--text); + min-height: 2.1rem; + padding: 0.2rem 0.55rem; +} + +input:focus, select:focus, button:focus { + outline: 2px solid rgba(255, 124, 67, 0.6); + outline-offset: 1px; +} + +button { + cursor: pointer; + background: linear-gradient(110deg, #17405a, #316a84); +} + +button:hover { + filter: brightness(1.08); +} + +.actions { + display: flex; + gap: 0.5rem; + align-items: end; +} + +.message { + min-height: 1.6rem; + color: var(--muted); +} + +.progress-card { + display: grid; + gap: 0.35rem; +} + +progress { + width: 100%; + height: 1rem; + border-radius: 999px; + overflow: hidden; +} + +progress::-webkit-progress-bar { + background: rgba(7, 19, 29, 0.86); +} + +progress::-webkit-progress-value { + background: linear-gradient(120deg, var(--accent), var(--accent-2)); +} + +progress::-moz-progress-bar { + background: linear-gradient(120deg, var(--accent), var(--accent-2)); +} + +.message.ok { + color: var(--ok); +} + +.message.err { + color: var(--danger); +} + +.controls { + display: grid; + grid-template-columns: repeat(3, minmax(120px, 1fr)); + gap: 0.6rem; + align-items: end; +} + +.stream-list { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: 0.5rem; +} + +.stream-item { + border: 1px solid var(--line); + border-radius: 11px; + background: rgba(6, 18, 27, 0.72); + padding: 0.55rem 0.65rem; + display: flex; + gap: 0.5rem; + justify-content: space-between; + align-items: flex-start; + flex-wrap: wrap; +} + +.stream-item > div:first-child { + min-width: 0; + flex: 1 1 280px; +} + +.stream-title { + font-weight: 600; + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; +} + +.stream-link { + min-height: 0; + padding: 0; + border: 0; + background: transparent; + color: var(--text); + text-align: left; + font: inherit; + cursor: pointer; +} + +.stream-link:hover, +.stream-link:focus-visible { + color: var(--accent-2); + text-decoration: underline; +} + +.stream-meta { + color: var(--muted); + font-size: 0.86rem; + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; +} + +.stream-actions { + display: flex; + gap: 0.35rem; + flex: 0 0 auto; + margin-left: auto; +} + +.favorite-toggle { + min-width: 2rem; + padding: 0.15rem 0.45rem; + font-size: 1.05rem; + line-height: 1; +} + +.player-panel { + position: sticky; + top: 0.6rem; + align-self: start; +} + +#player { + width: 100%; + border-radius: 12px; + border: 1px solid var(--line); + background: #03080d; + min-height: 220px; +} + +.player-actions { + display: flex; + gap: 0.45rem; + margin-top: 0.5rem; + flex-wrap: wrap; +} + +#open-direct, +#open-system-player { + min-height: 1.95rem; + padding: 0.25rem 0.6rem; +} + +#open-direct:disabled, +#open-system-player:disabled { + cursor: not-allowed; + filter: grayscale(0.35); + opacity: 0.65; +} + +.epg { + margin-top: 0.8rem; +} + +.stream-info-box { + margin-top: 0.8rem; +} + +.subtitles-box { + margin-top: 0.8rem; +} + +.subtitles-form { + display: grid; + gap: 0.35rem; +} + +.stream-info-grid { + margin-top: 0.35rem; + display: grid; + gap: 0.28rem; +} + +.stream-info-row { + display: grid; + grid-template-columns: 130px 1fr; + gap: 0.55rem; + align-items: start; + font-size: 0.88rem; +} + +.stream-info-key { + color: var(--muted); +} + +.stream-info-value { + word-break: break-word; +} + +.epg-header { + display: flex; + justify-content: space-between; + gap: 0.4rem; + align-items: center; +} + +.epg-list { + margin-top: 0.5rem; + max-height: 330px; + overflow: auto; + display: grid; + gap: 0.45rem; +} + +.epg-item { + border-left: 3px solid rgba(255, 124, 67, 0.7); + padding: 0.28rem 0.48rem; + background: rgba(9, 23, 34, 0.76); +} + +.epg-time { + color: var(--accent-2); + font-size: 0.82rem; +} + +.episodes { + display: grid; + gap: 0.4rem; +} + +.series-inline-episodes { + border-style: dashed; +} + +.series-inline-episodes-wrap { + display: grid; + gap: 0.4rem; +} + +.season-block { + border: 1px solid rgba(158, 189, 211, 0.2); + border-radius: 10px; + padding: 0.45rem; + background: rgba(5, 15, 23, 0.42); +} + +.season-title { + font-weight: 700; + color: var(--accent-2); + margin-bottom: 0.35rem; +} + +.season-toggle { + min-height: 0; + width: 100%; + padding: 0; + border: 0; + background: transparent; + color: var(--accent-2); + text-align: left; + font: inherit; + cursor: pointer; +} + +.season-toggle:hover, +.season-toggle:focus-visible { + text-decoration: underline; +} + +.season-list { + display: grid; + gap: 0.35rem; +} + +.search-inline { + display: grid; + gap: 0.2rem; + margin-bottom: 0.7rem; +} + +.muted { + color: var(--muted); +} + +.danger { + color: var(--danger); +} + +@keyframes rise { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes pulse { + from { + opacity: 0.6; + } + to { + opacity: 0.92; + } +} + +@media (max-width: 1120px) { + .layout { + grid-template-columns: 1fr; + } + + .player-panel { + position: static; + } +} + +@media (max-width: 680px) { + body { + padding: 0.6rem; + } + + .controls { + grid-template-columns: 1fr; + } + + .form-grid { + grid-template-columns: 1fr; + } +} diff --git a/src/main/resources/web/index.html b/src/main/resources/web/index.html new file mode 100644 index 0000000..19f80a3 --- /dev/null +++ b/src/main/resources/web/index.html @@ -0,0 +1,200 @@ + + + + + + Xtream Player + + + +
    +
    +
    +

    Selfhosted IPTV

    +

    Xtream HTML5 Player

    +
    +
    Not configured
    +
    + + + +
    +
    +
    +

    Settings and login

    +
    + + + + +
    + + + +
    +
    +
    + + +
    Sources have not been preloaded yet.
    +
    +
    +
    + +
    +

    Live channels

    +
    + + +
    + + +
    +
    +
      +
      + +
      +

      VOD

      +
      + + +
      + + +
      +
      +
        +
        + +
        +

        Series

        +
        + + + +
        +
          +
          + +
          +

          Custom streams

          +
          + + +
          + +
          +
          + +
            +
            + +
            +

            Favorites

            + +
              +
              +
              + + +
              + + + + +