first commit
This commit is contained in:
commit
0009920ac0
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@ -0,0 +1,7 @@
|
||||
.git
|
||||
.idea
|
||||
.vscode
|
||||
target
|
||||
*.iml
|
||||
*.log
|
||||
README.md
|
||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
25
Dockerfile
Normal 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
47
README.md
Normal 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
55
pom.xml
Normal 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>
|
||||
164
src/main/java/cz/kamma/xtreamplayer/ConfigStore.java
Normal file
164
src/main/java/cz/kamma/xtreamplayer/ConfigStore.java
Normal 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;
|
||||
}
|
||||
}
|
||||
577
src/main/java/cz/kamma/xtreamplayer/LibraryRepository.java
Normal file
577
src/main/java/cz/kamma/xtreamplayer/LibraryRepository.java
Normal 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) {
|
||||
}
|
||||
}
|
||||
11
src/main/java/cz/kamma/xtreamplayer/XtreamConfig.java
Normal file
11
src/main/java/cz/kamma/xtreamplayer/XtreamConfig.java
Normal 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();
|
||||
}
|
||||
}
|
||||
597
src/main/java/cz/kamma/xtreamplayer/XtreamLibraryService.java
Normal file
597
src/main/java/cz/kamma/xtreamplayer/XtreamLibraryService.java
Normal 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;
|
||||
}
|
||||
}
|
||||
847
src/main/java/cz/kamma/xtreamplayer/XtreamPlayerApplication.java
Normal file
847
src/main/java/cz/kamma/xtreamplayer/XtreamPlayerApplication.java
Normal 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();
|
||||
}
|
||||
}
|
||||
16
src/main/resources/log4j2.xml
Normal file
16
src/main/resources/log4j2.xml
Normal 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>
|
||||
2182
src/main/resources/web/assets/app.js
Normal file
2182
src/main/resources/web/assets/app.js
Normal file
File diff suppressed because it is too large
Load Diff
482
src/main/resources/web/assets/style.css
Normal file
482
src/main/resources/web/assets/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
200
src/main/resources/web/index.html
Normal file
200
src/main/resources/web/index.html
Normal 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>
|
||||
Loading…
x
Reference in New Issue
Block a user