feat: add M3uXtreamAdapter service for unified API handling
This commit is contained in:
@@ -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 };
|
||||
Reference in New Issue
Block a user