Initial commit (v1.5.9)
This commit is contained in:
@@ -0,0 +1,563 @@
|
||||
import { ISource, SearchResult, MediaType, ContentLinks, SelectionData, VideoLink } from '../../src/types/source.js';
|
||||
import { CONFIG } from '../../src/utils/config.js';
|
||||
import { sourceRegistry } from '../../src/core/registry.js';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
type IndexedTitle = {
|
||||
norm: string;
|
||||
normOrig: string;
|
||||
// Distinct token list for the entry (union of norm + normOrig words).
|
||||
// Precomputed at index build time so the search hot path never
|
||||
// re-splits/dedupes these strings.
|
||||
words: string[];
|
||||
title_name: string;
|
||||
original_title: string | null;
|
||||
tmdb_id: number;
|
||||
category_name: string;
|
||||
title_poster: string | null;
|
||||
created_at: string | null;
|
||||
};
|
||||
|
||||
export class LocalDatabaseAPI implements ISource {
|
||||
name = 'localdb';
|
||||
displayName = 'Base de données locale';
|
||||
private db: any = null;
|
||||
private dbPath: string;
|
||||
private titleIndex: IndexedTitle[] | null = null;
|
||||
// Inverted indexes used by search() to shrink the candidate set from
|
||||
// ~104K rows down to <2K before running tier scoring. Populated by
|
||||
// buildTitleIndex(); never read or written outside of that method
|
||||
// and search().
|
||||
private tokenIndex: Map<string, number[]> | null = null; // exact token -> row indices
|
||||
private titleByNorm: Map<string, number[]> | null = null; // full norm -> row indices (Tier 1)
|
||||
private prefixIndex: Map<string, number[]> | null = null; // 2-char prefix-> row indices (Tier 2 + fuzzy)
|
||||
|
||||
constructor() {
|
||||
this.dbPath = path.resolve(CONFIG.DB_PATH || './database/darkiworld.db');
|
||||
}
|
||||
|
||||
private initDb(): boolean {
|
||||
if (this.db) return true;
|
||||
if (!fs.existsSync(this.dbPath)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
// readOnly avoids journal/WAL writes (plugin only reads).
|
||||
this.db = new DatabaseSync(this.dbPath, { readOnly: true });
|
||||
// Keep SQLite's temp store in RAM so big GROUP BY / sort
|
||||
// operations don't spill to /tmp (a small tmpfs in the
|
||||
// hardened container). Also bump page cache + mmap for
|
||||
// the initial index scan.
|
||||
for (const p of [
|
||||
'PRAGMA temp_store = MEMORY',
|
||||
'PRAGMA cache_size = -8000', // ~8MB page cache
|
||||
'PRAGMA mmap_size = 67108864', // 64MB mmap, not 256MB
|
||||
]) {
|
||||
this.db.prepare(p).run();
|
||||
}
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
console.error('[LocalDB] ❌ Erreur lors de l\'ouverture de la base SQLite native:', e.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
const ok = this.initDb();
|
||||
if (ok) {
|
||||
// Warm the search indexes right after registration so the
|
||||
// first /search request doesn't eat the multi-second build
|
||||
// cost. setImmediate yields the current tick — the parallel
|
||||
// health checks of other plugins still run first.
|
||||
setImmediate(() => {
|
||||
try { this.buildTitleIndex(); }
|
||||
catch (e: any) { console.error('[LocalDB] Index warmup failed:', e.message); }
|
||||
});
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
// Lowercase, strip diacritics, strip apostrophes, collapse to alnum tokens.
|
||||
// "Pokémon: l'aventure" -> "pokemon l aventure"
|
||||
private static normalize(s: string | null | undefined): string {
|
||||
if (!s) return '';
|
||||
return s
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
.replace(/['"`’ʼ]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Bounded Levenshtein. Returns max+1 if it would exceed `max` (cheap exit).
|
||||
private static editDistance(a: string, b: string, max: number): number {
|
||||
const la = a.length, lb = b.length;
|
||||
if (Math.abs(la - lb) > max) return max + 1;
|
||||
if (la === 0) return lb;
|
||||
if (lb === 0) return la;
|
||||
let prev = new Array(lb + 1);
|
||||
let curr = new Array(lb + 1);
|
||||
for (let j = 0; j <= lb; j++) prev[j] = j;
|
||||
for (let i = 1; i <= la; i++) {
|
||||
curr[0] = i;
|
||||
let rowMin = curr[0];
|
||||
const ai = a.charCodeAt(i - 1);
|
||||
for (let j = 1; j <= lb; j++) {
|
||||
const cost = ai === b.charCodeAt(j - 1) ? 0 : 1;
|
||||
const v = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
|
||||
curr[j] = v;
|
||||
if (v < rowMin) rowMin = v;
|
||||
}
|
||||
if (rowMin > max) return max + 1;
|
||||
const tmp = prev; prev = curr; curr = tmp;
|
||||
}
|
||||
return prev[lb];
|
||||
}
|
||||
|
||||
// Per-token edit-distance budget. Short words must match almost exactly;
|
||||
// longer words tolerate more typos.
|
||||
private static fuzzyBudget(tok: string): number {
|
||||
if (tok.length <= 3) return 0;
|
||||
if (tok.length <= 5) return 1;
|
||||
if (tok.length <= 8) return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
private buildTitleIndex(): void {
|
||||
if (this.titleIndex !== null) return;
|
||||
if (!this.initDb()) {
|
||||
this.titleIndex = [];
|
||||
this.tokenIndex = new Map();
|
||||
this.titleByNorm = new Map();
|
||||
this.prefixIndex = new Map();
|
||||
return;
|
||||
}
|
||||
|
||||
const t0 = Date.now();
|
||||
const sql = `
|
||||
SELECT title_name,
|
||||
original_title,
|
||||
tmdb_id,
|
||||
category_name,
|
||||
title_poster,
|
||||
MIN(created_at) AS created_at
|
||||
FROM links_small
|
||||
GROUP BY title_name, tmdb_id
|
||||
`;
|
||||
const rows = this.db.prepare(sql).all() as any[];
|
||||
|
||||
const titleIndex = new Array<IndexedTitle>(rows.length);
|
||||
const tokenIndex = new Map<string, number[]>();
|
||||
const titleByNorm = new Map<string, number[]>();
|
||||
const prefixIndex = new Map<string, number[]>();
|
||||
|
||||
const push = (m: Map<string, number[]>, key: string, idx: number) => {
|
||||
const list = m.get(key);
|
||||
if (list) list.push(idx);
|
||||
else m.set(key, [idx]);
|
||||
};
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i];
|
||||
const norm = LocalDatabaseAPI.normalize(r.title_name);
|
||||
const normOrig = LocalDatabaseAPI.normalize(r.original_title);
|
||||
|
||||
// Deduplicated union of words from both title fields.
|
||||
const seen = new Set<string>();
|
||||
const words: string[] = [];
|
||||
if (norm) for (const w of norm.split(' ')) if (w && !seen.has(w)) { seen.add(w); words.push(w); }
|
||||
if (normOrig) for (const w of normOrig.split(' ')) if (w && !seen.has(w)) { seen.add(w); words.push(w); }
|
||||
|
||||
titleIndex[i] = {
|
||||
norm, normOrig, words,
|
||||
title_name: r.title_name,
|
||||
original_title: r.original_title,
|
||||
tmdb_id: r.tmdb_id || 0,
|
||||
category_name: r.category_name,
|
||||
title_poster: r.title_poster,
|
||||
created_at: r.created_at,
|
||||
};
|
||||
|
||||
if (norm) push(titleByNorm, norm, i);
|
||||
if (normOrig && normOrig !== norm) push(titleByNorm, normOrig, i);
|
||||
for (const w of words) {
|
||||
push(tokenIndex, w, i);
|
||||
if (w.length >= 2) push(prefixIndex, w.slice(0, 2), i);
|
||||
}
|
||||
}
|
||||
|
||||
this.titleIndex = titleIndex;
|
||||
this.tokenIndex = tokenIndex;
|
||||
this.titleByNorm = titleByNorm;
|
||||
this.prefixIndex = prefixIndex;
|
||||
|
||||
console.log(`[LocalDB] Index construit: ${titleIndex.length} titres en ${Date.now() - t0}ms ` +
|
||||
`(tokens=${tokenIndex.size}, prefixes=${prefixIndex.size})`);
|
||||
}
|
||||
|
||||
private mapCategoryToType(category: string): MediaType {
|
||||
const cat = (category || '').toLowerCase().trim();
|
||||
|
||||
// Livres & BD
|
||||
if (cat.match(/\b(bd|livres?|ebooks?|magazines?|journaux)\b/)) return 'book';
|
||||
|
||||
// Jeux
|
||||
if (cat.match(/\b(jeux?|consoles?)\b/)) return 'game';
|
||||
|
||||
// Logiciels & Formations
|
||||
if (cat.match(/\b(logiciels?|formations?)\b/)) return 'software';
|
||||
|
||||
// Musique
|
||||
if (cat.match(/\b(musiques?|audio)\b/)) return 'music';
|
||||
|
||||
// Séries
|
||||
if (cat.includes('série') || cat.includes('serie') || cat.includes('tv') || cat.includes('emission')) return 'series';
|
||||
|
||||
// Animes / Dessins animés
|
||||
if (cat.includes('anime') || cat.includes('manga') || cat.includes('dessin')) return 'anime';
|
||||
|
||||
// Films (Films HD, Documentaires, Spectacles...)
|
||||
if (cat.includes('film') || cat.includes('spectacle') || cat.includes('documentaire') || cat === '') return 'movie';
|
||||
|
||||
// Tout le reste
|
||||
return 'other';
|
||||
}
|
||||
|
||||
async search(query: string, mediaType: any = 'movie'): Promise<SearchResult[]> {
|
||||
if (!this.initDb()) {
|
||||
console.warn('[LocalDB] ⚠️ Base de données non initialisée ou introuvable.');
|
||||
return [];
|
||||
}
|
||||
this.buildTitleIndex();
|
||||
if (!this.titleIndex || this.titleIndex.length === 0) return [];
|
||||
|
||||
const t0 = Date.now();
|
||||
const q = LocalDatabaseAPI.normalize(query);
|
||||
if (!q) return [];
|
||||
const tokens = q.split(' ').filter(Boolean);
|
||||
if (tokens.length === 0) return [];
|
||||
|
||||
// Candidate row indices, gathered from the inverted indexes. For
|
||||
// a typical query this drops the working set from ~104K rows to
|
||||
// a few hundred. Rows that don't show up here cannot match Tier
|
||||
// 1, 2, 3 or 5 — the only thing they could theoretically hit is
|
||||
// Tier 4 substring-inside-a-word, which is rare enough not to
|
||||
// justify a trigram index.
|
||||
const candidates = new Set<number>();
|
||||
const exactHits = this.titleByNorm!.get(q);
|
||||
if (exactHits) for (const i of exactHits) candidates.add(i);
|
||||
for (const tok of tokens) {
|
||||
const rows = this.tokenIndex!.get(tok);
|
||||
if (rows) for (const i of rows) candidates.add(i);
|
||||
if (tok.length >= 2) {
|
||||
const pRows = this.prefixIndex!.get(tok.slice(0, 2));
|
||||
if (pRows) for (const i of pRows) candidates.add(i);
|
||||
}
|
||||
}
|
||||
|
||||
const scored: Array<{ idx: number; score: number }> = [];
|
||||
|
||||
for (const i of candidates) {
|
||||
const entry = this.titleIndex[i];
|
||||
const t = entry.norm;
|
||||
const o = entry.normOrig;
|
||||
|
||||
let score = 0;
|
||||
|
||||
// Tier 1: exact normalized match on either title field
|
||||
if (t === q || (o && o === q)) {
|
||||
score = 1000;
|
||||
}
|
||||
// Tier 2: title starts with the full query
|
||||
else if (t.startsWith(q) || (o && o.startsWith(q))) {
|
||||
score = 800;
|
||||
}
|
||||
// Tier 3: query appears as a whole-word substring
|
||||
else if ((' ' + t + ' ').includes(' ' + q + ' ') ||
|
||||
(o && (' ' + o + ' ').includes(' ' + q + ' '))) {
|
||||
score = 700;
|
||||
}
|
||||
// Tier 4: raw substring (partial word)
|
||||
else if (t.includes(q) || (o && o.includes(q))) {
|
||||
score = 600;
|
||||
}
|
||||
// Tier 5: per-token matching, exact-then-fuzzy, any word order.
|
||||
// Uses the precomputed entry.words instead of re-splitting on
|
||||
// every row.
|
||||
else {
|
||||
const words = entry.words;
|
||||
let exactMatched = 0;
|
||||
let fuzzyMatched = 0;
|
||||
let fuzzyPenalty = 0;
|
||||
let anyMatched = false;
|
||||
|
||||
for (const tok of tokens) {
|
||||
let exact = false;
|
||||
for (const w of words) {
|
||||
if (w === tok || w.startsWith(tok)) { exact = true; break; }
|
||||
}
|
||||
if (exact) {
|
||||
exactMatched++;
|
||||
anyMatched = true;
|
||||
continue;
|
||||
}
|
||||
const budget = LocalDatabaseAPI.fuzzyBudget(tok);
|
||||
if (budget === 0) continue;
|
||||
let best = budget + 1;
|
||||
for (const w of words) {
|
||||
if (Math.abs(w.length - tok.length) > budget) continue;
|
||||
const d = LocalDatabaseAPI.editDistance(tok, w, budget);
|
||||
if (d < best) { best = d; if (best <= 1) break; }
|
||||
}
|
||||
if (best <= budget) {
|
||||
fuzzyMatched++;
|
||||
fuzzyPenalty += best;
|
||||
anyMatched = true;
|
||||
}
|
||||
}
|
||||
|
||||
const totalMatched = exactMatched + fuzzyMatched;
|
||||
if (totalMatched === tokens.length) {
|
||||
// All tokens covered — strong signal even when some were fuzzy
|
||||
score = 400 - fuzzyPenalty * 30 + exactMatched * 5;
|
||||
} else if (anyMatched) {
|
||||
// Partial coverage — only meaningful for multi-word queries
|
||||
score = Math.round(120 * (totalMatched / tokens.length)) - fuzzyPenalty * 10;
|
||||
}
|
||||
}
|
||||
|
||||
if (score > 0) {
|
||||
// Tiebreakers: shorter titles win; original_title field is a small bonus when it helped
|
||||
score += Math.max(0, 30 - t.length);
|
||||
scored.push({ idx: i, score });
|
||||
}
|
||||
}
|
||||
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
|
||||
const results: SearchResult[] = scored.slice(0, 150).map(({ idx }) => {
|
||||
const r = this.titleIndex![idx];
|
||||
const type = this.mapCategoryToType(r.category_name);
|
||||
return {
|
||||
title: r.title_name,
|
||||
year: r.created_at ? r.created_at.substring(0, 4) : null,
|
||||
image: r.title_poster || null,
|
||||
hrefPath: `localdb:${r.tmdb_id}:${r.title_name}`,
|
||||
type,
|
||||
source: this.name
|
||||
};
|
||||
});
|
||||
|
||||
const filtered = (mediaType === 'movie')
|
||||
? results.filter(r => r.type === 'movie' || r.type === 'anime')
|
||||
: (mediaType === 'series')
|
||||
? results.filter(r => r.type === 'series' || r.type === 'anime')
|
||||
: (mediaType === 'movie_series')
|
||||
? results.filter(r => r.type === 'movie' || r.type === 'series' || r.type === 'anime')
|
||||
: results.filter(r => r.type === mediaType);
|
||||
|
||||
console.log(`[LocalDB] search "${query}" → ${candidates.size} candidats, ${filtered.length} résultats en ${Date.now() - t0}ms`);
|
||||
return filtered;
|
||||
}
|
||||
|
||||
async getTrending(mediaType: MediaType): Promise<SearchResult[]> {
|
||||
// Pas de tendances en base de données locale
|
||||
return [];
|
||||
}
|
||||
|
||||
// Distinct quality/host values from the DB, grouped by media bucket.
|
||||
// Cached after the first call — the underlying data is static.
|
||||
private optionsCache: { qualities: { movies: string[]; series: string[] }; hosts: string[] } | null = null;
|
||||
listConfigOptions(): { qualities: { movies: string[]; series: string[] }; hosts: string[] } {
|
||||
if (this.optionsCache) return this.optionsCache;
|
||||
const empty = { qualities: { movies: [] as string[], series: [] as string[] }, hosts: [] as string[] };
|
||||
if (!this.initDb()) return empty;
|
||||
try {
|
||||
const movieCats = ['Films', 'Animes', 'Films et series', 'Documentaire', 'Spectacle'];
|
||||
const seriesCats = ['Séries', 'Animes', 'Téléréalité', 'Émissions TV', 'Mangas'];
|
||||
const sql = (cats: string[]) => `
|
||||
SELECT DISTINCT quality_name FROM links_small
|
||||
WHERE category_name IN (${cats.map(() => '?').join(',')})
|
||||
AND quality_name IS NOT NULL AND quality_name != ''
|
||||
ORDER BY quality_name`;
|
||||
const pick = (cats: string[]): string[] =>
|
||||
this.db.prepare(sql(cats)).all(...cats).map((r: any) => r.quality_name);
|
||||
|
||||
const hosts = this.db.prepare(
|
||||
`SELECT DISTINCT host_name FROM links_small
|
||||
WHERE host_name IS NOT NULL AND host_name != ''
|
||||
ORDER BY host_name`
|
||||
).all().map((r: any) => r.host_name);
|
||||
|
||||
this.optionsCache = {
|
||||
qualities: { movies: pick(movieCats), series: pick(seriesCats) },
|
||||
hosts,
|
||||
};
|
||||
return this.optionsCache;
|
||||
} catch (e: any) {
|
||||
console.error('[LocalDB] listConfigOptions error:', e.message);
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
|
||||
private parseIdentifier(identifier: string): { tmdbId: number; titleName: string } {
|
||||
const parts = identifier.split(':');
|
||||
if (parts[0] === 'localdb') {
|
||||
return {
|
||||
tmdbId: parseInt(parts[1], 10) || 0,
|
||||
titleName: parts.slice(2).join(':')
|
||||
};
|
||||
}
|
||||
return { tmdbId: 0, titleName: identifier };
|
||||
}
|
||||
|
||||
async getContentLinks(identifier: string, season: number = 1): Promise<ContentLinks> {
|
||||
if (!this.initDb()) return { links: [] };
|
||||
|
||||
const { tmdbId, titleName } = this.parseIdentifier(identifier);
|
||||
|
||||
try {
|
||||
let categoryStmt = this.db.prepare('SELECT category_name FROM links_small WHERE tmdb_id = ? OR title_name = ? LIMIT 1');
|
||||
let sample = categoryStmt.get(tmdbId, titleName) as any;
|
||||
|
||||
if (!sample && tmdbId > 0) {
|
||||
sample = categoryStmt.get(0, titleName) as any;
|
||||
}
|
||||
|
||||
if (!sample) return { links: [] };
|
||||
|
||||
const mediaType = this.mapCategoryToType(sample.category_name);
|
||||
if (!mediaType) return { links: [] };
|
||||
|
||||
const isSeries = mediaType === 'series';
|
||||
let rows: any[] = [];
|
||||
|
||||
if (isSeries) {
|
||||
const sql = `
|
||||
SELECT * FROM links_small
|
||||
WHERE (tmdb_id = ? OR title_name = ?) AND season_number = ?
|
||||
ORDER BY episode_number ASC, quality_name DESC
|
||||
`;
|
||||
rows = this.db.prepare(sql).all(tmdbId, titleName, season) as any[];
|
||||
} else {
|
||||
const sql = `
|
||||
SELECT * FROM links_small
|
||||
WHERE tmdb_id = ? OR title_name = ?
|
||||
ORDER BY quality_name DESC
|
||||
`;
|
||||
rows = this.db.prepare(sql).all(tmdbId, titleName) as any[];
|
||||
}
|
||||
|
||||
const splitLangs = (s: string | null | undefined): string[] => {
|
||||
if (!s) return [];
|
||||
return s.split(/[,;/]+/).map(p => p.trim()).filter(Boolean);
|
||||
};
|
||||
|
||||
const links: VideoLink[] = rows.map((row: any, i: number) => {
|
||||
const idKey = row.link_id != null ? String(row.link_id) : `local_${i}`;
|
||||
const audioLangs = splitLangs(row.audio_langs);
|
||||
const subLangs = splitLangs(row.sub_langs);
|
||||
|
||||
// Legacy `langs` field — kept for plugins/clients that don't
|
||||
// know about audioLangs/subLangs yet.
|
||||
const langsList = [...audioLangs];
|
||||
if (subLangs.length) langsList.push(`Subs: ${subLangs.join(', ')}`);
|
||||
|
||||
return {
|
||||
id: idKey,
|
||||
host: row.host_name || 'Inconnu',
|
||||
url: row.link_url || null,
|
||||
size: row.size_human || '0 Bytes',
|
||||
sizeBytes: row.size_bytes || 0,
|
||||
quality: row.quality_name || 'BDRip',
|
||||
langs: langsList,
|
||||
episode: row.is_full_season
|
||||
? 'Saison complète'
|
||||
: (row.episode_number ? `Épisode ${row.episode_number}` : null),
|
||||
episodeNumber: row.episode_number || null,
|
||||
episodeName: row.episode_name || null,
|
||||
isFullSeason: !!row.is_full_season,
|
||||
audioLangs,
|
||||
subLangs,
|
||||
};
|
||||
});
|
||||
|
||||
return { links };
|
||||
} catch (e: any) {
|
||||
console.error('[LocalDB] Erreur getContentLinks:', e.message);
|
||||
return { links: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async getSelection(identifier: string, type?: string, seasonValue?: string | number): Promise<SelectionData> {
|
||||
if (!this.initDb()) return { links: [], seasons: [], isSeries: false };
|
||||
|
||||
const { tmdbId, titleName } = this.parseIdentifier(identifier);
|
||||
|
||||
try {
|
||||
const sample = this.db.prepare('SELECT category_name FROM links_small WHERE tmdb_id = ? OR title_name = ? LIMIT 1').get(tmdbId, titleName) as any;
|
||||
if (!sample) return { links: [], seasons: [], isSeries: false };
|
||||
|
||||
const mediaType = this.mapCategoryToType(sample.category_name);
|
||||
if (!mediaType) return { links: [], seasons: [], isSeries: false };
|
||||
|
||||
const isSeries = mediaType === 'series';
|
||||
let seasonsList: any[] = [];
|
||||
let currentSeason = 1;
|
||||
|
||||
if (isSeries) {
|
||||
const seasonsRows = this.db.prepare(`
|
||||
SELECT DISTINCT season_number
|
||||
FROM links_small
|
||||
WHERE tmdb_id = ? OR title_name = ?
|
||||
ORDER BY season_number ASC
|
||||
`).all(tmdbId, titleName) as any[];
|
||||
|
||||
seasonsList = seasonsRows.map((r: any) => ({
|
||||
label: `Saison ${r.season_number}`,
|
||||
value: r.season_number
|
||||
}));
|
||||
|
||||
if (seasonValue) {
|
||||
currentSeason = parseInt(String(seasonValue), 10) || 1;
|
||||
} else if (seasonsRows.length > 0) {
|
||||
// Prefer season 1 if it exists (matches the UI's auto-selected
|
||||
// dropdown option); otherwise fall back to the lowest season
|
||||
// number — usually "Saison 0" specials.
|
||||
const hasSeason1 = seasonsRows.some((r: any) => r.season_number === 1);
|
||||
currentSeason = hasSeason1 ? 1 : seasonsRows[0].season_number;
|
||||
}
|
||||
}
|
||||
|
||||
const content = await this.getContentLinks(identifier, currentSeason);
|
||||
|
||||
return {
|
||||
links: content.links,
|
||||
seasons: seasonsList,
|
||||
isSeries
|
||||
};
|
||||
} catch (e: any) {
|
||||
console.error('[LocalDB] Erreur getSelection:', e.message);
|
||||
return { links: [], seasons: [], isSeries: false };
|
||||
}
|
||||
}
|
||||
|
||||
resolveLocalLink(linkId: string | number): string | null {
|
||||
if (!this.initDb()) return null;
|
||||
try {
|
||||
const row = this.db.prepare('SELECT link_url FROM links_small WHERE link_id = ? LIMIT 1').get(linkId) as any;
|
||||
if (row && row.link_url) {
|
||||
return row.link_url;
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('[LocalDB] Erreur resolveLocalLink:', e.message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Enregistrement automatique du plugin
|
||||
sourceRegistry.register(new LocalDatabaseAPI());
|
||||
Reference in New Issue
Block a user