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/httpJavaScript + «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"]
}

Полная схема

ПолеТипОписание
idstring, обязательноID модуля и имя маршрута ([a-z][a-z0-9_]{1,31})
namestringОтображаемое название (по умолчанию = id)
versionstringSemVer (по умолчанию 0.0.0)
authorstringАвтор/команда
descriptionstringОписание для карточки в админке
iconstringПуть к файлу иконки внутри папки модуля
entrystringИмя JS-файла (по умолчанию index.js)
content_typesstring[]movie, serial, anime, tv
qualitystringБейдж в выдаче: SD, HD, FHD, 4K
languagesstring[]Коды языков контента (uk, ru, en, ja, …)
animeboolПометить как аниме-источник (влияет на фильтрацию по original_language)
ukrainianboolМаркер украинского источника (для фильтра в админке)
tagsstring[]Произвольные теги для фильтрации
repositorystringСсылка на исходники (GitHub и т.п.)
config_schemaConfigField[]Поля настроек, показываемые в админке
default_transportstringТранспорт по умолчанию для всех http.* (см. таблицу выше). Без явного opts.transport модуль будет использовать его.
default_proxystringSOCKS5 host:port по умолчанию.
default_balancerstringИмя в прокси-реестре. Если пусто — равно id модуля; то есть [[proxy.direct.entries]] balancers=["мой-модуль"] подхватится автоматически.

ConfigField

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

Пример enum:

{
  "key": "ladoniTransport",
  "label": "Транспорт для ladoni",
  "type": "enum",
  "default": "utls-socks5",
  "options": ["default","utls","utls-socks5","socks5","balancer","balancer-utls","flaresolverr"],
  "description": "utls-socks5 нужен когда CDN режет datacenter-IP; flaresolverr — когда есть JS-челлендж."
}

Значения, которые пользователь задал в админке, сохраняются в 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
transport'utls-socks5'Селектор транспорта (см. таблицу ниже)
proxy'1.2.3.4:1080'Явный SOCKS5/HTTP-прокси для этого запроса
balancer'redheadsound'Имя в реестре прокси (берёт SOCKS5 из [[proxy.direct.entries]])
timeout45Таймаут запроса в секундах

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

Лимиты:

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

transport — какой клиент использовать

ЗначениеЧто делает
default (или пусто)Голый Go HTTP-клиент. Подходит для большинства публичных API.
utlsChrome TLS-fingerprint (uTLS Chrome 120). Сайт видит ровно тот же ClientHello, что и реальный Chrome — обходит большинство JA3-фильтров.
utls-socks5uTLS Chrome через SOCKS5 (residential / VLESS). Главный путь для сайтов с DDoS-Guard и одновременным IP-блоком VPS-датацентров. Адрес SOCKS5 берётся из opts.proxy, либо из opts.balancer, либо из manifest default_balancer, либо из [[proxy.direct.entries]] whose balancers includes имя модуля.
socks5Обычный HTTP через SOCKS5 без uTLS.
balancerСтандартный transport, зарегистрированный для balancer-имени (читай: тот же транспорт, что использует встроенный балансер с тем же именем).
balancer-utlsuTLS-вариант balancer-транспорта.
flaresolverrЗапрос идёт через FlareSolverr (см. ниже) — JS-челлендж пройден, кука получена. Возвращается рендеренный HTML + cookies + UserAgent. Если в opts.proxy или у балансера задан SOCKS5 — Chrome внутри FlareSolverr пойдёт через него (обходит и JS-челлендж, и геоблок одновременно).

Cookie jar внутри одного handle()

Каждый вызов handle(inv) получает свой собственный cookiejar.Jar. Cookies, которые сервер ставит ответом, автоматически отправляются в следующих http.* вызовах того же invoke. Это означает:

  • DLE-search с PHPSESSID-сессией работает «из коробки»: первый GET ставит cookie, POST поиска её получает.
  • DDoS-Guard и подобные «set-cookie на первом запросе → реальный ответ на втором» работают без ручного парсинга Set-Cookie.
  • Между invoke jar обнуляется, чтобы не утекали cookies между разными запросами от Lampa.

Если вам нужно отдельную cookie увидеть руками — она по-прежнему доступна в res.cookies.

transport: 'flaresolverr' — JS-челлендж + (опц.) геоблок

