2026-03-10 15:15:20 +01:00

1202 lines
53 KiB
Java

package cz.kamma.xtreamplayer;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
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.sql.Timestamp;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
/**
* Unified Data Access Object for both user credentials and library/stream data.
* Manages persistent data in H2 database for users, categories, streams, series, and favorites.
*/
final class ApplicationDao {
private static final Logger LOGGER = LogManager.getLogger(ApplicationDao.class);
private final Path dbPath;
private final String jdbcUrl;
public ApplicationDao(Path dbPath) {
this.dbPath = dbPath;
this.jdbcUrl = "jdbc:h2:file:" + dbPath.toAbsolutePath().toString().replace("\\", "/") + ";AUTO_SERVER=TRUE";
}
public void initialize() {
try {
Files.createDirectories(dbPath.getParent());
} catch (IOException e) {
LOGGER.error("Failed to create database directory", e);
throw new RuntimeException(e);
}
try {
try (Connection connection = openConnection(); Statement statement = connection.createStatement()) {
// User tables
statement.execute("""
CREATE TABLE IF NOT EXISTS users (
id IDENTITY PRIMARY KEY,
username VARCHAR(120) UNIQUE NOT NULL,
password_hash VARCHAR(256) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""");
// Library metadata
statement.execute("""
CREATE TABLE IF NOT EXISTS source_meta (
meta_key VARCHAR(120) PRIMARY KEY,
meta_value CLOB
)
""");
// Categories
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
)
""");
// Streams
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)
)
""");
// Series
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)
)
""");
// Favorites
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
)
""");
// Indexes
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_series_episodes_series_sort ON series_episodes(series_id, season, episode_num, title)");
statement.execute("CREATE INDEX IF NOT EXISTS idx_live_categories_name ON live_categories(category_name)");
statement.execute("CREATE INDEX IF NOT EXISTS idx_vod_categories_name ON vod_categories(category_name)");
statement.execute("CREATE INDEX IF NOT EXISTS idx_series_categories_name ON series_categories(category_name)");
statement.execute("CREATE INDEX IF NOT EXISTS idx_live_streams_name ON live_streams(name)");
statement.execute("CREATE INDEX IF NOT EXISTS idx_vod_streams_name ON vod_streams(name)");
statement.execute("CREATE INDEX IF NOT EXISTS idx_series_items_name ON series_items(name)");
statement.execute("CREATE INDEX IF NOT EXISTS idx_favorites_created_at ON favorites(created_at DESC)");
LOGGER.info("Database tables and indexes initialized at {}", dbPath);
}
try (Connection connection = openConnection()) {
initializeFullText(connection);
}
LOGGER.info("H2 repository initialized at {}", dbPath);
} catch (SQLException exception) {
LOGGER.error("Failed to initialize database", exception);
throw new RuntimeException(exception);
}
}
// ===== USER MANAGEMENT =====
public void createUser(String username, String password) {
if (username == null || username.isBlank() || password == null || password.isBlank()) {
throw new IllegalArgumentException("Username and password must not be null or empty");
}
try (Connection connection = openConnection()) {
String sql = "INSERT INTO users (username, password_hash) VALUES (?, ?)";
try (PreparedStatement stmt = connection.prepareStatement(sql)) {
stmt.setString(1, username);
stmt.setString(2, hashPassword(password));
stmt.executeUpdate();
LOGGER.info("User created: {}", username);
}
} catch (SQLException exception) {
if (exception.getMessage().contains("Unique constraint")) {
throw new IllegalArgumentException("User '" + username + "' already exists");
}
LOGGER.error("Failed to create user: {}", username, exception);
throw new RuntimeException(exception);
}
}
public void updatePassword(String username, String newPassword) {
if (username == null || username.isBlank() || newPassword == null || newPassword.isBlank()) {
throw new IllegalArgumentException("Username and password must not be null or empty");
}
try (Connection connection = openConnection()) {
String sql = "UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE username = ?";
try (PreparedStatement stmt = connection.prepareStatement(sql)) {
stmt.setString(1, hashPassword(newPassword));
stmt.setString(2, username);
int updated = stmt.executeUpdate();
if (updated == 0) {
throw new IllegalArgumentException("User '" + username + "' not found");
}
LOGGER.info("Password updated for user: {}", username);
}
} catch (SQLException exception) {
LOGGER.error("Failed to update password for user: {}", username, exception);
throw new RuntimeException(exception);
}
}
public void deleteUser(String username) {
if (username == null || username.isBlank()) {
throw new IllegalArgumentException("Username must not be null or empty");
}
try (Connection connection = openConnection()) {
String sql = "DELETE FROM users WHERE username = ?";
try (PreparedStatement stmt = connection.prepareStatement(sql)) {
stmt.setString(1, username);
int deleted = stmt.executeUpdate();
if (deleted == 0) {
throw new IllegalArgumentException("User '" + username + "' not found");
}
LOGGER.info("User deleted: {}", username);
}
} catch (SQLException exception) {
LOGGER.error("Failed to delete user: {}", username, exception);
throw new RuntimeException(exception);
}
}
public Optional<User> getUser(String username) {
if (username == null || username.isBlank()) {
return Optional.empty();
}
try (Connection connection = openConnection()) {
String sql = "SELECT id, username, password_hash, created_at, updated_at FROM users WHERE username = ?";
try (PreparedStatement stmt = connection.prepareStatement(sql)) {
stmt.setString(1, username);
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
return Optional.of(new User(
rs.getInt("id"),
rs.getString("username"),
rs.getString("password_hash"),
rs.getTimestamp("created_at"),
rs.getTimestamp("updated_at")
));
}
}
}
} catch (SQLException exception) {
LOGGER.error("Failed to get user: {}", username, exception);
}
return Optional.empty();
}
public List<User> getAllUsers() {
List<User> users = new ArrayList<>();
try (Connection connection = openConnection()) {
String sql = "SELECT id, username, password_hash, created_at, updated_at FROM users ORDER BY username";
try (Statement stmt = connection.createStatement();
ResultSet rs = stmt.executeQuery(sql)) {
while (rs.next()) {
users.add(new User(
rs.getInt("id"),
rs.getString("username"),
rs.getString("password_hash"),
rs.getTimestamp("created_at"),
rs.getTimestamp("updated_at")
));
}
}
} catch (SQLException exception) {
LOGGER.error("Failed to get all users", exception);
}
return users;
}
public boolean verifyPassword(String username, String password) {
Optional<User> user = getUser(username);
if (user.isEmpty()) {
return false;
}
return hashPassword(password).equals(user.get().getPasswordHash());
}
public boolean userExists(String username) {
return getUser(username).isPresent();
}
// ===== LIBRARY MANAGEMENT =====
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, Integer limit, Integer offset) {
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");
if (limit != null && limit > 0) {
sql.append(" LIMIT ").append(limit);
if (offset != null && offset > 0) {
sql.append(" OFFSET ").append(offset);
}
}
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, Integer limit, Integer offset) {
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");
if (limit != null && limit > 0) {
sql.append(" LIMIT ").append(limit);
if (offset != null && offset > 0) {
sql.append(" OFFSET ").append(offset);
}
}
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, Integer limit, Integer offset) {
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");
if (limit != null && limit > 0) {
sql.append(" LIMIT ").append(limit);
if (offset != null && offset > 0) {
sql.append(" OFFSET ").append(offset);
}
}
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);
}
}
List<GlobalSearchRow> globalSearch(String queryRaw, int limitRaw, int offsetRaw) {
String query = queryRaw == null ? "" : queryRaw.trim();
if (query.isBlank()) {
return List.of();
}
int limit = Math.max(1, Math.min(limitRaw, 500));
int offset = Math.max(0, offsetRaw);
List<GlobalSearchRow> rows = new ArrayList<>();
try (Connection connection = openConnection();
PreparedStatement preparedStatement = connection.prepareStatement(
"SELECT \"TABLE\", KEYS, SCORE FROM FT_SEARCH_DATA(?, ?, ?)")) {
preparedStatement.setString(1, query);
preparedStatement.setInt(2, limit);
preparedStatement.setInt(3, offset);
try (ResultSet resultSet = preparedStatement.executeQuery()) {
while (resultSet.next()) {
String tableName = resultSet.getString("TABLE");
String id = firstKeyValue(resultSet);
double score = resultSet.getDouble("SCORE");
if (tableName == null || tableName.isBlank() || id.isBlank()) {
continue;
}
GlobalSearchRow row = resolveGlobalSearchRow(connection, tableName, id, score);
if (row != null) {
rows.add(row);
}
}
}
rows.sort(Comparator
.comparing((GlobalSearchRow row) -> safeLower(row.title()))
.thenComparing(row -> safeLower(row.kind()))
.thenComparing(row -> safeLower(row.id())));
return rows;
} catch (SQLException exception) {
throw new IllegalStateException("Unable to search library.", exception);
}
}
void setMeta(String key, String value) {
try (Connection connection = openConnection();
PreparedStatement preparedStatement = connection.prepareStatement(
"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(String searchRaw, int limitRaw, int offsetRaw) {
String search = searchRaw == null ? "" : searchRaw.trim().toLowerCase(Locale.ROOT);
int limit = Math.max(1, limitRaw);
int offset = Math.max(0, offsetRaw);
List<FavoriteRow> rows = new ArrayList<>();
StringBuilder sql = new StringBuilder("""
SELECT favorite_key, mode, ref_id, ext, title, category_id, series_id, season, episode, url, created_at
FROM favorites
""");
List<Object> args = new ArrayList<>();
if (!search.isBlank()) {
sql.append("""
WHERE LOWER(title) LIKE ?
OR LOWER(mode) LIKE ?
OR LOWER(url) LIKE ?
OR LOWER(favorite_key) LIKE ?
OR (
mode = 'live_category'
AND EXISTS (
SELECT 1
FROM live_streams ls
WHERE ls.category_id = favorites.ref_id
AND (LOWER(ls.name) LIKE ? OR LOWER(ls.stream_id) LIKE ?)
)
)
OR (
mode = 'vod_category'
AND EXISTS (
SELECT 1
FROM vod_streams vs
WHERE vs.category_id = favorites.ref_id
AND (LOWER(vs.name) LIKE ? OR LOWER(vs.stream_id) LIKE ?)
)
)
OR (
mode = 'series_category'
AND EXISTS (
SELECT 1
FROM series_items si
WHERE si.category_id = favorites.ref_id
AND (LOWER(si.name) LIKE ? OR LOWER(si.series_id) LIKE ?)
)
)
""");
String value = "%" + search + "%";
args.add(value);
args.add(value);
args.add(value);
args.add(value);
args.add(value);
args.add(value);
args.add(value);
args.add(value);
args.add(value);
args.add(value);
}
sql.append(" ORDER BY created_at DESC, favorite_key LIMIT ? OFFSET ?");
try (Connection connection = openConnection();
PreparedStatement preparedStatement = connection.prepareStatement(sql.toString())) {
args.add(limit);
args.add(offset);
for (int i = 0; i < args.size(); i++) {
Object arg = args.get(i);
if (arg instanceof Integer intArg) {
preparedStatement.setInt(i + 1, intArg);
} else {
preparedStatement.setString(i + 1, String.valueOf(arg));
}
}
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);
}
}
int countFavorites(String searchRaw) {
String search = searchRaw == null ? "" : searchRaw.trim().toLowerCase(Locale.ROOT);
StringBuilder sql = new StringBuilder("SELECT COUNT(*) FROM favorites");
List<String> args = new ArrayList<>();
if (!search.isBlank()) {
sql.append("""
WHERE LOWER(title) LIKE ?
OR LOWER(mode) LIKE ?
OR LOWER(url) LIKE ?
OR LOWER(favorite_key) LIKE ?
OR (
mode = 'live_category'
AND EXISTS (
SELECT 1
FROM live_streams ls
WHERE ls.category_id = favorites.ref_id
AND (LOWER(ls.name) LIKE ? OR LOWER(ls.stream_id) LIKE ?)
)
)
OR (
mode = 'vod_category'
AND EXISTS (
SELECT 1
FROM vod_streams vs
WHERE vs.category_id = favorites.ref_id
AND (LOWER(vs.name) LIKE ? OR LOWER(vs.stream_id) LIKE ?)
)
)
OR (
mode = 'series_category'
AND EXISTS (
SELECT 1
FROM series_items si
WHERE si.category_id = favorites.ref_id
AND (LOWER(si.name) LIKE ? OR LOWER(si.series_id) LIKE ?)
)
)
""");
String value = "%" + search + "%";
args.add(value);
args.add(value);
args.add(value);
args.add(value);
args.add(value);
args.add(value);
args.add(value);
args.add(value);
args.add(value);
args.add(value);
}
try (Connection connection = openConnection();
PreparedStatement preparedStatement = connection.prepareStatement(sql.toString())) {
for (int i = 0; i < args.size(); i++) {
preparedStatement.setString(i + 1, args.get(i));
}
try (ResultSet resultSet = preparedStatement.executeQuery()) {
if (resultSet.next()) {
return resultSet.getInt(1);
}
return 0;
}
} catch (SQLException exception) {
throw new IllegalStateException("Unable to count 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 HELPERS =====
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 void initializeFullText(Connection connection) throws SQLException {
try (Statement statement = connection.createStatement()) {
statement.execute("CREATE ALIAS IF NOT EXISTS FTL_INIT FOR 'org.h2.fulltext.FullText.init'");
statement.execute("CALL FTL_INIT()");
}
ensureFullTextIndex(connection, "LIVE_CATEGORIES", "CATEGORY_NAME");
ensureFullTextIndex(connection, "VOD_CATEGORIES", "CATEGORY_NAME");
ensureFullTextIndex(connection, "SERIES_CATEGORIES", "CATEGORY_NAME");
ensureFullTextIndex(connection, "LIVE_STREAMS", "STREAM_ID,NAME");
ensureFullTextIndex(connection, "VOD_STREAMS", "STREAM_ID,NAME");
ensureFullTextIndex(connection, "SERIES_ITEMS", "SERIES_ID,NAME");
}
private void ensureFullTextIndex(Connection connection, String tableName, String columnsCsv) throws SQLException {
try (PreparedStatement preparedStatement = connection.prepareStatement("""
SELECT COUNT(*)
FROM FT.INDEXES
WHERE SCHEMA = 'PUBLIC'
AND "TABLE" = ?
""")) {
preparedStatement.setString(1, tableName);
try (ResultSet resultSet = preparedStatement.executeQuery()) {
resultSet.next();
if (resultSet.getInt(1) > 0) {
return;
}
}
}
try (PreparedStatement preparedStatement = connection.prepareStatement("CALL FT_CREATE_INDEX(?, ?, ?)")) {
preparedStatement.setString(1, "PUBLIC");
preparedStatement.setString(2, tableName);
preparedStatement.setString(3, columnsCsv);
preparedStatement.execute();
}
}
private String firstKeyValue(ResultSet resultSet) throws SQLException {
java.sql.Array array = resultSet.getArray("KEYS");
if (array == null) {
return "";
}
try {
Object raw = array.getArray();
if (raw instanceof Object[] values && values.length > 0 && values[0] != null) {
return String.valueOf(values[0]);
}
return "";
} finally {
array.free();
}
}
private GlobalSearchRow resolveGlobalSearchRow(Connection connection, String tableNameRaw, String id, double score)
throws SQLException {
String tableName = tableNameRaw.trim().toUpperCase(Locale.ROOT);
return switch (tableName) {
case "LIVE_CATEGORIES" -> findLiveCategoryHit(connection, id, score);
case "VOD_CATEGORIES" -> findVodCategoryHit(connection, id, score);
case "SERIES_CATEGORIES" -> findSeriesCategoryHit(connection, id, score);
case "LIVE_STREAMS" -> findLiveStreamHit(connection, id, score);
case "VOD_STREAMS" -> findVodStreamHit(connection, id, score);
case "SERIES_ITEMS" -> findSeriesItemHit(connection, id, score);
default -> null;
};
}
private GlobalSearchRow findLiveCategoryHit(Connection connection, String categoryId, double score) throws SQLException {
try (PreparedStatement preparedStatement = connection.prepareStatement("""
SELECT category_id, category_name
FROM live_categories
WHERE category_id = ?
""")) {
preparedStatement.setString(1, categoryId);
try (ResultSet resultSet = preparedStatement.executeQuery()) {
if (!resultSet.next()) {
return null;
}
String id = resultSet.getString("category_id");
String name = resultSet.getString("category_name");
return new GlobalSearchRow("live_category", id, name, id, name, "", "", score);
}
}
}
private GlobalSearchRow findVodCategoryHit(Connection connection, String categoryId, double score) throws SQLException {
try (PreparedStatement preparedStatement = connection.prepareStatement("""
SELECT category_id, category_name
FROM vod_categories
WHERE category_id = ?
""")) {
preparedStatement.setString(1, categoryId);
try (ResultSet resultSet = preparedStatement.executeQuery()) {
if (!resultSet.next()) {
return null;
}
String id = resultSet.getString("category_id");
String name = resultSet.getString("category_name");
return new GlobalSearchRow("vod_category", id, name, id, name, "", "", score);
}
}
}
private GlobalSearchRow findSeriesCategoryHit(Connection connection, String categoryId, double score) throws SQLException {
try (PreparedStatement preparedStatement = connection.prepareStatement("""
SELECT category_id, category_name
FROM series_categories
WHERE category_id = ?
""")) {
preparedStatement.setString(1, categoryId);
try (ResultSet resultSet = preparedStatement.executeQuery()) {
if (!resultSet.next()) {
return null;
}
String id = resultSet.getString("category_id");
String name = resultSet.getString("category_name");
return new GlobalSearchRow("series_category", id, name, id, name, "", "", score);
}
}
}
private GlobalSearchRow findLiveStreamHit(Connection connection, String streamId, double score) throws SQLException {
try (PreparedStatement preparedStatement = connection.prepareStatement("""
SELECT ls.stream_id, ls.name, ls.category_id, ls.epg_channel_id, lc.category_name
FROM live_streams ls
LEFT JOIN live_categories lc ON lc.category_id = ls.category_id
WHERE ls.stream_id = ?
""")) {
preparedStatement.setString(1, streamId);
try (ResultSet resultSet = preparedStatement.executeQuery()) {
if (!resultSet.next()) {
return null;
}
return new GlobalSearchRow(
"live",
resultSet.getString("stream_id"),
resultSet.getString("name"),
resultSet.getString("category_id"),
resultSet.getString("category_name"),
"",
resultSet.getString("epg_channel_id"),
score
);
}
}
}
private GlobalSearchRow findVodStreamHit(Connection connection, String streamId, double score) throws SQLException {
try (PreparedStatement preparedStatement = connection.prepareStatement("""
SELECT vs.stream_id, vs.name, vs.category_id, vs.container_extension, vc.category_name
FROM vod_streams vs
LEFT JOIN vod_categories vc ON vc.category_id = vs.category_id
WHERE vs.stream_id = ?
""")) {
preparedStatement.setString(1, streamId);
try (ResultSet resultSet = preparedStatement.executeQuery()) {
if (!resultSet.next()) {
return null;
}
return new GlobalSearchRow(
"vod",
resultSet.getString("stream_id"),
resultSet.getString("name"),
resultSet.getString("category_id"),
resultSet.getString("category_name"),
resultSet.getString("container_extension"),
"",
score
);
}
}
}
private GlobalSearchRow findSeriesItemHit(Connection connection, String seriesId, double score) throws SQLException {
try (PreparedStatement preparedStatement = connection.prepareStatement("""
SELECT si.series_id, si.name, si.category_id, sc.category_name
FROM series_items si
LEFT JOIN series_categories sc ON sc.category_id = si.category_id
WHERE si.series_id = ?
""")) {
preparedStatement.setString(1, seriesId);
try (ResultSet resultSet = preparedStatement.executeQuery()) {
if (!resultSet.next()) {
return null;
}
return new GlobalSearchRow(
"series_item",
resultSet.getString("series_id"),
resultSet.getString("name"),
resultSet.getString("category_id"),
resultSet.getString("category_name"),
"",
"",
score
);
}
}
}
private String normalizeType(String type) {
return type == null ? "" : type.trim().toLowerCase(Locale.ROOT);
}
private String safeLower(String value) {
return value == null ? "" : value.toLowerCase(Locale.ROOT);
}
private Connection openConnection() throws SQLException {
return DriverManager.getConnection(jdbcUrl);
}
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);
}
}
private String hashPassword(String password) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(password.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(hash);
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("SHA-256 not available", e);
}
}
// ===== DATA CLASSES =====
public static final class User {
private final int id;
private final String username;
private final String passwordHash;
private final Timestamp createdAt;
private final Timestamp updatedAt;
User(int id, String username, String passwordHash, Timestamp createdAt, Timestamp updatedAt) {
this.id = id;
this.username = username;
this.passwordHash = passwordHash;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
}
public int getId() {
return id;
}
public String getUsername() {
return username;
}
public String getPasswordHash() {
return passwordHash;
}
public Timestamp getCreatedAt() {
return createdAt;
}
public Timestamp getUpdatedAt() {
return updatedAt;
}
@Override
public String toString() {
return "User{" +
"id=" + id +
", username='" + username + '\'' +
", createdAt=" + createdAt +
", updatedAt=" + updatedAt +
'}';
}
}
@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 GlobalSearchRow(String kind, String id, String title, String categoryId, String categoryName,
String ext, String epgChannelId, double score) {
}
record LibraryCounts(int liveCategoryCount, int liveStreamCount, int vodCategoryCount, int vodStreamCount,
int seriesCategoryCount, int seriesItemCount, int seriesEpisodeCount) {
}
}