JS-модули источников

JS-модули источников

JS-модули — современный способ подключить новый источник контента без пересборки сервера и без Go-тулчейна у пользователя. Каждый модуль — это одна JavaScript-функция в изолированной песочнице (Goja), которая принимает HTTP-запрос от Lampa и возвращает JSON в формате плейлиста.

JS-модули — это эволюция старых кастомных балансеров (Go-subprocess). Старый механизм продолжает работать для обратной совместимости, но новые источники лучше писать как JS-модули. Они проще, быстрее ставятся, работают hot-reload и не требуют Go на машине пользователя.

Почему JS, а не Go

Критерий Go-subprocess (custom balancers) JS-модуль (новый)
Размер артефакта ~9 МБ бинарь на плагин 5–20 КБ .js файл
Нужен Go на машине Да Нет
Hot-reload После go build и restart subprocess Мгновенно, по кнопке в админке
Запуск Отдельный процесс + TCP Внутри основного процесса, in-process handler
Порог входа Go, конкурентность, net/http JavaScript + «fetch-like» API
Deploy Скомпилировать 11 бинарей под каждую ОС Скопировать папку
Редактирование IDE + компилятор Прямо в браузере через админку

Архитектура

modules/
  <module-id>/
    manifest.json     # метаданные + схема настроек
    index.js          # код модуля
    config.json       # (опционально) сохранённые настройки пользователя
    <любые файлы>     # иконки, README, тест-фикстуры…

При старте lampac-go:

  1. Сканирует modules/ — ищет поддиректории с manifest.json
  2. Компилирует index.js через Goja (pure-Go реализация ECMAScript 5.1+)
  3. Регистрирует HTTP-handler на /lite/<id> (и всех саб-путях /lite/<id>/...)
  4. Если в манифесте указан quality — добавляет бейдж в выдачу (「FHD」, 「4K」)

При каждом запросе /lite/<id>?...:

  1. Создаётся новый инстанс Goja VM (изолирован от других запросов)
  2. Вызывается глобальная функция handle(inv) модуля
  3. Возвращённое значение сериализуется в JSON и отдаётся клиенту

Приоритет маршрутов

JS-модули перехватывают встроенные Go-балансеры с таким же ID. Если у вас установлен и включён модуль starlight, то /lite/starlight будет идти в JS, а не в Go-код. Выключение модуля в админке возвращает поведение встроенного балансера.

ID модуля должен совпадать с route, который вы хотите обслуживать. Например, modules/starlight//lite/starlight. Использовать зарезервированные имена (events) нельзя — модуль не загрузится.

manifest.json

Минимальный манифест:

{
  "id": "mysource",
  "name": "My Source",
  "version": "1.0.0",
  "entry": "index.js",
  "content_types": ["movie", "serial"]
}

Полная схема

Поле Тип Описание
id string, обязательно ID модуля и имя маршрута ([a-z][a-z0-9_]{1,31})
name string Отображаемое название (по умолчанию = id)
version string SemVer (по умолчанию 0.0.0)
author string Автор/команда
description string Описание для карточки в админке
icon string Путь к файлу иконки внутри папки модуля
entry string Имя JS-файла (по умолчанию index.js)
content_types string[] movie, serial, anime, tv
quality string Бейдж в выдаче: SD, HD, FHD, 4K
languages string[] Коды языков контента (uk, ru, en, ja, …)
anime bool Пометить как аниме-источник (влияет на фильтрацию по original_language)
ukrainian bool Маркер украинского источника (для фильтра в админке)
tags string[] Произвольные теги для фильтрации
repository string Ссылка на исходники (GitHub и т.п.)
config_schema ConfigField[] Поля настроек, показываемые в админке

ConfigField

{
  "key": "host",
  "label": "Хост API",
  "type": "string",
  "default": "https://example.com",
  "description": "Базовый URL источника",
  "placeholder": "https://...",
  "secret": false
}
Поле Значения
type string, int, number, bool, secret, textarea
secret true — поле шифруется и маскируется в UI (••••••)

Значения, которые пользователь задал в админке, сохраняются в modules/<id>/config.json и приходят в JS как inv.config.

API песочницы

Внутри index.js доступны глобальные объекты:

http — HTTP-клиент

var res = http.get('https://example.com/api', {
  headers: { 'Accept': 'application/json', 'X-Token': '...' },
  userAgent: 'MyBot/1.0',
  referer: 'https://example.com/'
});

