first commit

This commit is contained in:
Radek Davidek 2026-03-04 13:47:51 +01:00
commit 0009920ac0
14 changed files with 5234 additions and 0 deletions

7
.dockerignore Normal file
View File

@ -0,0 +1,7 @@
.git
.idea
.vscode
target
*.iml
*.log
README.md

24
.gitignore vendored Normal file
View File

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

25
Dockerfile Normal file
View File

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

47
README.md Normal file
View File

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

55
pom.xml Normal file
View File

@ -0,0 +1,55 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>cz.kamma</groupId>
<artifactId>xtream-player</artifactId>
<version>1.0.0</version>
<name>xtream-player</name>
<properties>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>2.2.224</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.18.3</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-api</artifactId>
<version>2.24.3</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>2.24.3</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.0</version>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.5.1</version>
<configuration>
<mainClass>cz.kamma.xtreamplayer.XtreamPlayerApplication</mainClass>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

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

View File

@ -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<CategoryRow> rows) {
replaceCategories("live_categories", rows);
}
void replaceVodCategories(List<CategoryRow> rows) {
replaceCategories("vod_categories", rows);
}
void replaceSeriesCategories(List<CategoryRow> rows) {
replaceCategories("series_categories", rows);
}
private void replaceCategories(String tableName, List<CategoryRow> 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<LiveStreamRow> 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<VodStreamRow> 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<SeriesItemRow> 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<SeriesEpisodeRow> 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<CategoryRow> 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<CategoryRow> 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<LiveStreamRow> 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<String> 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<LiveStreamRow> 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<VodStreamRow> listVodStreams(String categoryId, String search) {
StringBuilder sql = new StringBuilder(
"SELECT stream_id, name, category_id, container_extension FROM vod_streams WHERE 1=1");
List<String> 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<VodStreamRow> 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<SeriesItemRow> listSeriesItems(String categoryId, String search) {
StringBuilder sql = new StringBuilder(
"SELECT series_id, name, category_id FROM series_items WHERE 1=1");
List<String> 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<SeriesItemRow> 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<SeriesEpisodeRow> listSeriesEpisodes(String seriesId) {
List<SeriesEpisodeRow> 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<FavoriteRow> listFavorites() {
List<FavoriteRow> 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<String> 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) {
}
}

View File

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

View File

@ -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<String> SENSITIVE_KEYS = Set.of("password", "pass", "pwd", "token", "authorization");
static final List<String> 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<String, Object> 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<String, Object> 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<String, Object> out = new LinkedHashMap<>();
out.put("step", step);
out.put("status", status());
LOGGER.info("Library load step finished: {}", step);
return out;
}
Map<String, Object> 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<String, Object> 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<LibraryRepository.CategoryRow> 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<LibraryRepository.FavoriteRow> 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<LibraryRepository.SeriesEpisodeRow> listSeriesEpisodes(String seriesIdRaw) {
String seriesId = seriesIdRaw == null ? "" : seriesIdRaw.trim();
if (seriesId.isBlank()) {
throw new IllegalArgumentException("Missing series_id.");
}
List<LibraryRepository.SeriesEpisodeRow> 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<LibraryRepository.SeriesEpisodeRow> 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<LibraryRepository.CategoryRow> parseCategories(JsonNode node) {
if (!node.isArray()) {
return List.of();
}
Map<String, LibraryRepository.CategoryRow> 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<LibraryRepository.LiveStreamRow> parseLiveStreams(JsonNode node) {
if (!node.isArray()) {
return List.of();
}
Map<String, LibraryRepository.LiveStreamRow> 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<LibraryRepository.VodStreamRow> parseVodStreams(JsonNode node) {
if (!node.isArray()) {
return List.of();
}
Map<String, LibraryRepository.VodStreamRow> 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<LibraryRepository.SeriesItemRow> parseSeriesItems(JsonNode node) {
if (!node.isArray()) {
return List.of();
}
Map<String, LibraryRepository.SeriesItemRow> 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<LibraryRepository.SeriesEpisodeRow> parseSeriesEpisodes(String seriesId, JsonNode episodesBySeason) {
if (episodesBySeason == null || episodesBySeason.isMissingNode() || episodesBySeason.isNull()) {
return List.of();
}
Map<String, LibraryRepository.SeriesEpisodeRow> 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<String, String> params) {
URI uri = buildPlayerApiUri(config, params);
List<URI> attempts = candidateUris(uri);
List<String> 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<byte[]> 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<String, String> params) {
StringBuilder query = new StringBuilder();
query.append("username=").append(urlEncode(config.username()));
query.append("&password=").append(urlEncode(config.password()));
for (Map.Entry<String, String> 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<URI> candidateUris(URI uri) {
LinkedHashSet<URI> 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<String, String> params = parseKeyValue(rawQuery);
Map<String, String> masked = maskSensitive(params);
StringBuilder query = new StringBuilder();
for (Map.Entry<String, String> 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<String, String> parseKeyValue(String raw) {
Map<String, String> 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<String, String> maskSensitive(Map<String, String> params) {
Map<String, String> masked = new LinkedHashMap<>();
for (Map.Entry<String, String> 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;
}
}

View File

@ -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<String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, Object> 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<String, String> 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<String, Object> 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<String, String> 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<String, Object> 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<String, String> 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<String, String> query = parseKeyValue(exchange.getRequestURI().getRawQuery());
String method = exchange.getRequestMethod();
logApiRequest(exchange, "/api/favorites", query);
try {
if ("GET".equalsIgnoreCase(method)) {
Map<String, Object> out = new LinkedHashMap<>();
out.put("items", libraryService.listFavorites());
writeJsonObject(exchange, 200, out);
return;
}
if ("POST".equalsIgnoreCase(method)) {
String body = readBody(exchange);
@SuppressWarnings("unchecked")
Map<String, Object> 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<String, Object> 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<String, Object> 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<URI> attempts = candidateUris(uri);
List<String> errors = new ArrayList<>();
for (URI candidate : attempts) {
long startedAt = System.nanoTime();
try {
LOGGER.info("Upstream request started uri={}", maskUri(candidate));
HttpResponse<byte[]> 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<byte[]> 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<URI> candidateUris(URI uri) {
LinkedHashSet<URI> 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<String, String> 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<String, String> 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<String, String> parseKeyValue(String raw) {
Map<String, String> 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<String, String> params) {
exchange.setAttribute(ATTR_REQ_START_NANOS, System.nanoTime());
Map<String, String> 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<String, String> 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<String, String> 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<String, String> maskSensitive(Map<String, String> params) {
Map<String, String> masked = new LinkedHashMap<>();
for (Map.Entry<String, String> 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<String, String> map) {
if (map == null || map.isEmpty()) {
return "";
}
StringBuilder out = new StringBuilder();
for (Map.Entry<String, String> 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();
}
}

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
<Appenders>
<Console name="Console" target="SYSTEM_OUT">
<PatternLayout pattern="%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%t] %c{1} - %msg%n"/>
</Console>
</Appenders>
<Loggers>
<Logger name="cz.kamma.xtreamplayer" level="debug" additivity="false">
<AppenderRef ref="Console"/>
</Logger>
<Root level="info">
<AppenderRef ref="Console"/>
</Root>
</Loggers>
</Configuration>

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -0,0 +1,200 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Xtream Player</title>
<link rel="stylesheet" href="/assets/style.css">
</head>
<body>
<div class="bg-glow"></div>
<header class="app-header">
<div>
<p class="eyebrow">Selfhosted IPTV</p>
<h1>Xtream HTML5 Player</h1>
</div>
<div id="global-status" class="status-chip">Not configured</div>
</header>
<nav class="tabs" id="tabs">
<button class="tab" data-tab="live">Live</button>
<button class="tab" data-tab="vod">VOD</button>
<button class="tab" data-tab="series">Series</button>
<button class="tab" data-tab="custom">Custom streams</button>
<button class="tab" data-tab="favorites">Favorites</button>
<button class="tab active" data-tab="settings">Settings</button>
</nav>
<main class="layout">
<section class="panels">
<article class="tab-panel active" data-panel="settings">
<h2>Settings and login</h2>
<form id="config-form" class="card form-grid">
<label>
Server URL
<input type="text" id="server-url" name="serverUrl" placeholder="http://provider:port" required>
</label>
<label>
Username
<input type="text" id="username" name="username" required>
</label>
<label>
Password
<input type="password" id="password" name="password" required>
</label>
<label>
Live format
<select id="live-format" name="liveFormat">
<option value="m3u8">m3u8 (recommended for browser)</option>
<option value="ts">ts</option>
</select>
</label>
<div class="actions">
<button type="submit">Save</button>
<button type="button" id="test-login">Test login</button>
<button type="button" id="load-sources">Load sources</button>
</div>
</form>
<div class="card progress-card">
<label for="sources-progress">Source loading progress</label>
<progress id="sources-progress" max="100" value="0"></progress>
<div id="sources-progress-text" class="muted">Sources have not been preloaded yet.</div>
</div>
<div id="settings-message" class="message"></div>
</article>
<article class="tab-panel" data-panel="live">
<h2>Live channels</h2>
<div class="card controls">
<label>
Category
<select id="live-category"></select>
</label>
<label>
Search
<input id="live-search" type="search" placeholder="Channel name">
</label>
<div class="actions">
<button id="live-refresh">Reload</button>
<button id="live-favorite-category" class="favorite-toggle" type="button" title="Add selected category to favorites" aria-label="Add selected category to favorites"></button>
</div>
</div>
<ul id="live-list" class="stream-list"></ul>
</article>
<article class="tab-panel" data-panel="vod">
<h2>VOD</h2>
<div class="card controls">
<label>
Category
<select id="vod-category"></select>
</label>
<label>
Search
<input id="vod-search" type="search" placeholder="VOD title">
</label>
<div class="actions">
<button id="vod-refresh">Reload</button>
<button id="vod-favorite-category" class="favorite-toggle" type="button" title="Add selected category to favorites" aria-label="Add selected category to favorites"></button>
</div>
</div>
<ul id="vod-list" class="stream-list"></ul>
</article>
<article class="tab-panel" data-panel="series">
<h2>Series</h2>
<div class="card controls">
<label>
Category
<select id="series-category"></select>
</label>
<label>
Search
<input id="series-search" type="search" placeholder="Series title">
</label>
<button id="series-refresh">Reload</button>
</div>
<ul id="series-list" class="stream-list"></ul>
</article>
<article class="tab-panel" data-panel="custom">
<h2>Custom streams</h2>
<form id="custom-form" class="card form-grid custom-form">
<label>
Name
<input id="custom-name" required placeholder="My stream">
</label>
<label>
URL
<input id="custom-url" required placeholder="https://...m3u8">
</label>
<div class="actions">
<button type="submit">Add</button>
</div>
</form>
<label class="search-inline">
Search
<input id="custom-search" type="search" placeholder="Filter custom streams">
</label>
<ul id="custom-list" class="stream-list"></ul>
</article>
<article class="tab-panel" data-panel="favorites">
<h2>Favorites</h2>
<label class="search-inline">
Search
<input id="favorites-search" type="search" placeholder="Filter favorites">
</label>
<ul id="favorites-list" class="stream-list"></ul>
</article>
</section>
<aside class="player-panel card">
<h2 id="player-title">Player</h2>
<video id="player" controls playsinline></video>
<div class="player-actions">
<button type="button" id="open-direct" disabled>Open stream directly</button>
<button type="button" id="open-system-player" disabled>Open in system player</button>
</div>
<section class="subtitles-box">
<h3>Subtitles</h3>
<div class="subtitles-form">
<label>
Subtitle URL (.vtt)
<input id="subtitle-url" type="url" placeholder="https://.../subtitle.vtt">
</label>
<label>
Language
<input id="subtitle-lang" type="text" value="en" maxlength="8" placeholder="en">
</label>
<label>
Embedded track
<select id="embedded-subtitle-track">
<option value="">No embedded subtitles detected</option>
</select>
</label>
<div class="actions">
<button type="button" id="load-subtitle">Load subtitle</button>
<button type="button" id="clear-subtitle">Clear subtitle</button>
</div>
<div id="subtitle-status" class="muted">No subtitle loaded.</div>
</div>
</section>
<section class="stream-info-box">
<h3>Stream info</h3>
<div id="stream-info" class="stream-info-grid muted">No stream selected.</div>
</section>
<section class="epg">
<div class="epg-header">
<h3>EPG</h3>
<button id="refresh-epg">Refresh EPG</button>
</div>
<div id="epg-list" class="epg-list">Select a live channel for EPG.</div>
</section>
</aside>
</main>
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.18/dist/hls.min.js"></script>
<script src="/assets/app.js"></script>
</body>
</html>