JS-модули источников
JS-модули — современный способ подключить новый источник контента без пересборки сервера и без Go-тулчейна у пользователя. Каждый модуль — это одна JavaScript-функция в изолированной песочнице (Goja), которая принимает HTTP-запрос от Lampa и возвращает JSON в формате плейлиста.
Почему 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:
- Сканирует
modules/— ищет поддиректории сmanifest.json - Компилирует
index.jsчерез Goja (pure-Go реализация ECMAScript 5.1+) - Регистрирует HTTP-handler на
/lite/<id>(и всех саб-путях/lite/<id>/...) - Если в манифесте указан
quality— добавляет бейдж в выдачу (「FHD」,「4K」)
При каждом запросе /lite/<id>?...:
- Создаётся новый инстанс Goja VM (изолирован от других запросов)
- Вызывается глобальная функция
handle(inv)модуля - Возвращённое значение сериализуется в JSON и отдаётся клиенту
Приоритет маршрутов
JS-модули перехватывают встроенные Go-балансеры с таким же ID. Если у вас установлен и включён модуль starlight, то /lite/starlight будет идти в JS, а не в Go-код. Выключение модуля в админке возвращает поведение встроенного балансера.
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[] | Поля настроек, показываемые в админке |
default_transport | string | Транспорт по умолчанию для всех http.* (см. таблицу выше). Без явного opts.transport модуль будет использовать его. |
default_proxy | string | SOCKS5 host:port по умолчанию. |
default_balancer | string | Имя в прокси-реестре. Если пусто — равно id модуля; то есть [[proxy.direct.entries]] balancers=["мой-модуль"] подхватится автоматически. |
ConfigField
{
"key": "host",
"label": "Хост API",
"type": "string",
"default": "https://example.com",
"description": "Базовый URL источника",
"placeholder": "https://...",
"secret": false
}| Поле | Значения |
|---|---|
type | string, int, number, bool, secret, textarea, enum |
secret | true — поле шифруется и маскируется в 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]]) |
timeout | 45 | Таймаут запроса в секундах |
Важно: 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. |
utls | Chrome TLS-fingerprint (uTLS Chrome 120). Сайт видит ровно тот же ClientHello, что и реальный Chrome — обходит большинство JA3-фильтров. |
utls-socks5 | uTLS 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-utls | uTLS-вариант 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.proxy → opts.balancer → manifest.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 отправляет:
| Ключ | Пример | Описание |
|---|---|---|
id | 157336 | TMDB ID (или внутренний Lampa ID) |
imdb_id | tt0816692 | IMDB идентификатор |
kinopoisk_id | 258687 | На самом деле TMDB ID — несмотря на название (исторически) |
title | Интерстеллар | Название на языке интерфейса (рус/укр/анг) |
original_title | Interstellar | Оригинальное английское название |
year | 2014 | Год релиза |
serial | 0 / 1 | 1 = сериал, 0 = фильм |
original_language | en / ja / ko | ISO язык оригинала (аниме ≈ ja) |
source | tmdb / cub | Откуда пришли метаданные |
clarification | 0 / 1 | 1 = пользователь вручную уточняет поиск |
similar | false / true | Lampa просит список «похожих» |
rchtype | web / apk / cors | Тип клиента (браузер / Android / iframe) |
uid, token, fp, nws_id | ... | Клиентские идентификаторы, сервер использует для аутентификации |
rjson | true | Если присутствует — вернуть JSON; иначе runtime сконвертит в HTML |
checksearch | true | Пинг-запрос «есть ли что-то» — вернуть {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' }
]
};Поля записей
| Поле | Назначение |
|---|---|
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 отвечает.
Частые грабли
Собрали в одном списке ошибки, на которые наступают разработчики при первом запуске модуля:
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/'
});