if (res.ok) {
  var data = res.json();       // распарсить как JSON (бросит исключение при ошибке)
  // или res.text    — тело как строка
  // res.status      — код
  // res.headers     — объект с первой value каждого заголовка (lowercase)
  // res.cookies     — [{name, value}, ...]
  // res.duration_ms
}
Метод Назначение
http.get(url, opts?) GET
http.post(url, opts?) POST
http.put(url, opts?) PUT
http.delete(url, opts?) DELETE
http.head(url, opts?) HEAD
http.request(method, url, opts?) произвольный метод

opts могут содержать:

Поле Пример Описание
headers {Accept:'...', 'X-Foo':'bar'} HTTP-заголовки
body 'raw' или {json:true} Тело (строка или JSON-объект)
form {q:'foo', page:1} Форма x-www-form-urlencoded
userAgent 'Mozilla/5.0 ...' User-Agent
referer 'https://example.com/' Referer

Важно: http.get не бросает исключение при сетевой ошибке — он возвращает {ok:false, error:'...', status:0}. Всегда проверяйте res.ok.

Лимиты:

  • Таймаут запроса — 30 секунд (совокупно для всех HTTP-вызовов в одном invoke)
  • Макс. размер ответа — 8 МБ
  • User-Agent по умолчанию — Mozilla/5.0 ... Chrome/120.0 (можно переопределить)

proxy — проксирование стримов

var safe = proxy.url(streamUrl, 'mysource');
// → http://your-server/proxy/<encrypted>.m3u8
//   сервер проксирует трафик к источнику, пряча реальный URL
//   и обходя Origin/Referer-ограничения CDN

Когда нужно пробросить специфические заголовки в CDN (например Origin, кастомный Authorization):

var safe = proxy.urlWithHeaders(streamUrl, 'mysource', {
  'Origin': 'https://cdn-origin.example.com',
  'Referer': 'https://player.example.com/'
});

Прокси работает для любых HLS/MP4/DASH-стримов. Второй аргумент plugin используется для учёта трафика в статистике админки.

cache — per-module кэш

var cached = cache.get('search:' + query);
if (cached === undefined) {
  cached = fetchSomething(query);
  cache.set('search:' + query, cached, 600);  // TTL 600 секунд
}
Метод Описание
cache.get(key) Получить (возвращает undefined при промахе)
cache.set(key, value, ttlSeconds) Сохранить
cache.delete(key) Удалить ключ
cache.clear() Очистить весь кэш модуля

Кэш живёт в RAM, не переживает перезагрузку сервера. Изолирован по модулям (ключи не конфликтуют).

util — утилиты

util.urlencode(s)     // encodeURIComponent
util.urldecode(s)     // decodeURIComponent
util.md5(s)           // hex-строка
util.sha1(s)
util.sha256(s)
util.hexEncode(s)     // string → hex
util.hexDecode(hex)   // hex → string
util.trim(s)
util.sleep(ms)        // поспать (с учётом глобального таймаута)
util.buildEpisodeIdent(base, s, e, t)  // "base_s_e_t" для Lampa ident

btoa / atob — base64

atob('SGVsbG8=')              // → "Hello"
btoa('Hello')                 // → "SGVsbG8="

atob понимает как стандартный base64, так и url-safe без padding.

console / log — логирование

console.log('fetched', count, 'items');
console.warn('unexpected format:', data);
console.error('CDN returned', res.status);

Логи видны в админке на вкладке Модули → конкретный модуль → Логи, а также в общем логе lampac-go. Ring-buffer на 500 записей на модуль.

manifest — ваш manifest.json

console.log(manifest.version, manifest.quality);

Глобальный readonly-объект с данными из manifest.json.

Контракт handle(inv)

Каждый модуль должен экспортировать функцию handle(inv). Это единственная точка входа.

inv — контекст вызова

function handle(inv) {
  // inv.query       — { title: 'Inception', year: '2010', ... } (HTTP query params)
  // inv.headers     — { accept: '...', ... } (lowercase)
  // inv.host        — 'http://lampac.example.com:888' (URL самого сервера)
  // inv.requestIP   — IP клиента
  // inv.path        — 'mysource/play' (без /lite/ префикса)
  // inv.life        — true для автоматической выдачи (`?life=true`)
  // inv.checksearch — true для проверки доступности (`?checksearch=true`)
  // inv.userAgent
  // inv.config      — объект с настройками из config_schema
}

Ответ

Возвращайте обычный JS-объект/массив — runtime сериализует его в JSON и отдаёт клиенту. Поддерживаемые форматы описаны в разделе «Формат ответа» далее.

