From a86f9ca878fab35719d3904dced4205c4a665a37 Mon Sep 17 00:00:00 2001 From: Trevor Mears Date: Sun, 11 Jan 2026 23:52:45 -0800 Subject: [PATCH] feat: add M3uXtreamAdapter service for unified API handling --- server/services/m3uXtreamAdapter.js | 133 ++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 server/services/m3uXtreamAdapter.js diff --git a/server/services/m3uXtreamAdapter.js b/server/services/m3uXtreamAdapter.js new file mode 100644 index 0000000..adb7b9b --- /dev/null +++ b/server/services/m3uXtreamAdapter.js @@ -0,0 +1,133 @@ +/** + * M3U Xtream Adapter + * + * Makes M3U sources respond to Xtream-style API methods. + * Queries data from SQLite (already synced during source refresh). + */ + +const { getDb } = require('../db/sqlite'); + +class M3uXtreamAdapter { + constructor(sourceId) { + this.sourceId = sourceId; + } + + /** + * Get live categories (groups) for this M3U source. + * Returns Xtream-compatible format: [{ category_id, category_name, parent_id }] + */ + getLiveCategories(includeHidden = false) { + const db = getDb(); + + // M3U stores group name in category_id field of playlist_items + // We need to aggregate unique groups with counts + let query = ` + SELECT + category_id, + category_id as category_name, + NULL as parent_id, + COUNT(*) as channel_count + FROM playlist_items + WHERE source_id = ? AND type = 'live' + ${!includeHidden ? 'AND is_hidden = 0' : ''} + GROUP BY category_id + ORDER BY category_id ASC + `; + + const rows = db.prepare(query).all(this.sourceId); + + return rows.map(row => ({ + category_id: row.category_id || 'Uncategorized', + category_name: row.category_id || 'Uncategorized', + parent_id: null, + // Bonus: include count for lazy-loading UI + channel_count: row.channel_count + })); + } + + /** + * Get live streams (channels), optionally filtered by category. + * Returns Xtream-compatible format: [{ stream_id, name, stream_icon, category_id, ... }] + */ + getLiveStreams(categoryId = null, includeHidden = false) { + const db = getDb(); + + let query = ` + SELECT + item_id as stream_id, + name, + stream_icon, + stream_url, + category_id, + added_at, + data + FROM playlist_items + WHERE source_id = ? AND type = 'live' + ${!includeHidden ? 'AND is_hidden = 0' : ''} + `; + + const params = [this.sourceId]; + + if (categoryId) { + query += ` AND category_id = ?`; + params.push(categoryId); + } + + query += ` ORDER BY name ASC`; + + const rows = db.prepare(query).all(...params); + + return rows.map(row => { + // Parse any extra data stored as JSON + let extra = {}; + if (row.data) { + try { extra = JSON.parse(row.data); } catch (e) { } + } + + return { + stream_id: row.stream_id, + name: row.name, + stream_icon: row.stream_icon, + category_id: row.category_id, + added: row.added_at, + // M3U-specific: direct stream URL (Xtream builds URLs from credentials) + stream_url: row.stream_url, + // Include extra fields from parser (tvgId, etc.) + epg_channel_id: extra.tvgId || null, + ...extra + }; + }); + } + + /** + * Build stream URL for playback. + * For M3U, we return the stored URL directly (no credential building). + */ + buildStreamUrl(streamId, type = 'live', container = 'ts') { + const db = getDb(); + + const row = db.prepare(` + SELECT stream_url FROM playlist_items + WHERE source_id = ? AND item_id = ? + `).get(this.sourceId, streamId); + + return row?.stream_url || null; + } + + /** + * Get XMLTV EPG URL - M3U sources don't have built-in EPG URLs. + * Returns null; EPG must be configured as a separate source. + */ + getXmltvUrl() { + return null; + } +} + +/** + * Factory function to create adapter from source ID + */ +function createFromSourceId(sourceId) { + return new M3uXtreamAdapter(sourceId); +} + +module.exports = { M3uXtreamAdapter, createFromSourceId };