var res = http.get('https://kinogo.example/movie/123', {
  transport: 'flaresolverr',     // PUT через FlareSolverr
  proxy:     'socks5://...',     // опц. — Chrome внутри FlareSolverr пойдёт через этот SOCKS5
  timeout:   60                  // FlareSolverr медленный (5–15 c per solve)
});

Если глобальный URL FlareSolverr не задан в [online].flaresolverr, ветка transport:'flaresolverr' молча падает на default-клиент — никаких ошибок, поведение деградирует. Адрес SOCKS5 резолвится в порядке: opts.proxyopts.balancermanifest.default_balancer (по умолчанию = ID модуля). См. Plugins → FlareSolverr.

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
util.normalizeTitle(s)  // lowercase, убирает пунктуацию/диакритику, коллапс пробелов
                        // — полезно для strict-match по названию в checksearch

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        — 'https://lampac.example.com' (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
}

Что реально шлёт Lampa в inv.query

При открытии фильма/сериала клиент Lampa отправляет:

КлючПримерОписание
id157336TMDB ID (или внутренний Lampa ID)
imdb_idtt0816692IMDB идентификатор
kinopoisk_id258687На самом деле TMDB ID — несмотря на название (исторически)
titleИнтерстелларНазвание на языке интерфейса (рус/укр/анг)
original_titleInterstellarОригинальное английское название
year2014Год релиза
serial0 / 11 = сериал, 0 = фильм
original_languageen / ja / koISO язык оригинала (аниме ≈ ja)
sourcetmdb / cubОткуда пришли метаданные
clarification0 / 11 = пользователь вручную уточняет поиск
similarfalse / trueLampa просит список «похожих»
rchtypeweb / apk / corsТип клиента (браузер / Android / iframe)
uid, token, fp, nws_id...Клиентские идентификаторы, сервер использует для аутентификации
rjsontrueЕсли присутствует — вернуть JSON; иначе runtime сконвертит в HTML
checksearchtrueПинг-запрос «есть ли что-то» — вернуть {rch:true/false}

Главный совет: Lampa обычно шлёт title на языке интерфейса (часто русский/украинский), а ваш источник может индексировать только украинский/английский. Всегда пробуйте оба варианта:

function searchAllQueries(inv) {
  var tried = {};
  var queries = [
    (inv.query.original_title || '').trim(),  // английский — часто совпадает с индексом
    (inv.query.title || '').trim(),           // язык UI — запасной вариант
    (inv.query.imdb_id || '').trim()          // по IMDB, если API поддерживает
  ];
  for (var i = 0; i < queries.length; i++) {
    var q = queries[i];
    if (!q || tried[q.toLowerCase()]) continue;
    tried[q.toLowerCase()] = true;
    var r = searchSite(inv, q);
    if (r && r.length) return r;
  }
  return [];
}

Ответ

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

Lampa ждёт HTML, если клиент не прислал ?rjson=true. Ваш модуль всегда возвращает JS-объект — runtime сам конвертирует его в HTML-формат Lampa (<div class="videos__item videos__movie selector" data-json='...'>) когда в query нет rjson=true и это не checksearch. Поэтому не нужно формировать HTML вручную.

Что остаётся под JSON:

  • ?checksearch=true — всегда JSON ({rch:true/false,...})
  • ?rjson=true — всегда JSON
  • Play-ответ (top-level method:"play" + url без поля data) — всегда JSON

Если видите что Lampa пишет «Поиск не дал результатов», а curl возвращает JSON — значит Lampa-клиент ожидал HTML. Проверьте что ваш бинарь lampac-go свежий (содержит convertJSONToHTML в jsmodules/handler.go).

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

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

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

Lampa дёргает этот эндпоинт для каждого источника при открытии фильма. Если вернули rch:true — ваш источник появится в списке. Если rch:false — не появится.

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

Критически важно: возвращайте rch:true только когда фильм реально есть в каталоге источника. Многие API (особенно DLE-based сайты) при промахе возвращают fallback-список произвольных тайтлов. Если вы посмотрите на .length > 0 — источник засветится в UI для каждого фильма, но при клике покажет «Поиск не дал результатов» — и пользователь не поймёт что это не ваш источник, а просто каталог пустой.

Правильно — делать strict match по ID (imdb_id, tmdb_id) или по year + нормализованному названию. Пример:

function strictMatch(results, inv) {
  if (!results.length) return false;
  var want = {
    year: parseInt(inv.query.year || 0, 10),
    imdb: (inv.query.imdb_id || '').trim(),
    tmdb: (inv.query.kinopoisk_id || '').trim()  // Lampa шлёт TMDB id в kinopoisk_id
  };
  for (var i = 0; i < results.length; i++) {
    var r = results[i];
    if (want.imdb && r.imdb_id === want.imdb) return true;
    if (want.tmdb && String(r.tmdb_id || '') === want.tmdb) return true;
    if (want.year && String(r.year || '') === String(want.year)) return true;
  }
  return false;
}

function handleChecksearch(inv) {
  var r = searchAPI(inv);
  return strictMatch(r, inv)
    ? { rch: true, type: 'movie', quality: manifest.quality || 'FHD' }
    : { rch: false };
}

Для HTML-скрейперов где нет ID — используйте встроенный хелпер util.normalizeTitle(s) и сравнивайте с inv.query.original_title / inv.query.title.

Список похожих (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' }
  ]
};

Поля записей

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

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

Минимальный, но функциональный модуль для 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 отвечает.

Частые грабли

Собрали в одном списке ошибки, на которые наступают разработчики при первом запуске модуля:

1. Lampa пишет «Поиск не дал результатов», а curl возвращает JSON

Причина: клиент без rjson=true ждёт HTML. Наш runtime сам конвертирует JSON в HTML — но только если lampac-go скомпилирован с convertJSONToHTML(). Обновите бинарь.

Проверка:

curl -sv -H "Accept-Encoding: identity" "http://localhost:888/lite/yourmod?title=Test&year=2024" 2>&1 | grep Content-Type
# Должно: text/html; charset=utf-8   ← runtime сконвертил в HTML
# Если application/json — бинарь старый, без авто-конвертера

2. Источник виден в UI, но всегда «не дал результатов»

Причина: checksearch возвращает rch:true слишком либерально (на любой непустой ответ API). Реальный поиск даёт мусор вместо конкретного фильма → при клике Lampa видит data:[] или similar без точного совпадения.

Лечение: strict-match в checksearch по IMDB/TMDB/year (см. callout выше).

3. Стрим не играет: URL типа ib9a27S8...m3u8 без хоста

Причина: старая версия proxy.url(), которая возвращала только хеш. Новые версии бинаря строят <host>/proxy/<hash> автоматически. Обновите lampac-go.

4. url в ответе содержит 127.0.0.1:888 вместо реального домена

Причина: nginx не передаёт Host: $host в upstream. Добавьте в nginx:

location / {
    proxy_pass http://127.0.0.1:888;
    proxy_set_header Host $host;                 # <-- обязательно
    proxy_set_header X-Forwarded-Proto $scheme;  # <-- обязательно
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

hostFromRequest() смотрит сперва X-Forwarded-Host, потом Host, потом использует схему из X-Forwarded-Proto (fallback: http).

5. Модуль не перезагружается при изменении index.js

Причина: Goja держит скомпилированную программу в памяти. Просто scp нового файла не обновит поведение.

Лечение: после редактирования — нажать «↻ Перезагрузить» на карточке модуля в админке, либо сделать POST /<admin>/api/modules/{id}/reload, либо перезапустить сервис целиком.

6. Object.assign / Array.prototype.includes не работают

Причина: Goja реализует ES5.1. Фичи ES2015+ (Object.assign, async/await, arrow-functions, let/const, shorthand) не поддерживаются.

Лечение: пишите ES5 (var, function, for-loop). Полифилы можно подключить вручную, но обычно проще не усложнять.

7. Сайт требует Cookie между запросами

Goja не даёт cookie-jar «из коробки». Сохраняйте cookies вручную через cache:

var jar = cache.get('cookies') || {};
// Первый запрос
var r1 = http.get(loginURL, { headers: { 'Cookie': jar.sid ? 'sid='+jar.sid : '' }});
(r1.cookies || []).forEach(function(c){ jar[c.name] = c.value; });
cache.set('cookies', jar, 3600);

// Последующие запросы
var r2 = http.get(dataURL, { headers: { 'Cookie': Object.keys(jar).map(function(k){ return k+'='+jar[k]; }).join('; ') }});

8. CDN блокирует по Origin/Referer

Используйте proxy.urlWithHeaders():

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

Дальше