Формат ответа Lampa

Lampa ожидает один из нескольких «типов» ответа в зависимости от этапа:

checksearch — есть ли что-то для этого тайтла

if (inv.checksearch) {
  var results = searchAPI(query);
  return results.length > 0
    ? { rch: true, type: 'movie', quality: 'FHD' }
    : { rch: false };
}

Клиент дёргает этот эндпоинт, чтобы решить — показывать источник в выдаче или нет.

Список похожих (similar)

Когда одному поиску совпадает несколько тайтлов — пусть пользователь выберет:

return {
  type: 'similar',
  data: [
    {
      title: 'Interstellar',
      year: 2014,
      poster: 'https://.../poster.jpg',
      url: inv.host + '/lite/mysource?href=' + util.urlencode(detailURL)
    },
    // ...
  ]
};

Фильм (movie)

Один или несколько вариантов озвучки одного фильма:

return {
  type: 'movie',
  data: [{
    method: 'play',
    url: proxy.url(streamURL, 'mysource'),
    stream: proxy.url(streamURL, 'mysource'),  // дубль для совместимости
    name: 'Украинская озвучка',
    title: 'Interstellar (2014)',
    quality: { '1080p': proxy.url(hd, 'mysource'), '720p': proxy.url(sd, 'mysource') },
    qualitys: { /* то же самое — legacy */ }
  }]
};

Список сезонов (season)

return {
  type: 'season',
  data: [
    {
      method: 'link',
      id: 1,
      url: inv.host + '/lite/mysource?href=...&s=1',
      name: '1 сезон'
    },
    // ...
  ]
};

Список серий (episode)

return {
  type: 'episode',
  data: [
    {
      method: 'play',
      url: proxy.url(streamURL, 'mysource'),
      stream: proxy.url(streamURL, 'mysource'),
      name: 'Эпизод 1',
      title: 'Foundation S01E01',
      s: 1,
      e: 1
    }
  ],
  // опционально — переключатель озвучек/субтитров:
  voice: [
    { name: 'Дубляж', active: true,  url: inv.host + '/lite/mysource?...&t=dub' },
    { name: 'Субтитры', active: false, url: inv.host + '/lite/mysource?...&t=sub' }
  ]
};

Поля записей

Поле Назначение
method play — прямой стрим; link — переход на другой URL; call — запросить JSON по URL и продолжить
url Поток (HLS/MP4/DASH) или URL для link/call
stream Дубль url для старых клиентов
name Короткое имя (отображается в списке)
title Полное название (в плеере)
s, e Номер сезона / эпизода
quality / qualitys Мапа {quality: url} для переключения качеств
poster URL постера

Полный пример модуля

Минимальный, но функциональный модуль для API, возвращающего JSON:

modules/hello/manifest.json:

{
  "id": "hello",
  "name": "Hello Source",
  "version": "1.0.0",
  "description": "Demo JS module",
  "content_types": ["movie"],
  "quality": "FHD",
  "config_schema": [
    {"key": "host", "label": "API host", "default": "https://api.example.com"},
    {"key": "token", "label": "Token", "secret": true}
  ]
}

modules/hello/index.js:

function cfg(inv, k){ return (inv.config && inv.config[k]) || ''; }

function search(inv, title){
  var url = cfg(inv, 'host').replace(/\/+$/,'') + '/search?q=' + util.urlencode(title);
  var res = http.get(url, { headers: { 'X-Token': cfg(inv,'token') }});
  if (!res.ok) { console.warn('hello: API', res.status); return []; }
  return res.json().results || [];
}

function handle(inv){
  if (inv.checksearch) {
    var r = search(inv, inv.query.title || inv.query.original_title || '');
    return r.length ? { rch: true, type: 'movie', quality: manifest.quality } : { rch: false };
  }
  var title = (inv.query.title || '').trim();
  if (!title) return { type: 'movie', data: [] };
  var results = search(inv, title);
  return {
    type: 'movie',
    data: results.map(function(r){
      return {
        method: 'play',
        url: proxy.url(r.stream_url, 'hello'),
        stream: proxy.url(r.stream_url, 'hello'),
        name: r.title,
        title: r.title + ' (' + r.year + ')'
      };
    })
  };
}

Положите обе файла в modules/hello/, зайдите в админку → Модули → увидите карточку. Включите, откройте /lite/hello?title=Test&checksearch=true — должно вернуть {rch:true,...} если API отвечает.

Дальше