Каталог плагинов: полный локальный mirror в ./plugins (один directory-mount в wwwroot/plugins), доки и вычистка неиспользуемых модулей

- plugins/: скачаны все 30 плагинов store локально (sources.json - карта исходных URL);
  патчи: showy.js (абсолютный хост подгрузки), pubtorr/td/torrentmanager - тела вместо лоадеров,
  modss.js - local-patched копия (из каталога исключена, upstream мёртв)
- extensions.json: 32 позиции, все ссылки {localhost}/plugins/<файл>.js, удалены мёртвые
  (Skaz TV x3, export.js, hotkeys/gold_theme/select_weapon/want/mult, MODS's, o.js, др.)
- compose: ./plugins:/lampac/wwwroot/plugins (один маунт вместо 30 file-mount)
- вычистка: удалены неиспользуемые mods (DatabaseEditor, ExternalBind, LogUserRequest-Lite,
  Telemetry, Transcoding, WatchTogether) и дубль каталога modules/
- config/init.conf: секции Tracks/AdminPanel/Music, customPlugins без gst.js
- mods/Tracks: правки Controller.cs/plugin.js
- README.md / AGENT.md: mirror, кеш static, правила mount
This commit is contained in:
2026-10-02 16:37:30 +03:00
parent 7b8e28b3bf
commit 257b42f518
98 changed files with 62636 additions and 21113 deletions
-14
View File
@@ -1,14 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>library</OutputType>
<IsPackable>true</IsPackable>
<RootNamespace>DatabaseEditor</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Shared\Shared.csproj" />
</ItemGroup>
</Project>
@@ -1,328 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Shared;
using Shared.Attributes;
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
namespace DatabaseEditor;
[Authorization(redirectUri: "/weblog/auth")]
public class DatabaseEditorPageController : BaseController
{
[HttpGet]
[Route("/database-editor")]
public ActionResult Index()
{
return EditorFile("index.html", "text/html; charset=utf-8");
}
[HttpGet]
[Route("/database-editor/app.js")]
public ActionResult Script()
{
return EditorFile("app.js", "application/javascript; charset=utf-8");
}
[HttpGet]
[Route("/database-editor/style.css")]
public ActionResult Style()
{
return EditorFile("style.css", "text/css; charset=utf-8");
}
ActionResult EditorFile(string fileName, string contentType)
{
string path = Path.Combine(ModInit.modpath, fileName);
string content = System.IO.File.ReadAllText(path, Encoding.UTF8);
Response.Headers["Cache-Control"] = "no-store, no-cache, must-revalidate";
return Content(content, contentType);
}
}
[Authorization(accessDeniedMessage: "{\"success\":false,\"error\":\"unauthorized\"}")]
public class DatabaseEditorApiController : BaseController
{
[HttpGet]
[Route("/database-editor/api/summary")]
public async Task<ActionResult> Summary()
{
try
{
return Json(new { success = true, databases = await DatabaseStore.GetSummaryAsync() });
}
catch (Exception ex)
{
return Unexpected(ex, "summary");
}
}
[HttpGet]
[Route("/database-editor/api/records")]
public async Task<ActionResult> Records(string database, string query = null, int page = 1, int pageSize = 25, string user = null)
{
try
{
var result = await DatabaseStore.GetRecordsAsync(database, query, page, pageSize, user);
return Json(new
{
success = true,
result.database,
result.page,
result.pageSize,
result.total,
result.pages,
result.records
});
}
catch (Exception ex)
{
return KnownOrUnexpected(ex, "records");
}
}
[HttpGet]
[Route("/database-editor/api/users")]
public async Task<ActionResult> Users(string database)
{
try
{
return Json(new { success = true, users = await DatabaseStore.GetUsersAsync(database) });
}
catch (Exception ex)
{
return KnownOrUnexpected(ex, "users");
}
}
[HttpGet]
[Route("/database-editor/api/record")]
public async Task<ActionResult> Record(string database, long id)
{
try
{
var record = await DatabaseStore.GetRecordAsync(database, id);
return record == null
? NotFound(new { success = false, error = "record_not_found" })
: Json(new { success = true, record });
}
catch (Exception ex)
{
return KnownOrUnexpected(ex, "record");
}
}
[HttpGet]
[Route("/database-editor/api/sync-user")]
public async Task<ActionResult> SyncUser(long id)
{
try
{
var user = await DatabaseStore.GetSyncUserAsync(id);
return user == null
? NotFound(new { success = false, error = "record_not_found" })
: Json(new
{
success = true,
user,
categories = DatabaseStore.SyncEditorCategories,
statuses = DatabaseStore.SyncStatusCategories
});
}
catch (Exception ex)
{
return KnownOrUnexpected(ex, "sync-user");
}
}
[HttpPost]
[Route("/database-editor/api/save")]
public async Task<ActionResult> Save([FromBody] SaveRecordRequest request)
{
if (!IsEditorRequest())
return BadRequest(new { success = false, error = "invalid_editor_request" });
try
{
var record = await DatabaseStore.SaveAsync(request);
return Json(new { success = true, record });
}
catch (Exception ex)
{
return KnownOrUnexpected(ex, "save");
}
}
[HttpPost]
[Route("/database-editor/api/delete")]
public async Task<ActionResult> Delete([FromBody] DeleteRecordRequest request)
{
if (!IsEditorRequest())
return BadRequest(new { success = false, error = "invalid_editor_request" });
try
{
bool deleted = await DatabaseStore.DeleteAsync(request?.database, request?.id ?? 0);
return deleted
? Json(new { success = true })
: NotFound(new { success = false, error = "record_not_found" });
}
catch (Exception ex)
{
return KnownOrUnexpected(ex, "delete");
}
}
[HttpPost]
[Route("/database-editor/api/rename-user")]
public async Task<ActionResult> RenameUser([FromBody] RenameUserRequest request)
{
if (!IsEditorRequest())
return BadRequest(new { success = false, error = "invalid_editor_request" });
try
{
var result = await DatabaseStore.RenameUserAsync(request);
return Json(new { success = true, result });
}
catch (Exception ex)
{
return KnownOrUnexpected(ex, "rename-user");
}
}
[HttpPost]
[Route("/database-editor/api/delete-user")]
public async Task<ActionResult> DeleteUser([FromBody] DeleteUserRequest request)
{
if (!IsEditorRequest())
return BadRequest(new { success = false, error = "invalid_editor_request" });
try
{
var result = await DatabaseStore.DeleteUserAsync(request);
return Json(new { success = true, result });
}
catch (Exception ex)
{
return KnownOrUnexpected(ex, "delete-user");
}
}
[HttpPost]
[Route("/database-editor/api/sync-item/save")]
public async Task<ActionResult> SaveSyncItem([FromBody] SaveSyncItemRequest request)
{
if (!IsEditorRequest())
return BadRequest(new { success = false, error = "invalid_editor_request" });
try
{
var categories = await DatabaseStore.SaveSyncItemAsync(request);
return Json(new { success = true, categories });
}
catch (Exception ex)
{
return KnownOrUnexpected(ex, "sync-item-save");
}
}
[HttpPost]
[Route("/database-editor/api/sync-item/delete")]
public async Task<ActionResult> DeleteSyncItem([FromBody] DeleteSyncItemRequest request)
{
if (!IsEditorRequest())
return BadRequest(new { success = false, error = "invalid_editor_request" });
try
{
bool deleted = await DatabaseStore.DeleteSyncItemAsync(request);
return deleted
? Json(new { success = true })
: NotFound(new { success = false, error = "card_not_found" });
}
catch (Exception ex)
{
return KnownOrUnexpected(ex, "sync-item-delete");
}
}
[HttpPost]
[Route("/database-editor/api/backup")]
public async Task<ActionResult> Backup([FromBody] BackupRequest request)
{
if (!IsEditorRequest())
return BadRequest(new { success = false, error = "invalid_editor_request" });
try
{
if (string.IsNullOrWhiteSpace(request?.database) || string.Equals(request.database, "all", StringComparison.OrdinalIgnoreCase))
{
var backups = await DatabaseStore.BackupAllAsync();
return Json(new { success = true, backups });
}
string path = await DatabaseStore.BackupAsync(request?.database);
return Json(new { success = true, path, database = request.database });
}
catch (Exception ex)
{
return KnownOrUnexpected(ex, "backup");
}
}
[HttpGet]
[Route("/database-editor/api/backups")]
public ActionResult Backups(string database)
{
try
{
return Json(new { success = true, backups = DatabaseStore.GetBackups(database) });
}
catch (Exception ex)
{
return KnownOrUnexpected(ex, "backups");
}
}
[HttpPost]
[Route("/database-editor/api/restore")]
public async Task<ActionResult> Restore([FromBody] RestoreBackupRequest request)
{
if (!IsEditorRequest())
return BadRequest(new { success = false, error = "invalid_editor_request" });
try
{
var result = await DatabaseStore.RestoreAsync(request);
return Json(new { success = true, result });
}
catch (Exception ex)
{
return KnownOrUnexpected(ex, "restore");
}
}
bool IsEditorRequest() => string.Equals(Request.Headers["X-Database-Editor"], "1", StringComparison.Ordinal);
ActionResult KnownOrUnexpected(Exception ex, string operation)
{
if (ex is DatabaseEditorValidationException)
return BadRequest(new { success = false, error = ex.Message });
if (ex is DatabaseEditorConflictException)
return Conflict(new { success = false, error = ex.Message });
if (ex is DatabaseEditorBusyException)
return StatusCode(503, new { success = false, error = ex.Message });
return Unexpected(ex, operation);
}
ActionResult Unexpected(Exception ex, string operation)
{
Serilog.Log.Error(ex, "DatabaseEditor {Operation} failed", operation);
return StatusCode(500, new { success = false, error = "internal_error" });
}
}
File diff suppressed because it is too large Load Diff
-64
View File
@@ -1,64 +0,0 @@
using Microsoft.AspNetCore.Http;
using Shared.Models.Events;
using Shared.Models.Module;
using Shared.Models.Module.Interfaces;
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
namespace DatabaseEditor;
public class ModInit : IModuleLoaded
{
public static string modpath;
static readonly Func<bool, EventMiddleware, Task<bool>> MiddlewareHandler = OnMiddleware;
public void Loaded(InitspaceModel initspace)
{
modpath = initspace.path;
EventListener.MiddlewareAsync += MiddlewareHandler;
}
public void Dispose()
{
EventListener.MiddlewareAsync -= MiddlewareHandler;
}
static async Task<bool> OnMiddleware(bool first, EventMiddleware e)
{
if (first || !HttpMethods.IsGet(e.httpContext.Request.Method))
return true;
string path = (e.httpContext.Request.Path.Value ?? string.Empty).TrimEnd('/');
if (!string.Equals(path, "/weblog", StringComparison.OrdinalIgnoreCase))
return true;
string weblogPath = Path.Combine(AppContext.BaseDirectory, "module", "WebLog", "index.html");
if (!File.Exists(weblogPath))
return true;
string html = File.ReadAllText(weblogPath, Encoding.UTF8);
if (!html.Contains("href=\"/database-editor\"", StringComparison.OrdinalIgnoreCase))
{
const string activeLink = "<a href=\"/weblog\" aria-current=\"page\">Weblog</a>";
const string databaseLink = activeLink + "\n <a href=\"/database-editor\">Базы</a>";
if (html.Contains(activeLink, StringComparison.Ordinal))
html = html.Replace(activeLink, databaseLink, StringComparison.Ordinal);
else
{
const string navEnd = "</nav>";
int navIndex = html.IndexOf(navEnd, StringComparison.OrdinalIgnoreCase);
if (navIndex >= 0)
html = html.Insert(navIndex, "<a href=\"/database-editor\">Базы</a>\n ");
}
}
e.httpContext.Response.Headers["Cache-Control"] = "no-store, no-cache, must-revalidate";
e.httpContext.Response.ContentType = "text/html; charset=utf-8";
await e.httpContext.Response.WriteAsync(html, e.httpContext.RequestAborted);
return false;
}
}
-42
View File
@@ -1,42 +0,0 @@
# DatabaseEditor
Защищённый root-паролем WebLog редактор баз `database/TimeCode.sql` и
`database/Sync.sql`.
## Маршруты
- `/database-editor` — интерфейс редактора.
- `/database-editor/api/summary` — состояние и размер баз.
- `/database-editor/api/records` — поиск и постраничный список.
- `/database-editor/api/record` — чтение одной записи.
- `/database-editor/api/sync-user` — карточки и категории одного Sync-пользователя.
- `/database-editor/api/sync-item/save` — точечное изменение категорий карточки Sync.
- `/database-editor/api/sync-item/delete` — удаление одной карточки из Sync.
- `/database-editor/api/save` — создание или изменение записи.
- `/database-editor/api/delete` — удаление записи.
- `/database-editor/api/rename-user` — атомарное переименование пользователя во всех строках `bookmarks` (Sync) и `timecodes` (TimeCode).
- `/database-editor/api/delete-user` — атомарное удаление пользователя и всех его строк из `bookmarks` (Sync) и `timecodes` (TimeCode).
- `/database-editor/api/backup` — online-backup одной базы или обеих баз при
`database: "all"` в `database/backup/database-editor`.
- `/database-editor/api/backups` — список доступных резервных копий выбранной базы.
- `/database-editor/api/restore` — проверка и восстановление выбранной SQLite-копии; перед заменой автоматически создаётся страховочная копия текущей базы с маркером `before-restore`.
Изменяющие запросы требуют заголовок `X-Database-Editor: 1`. Все SQL-запросы
параметризованы; запись синхронизируется штатными семафорами `TimeCode` и
`Sync`. В корне поля `data` допускается только JSON-объект. Список TimeCode
дополняется названиями и постерами из карточек Sync того же пользователя.
TimeCode с версии типизированной схемы не хранит блоб: редактор по-прежнему
показывает и принимает road-объект Lampa (`time`, `duration`, `percent`,
`profile`, `updated`), но собирает его из колонок и раскладывает обратно.
Правка поднимает `updated_at` выше всех строк пользователя — иначе клиенты
не увидят её, они спрашивают дельты по `updated_at > since`.
Редактор Sync повторяет категории Lampa: `book` (Закладки), `like` (Нравится),
`wath` (Позже), `history` (История просмотров). Поле `wath` является актуальным
именем Lampa, несмотря на опечатку в английском ключе. Статусы `look` (Смотрю),
`viewed`, `scheduled`, `continued`, `thrown` взаимоисключающие. Служебное поле
`watch` сохраняется в поддерживаемой схеме Lampac, но в редакторе не выводится.
Мод также добавляет вкладку `Базы` в `/weblog` на лету, не изменяя файлы
штатного модуля WebLog.
-649
View File
@@ -1,649 +0,0 @@
(function () {
'use strict';
var categoryLabels = {
book: 'Закладки', like: 'Нравится', wath: 'Позже', history: 'История просмотров',
look: 'Смотрю', viewed: 'Просмотрено', scheduled: 'Запланировано',
continued: 'Продолжение следует', thrown: 'Брошено'
};
var state = {
database: 'timecode', page: 1, pageSize: 25, pages: 1, total: 0, query: '', selectedUser: '', loading: false,
editingId: 0, editingDatabase: 'timecode', syncUser: null, syncItems: [], syncCategories: [], syncStatuses: [],
syncPage: 1, syncPageSize: 48, syncQuery: '', syncCategory: '', syncItem: null
};
var $ = function (id) { return document.getElementById(id); };
var els = {
listView: $('listView'), summary: $('summary'), loading: $('loading'), head: $('tableHead'), body: $('tableBody'),
empty: $('empty'), pagerInfo: $('pagerInfo'), pageLabel: $('pageLabel'), prev: $('prevBtn'), next: $('nextBtn'),
search: $('searchInput'), userFilter: $('timecodeUserFilter'), pageSize: $('pageSize'), recordModal: $('recordModal'), recordTitle: $('recordModalTitle'),
recordId: $('recordModalId'), user: $('userField'), card: $('cardField'), item: $('itemField'), data: $('dataField'),
jsonStatus: $('jsonStatus'), saveRecord: $('saveRecordBtn'), deleteRecord: $('deleteRecordBtn'), toasts: $('toasts'),
syncDetail: $('syncDetail'), syncUserName: $('syncUserName'), syncUserMeta: $('syncUserMeta'), syncSearch: $('syncSearch'),
syncCategory: $('syncCategory'), syncResultCount: $('syncResultCount'), syncGrid: $('syncGrid'), syncEmpty: $('syncEmpty'),
syncPrev: $('syncPrev'), syncNext: $('syncNext'), syncPageLabel: $('syncPageLabel'), categoryModal: $('categoryModal'),
categoryHead: $('categoryCardHead'), categoryOptions: $('categoryOptions'), backupModal: $('backupModal'),
backupResults: $('backupResults'), renameUserModal: $('renameUserModal'), oldUser: $('oldUserField'), newUser: $('newUserField'),
confirmRenameUser: $('confirmRenameUserBtn'), deleteUserModal: $('deleteUserModal'), deleteUser: $('deleteUserField'),
confirmDeleteUser: $('confirmDeleteUserBtn'), restoreModal: $('restoreModal'), restoreResults: $('restoreResults'), restoreEmpty: $('restoreEmpty')
};
var errorMessages = {
unauthorized: 'Требуется повторный вход', unknown_database: 'Неизвестная база', database_not_found: 'Файл базы не найден',
database_busy: 'База занята, повторите попытку', invalid_json: 'JSON содержит ошибку',
data_must_be_json_object: 'В корне JSON должен быть объект', data_required: 'JSON не может быть пустым',
data_too_large: 'JSON превышает 8 МБ', user_required: 'Укажите пользователя', card_required: 'Укажите карточку',
item_required: 'Укажите элемент', duplicate_record_key: 'Запись с таким уникальным ключом уже существует',
record_not_found: 'Запись уже удалена', card_not_found: 'Карточка не найдена', unknown_category: 'Неизвестная категория',
multiple_statuses: 'Для карточки можно выбрать только один статус', old_user_required: 'Укажите текущее имя пользователя',
new_user_required: 'Укажите новое имя пользователя', user_name_unchanged: 'Новое имя совпадает с текущим',
user_not_found: 'Пользователь не найден ни в Sync, ни в TimeCode', rename_user_conflict: 'Новое имя уже занято или создаёт конфликт записей',
invalid_backup_file: 'Недопустимое имя файла резервной копии', backup_not_found: 'Резервная копия не найдена',
backup_integrity_failed: 'Проверка целостности резервной копии не пройдена', backup_schema_mismatch: 'Копия относится к другой базе',
invalid_backup_database: 'Файл не является исправной SQLite-базой',
internal_error: 'Внутренняя ошибка сервера', invalid_response: 'Сервер вернул неожиданный ответ'
};
function api(path, options) {
options = options || {};
options.credentials = 'same-origin';
options.headers = Object.assign({ Accept: 'application/json' }, options.headers || {});
if (options.body) {
options.headers['Content-Type'] = 'application/json';
options.headers['X-Database-Editor'] = '1';
}
return fetch('/database-editor/api/' + path, options).then(function (response) {
var type = response.headers.get('content-type') || '';
if (response.redirected && response.url.indexOf('/weblog/auth') >= 0) {
location.href = response.url;
throw new Error('unauthorized');
}
if (type.indexOf('application/json') < 0) throw new Error(response.status === 401 ? 'unauthorized' : 'invalid_response');
return response.json().then(function (data) {
if (!response.ok || data.success === false) throw new Error(data.error || 'request_failed');
return data;
});
});
}
function el(tag, className, text) {
var node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined && text !== null) node.textContent = text;
return node;
}
function toast(message, bad) {
var node = el('div', 'toast ' + (bad ? 'bad' : 'good'), message);
els.toasts.appendChild(node);
setTimeout(function () { node.remove(); }, 4500);
}
function showError(error) {
var key = error && error.message || 'internal_error';
toast(errorMessages[key] || key, true);
}
function setLoading(value) {
state.loading = value;
els.loading.classList.toggle('active', value);
els.prev.disabled = value || state.page <= 1;
els.next.disabled = value || state.page >= state.pages;
}
function formatBytes(bytes) {
if (!bytes) return '0 Б';
var units = ['Б', 'КБ', 'МБ', 'ГБ'];
var index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), 3);
return (bytes / Math.pow(1024, index)).toLocaleString('ru-RU', { maximumFractionDigits: index ? 1 : 0 }) + ' ' + units[index];
}
function formatDate(value) {
if (!value) return '—';
var normalized = String(value).trim();
var sqliteDateWithoutZone = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d+)?$/;
if (sqliteDateWithoutZone.test(normalized)) {
normalized = normalized.replace(' ', 'T').replace(/(\.\d{3})\d+$/, '$1') + 'Z';
}
var date = new Date(normalized);
return isNaN(date.getTime()) ? value : date.toLocaleString('ru-RU');
}
function formatTime(seconds) {
seconds = Math.max(0, Math.round(Number(seconds) || 0));
var hours = Math.floor(seconds / 3600);
var minutes = Math.floor((seconds % 3600) / 60);
var rest = seconds % 60;
return (hours ? hours + ':' + String(minutes).padStart(2, '0') : minutes) + ':' + String(rest).padStart(2, '0');
}
function posterUrl(value) {
if (!value) return '';
if (/^https?:\/\//i.test(value)) return value;
return '/tmdb/img/t/p/w300' + (value.charAt(0) === '/' ? value : '/' + value);
}
function posterNode(value, title) {
if (!value) return el('div', 'poster-placeholder', '◇');
var image = el('img', 'poster');
image.loading = 'lazy';
image.alt = title || '';
image.src = posterUrl(value);
image.addEventListener('error', function () { image.replaceWith(el('div', 'poster-placeholder', '◇')); }, { once: true });
return image;
}
function typeLabel(type) { return type === 'tv' ? 'Сериал' : type === 'movie' ? 'Фильм' : ''; }
function fallbackTitle(record) {
if (record.title) return record.title;
return typeLabel(record.mediaType) || 'Карточка';
}
function loadSummary() {
return api('summary').then(function (data) {
els.summary.textContent = '';
(data.databases || []).forEach(function (db) {
var card = el('article', 'summary-card');
var head = el('div', 'summary-head');
head.appendChild(el('span', '', db.title));
head.appendChild(el('span', db.available ? 'available' : 'missing', db.available ? 'доступна' : 'нет файла'));
card.appendChild(head);
card.appendChild(el('div', 'summary-value', Number(db.records || 0).toLocaleString('ru-RU') + ' записей'));
var foot = el('div', 'summary-foot');
foot.appendChild(el('div', 'summary-meta', formatBytes(db.bytes) + ' · ' + db.file + ' · ' + formatDate(db.updated)));
var backup = el('button', 'summary-backup', 'Backup этой базы');
backup.addEventListener('click', function () { createBackup(db.database); });
foot.appendChild(backup);
card.appendChild(foot);
els.summary.appendChild(card);
});
}).catch(showError);
}
function renderHead() {
els.head.textContent = '';
var table = els.head.closest('table');
table.classList.toggle('timecode-table', state.database === 'timecode');
table.classList.toggle('sync-table', state.database === 'sync');
var columns = state.database === 'timecode'
? [['ID', 'col-id'], ['Пользователь', 'col-user'], ['Карточка', 'col-media'], ['Позиция', 'col-progress'], ['Обновлено', 'col-date'], ['', 'col-actions']]
: [['ID', 'col-id'], ['Пользователь', 'col-user'], ['Обновлено', 'col-date'], ['', 'col-actions']];
columns.forEach(function (column) { els.head.appendChild(el('th', column[1], column[0])); });
}
function renderRows(records) {
els.body.textContent = '';
els.empty.classList.toggle('show', !records.length);
records.forEach(function (record) {
var row = document.createElement('tr');
row.appendChild(el('td', 'mono', '#' + record.id));
var userCell = el('td');
userCell.appendChild(el('div', 'record-user', record.user || '—'));
userCell.appendChild(el('div', 'record-sub', formatBytes(record.dataLength || 0)));
row.appendChild(userCell);
if (state.database === 'timecode') {
var mediaCell = el('td');
var media = el('div', 'media-cell');
var title = fallbackTitle(record);
media.appendChild(posterNode(record.poster, title));
var copy = el('div', 'media-copy');
copy.appendChild(el('div', 'media-title', title));
var meta = el('div', 'media-meta');
if (record.mediaType) meta.appendChild(el('span', 'type-badge', typeLabel(record.mediaType)));
var mediaDetails = [];
if (record.mediaType === 'tv' && record.season != null && record.episode != null) {
mediaDetails.push('Сезон ' + record.season, 'Серия ' + record.episode);
}
if (record.year) mediaDetails.push(record.year);
if (mediaDetails.length) meta.appendChild(document.createTextNode(mediaDetails.join(' · ')));
copy.appendChild(meta);
media.appendChild(copy);
mediaCell.appendChild(media);
row.appendChild(mediaCell);
var progressCell = el('td');
var percent = Number(record.percent);
if (!isFinite(percent) && Number(record.duration) > 0) percent = Number(record.position) / Number(record.duration) * 100;
percent = Math.max(0, Math.min(100, isFinite(percent) ? percent : 0));
progressCell.appendChild(el('div', 'record-user', Math.round(percent) + '%'));
var track = el('div', 'progress-track');
var fill = el('div', 'progress-fill');
fill.style.width = percent + '%';
track.appendChild(fill);
progressCell.appendChild(track);
var progressCopy = el('div', 'progress-copy');
progressCopy.appendChild(el('span', '', formatTime(record.position)));
progressCopy.appendChild(el('span', '', formatTime(record.duration)));
progressCell.appendChild(progressCopy);
row.appendChild(progressCell);
}
row.appendChild(el('td', '', formatDate(record.updated)));
var actions = el('td', 'row-actions');
if (state.database === 'sync') {
var open = el('button', 'mini-btn', 'Открыть');
open.addEventListener('click', function () { openSyncUser(record.id); });
actions.appendChild(open);
}
var edit = el('button', 'mini-btn', 'JSON');
edit.addEventListener('click', function () { openRecord(record.id, state.database); });
var remove = el('button', 'mini-btn delete', '×');
remove.title = 'Удалить всю запись';
remove.addEventListener('click', function () { deleteRecord(record.id, state.database); });
actions.appendChild(edit);
actions.appendChild(remove);
row.appendChild(actions);
els.body.appendChild(row);
});
}
function loadRecords() {
if (state.loading) return;
setLoading(true);
var params = new URLSearchParams({ database: state.database, page: String(state.page), pageSize: String(state.pageSize) });
if (state.query) params.set('query', state.query);
if (state.database === 'timecode' && state.selectedUser) params.set('user', state.selectedUser);
api('records?' + params.toString()).then(function (data) {
state.page = Number(data.page || 1); state.pages = Number(data.pages || 1); state.total = Number(data.total || 0);
renderHead(); renderRows(data.records || []);
var from = state.total ? (state.page - 1) * state.pageSize + 1 : 0;
var to = Math.min(state.page * state.pageSize, state.total);
els.pagerInfo.textContent = from + '–' + to + ' из ' + state.total.toLocaleString('ru-RU');
els.pageLabel.textContent = state.page + ' / ' + state.pages;
}).catch(showError).finally(function () { setLoading(false); });
}
function loadTimeCodeUsers() {
return api('users?database=timecode').then(function (data) {
var selected = state.selectedUser;
els.userFilter.textContent = '';
var all = document.createElement('option');
all.value = '';
all.textContent = 'Все пользователи TimeCode (' + (data.users || []).length.toLocaleString('ru-RU') + ')';
els.userFilter.appendChild(all);
(data.users || []).forEach(function (entry) {
var option = document.createElement('option');
option.value = entry.user;
option.textContent = entry.user + ' (' + Number(entry.records || 0).toLocaleString('ru-RU') + ')';
els.userFilter.appendChild(option);
});
if (selected && Array.from(els.userFilter.options).some(function (option) { return option.value === selected; })) {
els.userFilter.value = selected;
} else {
state.selectedUser = '';
if (selected && state.database === 'timecode') { state.page = 1; loadRecords(); }
}
}).catch(showError);
}
function switchDatabase(database) {
if (database === state.database) return;
state.database = database; state.page = 1;
document.querySelectorAll('.db-tab').forEach(function (tab) { tab.classList.toggle('active', tab.dataset.db === database); });
els.userFilter.hidden = database !== 'timecode';
loadRecords();
}
function openSyncUser(id, preserveFilters) {
var previousQuery = preserveFilters ? state.syncQuery : '';
var previousCategory = preserveFilters ? state.syncCategory : '';
api('sync-user?id=' + encodeURIComponent(id)).then(function (data) {
state.syncUser = data.user;
state.syncItems = data.user.items || [];
state.syncCategories = data.categories || [];
state.syncStatuses = data.statuses || [];
state.syncPage = 1; state.syncQuery = previousQuery; state.syncCategory = previousCategory;
els.syncSearch.value = previousQuery;
buildCategorySelect();
if (Array.from(els.syncCategory.options).some(function (option) { return option.value === previousCategory; })) {
els.syncCategory.value = previousCategory;
} else {
state.syncCategory = '';
}
els.syncUserName.textContent = data.user.user || '—';
els.syncUserMeta.textContent = Number(data.user.total || 0).toLocaleString('ru-RU') + ' карточек · обновлено ' + formatDate(data.user.updated);
els.listView.hidden = true;
els.syncDetail.hidden = false;
renderSyncItems();
window.scrollTo(0, 0);
}).catch(showError);
}
function buildCategorySelect() {
els.syncCategory.textContent = '';
var all = document.createElement('option'); all.value = ''; all.textContent = 'Все категории'; els.syncCategory.appendChild(all);
var generalGroup = document.createElement('optgroup'); generalGroup.label = 'Категории';
var statusGroup = document.createElement('optgroup'); statusGroup.label = 'Статус';
state.syncCategories.forEach(function (category) {
var option = document.createElement('option'); option.value = category; option.textContent = categoryLabels[category] || category;
(state.syncStatuses.indexOf(category) >= 0 ? statusGroup : generalGroup).appendChild(option);
});
els.syncCategory.appendChild(generalGroup);
els.syncCategory.appendChild(statusGroup);
}
function syncItemOrder(item, category) {
var value = item.categoryOrder && item.categoryOrder[category];
return value == null ? Number.MAX_SAFE_INTEGER : Number(value);
}
function filteredSyncItems() {
var query = state.syncQuery.toLowerCase();
var filtered = state.syncItems.filter(function (item) {
var matchesText = !query || String(item.title || '').toLowerCase().indexOf(query) >= 0 || String(item.cardId || '').toLowerCase().indexOf(query) >= 0;
var matchesCategory = !state.syncCategory || (item.categories || []).indexOf(state.syncCategory) >= 0;
return matchesText && matchesCategory;
});
return filtered.sort(function (left, right) {
if (state.syncCategory) return syncItemOrder(left, state.syncCategory) - syncItemOrder(right, state.syncCategory);
var leftHistory = syncItemOrder(left, 'history');
var rightHistory = syncItemOrder(right, 'history');
if (leftHistory !== rightHistory) return leftHistory - rightHistory;
return Number(left.order || 0) - Number(right.order || 0);
});
}
function renderSyncItems() {
var filtered = filteredSyncItems();
var pages = Math.max(1, Math.ceil(filtered.length / state.syncPageSize));
state.syncPage = Math.min(state.syncPage, pages);
var start = (state.syncPage - 1) * state.syncPageSize;
var visible = filtered.slice(start, start + state.syncPageSize);
els.syncGrid.textContent = '';
els.syncEmpty.classList.toggle('show', !visible.length);
els.syncResultCount.textContent = filtered.length.toLocaleString('ru-RU') + ' из ' + state.syncItems.length.toLocaleString('ru-RU');
els.syncPageLabel.textContent = state.syncPage + ' / ' + pages;
els.syncPrev.disabled = state.syncPage <= 1;
els.syncNext.disabled = state.syncPage >= pages;
visible.forEach(function (item) {
var card = el('article', 'sync-card');
card.appendChild(posterNode(item.poster, item.title));
var body = el('div', 'sync-card-body');
body.appendChild(el('div', 'sync-card-title', item.title || 'Карточка #' + item.cardId));
body.appendChild(el('div', 'sync-card-meta', [typeLabel(item.mediaType), item.year, '#' + item.cardId].filter(Boolean).join(' · ')));
var tags = el('div', 'category-tags');
if (item.categories && item.categories.length) item.categories.forEach(function (category) { tags.appendChild(el('span', 'category-tag', categoryLabels[category] || category)); });
else tags.appendChild(el('span', 'category-tag empty-tag', 'Без категории'));
body.appendChild(tags);
var actions = el('div', 'sync-card-actions');
var edit = el('button', 'btn', 'Изменить категории');
edit.addEventListener('click', function () { openCategoryModal(item); });
actions.appendChild(edit); body.appendChild(actions); card.appendChild(body); els.syncGrid.appendChild(card);
});
}
function openCategoryModal(item) {
state.syncItem = item;
els.categoryHead.textContent = '';
els.categoryHead.appendChild(posterNode(item.poster, item.title));
var copy = el('div');
copy.appendChild(el('div', 'media-title', item.title || 'Карточка #' + item.cardId));
copy.appendChild(el('div', 'media-meta mono', '#' + item.cardId));
els.categoryHead.appendChild(copy);
els.categoryOptions.textContent = '';
state.syncCategories.forEach(function (category, index) {
var isStatus = state.syncStatuses.indexOf(category) >= 0;
if (isStatus && category === state.syncStatuses[0]) {
els.categoryOptions.appendChild(el('div', 'category-section-title', 'Статус'));
}
var wrap = el('div', 'category-option' + (isStatus ? ' status-option' : ''));
var input = document.createElement('input');
input.type = 'checkbox'; input.id = 'sync-category-' + index; input.value = category;
if (isStatus) input.dataset.status = '1';
input.checked = (item.categories || []).indexOf(category) >= 0;
if (isStatus) input.addEventListener('change', function () {
if (!input.checked) return;
els.categoryOptions.querySelectorAll('input[data-status="1"]').forEach(function (other) {
if (other !== input) other.checked = false;
});
});
var label = document.createElement('label'); label.htmlFor = input.id; label.textContent = categoryLabels[category] || category;
wrap.appendChild(input); wrap.appendChild(label); els.categoryOptions.appendChild(wrap);
});
openModal('categoryModal');
}
function saveSyncItem() {
if (!state.syncUser || !state.syncItem) return;
var categories = Array.from(els.categoryOptions.querySelectorAll('input:checked')).map(function (input) { return input.value; });
$('saveSyncItemBtn').disabled = true;
api('sync-item/save', { method: 'POST', body: JSON.stringify({ recordId: state.syncUser.id, cardId: state.syncItem.cardId, categories: categories }) })
.then(function (data) {
closeModal('categoryModal'); toast('Категории и статус карточки сохранены'); loadSummary();
openSyncUser(state.syncUser.id, true);
}).catch(showError).finally(function () { $('saveSyncItemBtn').disabled = false; });
}
function deleteSyncItem() {
if (!state.syncUser || !state.syncItem) return;
if (!confirm('Удалить карточку «' + (state.syncItem.title || state.syncItem.cardId) + '» у этого пользователя?')) return;
api('sync-item/delete', { method: 'POST', body: JSON.stringify({ recordId: state.syncUser.id, cardId: state.syncItem.cardId }) })
.then(function () {
state.syncItems = state.syncItems.filter(function (item) { return item.cardId !== state.syncItem.cardId; });
state.syncUser.total = state.syncItems.length;
closeModal('categoryModal'); renderSyncItems(); toast('Карточка удалена'); loadSummary();
}).catch(showError);
}
function prettyJson(value) { return JSON.stringify(JSON.parse(value), null, 2); }
function validateJson() {
try {
var parsed = JSON.parse(els.data.value);
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') throw new Error('object');
els.jsonStatus.textContent = 'JSON корректен'; els.jsonStatus.className = 'json-status ok'; return true;
} catch (_) {
els.jsonStatus.textContent = 'Ошибка JSON'; els.jsonStatus.className = 'json-status bad'; return false;
}
}
function setRecordDatabase(database) {
document.querySelectorAll('.timecode-field').forEach(function (node) { node.style.display = database === 'timecode' ? 'flex' : 'none'; });
}
function openRecord(id, database) {
api('record?database=' + encodeURIComponent(database) + '&id=' + id).then(function (data) {
var record = data.record; state.editingId = record.id; state.editingDatabase = database; setRecordDatabase(database);
els.recordTitle.textContent = 'Редактирование ' + (database === 'sync' ? 'Sync' : 'TimeCode'); els.recordId.textContent = '#' + record.id;
els.user.value = record.user || ''; els.card.value = record.card || ''; els.item.value = record.item || '';
try { els.data.value = prettyJson(record.data || '{}'); } catch (_) { els.data.value = record.data || ''; }
els.deleteRecord.hidden = false; validateJson(); openModal('recordModal');
}).catch(showError);
}
function saveRecord() {
if (!validateJson()) return;
var payload = { database: state.editingDatabase, id: state.editingId || null, user: els.user.value, card: els.card.value, item: els.item.value, data: els.data.value };
els.saveRecord.disabled = true;
api('save', { method: 'POST', body: JSON.stringify(payload) }).then(function () {
toast('Запись сохранена'); closeModal('recordModal'); state.page = 1; loadRecords(); loadSummary();
if (state.editingDatabase === 'timecode') loadTimeCodeUsers();
if (state.syncUser && state.editingDatabase === 'sync') openSyncUser(state.syncUser.id);
}).catch(showError).finally(function () { els.saveRecord.disabled = false; });
}
function deleteRecord(id, database) {
if (!confirm('Удалить всю запись #' + id + ' из ' + database + '?')) return;
api('delete', { method: 'POST', body: JSON.stringify({ database: database, id: id }) }).then(function () {
toast('Запись удалена'); closeModal('recordModal'); loadRecords(); loadSummary();
if (database === 'timecode') loadTimeCodeUsers();
}).catch(showError);
}
function createBackup(database) {
$('backupAllBtn').disabled = true;
api('backup', { method: 'POST', body: JSON.stringify({ database: database }) }).then(function (data) {
var backups = data.backups || [{ database: data.database || database, path: data.path }];
els.backupResults.textContent = '';
backups.forEach(function (backup) {
var result = el('div', 'backup-result');
result.appendChild(el('strong', '', backup.database === 'timecode' ? 'TimeCode.sql' : 'Sync.sql'));
result.appendChild(el('div', 'backup-path', backup.path));
els.backupResults.appendChild(result);
});
openModal('backupModal'); toast(backups.length === 2 ? 'Созданы копии TimeCode и Sync' : 'Резервная копия создана');
}).catch(showError).finally(function () { $('backupAllBtn').disabled = false; });
}
var restoreDatabase = 'sync';
function openRestore() {
restoreDatabase = 'sync';
document.querySelectorAll('[data-restore-db]').forEach(function (tab) { tab.classList.toggle('active', tab.dataset.restoreDb === restoreDatabase); });
openModal('restoreModal'); loadBackups();
}
function loadBackups() {
els.restoreResults.textContent = ''; els.restoreEmpty.classList.remove('show');
els.restoreResults.appendChild(el('div', 'muted', 'Загрузка…'));
api('backups?database=' + encodeURIComponent(restoreDatabase)).then(function (data) {
els.restoreResults.textContent = '';
var backups = data.backups || [];
els.restoreEmpty.classList.toggle('show', !backups.length);
backups.forEach(function (backup) {
var row = el('div', 'backup-result restore-result');
var info = el('div', 'restore-info');
info.appendChild(el('strong', '', backup.file));
info.appendChild(el('div', 'backup-path', formatBytes(backup.bytes) + ' · ' + formatDate(backup.created)));
var button = el('button', 'btn danger', 'Восстановить');
button.addEventListener('click', function () { restoreBackup(backup, button); });
row.appendChild(info); row.appendChild(button); els.restoreResults.appendChild(row);
});
}).catch(showError);
}
function restoreBackup(backup, button) {
var title = backup.database === 'sync' ? 'Sync' : 'TimeCode';
if (!confirm('Восстановить базу ' + title + ' из файла «' + backup.file + '»?\n\nТекущее состояние сначала будет сохранено автоматически. Данные, записанные после даты этой копии, будут заменены.')) return;
button.disabled = true;
api('restore', { method: 'POST', body: JSON.stringify({ database: backup.database, file: backup.file }) }).then(function (data) {
closeModal('restoreModal');
toast(title + ' восстановлена. Страховочная копия: ' + data.result.safetyBackup);
setTimeout(function () { location.reload(); }, 1600);
}).catch(showError).finally(function () { button.disabled = false; });
}
function openRenameUser() {
var selected = state.syncUser ? state.syncUser.user || '' : state.selectedUser || '';
els.oldUser.textContent = '';
var loadingOption = document.createElement('option'); loadingOption.value = ''; loadingOption.textContent = 'Загрузка пользователей…';
els.oldUser.appendChild(loadingOption);
els.newUser.value = '';
els.confirmRenameUser.disabled = true;
openModal('renameUserModal');
Promise.all([api('users?database=sync'), api('users?database=timecode')]).then(function (responses) {
var users = new Map();
responses.forEach(function (response, databaseIndex) {
(response.users || []).forEach(function (entry) {
var key = (entry.user || '').toLowerCase();
if (!key) return;
var current = users.get(key) || { user: entry.user, sync: 0, timecode: 0 };
current[databaseIndex === 0 ? 'sync' : 'timecode'] += Number(entry.records || 0);
users.set(key, current);
});
});
var list = Array.from(users.values()).sort(function (left, right) { return left.user.localeCompare(right.user, 'ru', { sensitivity: 'base' }); });
els.oldUser.textContent = '';
var placeholder = document.createElement('option'); placeholder.value = ''; placeholder.textContent = list.length ? 'Выберите пользователя' : 'Пользователей нет';
els.oldUser.appendChild(placeholder);
list.forEach(function (entry) {
var option = document.createElement('option'); option.value = entry.user;
option.textContent = entry.user + ' · Sync: ' + entry.sync + ', TimeCode: ' + entry.timecode;
els.oldUser.appendChild(option);
});
var match = list.find(function (entry) { return entry.user.toLowerCase() === selected.toLowerCase(); });
if (match) els.oldUser.value = match.user;
els.confirmRenameUser.disabled = !list.length;
setTimeout(function () { (els.oldUser.value ? els.newUser : els.oldUser).focus(); }, 0);
}).catch(function (error) {
els.oldUser.textContent = '';
var failed = document.createElement('option'); failed.value = ''; failed.textContent = 'Не удалось загрузить пользователей'; els.oldUser.appendChild(failed);
showError(error);
});
}
function openDeleteUser() {
var selected = state.syncUser ? state.syncUser.user || '' : state.selectedUser || '';
els.deleteUser.textContent = '';
var loading = document.createElement('option'); loading.value = ''; loading.textContent = 'Загрузка пользователей…'; els.deleteUser.appendChild(loading);
els.confirmDeleteUser.disabled = true;
openModal('deleteUserModal');
Promise.all([api('users?database=sync'), api('users?database=timecode')]).then(function (responses) {
var users = new Map();
responses.forEach(function (response, databaseIndex) {
(response.users || []).forEach(function (entry) {
var key = (entry.user || '').toLowerCase();
if (!key) return;
var current = users.get(key) || { user: entry.user, sync: 0, timecode: 0 };
current[databaseIndex === 0 ? 'sync' : 'timecode'] += Number(entry.records || 0); users.set(key, current);
});
});
var list = Array.from(users.values()).sort(function (left, right) { return left.user.localeCompare(right.user, 'ru', { sensitivity: 'base' }); });
els.deleteUser.textContent = '';
var placeholder = document.createElement('option'); placeholder.value = ''; placeholder.textContent = list.length ? 'Выберите пользователя' : 'Пользователей нет'; els.deleteUser.appendChild(placeholder);
list.forEach(function (entry) { var option = document.createElement('option'); option.value = entry.user; option.textContent = entry.user + ' · Sync: ' + entry.sync + ', TimeCode: ' + entry.timecode; els.deleteUser.appendChild(option); });
var match = list.find(function (entry) { return entry.user.toLowerCase() === selected.toLowerCase(); });
if (match) els.deleteUser.value = match.user;
els.confirmDeleteUser.disabled = !list.length;
setTimeout(function () { els.deleteUser.focus(); }, 0);
}).catch(function (error) {
els.deleteUser.textContent = ''; var failed = document.createElement('option'); failed.value = ''; failed.textContent = 'Не удалось загрузить пользователей'; els.deleteUser.appendChild(failed); showError(error);
});
}
function deleteUser() {
var user = els.deleteUser.value.trim();
if (!user) return showError(new Error('user_required'));
if (!confirm('Безвозвратно удалить пользователя «' + user + '», все его закладки Sync и позиции TimeCode?')) return;
els.confirmDeleteUser.disabled = true;
api('delete-user', { method: 'POST', body: JSON.stringify({ user: user }) }).then(function (data) {
var result = data.result; closeModal('deleteUserModal'); state.selectedUser = '';
if (state.syncUser && state.syncUser.user.toLowerCase() === user.toLowerCase()) state.syncUser = null;
els.syncDetail.hidden = true; els.listView.hidden = false;
loadRecords(); loadSummary(); loadTimeCodeUsers();
toast('Пользователь удалён: Sync — ' + result.syncRecords + ', TimeCode — ' + result.timecodeRecords);
}).catch(showError).finally(function () { els.confirmDeleteUser.disabled = false; });
}
function renameUser() {
var oldUser = els.oldUser.value.trim();
var newUser = els.newUser.value.trim();
if (!oldUser) return showError(new Error('old_user_required'));
if (!newUser) return showError(new Error('new_user_required'));
if (!confirm('Перед продолжением выйдите из учётной записи «' + oldUser + '» на всех устройствах или измените её логин/account_email в Lampa и init/AccsDB.\n\nАктивная сессия со старым именем создаст пользователя повторно.\n\nПереименовать «' + oldUser + '» в «' + newUser + '»?')) return;
els.confirmRenameUser.disabled = true;
api('rename-user', { method: 'POST', body: JSON.stringify({ oldUser: oldUser, newUser: newUser }) }).then(function (data) {
var result = data.result;
closeModal('renameUserModal');
state.selectedUser = '';
if (state.syncUser && state.syncUser.user.toLowerCase() === oldUser.toLowerCase()) state.syncUser = null;
els.syncDetail.hidden = true; els.listView.hidden = false;
loadRecords(); loadSummary(); loadTimeCodeUsers();
toast('Пользователь переименован: Sync — ' + result.syncRecords + ', TimeCode — ' + result.timecodeRecords);
}).catch(showError).finally(function () { els.confirmRenameUser.disabled = false; });
}
function openModal(id) { $(id).classList.add('open'); }
function closeModal(id) { $(id).classList.remove('open'); }
function applyTheme(theme) {
document.documentElement.dataset.theme = theme;
var metaTheme = document.getElementById('metaTheme');
if (metaTheme) metaTheme.setAttribute('content', theme === 'dark' ? '#0c0e12' : '#f4f5f8');
localStorage.setItem('lampac-theme', theme);
}
document.querySelectorAll('.db-tab').forEach(function (tab) { tab.addEventListener('click', function () { switchDatabase(tab.dataset.db); }); });
var searchTimer;
els.search.addEventListener('input', function () { clearTimeout(searchTimer); searchTimer = setTimeout(function () { state.query = els.search.value.trim(); state.page = 1; loadRecords(); }, 280); });
els.userFilter.addEventListener('change', function () { state.selectedUser = els.userFilter.value; state.page = 1; loadRecords(); });
els.pageSize.addEventListener('change', function () { state.pageSize = Number(els.pageSize.value); state.page = 1; loadRecords(); });
els.prev.addEventListener('click', function () { if (state.page > 1) { state.page--; loadRecords(); } });
els.next.addEventListener('click', function () { if (state.page < state.pages) { state.page++; loadRecords(); } });
$('refreshBtn').addEventListener('click', function () { loadRecords(); loadSummary(); if (state.database === 'timecode') loadTimeCodeUsers(); });
$('backupAllBtn').addEventListener('click', function () { createBackup('all'); });
$('restoreBtn').addEventListener('click', openRestore);
document.querySelectorAll('[data-restore-db]').forEach(function (tab) { tab.addEventListener('click', function () { restoreDatabase = tab.dataset.restoreDb; document.querySelectorAll('[data-restore-db]').forEach(function (item) { item.classList.toggle('active', item === tab); }); loadBackups(); }); });
$('renameUserBtn').addEventListener('click', openRenameUser);
els.confirmRenameUser.addEventListener('click', renameUser);
$('deleteUserBtn').addEventListener('click', openDeleteUser);
els.confirmDeleteUser.addEventListener('click', deleteUser);
$('backToUsers').addEventListener('click', function () { els.syncDetail.hidden = true; els.listView.hidden = false; state.syncUser = null; loadRecords(); loadSummary(); });
$('rawSyncBtn').addEventListener('click', function () { if (state.syncUser) openRecord(state.syncUser.id, 'sync'); });
$('refreshSyncBtn').addEventListener('click', function () { if (state.syncUser) openSyncUser(state.syncUser.id, true); });
els.syncSearch.addEventListener('input', function () { state.syncQuery = els.syncSearch.value.trim(); state.syncPage = 1; renderSyncItems(); });
els.syncCategory.addEventListener('change', function () { state.syncCategory = els.syncCategory.value; state.syncPage = 1; renderSyncItems(); });
els.syncPrev.addEventListener('click', function () { if (state.syncPage > 1) { state.syncPage--; renderSyncItems(); window.scrollTo(0, 0); } });
els.syncNext.addEventListener('click', function () { state.syncPage++; renderSyncItems(); window.scrollTo(0, 0); });
$('saveSyncItemBtn').addEventListener('click', saveSyncItem);
$('deleteSyncItemBtn').addEventListener('click', deleteSyncItem);
$('formatBtn').addEventListener('click', function () { try { els.data.value = prettyJson(els.data.value); } catch (_) { } validateJson(); });
els.data.addEventListener('input', validateJson);
els.saveRecord.addEventListener('click', saveRecord);
els.deleteRecord.addEventListener('click', function () { deleteRecord(state.editingId, state.editingDatabase); });
document.querySelectorAll('[data-close]').forEach(function (button) { button.addEventListener('click', function () { closeModal(button.dataset.close); }); });
document.querySelectorAll('.modal-backdrop').forEach(function (modal) { modal.addEventListener('click', function (event) { if (event.target === modal) closeModal(modal.id); }); });
document.addEventListener('keydown', function (event) {
if (event.key === 'Escape') document.querySelectorAll('.modal-backdrop.open').forEach(function (modal) { closeModal(modal.id); });
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's' && els.recordModal.classList.contains('open')) { event.preventDefault(); saveRecord(); }
});
$('themeBtn').addEventListener('click', function () { applyTheme(document.documentElement.dataset.theme === 'light' ? 'dark' : 'light'); });
applyTheme(localStorage.getItem('lampac-theme') || 'dark');
loadSummary(); loadTimeCodeUsers(); renderHead(); loadRecords();
})();
-151
View File
@@ -1,151 +0,0 @@
<!doctype html>
<html lang="ru" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0c0e12" id="metaTheme">
<title>Базы — Lampac</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/database-editor/style.css">
</head>
<body>
<div class="app">
<header class="topbar">
<a class="topbar-logo" href="/database-editor">
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" aria-hidden="true">
<rect x="2" y="2" width="24" height="24" rx="6" stroke="currentColor" stroke-width="1.5"/>
<rect x="7" y="16" width="3" height="6" rx="1" fill="var(--accent)"/>
<rect x="12.5" y="11" width="3" height="11" rx="1" fill="var(--accent)" opacity=".7"/>
<rect x="18" y="6" width="3" height="16" rx="1" fill="var(--accent)" opacity=".45"/>
</svg>
Lampac <span>Базы</span>
</a>
<div class="topbar-divider"></div>
<nav class="topbar-app-nav" aria-label="Разделы Lampac"><a href="/adminpanel">Admin</a><a href="/stats">Stats</a><a href="/weblog">Weblog</a><a href="/telemetry">Telemetry</a><a href="/database-editor" aria-current="page">Базы</a></nav>
<div class="topbar-spacer"></div>
<button class="icon-btn theme-btn" id="themeBtn" title="Переключить тему" aria-label="Переключить тему">
<svg class="theme-icon theme-icon-moon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
<svg class="theme-icon theme-icon-sun" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
</button>
</header>
<main class="main">
<section id="listView">
<div class="hero">
<div><h1>Редактор TimeCode и Sync</h1><p>Пользователи, карточки, позиции просмотра и безопасные копии SQLite.</p></div>
<div class="hero-actions"><button class="btn" id="backupAllBtn">⇩ <span>Backup TimeCode + Sync</span></button><button class="btn" id="restoreBtn">↥ <span>Восстановить</span></button><button class="btn" id="renameUserBtn">Переименовать пользователя</button><button class="btn danger" id="deleteUserBtn">Удалить пользователя</button></div>
</div>
<section class="summary" id="summary"></section>
<section class="workspace">
<div class="toolbar">
<div class="db-tabs" role="tablist"><button class="db-tab active" data-db="timecode" role="tab">TimeCode</button><button class="db-tab" data-db="sync" role="tab">Sync</button></div>
<select class="select user-filter" id="timecodeUserFilter" title="Пользователь TimeCode"><option value="">Все пользователи TimeCode</option></select>
<div class="search"><span>⌕</span><input id="searchInput" type="search" placeholder="ID, пользователь, карточка или JSON…" autocomplete="off"></div>
<div class="toolbar-spacer"></div>
<select class="select" id="pageSize" title="Записей на странице"><option>25</option><option>50</option><option>100</option></select>
<button class="icon-btn" id="refreshBtn" title="Обновить" aria-label="Обновить">↻</button>
</div>
<div class="loading" id="loading"></div>
<div class="table-wrap">
<table class="data-table"><thead><tr id="tableHead"></tr></thead><tbody id="tableBody"></tbody></table>
<div class="empty" id="empty"><div><div class="empty-icon">⌕</div><strong>Записей не найдено</strong><div class="muted">Измените поиск или выберите другую базу.</div></div></div>
</div>
<div class="pager"><div class="pager-info" id="pagerInfo">—</div><div class="pager-buttons"><button class="icon-btn" id="prevBtn">←</button><span class="page-label" id="pageLabel">1 / 1</span><button class="icon-btn" id="nextBtn">→</button></div></div>
</section>
</section>
<section class="sync-detail" id="syncDetail" hidden>
<div class="detail-head">
<button class="btn" id="backToUsers">← Пользователи</button>
<div class="detail-identity"><div class="eyebrow">Sync-пользователь</div><h1 id="syncUserName">—</h1><div class="muted" id="syncUserMeta">—</div></div>
<div class="detail-actions"><button class="btn" id="rawSyncBtn">JSON целиком</button><button class="btn" id="refreshSyncBtn">↻ Обновить</button></div>
</div>
<div class="detail-toolbar">
<div class="search"><span>⌕</span><input id="syncSearch" type="search" placeholder="Название или ID карточки…" autocomplete="off"></div>
<select class="select" id="syncCategory"><option value="">Все категории</option></select>
<span class="result-count" id="syncResultCount"></span>
</div>
<div class="media-grid" id="syncGrid"></div>
<div class="empty sync-empty" id="syncEmpty"><div><div class="empty-icon">□</div><strong>Карточек не найдено</strong></div></div>
<div class="detail-pager"><button class="btn" id="syncPrev">← Назад</button><span id="syncPageLabel">1 / 1</span><button class="btn" id="syncNext">Вперёд →</button></div>
</section>
</main>
</div>
<div class="modal-backdrop" id="recordModal" role="dialog" aria-modal="true">
<div class="modal modal-wide">
<div class="modal-head"><span class="modal-title" id="recordModalTitle">Запись</span><span class="modal-id" id="recordModalId"></span><button class="icon-btn modal-close" data-close="recordModal">×</button></div>
<div class="modal-body"><div class="form-grid">
<div class="field full"><label for="userField">Пользователь</label><input id="userField" maxlength="512" autocomplete="off"></div>
<div class="field timecode-field"><label for="cardField">Карточка</label><input id="cardField" maxlength="512" autocomplete="off"></div>
<div class="field timecode-field"><label for="itemField">Элемент / hash</label><input id="itemField" maxlength="512" autocomplete="off"></div>
<div class="field full"><div class="json-tools"><label for="dataField">JSON-данные</label><button class="btn small" type="button" id="formatBtn">Форматировать</button><span class="json-status" id="jsonStatus"></span></div><textarea id="dataField" spellcheck="false"></textarea></div>
</div></div>
<div class="modal-foot"><button class="btn danger" id="deleteRecordBtn">Удалить</button><button class="btn" data-close="recordModal">Отмена</button><button class="btn primary" id="saveRecordBtn">Сохранить</button></div>
</div>
</div>
<div class="modal-backdrop" id="categoryModal" role="dialog" aria-modal="true">
<div class="modal modal-compact">
<div class="modal-head"><span class="modal-title">Категории карточки</span><button class="icon-btn modal-close" data-close="categoryModal">×</button></div>
<div class="modal-body">
<div class="category-card-head" id="categoryCardHead"></div>
<div class="category-options" id="categoryOptions"></div>
<p class="modal-hint">Изменяется только выбранная карточка. Остальные позиции пользователя останутся без изменений.</p>
</div>
<div class="modal-foot"><button class="btn danger" id="deleteSyncItemBtn">Удалить карточку</button><button class="btn" data-close="categoryModal">Отмена</button><button class="btn primary" id="saveSyncItemBtn">Сохранить категории</button></div>
</div>
</div>
<div class="modal-backdrop" id="renameUserModal" role="dialog" aria-modal="true">
<div class="modal modal-compact">
<div class="modal-head"><span class="modal-title">Переименовать пользователя</span><button class="icon-btn modal-close" data-close="renameUserModal">×</button></div>
<div class="modal-body"><div class="form-grid">
<div class="field full"><label for="oldUserField">Текущий пользователь</label><select class="select" id="oldUserField"><option value="">Загрузка пользователей…</option></select></div>
<div class="field full"><label for="newUserField">Новое имя</label><input id="newUserField" maxlength="512" autocomplete="off"></div>
<p class="modal-hint field full">Важно: перед переименованием выйдите из старой учётной записи на всех устройствах или заранее измените её логин/account_email в настройках Lampa и, если пользователь задан там, в init/AccsDB. Активный клиент со старым именем после обновления страницы создаст его в Sync и TimeCode повторно. Новое имя не должно быть занято.</p>
</div></div>
<div class="modal-foot"><button class="btn" data-close="renameUserModal">Отмена</button><button class="btn primary" id="confirmRenameUserBtn">Переименовать</button></div>
</div>
</div>
<div class="modal-backdrop" id="deleteUserModal" role="dialog" aria-modal="true">
<div class="modal modal-compact">
<div class="modal-head"><span class="modal-title">Удалить пользователя</span><button class="icon-btn modal-close" data-close="deleteUserModal">×</button></div>
<div class="modal-body"><div class="form-grid">
<div class="field full"><label for="deleteUserField">Пользователь</label><select class="select" id="deleteUserField"><option value="">Загрузка пользователей…</option></select></div>
<p class="modal-hint field full">Будут безвозвратно удалены все закладки Sync и все позиции TimeCode выбранного пользователя.</p>
</div></div>
<div class="modal-foot"><button class="btn" data-close="deleteUserModal">Отмена</button><button class="btn danger" id="confirmDeleteUserBtn">Удалить пользователя</button></div>
</div>
</div>
<div class="modal-backdrop" id="backupModal" role="dialog" aria-modal="true">
<div class="modal modal-compact">
<div class="modal-head"><span class="modal-title">Резервные копии созданы</span><button class="icon-btn modal-close" data-close="backupModal">×</button></div>
<div class="modal-body"><div class="backup-results" id="backupResults"></div></div>
<div class="modal-foot"><button class="btn primary" data-close="backupModal">Готово</button></div>
</div>
</div>
<div class="modal-backdrop" id="restoreModal" role="dialog" aria-modal="true">
<div class="modal modal-wide">
<div class="modal-head"><span class="modal-title">Восстановление базы</span><button class="icon-btn modal-close" data-close="restoreModal">×</button></div>
<div class="modal-body">
<p class="modal-hint restore-warning">Выберите резервную копию. Перед восстановлением текущая база будет автоматически сохранена в отдельный файл <code>before-restore</code>.</p>
<div class="restore-tabs"><button class="db-tab active" data-restore-db="sync">Sync</button><button class="db-tab" data-restore-db="timecode">TimeCode</button></div>
<div class="backup-results restore-results" id="restoreResults"></div>
<div class="empty restore-empty" id="restoreEmpty"><div><strong>Резервных копий нет</strong></div></div>
</div>
<div class="modal-foot"><button class="btn" data-close="restoreModal">Закрыть</button></div>
</div>
</div>
<div class="toast-wrap" id="toasts"></div>
<script src="/database-editor/app.js"></script>
</body>
</html>
-6
View File
@@ -1,6 +0,0 @@
{
"enable": true,
"references": [
"Microsoft.Data.Sqlite"
]
}
-439
View File
@@ -1,439 +0,0 @@
:root {
--font-body: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Consolas, monospace;
--text-xs: .6875rem;
--text-sm: .8125rem;
--text-base: .875rem;
--text-lg: 1rem;
--text-xl: 1.25rem;
--sp-1: .25rem;
--sp-2: .5rem;
--sp-3: .75rem;
--sp-4: 1rem;
--sp-5: 1.25rem;
--sp-6: 1.5rem;
--sp-8: 2rem;
--r-sm: 6px;
--r-md: 8px;
--r-lg: 12px;
--r-xl: 16px;
--ease: cubic-bezier(.16, 1, .3, 1);
--dur: 180ms;
}
[data-theme="dark"] {
color-scheme: dark;
--bg: #0c0e12;
--surface: #13161d;
--surface-2: #1a1e28;
--surface-3: #222733;
--surface-hover: #2a3142;
--border: #2a3040;
--border-subtle: rgba(255, 255, 255, .06);
--text: #e2e6ef;
--text-muted: #7c849a;
--text-faint: #4d5568;
--accent: #3ec9d1;
--accent-soft: #2ea8b0;
--accent-dim: rgba(62, 201, 209, .12);
--accent-hover: rgba(62, 201, 209, .22);
--accent-glow: rgba(62, 201, 209, .06);
--ok: #4ade80;
--ok-dim: rgba(74, 222, 128, .12);
--warn: #facc15;
--warn-dim: rgba(250, 204, 21, .12);
--err: #f87171;
--err-dim: rgba(248, 113, 113, .12);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, .3);
--shadow-md: 0 4px 16px rgba(0, 0, 0, .35);
--shadow-lg: 0 12px 40px rgba(0, 0, 0, .45);
--overlay: rgba(0, 0, 0, .6);
}
[data-theme="light"] {
color-scheme: light;
--bg: #f4f5f8;
--surface: #fff;
--surface-2: #f0f1f5;
--surface-3: #e8eaf0;
--surface-hover: #e2e4ec;
--border: #d4d8e3;
--border-subtle: rgba(0, 0, 0, .06);
--text: #1a1e28;
--text-muted: #6b7280;
--text-faint: #9ca3af;
--accent: #0d9499;
--accent-soft: #0b7d82;
--accent-dim: rgba(13, 148, 153, .1);
--accent-hover: rgba(13, 148, 153, .18);
--accent-glow: rgba(13, 148, 153, .04);
--ok: #16a34a;
--ok-dim: rgba(22, 163, 74, .1);
--warn: #ca8a04;
--warn-dim: rgba(202, 138, 4, .1);
--err: #dc2626;
--err-dim: rgba(220, 38, 38, .1);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, .06);
--shadow-md: 0 4px 12px rgba(0, 0, 0, .08);
--shadow-lg: 0 12px 32px rgba(0, 0, 0, .12);
--overlay: rgba(0, 0, 0, .3);
}
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; }
html { background: var(--bg); }
body {
min-width: 320px;
background: var(--bg);
color: var(--text);
font: var(--text-base)/1.5 var(--font-body);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
button, input, select, textarea { font: inherit; }
button { cursor: pointer; }
a { color: inherit; text-decoration: none; }
[hidden] { display: none !important; }
.muted { color: var(--text-muted); }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--surface-3); border: 2px solid transparent; border-radius: 999px; background-clip: padding-box; }
::-webkit-scrollbar-thumb:hover { background-color: var(--text-faint); }
.app { min-height: 100dvh; }
.topbar {
position: sticky;
top: 0;
z-index: 30;
display: flex;
align-items: center;
gap: var(--sp-4);
min-height: 52px;
padding: var(--sp-3) var(--sp-6);
padding-top: max(var(--sp-3), env(safe-area-inset-top, 0px));
padding-left: max(var(--sp-6), env(safe-area-inset-left, 0px));
padding-right: max(var(--sp-6), env(safe-area-inset-right, 0px));
background: color-mix(in srgb, var(--surface) 94%, transparent);
border-bottom: 1px solid var(--border);
backdrop-filter: blur(16px);
}
.topbar-logo {
display: flex;
flex-shrink: 0;
align-items: center;
gap: var(--sp-2);
color: var(--text);
font-size: var(--text-lg);
font-weight: 700;
letter-spacing: -.02em;
white-space: nowrap;
}
.topbar-logo svg { flex-shrink: 0; }
.topbar-logo span { margin-left: 2px; color: var(--text-muted); font-weight: 400; }
.topbar-divider { width: 1px; height: 24px; flex-shrink: 0; background: var(--border); }
.topbar-app-nav { display: flex; flex-shrink: 0; align-items: center; gap: var(--sp-2); }
.topbar-app-nav a {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 5.75rem;
min-height: 34px;
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: var(--r-md);
background: var(--surface-2);
color: var(--text-muted);
font-size: var(--text-xs);
font-weight: 600;
white-space: nowrap;
transition: background var(--dur) var(--ease), color var(--dur) var(--ease), border-color var(--dur) var(--ease), box-shadow var(--dur) var(--ease);
}
.topbar-app-nav a:hover { background: var(--surface-3); color: var(--text); }
.topbar-app-nav a[aria-current="page"] { border-color: var(--accent); background: var(--surface); color: var(--accent); box-shadow: var(--shadow-sm); }
.topbar-spacer { flex: 1; min-width: var(--sp-2); }
.theme-icon { display: block; }
.theme-icon-sun { display: none; }
[data-theme="dark"] .theme-icon-moon { display: none; }
[data-theme="dark"] .theme-icon-sun { display: block; }
.main { width: 100%; max-width: 1500px; margin: 0 auto; padding: var(--sp-6) var(--sp-6) 3rem; }
.hero { display: flex; align-items: center; gap: var(--sp-6); margin-bottom: var(--sp-5); }
.hero > div:first-child { min-width: 260px; }
.hero h1, .detail-head h1 { margin: 0 0 var(--sp-1); font-size: var(--text-xl); line-height: 1.25; letter-spacing: -.025em; }
.hero p { max-width: 720px; margin: 0; color: var(--text-muted); font-size: var(--text-sm); }
.hero-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--sp-2); margin-left: auto; }
.icon-btn, .btn, .mini-btn {
border: 1px solid var(--border);
border-radius: var(--r-md);
background: var(--surface-2);
color: var(--text-muted);
font-size: var(--text-xs);
font-weight: 600;
transition: transform var(--dur) var(--ease), background var(--dur) var(--ease), color var(--dur) var(--ease), border-color var(--dur) var(--ease), box-shadow var(--dur) var(--ease);
}
.icon-btn, .btn { display: inline-flex; min-height: 34px; align-items: center; justify-content: center; gap: var(--sp-2); }
.icon-btn { width: 34px; min-width: 34px; padding: 0; }
.btn { padding: 6px 14px; white-space: nowrap; }
.icon-btn:hover, .btn:hover, .mini-btn:hover { border-color: var(--accent); background: var(--accent-dim); color: var(--accent); transform: translateY(-1px); box-shadow: var(--shadow-sm); }
.btn.primary { border-color: var(--accent); background: var(--accent); color: #fff; }
.btn.primary:hover { border-color: var(--accent-soft); background: var(--accent-soft); color: #fff; }
.btn.danger { border-color: color-mix(in srgb, var(--err) 65%, var(--border)); background: var(--err-dim); color: var(--err); }
.btn.danger:hover { border-color: var(--err); background: color-mix(in srgb, var(--err) 18%, transparent); color: var(--err); }
.btn.small { min-height: 28px; padding: 4px 10px; }
.btn:disabled, .icon-btn:disabled, .mini-btn:disabled { cursor: not-allowed; opacity: .45; transform: none; box-shadow: none; }
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.summary { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--sp-3); margin-bottom: var(--sp-4); }
.summary-card {
position: relative;
overflow: hidden;
min-width: 0;
padding: var(--sp-4);
border: 1px solid var(--border);
border-radius: var(--r-lg);
background: var(--surface);
box-shadow: var(--shadow-sm);
}
.summary-card::before { position: absolute; inset: 0 0 auto; height: 2px; background: linear-gradient(90deg, var(--accent), transparent 72%); content: ""; opacity: .75; }
.summary-head { display: flex; justify-content: space-between; gap: var(--sp-2); color: var(--text-muted); font-size: var(--text-xs); }
.summary-value { margin: var(--sp-2) 0 var(--sp-1); color: var(--text); font-size: 1.45rem; font-weight: 700; letter-spacing: -.03em; }
.summary-foot { display: flex; align-items: center; gap: var(--sp-3); margin-top: var(--sp-2); }
.summary-meta { flex: 1; overflow: hidden; color: var(--text-faint); font: var(--text-xs) var(--font-mono); text-overflow: ellipsis; white-space: nowrap; }
.summary-backup { padding: 0; border: 0; background: transparent; color: var(--accent); font-size: var(--text-xs); font-weight: 600; }
.summary-backup:hover { color: var(--accent-soft); }
.available { color: var(--ok); }
.missing { color: var(--err); }
.workspace, .sync-detail { overflow: hidden; border: 1px solid var(--border); border-radius: var(--r-lg); background: var(--surface); box-shadow: var(--shadow-md); }
.toolbar, .detail-toolbar { display: flex; min-height: 58px; align-items: center; gap: var(--sp-3); padding: 10px var(--sp-3); border-bottom: 1px solid var(--border); background: var(--surface); }
.db-tabs, .restore-tabs { display: flex; gap: 2px; padding: 3px; border: 1px solid var(--border-subtle); border-radius: var(--r-md); background: var(--surface-2); }
.db-tab { padding: 5px 12px; border: 0; border-radius: 5px; background: transparent; color: var(--text-muted); font-size: var(--text-xs); font-weight: 600; white-space: nowrap; transition: all var(--dur) var(--ease); }
.db-tab:hover { color: var(--text); }
.db-tab.active { background: var(--surface-3); color: var(--accent); box-shadow: var(--shadow-sm); }
.search { position: relative; flex: 1; max-width: 560px; }
.search input, .select, .field input, .field textarea {
border: 1px solid var(--border);
border-radius: var(--r-md);
background: var(--surface-2);
color: var(--text);
outline: none;
transition: border-color var(--dur) var(--ease), background var(--dur) var(--ease), box-shadow var(--dur) var(--ease);
}
.search input { width: 100%; height: 34px; padding: 0 12px 0 35px; font-size: var(--text-sm); }
.search input::placeholder, .field input::placeholder, .field textarea::placeholder { color: var(--text-faint); }
.search > span { position: absolute; top: 6px; left: 11px; z-index: 1; color: var(--text-faint); font-size: 17px; pointer-events: none; }
.search input:focus, .select:focus, .field input:focus, .field textarea:focus { border-color: var(--accent); background: var(--surface); box-shadow: 0 0 0 3px var(--accent-dim); }
.toolbar-spacer { flex: 1; }
.select { height: 34px; padding: 0 30px 0 10px; color: var(--text-muted); font-size: var(--text-xs); }
.user-filter { width: clamp(210px, 22vw, 330px); }
.loading { height: 2px; overflow: hidden; background: transparent; }
.loading::after { display: block; width: 35%; height: 100%; background: var(--accent); content: ""; transform: translateX(-110%); }
.loading.active::after { animation: load 1s infinite var(--ease); }
@keyframes load { to { transform: translateX(310%); } }
.table-wrap { min-height: 370px; overflow: auto; }
.data-table { width: 100%; border-collapse: collapse; table-layout: fixed; }
.data-table th { position: sticky; top: 0; z-index: 2; height: 38px; padding: 0 var(--sp-3); border-bottom: 1px solid var(--border); background: var(--surface-2); color: var(--text-faint); font-size: var(--text-xs); font-weight: 600; letter-spacing: .05em; text-align: left; text-transform: uppercase; }
.data-table td { padding: var(--sp-3); border-bottom: 1px solid var(--border-subtle); vertical-align: middle; }
.data-table tbody tr { transition: background var(--dur) var(--ease); }
.data-table tbody tr:hover { background: var(--accent-glow); }
.col-id { width: 70px; }
.col-user { width: 250px; }
.col-media { width: 340px; }
.col-progress { width: 240px; }
.col-date { width: 170px; }
.col-actions { width: 190px; }
.mono { font-family: var(--font-mono); }
.record-user { overflow: hidden; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.record-sub { overflow: hidden; margin-top: 3px; color: var(--text-faint); font: var(--text-xs) var(--font-mono); text-overflow: ellipsis; white-space: nowrap; }
.preview { display: -webkit-box; overflow: hidden; color: var(--text-muted); font: var(--text-xs)/1.5 var(--font-mono); overflow-wrap: anywhere; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
.row-actions { display: flex; justify-content: flex-end; gap: 5px; }
.mini-btn { display: inline-flex; height: 31px; align-items: center; justify-content: center; padding: 0 9px; }
.mini-btn.delete:hover { border-color: var(--err); background: var(--err-dim); color: var(--err); }
.media-cell { display: flex; min-width: 0; align-items: center; gap: 11px; }
.poster, .poster-placeholder { width: 46px; height: 68px; flex: 0 0 46px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--surface-3); object-fit: cover; }
.poster-placeholder { display: grid; place-items: center; color: var(--text-faint); font-size: 20px; }
.media-copy { min-width: 0; }
.media-title { overflow: hidden; color: var(--text); font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.media-meta { overflow: hidden; margin-top: 3px; color: var(--text-muted); font-size: var(--text-xs); text-overflow: ellipsis; white-space: nowrap; }
.type-badge, .category-tag { display: inline-flex; border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent); border-radius: 4px; background: var(--accent-dim); color: var(--accent); font-size: .625rem; }
.type-badge { margin-right: 5px; padding: 1px 5px; text-transform: uppercase; }
.progress-track { height: 5px; overflow: hidden; margin: 7px 0 5px; border-radius: 999px; background: var(--surface-3); }
.progress-fill { height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--accent-soft), var(--accent)); }
.progress-copy { display: flex; justify-content: space-between; color: var(--text-muted); font: var(--text-xs) var(--font-mono); }
.empty { display: none; min-height: 360px; place-items: center; padding: var(--sp-6); color: var(--text-muted); text-align: center; }
.empty.show { display: grid; }
.empty-icon { display: grid; width: 52px; height: 52px; margin: 0 auto var(--sp-3); place-items: center; border: 1px solid var(--border); border-radius: var(--r-lg); background: var(--surface-2); color: var(--text-faint); }
.pager, .detail-pager { display: flex; min-height: 54px; align-items: center; gap: var(--sp-2); padding: 9px var(--sp-3); border-top: 1px solid var(--border); background: var(--surface); }
.pager-info, .page-label { color: var(--text-muted); font-size: var(--text-xs); }
.pager-buttons { display: flex; gap: 6px; margin-left: auto; }
.page-label { display: flex; align-items: center; padding: 0 9px; font-family: var(--font-mono); }
.detail-head { display: flex; align-items: center; gap: var(--sp-5); padding: var(--sp-4); border-bottom: 1px solid var(--border); background: var(--surface); }
.detail-identity { min-width: 0; }
.detail-identity h1 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.eyebrow { margin-bottom: var(--sp-1); color: var(--accent); font-size: var(--text-xs); font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
.detail-actions { display: flex; gap: var(--sp-2); margin-left: auto; }
.detail-toolbar .result-count { margin-left: auto; color: var(--text-muted); font: var(--text-xs) var(--font-mono); white-space: nowrap; }
.media-grid { display: grid; min-height: 380px; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); gap: var(--sp-3); padding: var(--sp-3); }
.sync-card { display: flex; min-width: 0; min-height: 132px; gap: var(--sp-3); padding: 10px; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--surface-2); transition: transform var(--dur) var(--ease), border-color var(--dur) var(--ease), box-shadow var(--dur) var(--ease); }
.sync-card:hover { border-color: var(--accent); transform: translateY(-1px); box-shadow: var(--shadow-sm); }
.sync-card .poster, .sync-card .poster-placeholder { width: 74px; height: 110px; flex-basis: 74px; }
.sync-card-body { display: flex; min-width: 0; flex: 1; flex-direction: column; }
.sync-card-title { display: -webkit-box; overflow: hidden; font-weight: 600; line-height: 1.35; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
.sync-card-meta { margin-top: var(--sp-1); color: var(--text-muted); font: var(--text-xs) var(--font-mono); }
.category-tags { display: flex; flex-wrap: wrap; gap: 4px; margin: var(--sp-2) 0; }
.category-tag { padding: 2px 6px; }
.category-tag.empty-tag { border-color: var(--border); background: var(--surface-3); color: var(--text-faint); }
.sync-card-actions { display: flex; gap: 5px; margin-top: auto; }
.sync-card-actions .btn { min-height: 28px; padding: 4px 9px; }
.sync-empty { min-height: 360px; }
.detail-pager { justify-content: center; gap: var(--sp-4); }
.modal-backdrop { position: fixed; inset: 0; z-index: 100; display: none; align-items: center; justify-content: center; padding: var(--sp-5); background: var(--overlay); backdrop-filter: blur(5px); }
.modal-backdrop.open { display: flex; }
.modal { display: flex; overflow: hidden; width: min(850px, 100%); max-height: calc(100dvh - 40px); flex-direction: column; border: 1px solid var(--border); border-radius: var(--r-lg); background: var(--surface); box-shadow: var(--shadow-lg); }
.modal-compact { width: min(600px, 100%); }
.modal-head, .modal-foot { display: flex; align-items: center; gap: 10px; padding: var(--sp-3) var(--sp-4); background: var(--surface-2); }
.modal-head { border-bottom: 1px solid var(--border); }
.modal-foot { justify-content: flex-end; border-top: 1px solid var(--border); }
.modal-title { color: var(--text); font-size: var(--text-lg); font-weight: 700; }
.modal-id { color: var(--text-faint); font: var(--text-xs) var(--font-mono); }
.modal-close { margin-left: auto; }
.modal-body { overflow: auto; padding: var(--sp-4); }
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--sp-3); }
.field { display: flex; min-width: 0; flex-direction: column; gap: 6px; }
.field.full { grid-column: 1 / -1; }
.field label { color: var(--text-muted); font-size: var(--text-xs); font-weight: 600; }
.field input, .field textarea { width: 100%; padding: 9px 10px; }
.field textarea { min-height: 340px; resize: vertical; font: var(--text-xs)/1.55 var(--font-mono); tab-size: 2; }
.json-tools { display: flex; align-items: center; gap: var(--sp-2); }
.json-status { color: var(--text-faint); font-size: var(--text-xs); }
.json-status.ok { color: var(--ok); }
.json-status.bad { color: var(--err); }
.category-card-head { display: flex; align-items: center; gap: var(--sp-3); margin-bottom: var(--sp-3); padding-bottom: var(--sp-3); border-bottom: 1px solid var(--border); }
.category-card-head .poster, .category-card-head .poster-placeholder { width: 54px; height: 80px; flex-basis: 54px; }
.category-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--sp-2); }
.category-section-title { grid-column: 1 / -1; margin-top: 7px; padding-top: var(--sp-3); border-top: 1px solid var(--border); color: var(--text-muted); font-size: var(--text-xs); font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
.category-option { position: relative; }
.category-option input { position: absolute; opacity: 0; }
.category-option label { display: flex; align-items: center; gap: 9px; padding: 10px; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--surface-2); color: var(--text-muted); cursor: pointer; transition: all var(--dur) var(--ease); }
.category-option label::before { width: 15px; height: 15px; flex-shrink: 0; border: 1px solid var(--text-faint); border-radius: 4px; content: ""; }
.category-option.status-option label::before { border-radius: 50%; }
.category-option input:focus-visible + label { outline: 2px solid var(--accent); outline-offset: 2px; }
.category-option input:checked + label { border-color: var(--accent); background: var(--accent-dim); color: var(--text); }
.category-option input:checked + label::before { border-color: var(--accent); background: var(--accent); box-shadow: inset 0 0 0 3px var(--surface-2); }
.modal-hint { margin: var(--sp-3) 0 0; color: var(--text-muted); font-size: var(--text-xs); }
.backup-results { display: flex; flex-direction: column; gap: 10px; }
.backup-result { padding: var(--sp-3); border: 1px solid var(--border); border-radius: var(--r-md); background: var(--surface-2); }
.backup-result strong { display: block; margin-bottom: 5px; }
.backup-path { color: var(--text-muted); font: var(--text-xs)/1.5 var(--font-mono); overflow-wrap: anywhere; }
.restore-warning { margin: 0 0 var(--sp-3); padding: var(--sp-3); border: 1px solid color-mix(in srgb, var(--err) 45%, var(--border)); border-radius: var(--r-md); background: var(--err-dim); color: var(--err); }
.restore-tabs { width: max-content; margin-bottom: var(--sp-3); }
.restore-result { display: flex; align-items: center; gap: var(--sp-3); }
.restore-info { min-width: 0; flex: 1; }
.restore-result .btn { flex: 0 0 auto; }
.restore-empty { min-height: 160px; }
.restore-empty:not(.show) { display: none; }
.toast-wrap { position: fixed; right: 18px; bottom: 18px; z-index: 200; display: flex; flex-direction: column; gap: var(--sp-2); }
.toast { width: min(390px, calc(100vw - 36px)); padding: var(--sp-3) var(--sp-4); border: 1px solid var(--border); border-radius: var(--r-md); background: var(--surface-2); color: var(--text); box-shadow: var(--shadow-lg); animation: toast-in var(--dur) var(--ease); }
.toast.good { border-left: 3px solid var(--ok); }
.toast.bad { border-left: 3px solid var(--err); }
@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } }
@media (max-width: 1120px) {
.hero { align-items: flex-start; }
.hero-actions { max-width: 620px; }
.toolbar { flex-wrap: wrap; }
.toolbar .search { order: 3; max-width: none; flex-basis: 100%; }
.toolbar .user-filter { flex: 1; width: auto; }
.toolbar-spacer { display: none; }
.data-table { min-width: 1060px; }
.detail-head { align-items: flex-start; flex-wrap: wrap; }
.detail-actions { margin-left: 0; }
.detail-toolbar { flex-wrap: wrap; }
.detail-toolbar .search { max-width: none; flex-basis: 100%; }
.detail-toolbar .result-count { margin-left: 0; }
}
@media (max-width: 900px) {
.topbar { display: grid; grid-template-columns: auto 1fr auto; gap: var(--sp-2) var(--sp-3); padding: var(--sp-2) var(--sp-3); }
.topbar-logo { grid-column: 1; grid-row: 1; }
.topbar-spacer { display: block; grid-column: 2; grid-row: 1; }
.theme-btn { grid-column: 3; grid-row: 1; }
.topbar-divider { display: none; }
.topbar-app-nav { grid-column: 1 / -1; grid-row: 2; width: 100%; min-width: 0; overflow-x: auto; padding-top: var(--sp-2); border-top: 1px solid var(--border-subtle); scrollbar-width: none; }
.topbar-app-nav::-webkit-scrollbar { display: none; }
.topbar-app-nav a { min-width: 5.25rem; min-height: 42px; padding: 9px 13px; font-size: var(--text-sm); }
.main { padding: var(--sp-4) var(--sp-3) 2.25rem; }
.hero { display: block; }
.hero-actions { justify-content: flex-start; max-width: none; margin: var(--sp-3) 0 0; }
.summary { grid-template-columns: 1fr; }
}
@media (max-width: 720px) {
.toolbar .db-tabs { order: 1; flex: 1 0 100%; }
.toolbar .db-tab { flex: 1; }
.toolbar .user-filter { order: 2; width: 100%; min-width: 0; flex: 1 0 100%; }
.toolbar .search { order: 3; flex: 1 0 100%; }
.toolbar #pageSize { order: 4; flex: 1; }
.toolbar #refreshBtn { order: 4; }
.data-table.timecode-table, .data-table.sync-table { display: block; width: 100%; min-width: 0; border-collapse: separate; table-layout: auto; }
.timecode-table thead, .sync-table thead { display: none; }
.timecode-table tbody, .sync-table tbody { display: grid; gap: 10px; padding: 10px; }
.timecode-table tr, .sync-table tr { display: grid; width: 100%; grid-template-columns: minmax(0, 1fr) auto; gap: 10px 12px; padding: var(--sp-3); border: 1px solid var(--border); border-radius: var(--r-md); background: var(--surface-2); }
.timecode-table td, .sync-table td { display: block; min-width: 0; padding: 0; border: 0; }
.timecode-table td:nth-child(1), .sync-table td:nth-child(1) { grid-column: 2; grid-row: 1; color: var(--text-faint); font: var(--text-xs) var(--font-mono); text-align: right; }
.timecode-table td:nth-child(2), .sync-table td:nth-child(2) { grid-column: 1; grid-row: 1; }
.timecode-table td:nth-child(3) { grid-column: 1 / -1; grid-row: 2; padding-top: 10px; border-top: 1px solid var(--border); }
.timecode-table td:nth-child(4) { grid-column: 1 / -1; grid-row: 3; }
.timecode-table td:nth-child(5) { grid-column: 1; grid-row: 4; align-self: center; color: var(--text-muted); font-size: var(--text-xs); }
.timecode-table td:nth-child(5)::before, .sync-table td:nth-child(3)::before { content: "Обновлено: "; }
.timecode-table td:nth-child(6) { display: flex; grid-column: 2; grid-row: 4; align-items: center; justify-content: flex-end; }
.sync-table td:nth-child(3) { grid-column: 1 / -1; grid-row: 2; padding-top: 10px; border-top: 1px solid var(--border); color: var(--text-muted); font-size: var(--text-xs); }
.sync-table td:nth-child(4) { display: flex; grid-column: 1 / -1; grid-row: 3; align-items: center; justify-content: flex-end; }
.sync-table td:nth-child(4) .mini-btn:not(.delete) { flex: 1; }
.sync-table td:nth-child(4) .mini-btn.delete { flex: 0 0 40px; }
.timecode-table .poster, .timecode-table .poster-placeholder { width: 52px; height: 78px; flex-basis: 52px; }
.timecode-table .media-title { display: -webkit-box; white-space: normal; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
.timecode-table .media-meta { white-space: normal; }
.timecode-table .progress-copy { gap: var(--sp-3); }
.timecode-table tbody tr:hover, .sync-table tbody tr:hover { background: var(--surface-2); }
}
@media (max-width: 600px) {
.topbar-logo span { display: none; }
.hero-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.hero-actions .btn { min-width: 0; white-space: normal; text-align: center; }
.media-grid { grid-template-columns: 1fr; padding: 9px; }
.form-grid, .category-options { grid-template-columns: 1fr; }
.field.full { grid-column: auto; }
.modal-backdrop { padding: 0; }
.modal { width: 100%; height: 100dvh; max-height: none; border: 0; border-radius: 0; }
.modal-foot { flex-wrap: wrap; }
.detail-head { gap: 10px; }
.detail-identity { order: 3; flex-basis: 100%; }
.detail-actions { margin-left: auto; }
.detail-actions .btn { padding: 6px 9px; }
.restore-result { align-items: flex-start; flex-direction: column; }
.restore-result .btn { width: 100%; }
}
@media (max-width: 430px) {
.topbar { padding-right: var(--sp-2); padding-left: var(--sp-2); }
.topbar-app-nav a { min-width: 5rem; }
.main { padding-right: var(--sp-2); padding-left: var(--sp-2); }
.hero-actions { grid-template-columns: 1fr; }
.summary-card { padding: var(--sp-3); }
.pager { flex-wrap: wrap; }
.pager-buttons { margin-left: 0; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
}
-13
View File
@@ -1,13 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>library</OutputType>
<IsPackable>true</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Shared\Shared.csproj" />
</ItemGroup>
</Project>
-47
View File
@@ -1,47 +0,0 @@
using Microsoft.AspNetCore.Http;
using Shared.Models.Base;
using Shared.Models.Events;
using Shared.Models.Module;
using Shared.Models.Module.Interfaces;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace ExternalBind
{
public class ModInit : IModuleLoaded
{
static readonly HashSet<string> ExternalBindPaths = new(StringComparer.OrdinalIgnoreCase)
{
"/lite/filmixpro",
"/lite/rhs/bind",
"/lite/kinopubpro",
"/lite/getstv/bind",
"/lite/sakhtv/bind",
"/lite/vokinotk",
"/lite/iptvonline/bind",
};
static readonly Func<bool, EventMiddleware, bool> MiddlewareHandler = OnMiddleware;
public void Loaded(InitspaceModel initspace)
{
EventListener.Middleware += MiddlewareHandler;
}
public void Dispose()
{
EventListener.Middleware -= MiddlewareHandler;
}
static bool OnMiddleware(bool first, EventMiddleware e)
{
var requestInfo = e.httpContext.Features.Get<RequestModel>();
var path = e.httpContext.Request.Path.Value;
if (requestInfo != null && path != null && ExternalBindPaths.Contains(path))
requestInfo.IsLocalIp = true;
return true;
}
}
}
-45
View File
@@ -1,45 +0,0 @@
# ExternalBind
Опциональный модуль в **`Modules/ExternalBind/`**: для **заданных URL привязки Lite / Online** запрос считается «локальным» при классификации (`RequestModel.IsLocalIp`).
В [`manifest.json`](manifest.json) по умолчанию **`"enable": false`**. В [корневом README](../../README.md) модуль перечислен в таблице модулей.
## Зачем нужен
Несколько контроллеров Online (Filmix Pro, KinoPub, GetsTV и аналоги) завершают привязку устройства / выдачу токена только если `requestInfo.IsLocalIp == true`. Обычно флаг выставляется лишь для адреса клиента из локальной сети (см. `IPNetwork.IsLocalIp`).
Если ссылку привязки открыть с другого компьютера, через обратный прокси или в любой схеме, где браузер **не** определяется как локальный IP, эти эндпоинты отвечают ошибкой вроде **«is not local ip»**, и сценарий не завершается.
ExternalBind подключается рано в цепочке middleware (`EventListener.Middleware`) и **только для перечисленных ниже путей** принудительно выставляет `IsLocalIp = true`, чтобы шаг привязки мог выполниться.
## Затронутые пути
Точное совпадение пути (без учёта регистра):
| Путь | Назначение |
|------|------------|
| `/lite/filmixpro` | Привязка Filmix Pro |
| `/lite/rhs/bind` | Привязка Rezka |
| `/lite/kinopubpro` | Привязка KinoPub Pro |
| `/lite/getstv/bind` | Привязка GetsTV |
| `/lite/vokinotk` | Привязка Vokino |
| `/lite/iptvonline/bind` | Привязка IPTV Online |
Чтобы добавить или изменить пути, правьте `ExternalBindPaths` в `ModInit.cs`.
## Включение
В `manifest.json` установите `"enable": true` и перезагрузите хост, чтобы модуль подхватился.
По умолчанию **`enable: false`** — URL привязки остаются «только для локальной сети», пока вы явно не включите модуль.
## WAF и безопасность
При `IsLocalIp == true` поведение WAF может отличаться от обычного удалённого клиента (например, в `init.conf` параметр `bypassLocalIP` может ослаблять часть проверок WAF для таких запросов). Включение ExternalBind расширяет круг тех, кто может завершить привязку **по перечисленным путям**, до любого, кто достучится до сервера по этим URL.
Включайте модуль только если нужны удалённая или проксированная привязка; сохраняйте обычные меры: HTTPS, контроль доступа, файрвол.
## Зависимости
- Реализует `IModuleLoaded`; отдельных ключей конфигурации нет.
- Зависит только от `Shared`; контроллеров и статики в модуле нет.
-7
View File
@@ -1,7 +0,0 @@
{
"enable": true,
"dynamic": true,
"tree": [
"ModInit.cs"
]
}
-88
View File
@@ -1,88 +0,0 @@
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json;
namespace LogUserRequest;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public AppDbContext() : base() { }
public DbSet<LogModelSql> jurnal { get; set; }
public DbSet<UserInfoModelSql> unfo { get; set; }
public DbSet<HeaderModelSql> headers { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
if (!optionsBuilder.IsConfigured)
{
var dbPath = Path.Combine(AppContext.BaseDirectory, "database", "LogUserRequest", "userlog.db");
var dbDir = Path.GetDirectoryName(dbPath);
if (!Directory.Exists(dbDir)) Directory.CreateDirectory(dbDir!);
optionsBuilder.UseSqlite($"Data Source={dbPath};Cache=Shared");
optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
}
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<LogModelSql>().HasIndex(x => x.uid);
modelBuilder.Entity<LogModelSql>().HasIndex(x => x.time);
modelBuilder.Entity<LogModelSql>().HasIndex(x => x.balancer);
modelBuilder.Entity<LogModelSql>().HasIndex(x => new { x.uid, x.time });
}
}
public class LogModelSql
{
public int Id { get; set; }
public DateTime time { get; set; }
public string uri { get; set; } = "";
public string uid { get; set; } = "";
public string unfo { get; set; } = "";
public string header { get; set; } = "";
public int duration_ms { get; set; }
public string? balancer { get; set; }
public int? status_code { get; set; }
}
public class UserInfoModelSql
{
[Key] public string Id { get; set; } = "";
public string IP { get; set; } = "";
public string Country { get; set; } = "";
public string UserAgent { get; set; } = "";
}
public class HeaderModelSql
{
[Key] public string Id { get; set; } = "";
public string HeadersJson { get; set; } = "";
[NotMapped]
public Dictionary<string, string> Headers
{
get
{
if (string.IsNullOrEmpty(HeadersJson)) return new();
try { return JsonSerializer.Deserialize<Dictionary<string, string>>(HeadersJson) ?? new(); }
catch { return new(); }
}
set
{
var safe = (value ?? new Dictionary<string, string>())
.Where(x => !string.IsNullOrWhiteSpace(x.Key))
.Take(50)
.ToDictionary(
x => x.Key.Length > 100 ? x.Key[..100] : x.Key,
x =>
{
var v = x.Value ?? "";
return v.Length > 500 ? v[..500] : v;
});
HeadersJson = JsonSerializer.Serialize(safe);
}
}
}
@@ -1,229 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using System.Text;
using Microsoft.EntityFrameworkCore;
using LogUserRequest.Models.DTO;
namespace LogUserRequest.Controllers;
[ApiController]
[Route("lite/logrequest")]
public class ApiController : ControllerBase
{
private static readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions());
private readonly IDbContextFactory<AppDbContext> _contextFactory;
public ApiController(IDbContextFactory<AppDbContext> contextFactory)
{
_contextFactory = contextFactory;
}
private static string GetAttemptsKey(string ip) => $"LogUserRequest:auth:IP:{ip}";
private bool IsAuthorized()
{
var sessionToken = Request.Cookies["loguser_session"];
if (!string.IsNullOrEmpty(sessionToken) && ModInit.ValidateSessionToken(sessionToken))
return true;
return false;
}
[HttpGet]
public IActionResult Index()
{
if (!IsAuthorized()) return Redirect("/lite/logrequest/auth");
var htmlPath = Path.Combine(ModInit.init.path, "index.html");
if (!System.IO.File.Exists(htmlPath)) return Content("UI missing", "text/plain");
var html = System.IO.File.ReadAllText(htmlPath, Encoding.UTF8);
return Content(html, "text/html; charset=utf-8");
}
[HttpGet("auth")]
public IActionResult Auth()
{
var sessionToken = Request.Cookies["loguser_session"];
if (!string.IsNullOrEmpty(sessionToken) && ModInit.ValidateSessionToken(sessionToken))
return Redirect("/lite/logrequest");
var htmlPath = Path.Combine(ModInit.init.path, "auth.html");
if (!System.IO.File.Exists(htmlPath)) return Content("Auth page missing", "text/plain");
Response.Headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0";
Response.Headers["Pragma"] = "no-cache";
Response.Headers["Expires"] = "0";
var html = System.IO.File.ReadAllText(htmlPath, Encoding.UTF8);
return Content(html, "text/html; charset=utf-8");
}
[HttpPost("login")]
public async Task<IActionResult> Login([FromForm] string password)
{
var ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
var cacheKey = GetAttemptsKey(ip);
var attempts = _cache.Get<int?>(cacheKey) ?? 0;
if (attempts > 0) await Task.Delay(attempts * 200);
if (attempts >= 5) return Redirect("/lite/logrequest/auth?blocked=1");
if (password == ModInit.conf.adminPassword)
{
_cache.Remove(cacheKey);
var sessionToken = ModInit.CreateSessionToken();
Response.Cookies.Append("loguser_session", sessionToken, new CookieOptions
{
HttpOnly = true,
Secure = Request.IsHttps,
SameSite = SameSiteMode.Strict,
MaxAge = TimeSpan.FromDays(30),
Path = "/lite/logrequest",
IsEssential = true
});
var script = @"
<!DOCTYPE html>
<html><head><meta charset='utf-8'></head><body>
<script>
if (window.history && window.history.replaceState) {
window.history.replaceState(null, '', '/lite/logrequest');
}
window.location.replace('/lite/logrequest');
</script>
</body></html>";
return Content(script, "text/html; charset=utf-8");
}
attempts++;
_cache.Set(cacheKey, attempts, new MemoryCacheEntryOptions
{
AbsoluteExpiration = DateTime.Today.AddDays(1),
SlidingExpiration = TimeSpan.FromHours(1)
});
await Task.Delay(Random.Shared.Next(300, 800));
return Redirect($"/lite/logrequest/auth?error=1&remaining={5 - attempts}");
}
[HttpGet("logout")]
public IActionResult Logout()
{
var sessionToken = Request.Cookies["loguser_session"];
if (!string.IsNullOrEmpty(sessionToken)) ModInit.RevokeSessionToken(sessionToken);
Response.Cookies.Delete("loguser_session", new CookieOptions
{
Path = "/lite/logrequest"
});
return Redirect("/lite/logrequest/auth");
}
[HttpGet("api")]
public async Task<IActionResult> Api(string? uid, int skip = 0, int take = 200)
{
if (!IsAuthorized()) return UnauthorizedResponse();
take = Math.Min(take, 500);
await using var dbContext = await _contextFactory.CreateDbContextAsync();
var query = dbContext.jurnal.AsNoTracking();
if (!string.IsNullOrEmpty(uid)) query = query.Where(j => j.uid == uid);
var jurnal = await query.OrderByDescending(x => x.Id).Skip(skip).Take(take).ToListAsync();
if (jurnal.Count == 0) return Ok(ApiResponse<List<JournalItemDto>>.Fail("Empty"));
var unfoIds = jurnal.Select(j => j.unfo).Where(id => !string.IsNullOrEmpty(id)).Distinct().ToList();
var unfoDict = await dbContext.unfo.Where(u => unfoIds.Contains(u.Id)).ToDictionaryAsync(u => u.Id);
var result = jurnal.Select(j =>
{
unfoDict.TryGetValue(j.unfo ?? "", out var unfo);
return new JournalItemDto
{
Id = j.Id,
Time = j.time,
Uri = j.uri,
UserUid = j.uid,
Ip = unfo?.IP ?? "unknown",
Country = unfo?.Country ?? "",
UserAgent = unfo?.UserAgent ?? "unknown",
DurationMs = j.duration_ms,
Balancer = j.balancer
};
}).ToList();
return Ok(ApiResponse<List<JournalItemDto>>.Ok(result));
}
[HttpGet("premium-chart")]
public async Task<IActionResult> PremiumChart(int days = 7)
{
if (!IsAuthorized()) return UnauthorizedResponse();
days = Math.Min(days, 3);
var startDate = DateTime.UtcNow.AddDays(-days);
await using var dbContext = await _contextFactory.CreateDbContextAsync();
var data = await dbContext.jurnal
.Where(j => j.time >= startDate)
.GroupBy(j => j.time.Date)
.Select(g => new { date = g.Key, count = g.Count() })
.OrderBy(x => x.date)
.ToListAsync();
var result = new
{
data = data,
message = "В Lite-версии доступно только 2 дня. Premium — до 90 дней.",
premiumAvailable = true
};
return Ok(ApiResponse<object>.Ok(result));
}
[HttpGet("stats")]
public async Task<IActionResult> Stats(string? uid)
{
if (!IsAuthorized()) return UnauthorizedResponse();
await using var dbContext = await _contextFactory.CreateDbContextAsync();
var query = dbContext.jurnal.AsNoTracking();
if (!string.IsNullOrEmpty(uid)) query = query.Where(j => j.uid == uid);
var userLogs = await query.OrderByDescending(j => j.time).Take(50000).ToListAsync();
if (userLogs.Count == 0) return Ok(ApiResponse<StatsDto>.Fail("Empty"));
var unfoIds = userLogs.Select(j => j.unfo).Where(id => !string.IsNullOrEmpty(id)).Distinct().ToList();
var unfoList = await dbContext.unfo.Where(u => unfoIds.Contains(u.Id)).ToListAsync();
var uniqueIp = unfoList.Select(u => u.IP).Where(ip => !string.IsNullOrEmpty(ip)).Distinct().Count();
var uniqueUserAgent = unfoList.Select(u => u.UserAgent).Where(ua => !string.IsNullOrEmpty(ua)).Distinct().Count();
var now = DateTime.UtcNow;
var today = userLogs.Count(l => l.time.Day == now.Day && l.time.Month == now.Month && l.time.Year == now.Year);
var month = userLogs.Count(l => l.time.Month == now.Month && l.time.Year == now.Year);
var topBalancers = userLogs
.GroupBy(l => l.balancer)
.Where(g => !string.IsNullOrEmpty(g.Key))
.Select(g => new TopItemDto { Name = g.Key ?? "", Count = g.Count() })
.OrderByDescending(x => x.Count).Take(50).ToArray();
var topUsers = userLogs
.GroupBy(j => j.uid)
.Select(g => new TopItemDto { Name = g.Key, Count = g.Count() })
.OrderByDescending(x => x.Count).Take(20).ToArray();
return Ok(ApiResponse<StatsDto>.Ok(new StatsDto
{
Today = today,
Month = month,
UniqueIp = uniqueIp,
UniqueUserAgent = uniqueUserAgent,
TopUsers = topUsers,
TopBalancers = topBalancers
}));
}
private IActionResult UnauthorizedResponse() => StatusCode(401, ApiResponse<object>.Fail("Unauthorized"));
private IActionResult Ok<T>(ApiResponse<T> response) => new JsonResult(response);
}
-24
View File
@@ -1,24 +0,0 @@
global using Microsoft.AspNetCore.Authorization;
global using Microsoft.AspNetCore.Builder;
global using Microsoft.AspNetCore.Http;
global using Microsoft.AspNetCore.Mvc;
global using Microsoft.EntityFrameworkCore;
global using Microsoft.Extensions.Caching.Memory;
global using Microsoft.Extensions.DependencyInjection;
global using Shared;
global using Shared.Attributes;
global using Shared.Models;
global using Shared.Models.Base;
global using Shared.Models.Events;
global using Shared.Models.Module;
global using Shared.Models.Module.Interfaces;
global using System;
global using System.Collections.Concurrent;
global using System.Collections.Generic;
global using System.IO;
global using System.Linq;
global using System.Security.Cryptography;
global using System.Text;
global using System.Text.Json;
global using System.Threading;
global using System.Threading.Tasks;
@@ -1,13 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<IsPackable>true</IsPackable>
<RootNamespace>LogUserRequest</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Shared\Shared.csproj" />
</ItemGroup>
</Project>
@@ -1,182 +0,0 @@
using Microsoft.AspNetCore.Http;
using System.Security.Cryptography;
using Microsoft.Extensions.Caching.Memory;
namespace LogUserRequest;
public static class LogUserRequestListener
{
public static readonly ConcurrentQueue<(LogModelSql jurnal, UserInfoModelSql unfo, HeaderModelSql header)> Queue = new();
private static int _queueSize = 0;
private const int MaxQueueSize = 20000;
private static long _lastQueueOverflowLog = 0;
private static long _lastErrorLog = 0;
private static readonly MemoryCache _rateLimitCache = new(new MemoryCacheOptions { SizeLimit = 100_000 });
private static readonly string[] _skipPrefixes =
{
"/.well-known",
"/admin/health",
"/admin/ping",
"/testaccsdb",
"/nws",
"/lifeevents",
"/proxyimg",
"/lite/logrequest",
"/lite/events",
"/nexthub",
"/externalids",
"/lampa-main",
"/lite/withsearch",
"/timecode",
"/bookmark",
"/storage",
"/cub",
"/cub/",
"/sisi/bookmarks",
"/proxy/"
};
private static readonly string[] _skipExtensions =
{
".js", ".css", ".svg", ".png", ".jpg", ".jpeg",
".woff", ".woff2", ".ogg", ".ico", ".map"
};
public static void DequeueItem() => Interlocked.Decrement(ref _queueSize);
private static bool ShouldLogError()
{
var nowTicks = DateTime.UtcNow.Ticks;
var last = Interlocked.Read(ref _lastErrorLog);
if (nowTicks - last > TimeSpan.FromMinutes(1).Ticks)
{
Interlocked.Exchange(ref _lastErrorLog, nowTicks);
return true;
}
return false;
}
public static Task<bool> InvokeAsync(bool first, EventMiddleware e)
{
if (first)
return Task.FromResult(true);
var httpContext = e.httpContext;
var requestInfo = httpContext.Features.Get<RequestModel>();
if (requestInfo == null)
return Task.FromResult(true);
if (requestInfo.IsLocalRequest || requestInfo.IsAnonymousRequest)
return Task.FromResult(true);
try
{
var path = httpContext.Request.Path.Value ?? "";
// Отсев статики по расширениям
foreach (var ext in _skipExtensions)
{
if (path.EndsWith(ext, StringComparison.OrdinalIgnoreCase))
return Task.FromResult(true);
}
// Отсев по префиксам
foreach (var prefix in _skipPrefixes)
{
if (path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
return Task.FromResult(true);
}
var realIP = requestInfo.IP;
if (string.IsNullOrEmpty(realIP) || realIP == "127.0.0.1" || realIP == "::1")
return Task.FromResult(true);
if (_rateLimitCache.TryGetValue(realIP, out _))
return Task.FromResult(true);
_rateLimitCache.Set(realIP, DateTime.UtcNow, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMilliseconds(33),
Size = 1
});
string userAgent = httpContext.Request.Headers["User-Agent"].FirstOrDefault() ?? "unknown";
if (userAgent.Length > 512) userAgent = userAgent[..512] + "...[truncated]";
string userUid = httpContext.Request.Query["uid"].FirstOrDefault() ?? "";
if (string.IsNullOrEmpty(userUid)) userUid = httpContext.Request.Cookies["uid"] ?? "";
if (string.IsNullOrEmpty(userUid)) userUid = "anonymous";
if (userUid.Length > 256) userUid = userUid[..256];
string country = "";
if (httpContext.Request.Headers.TryGetValue("CF-IPCountry", out var cfCountry))
country = cfCountry.ToString();
static string GetHash(string input)
{
var bytes = Encoding.UTF8.GetBytes(input);
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLower();
}
var unfo = new UserInfoModelSql
{
Id = GetHash($"{realIP}:{country}:{userAgent}"),
IP = realIP,
Country = country,
UserAgent = userAgent
};
var header = new HeaderModelSql { Headers = new Dictionary<string, string>() };
header.Id = "";
var fullUri = path + httpContext.Request.QueryString;
if (fullUri.Length > 2048) fullUri = fullUri[..2048];
string balancer = "";
if (path.StartsWith("/lite/"))
balancer = path[6..].Split('/', '?')[0];
else if (path.StartsWith("/rc/"))
balancer = path[4..].Split('/', '?')[0];
var jurnal = new LogModelSql
{
time = DateTime.UtcNow,
uri = fullUri,
uid = userUid,
duration_ms = 0,
balancer = balancer,
status_code = httpContext.Response?.StatusCode ?? 0
};
if (Interlocked.Increment(ref _queueSize) <= MaxQueueSize)
{
try { Queue.Enqueue((jurnal, unfo, header)); }
catch { Interlocked.Decrement(ref _queueSize); }
}
else
{
Interlocked.Decrement(ref _queueSize);
var nowTicks = DateTime.UtcNow.Ticks;
var last = Interlocked.Read(ref _lastQueueOverflowLog);
if (nowTicks - last > TimeSpan.FromMinutes(1).Ticks)
{
if (Interlocked.CompareExchange(ref _lastQueueOverflowLog, nowTicks, last) == last)
{
Console.WriteLine($"[LogUserRequest] Queue overflow, dropping logs. Size: {_queueSize}");
}
}
}
}
catch (Exception ex)
{
if (ShouldLogError())
Console.WriteLine($"[LogUserRequest] Listener error: {ex}");
}
return Task.FromResult(true);
}
}
-375
View File
@@ -1,375 +0,0 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using System.Security.Cryptography;
using Microsoft.Extensions.Caching.Memory;
namespace LogUserRequest;
public class ModInit : IModuleLoaded, IModuleConfigure
{
public static InitspaceModel init { get; set; } = null!;
public static (int logDay, string adminPassword) conf = (90, "");
public static object? stats = null;
static Timer? _statsTimer, _clearJurnalTimer, _updateDbTimer;
private static int _updatingStats = 0;
private static int _updatingDb = 0;
private static readonly MemoryCache _sessionTokens = new(new MemoryCacheOptions { SizeLimit = 10000 });
private static readonly TimeSpan _sessionLifetime = TimeSpan.FromDays(30);
private static string _workPath = AppContext.BaseDirectory;
private static string _dbDirectory = Path.Combine(AppContext.BaseDirectory, "database", "LogUserRequest");
private static string _dbPath = Path.Combine(AppContext.BaseDirectory, "database", "LogUserRequest", "userlog.db");
private static string _passwdPath = Path.Combine(AppContext.BaseDirectory, "database", "LogUserRequest", "passlogreg");
// === IModuleConfigure ===
public void Configure(ConfigureModel app)
{
app.services.AddDbContextFactory<AppDbContext>(options =>
{
options.UseSqlite($"Data Source={_dbPath};Cache=Shared");
options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
});
}
public static bool ValidateSessionToken(string token)
{
if (string.IsNullOrEmpty(token)) return false;
return _sessionTokens.TryGetValue(token, out _);
}
public static string CreateSessionToken()
{
var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
_sessionTokens.Set(token, true, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = _sessionLifetime,
SlidingExpiration = TimeSpan.FromDays(7),
Size = 1
});
return token;
}
public static void RevokeSessionToken(string token) => _sessionTokens.Remove(token);
private static string GenerateRandomPassword(int length = 36)
{
using var rng = RandomNumberGenerator.Create();
var bytes = new byte[length];
rng.GetBytes(bytes);
var base64 = Convert.ToBase64String(bytes);
return base64.Replace('+', 'A').Replace('/', 'B')[..length];
}
private static string GetOrCreateAdminPassword()
{
var envPassword = Environment.GetEnvironmentVariable("LOGUSER_ADMIN_PASSWORD");
if (!string.IsNullOrEmpty(envPassword))
return envPassword;
if (File.Exists(_passwdPath))
{
try
{
var filePassword = File.ReadAllText(_passwdPath).Trim();
if (!string.IsNullOrEmpty(filePassword))
return filePassword;
}
catch { }
}
var oldPasswdPath = Path.Combine(_workPath, "passlogreg");
if (File.Exists(oldPasswdPath))
{
try
{
var filePassword = File.ReadAllText(oldPasswdPath).Trim();
if (!string.IsNullOrEmpty(filePassword))
{
Directory.CreateDirectory(Path.GetDirectoryName(_passwdPath)!);
File.Move(oldPasswdPath, _passwdPath);
return filePassword;
}
}
catch { }
}
var newPassword = GenerateRandomPassword(36);
try
{
var passwdDir = Path.GetDirectoryName(_passwdPath);
if (!string.IsNullOrEmpty(passwdDir))
Directory.CreateDirectory(passwdDir);
File.WriteAllText(_passwdPath, newPassword);
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS() || OperatingSystem.IsFreeBSD())
{
try { File.SetUnixFileMode(_passwdPath, UnixFileMode.UserRead | UnixFileMode.UserWrite); } catch { }
}
}
catch
{
Console.WriteLine("[LogUserRequest-Lite] Warning: Cannot save password to file. Using memory-only password.");
}
return newPassword;
}
public void Loaded(InitspaceModel initspace)
{
init = initspace;
_workPath = AppContext.BaseDirectory;
_dbDirectory = Path.Combine(_workPath, "database", "LogUserRequest");
_dbPath = Path.Combine(_dbDirectory, "userlog.db");
_passwdPath = Path.Combine(_dbDirectory, "passlogreg");
Console.WriteLine($"[LogUserRequest-Lite] Work path: {_workPath}");
Console.WriteLine($"[LogUserRequest-Lite] DB path: {_dbPath}");
Console.WriteLine($"[LogUserRequest-Lite] Password path: {_passwdPath}");
try
{
Directory.CreateDirectory(_dbDirectory);
}
catch (IOException ex)
{
Console.WriteLine($"[LogUserRequest-Lite] Warning: Cannot create directory {_dbDirectory}: {ex.Message}");
}
using (var sqlDb = new AppDbContext())
{
sqlDb.Database.EnsureCreated();
try { sqlDb.Database.ExecuteSqlRaw("ALTER TABLE jurnal ADD COLUMN balancer TEXT;"); } catch { }
try { sqlDb.Database.ExecuteSqlRaw("ALTER TABLE jurnal ADD COLUMN status_code INTEGER NULL;"); } catch { }
try { sqlDb.Database.ExecuteSqlRaw("ALTER TABLE jurnal ADD COLUMN duration_ms INTEGER DEFAULT 0;"); } catch { }
try { sqlDb.Database.ExecuteSqlRaw("CREATE INDEX IF NOT EXISTS IX_jurnal_uid ON jurnal(uid);"); } catch { }
try { sqlDb.Database.ExecuteSqlRaw("CREATE INDEX IF NOT EXISTS IX_jurnal_time ON jurnal(time);"); } catch { }
try
{
sqlDb.Database.ExecuteSqlRaw("PRAGMA journal_mode = WAL;");
sqlDb.Database.ExecuteSqlRaw("PRAGMA synchronous = NORMAL;");
sqlDb.Database.ExecuteSqlRaw("PRAGMA cache_size = -64000;");
sqlDb.Database.ExecuteSqlRaw("PRAGMA temp_store = MEMORY;");
sqlDb.Database.ExecuteSqlRaw("PRAGMA mmap_size = 33554432;");
}
catch { }
}
var manifestPath = Path.Combine(init.path, "manifest.json");
JObject manifest = new();
if (File.Exists(manifestPath))
{
manifest = JsonConvert.DeserializeObject<JObject>(File.ReadAllText(manifestPath)) ?? new();
conf.logDay = manifest["logDay"]?.Value<int>() ?? 90;
}
conf.adminPassword = GetOrCreateAdminPassword();
_clearJurnalTimer = new Timer(ClearJurnal, null, TimeSpan.FromMinutes(1), TimeSpan.FromHours(24));
_statsTimer = new Timer(UpdateStatsCallback, null, TimeSpan.FromSeconds(10), TimeSpan.FromMinutes(5));
_updateDbTimer = new Timer(UpdateDbCallback, null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(30));
// === Подписка на EventListener вместо app.UseMiddleware ===
EventListener.MiddlewareAsync -= LogUserRequestListener.InvokeAsync;
EventListener.MiddlewareAsync += LogUserRequestListener.InvokeAsync;
Console.WriteLine($"[LogUserRequest-Lite] Module loaded (logDay={conf.logDay})");
}
public void Dispose()
{
// === Отписка ===
EventListener.MiddlewareAsync -= LogUserRequestListener.InvokeAsync;
_clearJurnalTimer?.Dispose();
_statsTimer?.Dispose();
_updateDbTimer?.Dispose();
_sessionTokens.Dispose();
}
// ... остальные методы (ClearJurnal, UpdateStats, UpdateDb) без изменений
static void ClearJurnal(object? state)
{
try
{
using var sqlDb = new AppDbContext();
var cutoff = DateTime.UtcNow.AddDays(-conf.logDay);
const int batchSize = 5000;
int totalDeleted = 0;
while (true)
{
var idsToDelete = sqlDb.jurnal
.Where(j => j.time < cutoff)
.OrderBy(j => j.Id)
.Select(j => j.Id)
.Take(batchSize)
.ToList();
if (idsToDelete.Count == 0) break;
sqlDb.jurnal.Where(j => idsToDelete.Contains(j.Id)).ExecuteDelete();
totalDeleted += idsToDelete.Count;
if (idsToDelete.Count < batchSize) break;
}
if (totalDeleted > 0)
{
var usedUnfo = sqlDb.jurnal.Select(j => j.unfo).Distinct().Take(50000).ToHashSet();
var usedHeaders = sqlDb.jurnal.Select(j => j.header).Distinct().Take(50000).ToHashSet();
sqlDb.unfo.Where(u => !usedUnfo.Contains(u.Id)).Take(batchSize).ExecuteDelete();
sqlDb.headers.Where(h => !usedHeaders.Contains(h.Id)).Take(batchSize).ExecuteDelete();
Console.WriteLine($"[LogUserRequest-Lite] Cleaned {totalDeleted} old records");
}
}
catch (Exception ex)
{
Console.WriteLine($"[LogUserRequest-Lite] ClearJurnal error: {ex.Message}");
}
}
private static void UpdateStatsCallback(object? state) => UpdateStats();
public static void UpdateStats()
{
if (Interlocked.Exchange(ref _updatingStats, 1) == 1) return;
try
{
using var sqlDb = new AppDbContext();
var now = DateTime.UtcNow;
var monthStart = new DateTime(now.Year, now.Month, 1);
var todayStart = new DateTime(now.Year, now.Month, now.Day);
var tomorrowStart = todayStart.AddDays(1);
var monthQuery = sqlDb.jurnal.Where(j => j.time >= monthStart);
int today = monthQuery.Count(j => j.time >= todayStart && j.time < tomorrowStart);
int month = monthQuery.Count();
var unfoIds = monthQuery.Select(j => j.unfo).Distinct().Take(10000).ToList();
if (unfoIds.Count > 0)
{
var unfoData = sqlDb.unfo
.Where(u => unfoIds.Contains(u.Id))
.Select(u => new { u.IP, u.UserAgent })
.Take(10000)
.ToList();
int uniqueUserAgent = unfoData.Select(u => u.UserAgent).Distinct().Count();
int uniqueIp = unfoData.Select(u => u.IP).Where(ip => !string.IsNullOrEmpty(ip)).Distinct().Count();
var topUsers = monthQuery
.GroupBy(j => j.uid)
.Select(g => new { uid = g.Key, count = g.Count() })
.OrderByDescending(x => x.count)
.Take(20)
.ToArray();
var topBalancers = monthQuery
.Where(j => j.balancer != null)
.GroupBy(j => j.balancer)
.Select(g => new { balancer = g.Key, count = g.Count() })
.OrderByDescending(x => x.count)
.Take(50)
.ToArray();
stats = new { today, month, uniqueUserAgent, uniqueIp, topUsers, topBalancers };
}
else
{
stats = new
{
today = 0,
month = 0,
uniqueUserAgent = 0,
uniqueIp = 0,
topUsers = Array.Empty<object>(),
topBalancers = Array.Empty<object>()
};
}
}
catch (Exception ex)
{
Console.WriteLine($"[LogUserRequest-Lite] UpdateStats error: {ex.Message}");
}
finally { Interlocked.Exchange(ref _updatingStats, 0); }
}
private static void UpdateDbCallback(object? state) => UpdateDb();
static void UpdateDb()
{
if (Interlocked.Exchange(ref _updatingDb, 1) == 1) return;
try
{
using var sqlDb = new AppDbContext();
sqlDb.ChangeTracker.AutoDetectChangesEnabled = false;
var batch = new List<(LogModelSql jurnal, UserInfoModelSql unfo, HeaderModelSql header)>();
int batchSize = 1000;
while (LogUserRequestListener.Queue.TryDequeue(out var item) && batch.Count < batchSize)
{
batch.Add(item);
LogUserRequestListener.DequeueItem();
}
if (batch.Count == 0) return;
var unfoIds = batch.Select(b => b.unfo.Id).Distinct().ToList();
var headerIds = batch.Select(b => b.header.Id).Distinct().ToList();
var existingUnfo = sqlDb.unfo
.Where(u => unfoIds.Contains(u.Id))
.Select(u => u.Id)
.ToHashSet();
var existingHeaders = sqlDb.headers
.Where(h => headerIds.Contains(h.Id))
.Select(h => h.Id)
.ToHashSet();
foreach (var item in batch.OrderBy(i => i.jurnal.time))
{
if (!existingUnfo.Contains(item.unfo.Id))
{
sqlDb.unfo.Add(item.unfo);
existingUnfo.Add(item.unfo.Id);
}
if (!existingHeaders.Contains(item.header.Id))
{
sqlDb.headers.Add(item.header);
existingHeaders.Add(item.header.Id);
}
sqlDb.jurnal.Add(new LogModelSql
{
time = item.jurnal.time,
uri = item.jurnal.uri,
uid = item.jurnal.uid,
unfo = item.unfo.Id,
header = item.header.Id,
duration_ms = item.jurnal.duration_ms,
balancer = item.jurnal.balancer,
status_code = item.jurnal.status_code
});
}
sqlDb.SaveChanges();
}
catch (Exception ex)
{
Console.WriteLine($"[LogUserRequest-Lite] UpdateDb error: {ex.Message}");
}
finally { Interlocked.Exchange(ref _updatingDb, 0); }
}
}
@@ -1,11 +0,0 @@
namespace LogUserRequest.Models.DTO;
public class ApiResponse<T>
{
public bool Success { get; set; } = true;
public string? Error { get; set; }
public T? Data { get; set; }
public static ApiResponse<T> Ok(T data) => new() { Success = true, Data = data };
public static ApiResponse<T> Fail(string error) => new() { Success = false, Error = error };
}
@@ -1,14 +0,0 @@
namespace LogUserRequest.Models.DTO;
public class JournalItemDto
{
public int Id { get; set; }
public DateTime Time { get; set; }
public string Uri { get; set; } = "";
public string UserUid { get; set; } = "";
public string Ip { get; set; } = "unknown";
public string Country { get; set; } = "";
public string UserAgent { get; set; } = "unknown";
public int DurationMs { get; set; }
public string? Balancer { get; set; }
}
@@ -1,17 +0,0 @@
namespace LogUserRequest.Models.DTO;
public class StatsDto
{
public int Today { get; set; }
public int Month { get; set; }
public int UniqueIp { get; set; }
public int UniqueUserAgent { get; set; }
public TopItemDto[] TopUsers { get; set; } = Array.Empty<TopItemDto>();
public TopItemDto[] TopBalancers { get; set; } = Array.Empty<TopItemDto>();
}
public class TopItemDto
{
public string Name { get; set; } = "";
public int Count { get; set; }
}
-336
View File
@@ -1,336 +0,0 @@
## Установка
1. Скачайте архив с модулем
2. Распакуйте папку `LogUserRequest-Lite` в `/lampac/mods/`
3. Отредактируйте init.conf. Разрешите доступ к странице админки LogUserRequest-Lite:
"accsdb": {
"enable": true,
"whitepattern": "^/lite/logrequest"
},
4. Перезапустите Lampac
5. Админка: http://ваш_сервер/lite/logrequest
## Настройка модуля:
1. Конфигурация в manifest.json:
{
"enable": true,
"version": 4,
"namespace": "LogUserRequest",
"logDay": 90
}
| Параметр | Описание | Значение по умолчанию |
|---|---|---|
| enable | Включить модуль | true |
| logDay | Сколько дней хранить логи | 90 |
Пароль администратора:
1. Переменная окружения (LOGUSER_ADMIN_PASSWORD=my-super-super-secret-key)
2. Пароль генерируется автоматически при первом запуске и сохраняется в файл: /lampac/database/LogUserRequest/passlogreg
(Посмотреть пароль: docker exec lampac cat /lampac/database/LogUserRequest/passlogreg)
Что произойдёт при первом запуске
| Ситуация | Результат |
|---|---|
| ENV задан | Используется ENV, файл не создаётся |
| ENV пуст, файла нет | Создаётся /lampac/database/LogUserRequest/passlogreg со случайным паролем (36 символов) |
| ENV пуст, файл есть | Используется пароль из файла |
⚠️ Важно:
Если переменная окружения задана - она имеет наивысший приоритет, и остальные источники игнорируются.
Блокировка при входе в LogUserRequest
Как работает блокировка:
| Параметр | Значение |
|---|---|
| Максимум попыток | 5 неверных паролей в день |
| Блокировка | После 5 неудачных попыток |
| Сброс счётчика | В полночь (по UTC) |
| Блокируется | Только IP нарушителя (остальные пользователи не страдают) |
Что делать при блокировке
1. Подождать до полуночи
Счётчик сбрасывается автоматически в 00:00 UTC. (в коде `DateTime.Today.AddDays(1)` использует локальное время сервера, убедитесь что часовой пояс UTC)
2. Сменить IP (если нужно срочно)
Переподключиться к VPN
Использовать другой интернет
Перезагрузить роутер
3. Проверить правильность пароля
docker exec lampac cat /lampac/database/LogUserRequest/passlogreg
4. Сбросить блокировку вручную (требуется root доступ)
Перезапустить контейнер (сбросит кэш попыток)
docker restart lampac
Сброс счётчика в полночь:
Изменения можно внести в Controllers/ApiController.cs
// В коде используется
AbsoluteExpiration = DateTime.Today.AddDays(1)
## Инструкция по настройке фильтрации в Lite-версии
### Где находятся фильтры
Все фильтры находятся в файле `LogUserRequestListener.cs` в начале класса `LogUserRequestListener`.
### Как работает фильтрация
Модуль использует **три уровня фильтрации**:
1. **Автоматическая (EventListener lampac)** — отсеивает анонимные запросы, локальные IP, служебные эндпоинты ядра
2. **Список префиксов (`_skipPrefixes`)** — пути, которые начинаются с указанных строк
3. **Список расширений (`_skipExtensions`)** — статические файлы по расширению
Попадание в любой из фильтров — запрос не логируется.
---
### 1. Список пропускаемых префиксов (`_skipPrefixes`)
private static readonly string[] _skipPrefixes =
{
"/.well-known",
"/admin/health",
"/admin/ping",
"/testaccsdb",
"/nws",
"/lifeevents",
"/proxyimg",
"/lite/logrequest",
"/lite/events",
"/nexthub",
"/externalids",
"/lampa-main",
"/lite/withsearch",
"/timecode",
"/bookmark",
"/storage",
"/cub",
"/cub/",
"/sisi/bookmarks",
"/proxy/"
};
#### Как добавить новый путь:
private static readonly string[] _skipPrefixes =
{
// ... существующие ...
"/ваш_путь", // Блокирует /ваш_путь и /ваш_путь/что-угодно
"/api/internal", // Ещё один пример
};
### 2. Список расширений (_skipExtensions)
private static readonly string[] _skipExtensions =
{
".js", ".css", ".svg", ".png", ".jpg", ".jpeg",
".woff", ".woff2", ".ogg", ".ico", ".map"
};
**Как добавить новое расширение:**
private static readonly string[] _skipExtensions =
{
// ... существующие ...
".php", // Блокирует все .php файлы
".webp", // Блокирует .webp изображения
};
### 3. Rate Limit (ограничение частоты запросов)
_rateLimitCache.Set(realIP, DateTime.UtcNow, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMilliseconds(33), // ← интервал
Size = 1
});
**Как изменить интервал:**
TimeSpan.FromMilliseconds(33) — ~30 запросов/сек с одного IP
TimeSpan.FromMilliseconds(100) — 10 запросов/сек
TimeSpan.FromSeconds(1) — 1 запрос/сек
### 4. Максимальный размер очереди
private const int MaxQueueSize = 20000;
При превышении лимита новые записи отбрасываются с throttled-логом (не чаще раза в минуту).
#### 📋 Примеры фильтрации
| Что хотите заблокировать | Куда добавить | Код |
|---|---|---|
| Конкретный путь /api/test | _skipPrefixes | "/api/test" |
| Все пути с префиксом /bot/ | _skipPrefixes | "/bot" |
| Все .php файлы | _skipExtensions | ".php" |
| Слишком частые запросы | Rate Limit | Увеличить FromMilliseconds(33) до FromMilliseconds(100) |
#### Что фильтруется автоматически (EventListener lampac)
Это не нужно добавлять в списки — lampac делает это сам:
Статические файлы (js, css, png, svg, woff)
Анонимные запросы
Локальные IP (127.0.0.1, ::1)
Служебные эндпоинты lampac
Запросы до авторизации
#### ⚠️ Важные замечания
После изменения файла нужна перекомпиляция модуля
Синтаксис C# чувствителен к регистру и запятым
Не удаляйте /lite/logrequest из _skipPrefixes — иначе страница модуля будет логировать саму себя
При ошибке в синтаксисе модуль не загрузится
## База данных
Путь: /lampac/database/LogUserRequest/userlog.db
Очистка старых записей
В Lite-версии реализована **очистка по дням** через параметр logDay в manifest.json.
{
"logDay": 90
}
### Как работает:
Записи старше logDay дней удаляются раз в сутки
Проверка запускается через 1 минуту после старта модуля и затем каждые 24 часа
Удаление происходит батчами по 5000 записей для защиты от блокировки SQLite при больших объёмах
После удаления старых записей также удаляются неиспользуемые unfo и headers
**Код в ModInit.cs:**
Код находится в ModInit.cs, метод ClearJurnal. Удаление происходит батчами по 5000 записей.
**Почему батчами:**
SQLite блокирует всю таблицу при удалении
При большом количестве записей (100 000+) одиночный DELETE может занять секунды
Батчи по 5000 позволяют другим операциям (запись логов, чтение админки) работать между удалениями
### Рекомендуемые значения
| Сценарий | logDay: |
|---|---|
| Домашнее использование (1-5 пользователей) | 90 дней |
| Небольшой сервер (5-20 пользователей) | 60 дней |
| Крупный сервер (20+ пользователей) | 30 дней |
Оптимизация SQLite (уже настроено)
В модуле применены:
WAL — ускорение записи
cache_size = -64000 — кэш 64 МБ
temp_store = MEMORY — временные таблицы в памяти
mmap_size = 33554432 — memory-mapped I/O (32 МБ)
### Ручная очистка и редактирование базы данных
Для редактирования базы данных используйте **DB Browser for SQLite** — бесплатный визуальный редактор.
🔗 Скачать: [https://sqlitebrowser.org/](https://sqlitebrowser.org/)
Пошаговая инструкция
1. **Скопируйте** файл `userlog.db` из папки `lampac-docker/database/LogUserRequest/` в любое удобное место (это будет ваш бэкап)
2. **Отредактируйте копию** в DB Browser for SQLite:
- Откройте скопированный файл
- Перейдите на вкладку **"Execute SQL"**
- Вставьте и выполните запросы:
DELETE FROM jurnal WHERE time < datetime('now', '-30 days');
VACUUM;
Сохраните изменения (Ctrl+S)
Замените оригинал — скопируйте отредактированный файл обратно в lampac-docker/database/LogUserRequest/ с заменой
Перезапустите контейнер:
docker restart lampac
✅ Готово! База очищена, старые записи удалены.
📋 Альтернативные запросы
| Задача | SQL-запрос |
|---|---|
| Удалить старше 7 дней | DELETE FROM jurnal WHERE time < datetime('now', '-7 days'); |
| Удалить старше 90 дней | DELETE FROM jurnal WHERE time < datetime('now', '-90 days'); |
| Удалить ВСЕ записи | DELETE FROM jurnal; |
| Посмотреть количество записей | SELECT COUNT(*) FROM jurnal; |
## 🕐 Время записей не совпадает с местным
По умолчанию модуль сохраняет время запросов в **UTC**. Если в админке время отличается от вашего локального — это нормально, но можно настроить.
Варианты решения
1. Сменить часовой пояс контейнера (рекомендуется)
Добавьте переменную окружения TZ при запуске контейнера:
docker run -e TZ=Europe/…
docker run -e TZ=Asia/…
Или в docker-compose.yml:
environment:
- TZ=Europe/….
После изменения — перезапустите контейнер.
2. Если TZ не поддерживается нужно изменить код модуля (⚠️Не рекомендуется)
Если нужно хранить локальное время вместо UTC, измените во всех файлах модуля
// Было
DateTime.UtcNow
DateTime.UtcNow.AddDays(-X)
DateTime.UtcNow.Date
// Стало
DateTime.Now
DateTime.Now.AddDays(-X)
DateTime.Now.Date
Не заменяйте:
DateTime.Now.Ticks
В именах файлов экспорта ($"export_{DateTime.Now:yyyyMMdd}.zip")
### ⚠️ Важно: последствия перехода на локальное время
| Проблема | Описание |
|---|---|
| **Переход на летнее/зимнее время** | Дважды в год время сдвигается на час. Записи могут дублироваться или нарушится сортировка |
| **Смена часового пояса сервера** | При переезде на другой сервер все исторические данные будут показывать неверное время |
| **Сравнение логов с разных серверов** | Если у вас несколько серверов в разных часовых поясах — логи невозможно сопоставить |
| **Очистка по `logDay`** | Может удалить на час больше или меньше записей при переходе времени |
#### 💡 Рекомендация: Оставьте UTC. Браузер сам покажет локальное время. Это стандарт для логирования.
## ❓ Частые проблемы
### Модуль не загружается
- Проверьте синтаксис в `manifest.json` (запятые, кавычки)
- Посмотрите логи: `docker logs lampac | grep LogUserRequest`
### Не открывается админка (404)
- Убедитесь, что путь `/lite/logrequest` не заблокирован в чёрном списке
- Проверьте, что модуль включён: `"enable": true`
### Пароль не подходит
- ENV переменная имеет приоритет: `docker exec lampac env | grep LOGUSER`
- Проверьте файл: `docker exec lampac cat /lampac/database/LogUserRequest/passlogreg`
### База данных растёт слишком быстро
- Уменьшите `logDay` до 30
- Добавьте в чёрный список часто вызываемые служебные пути
### После входа в админку белый экран
- Проверьте консоль браузера (F12) на ошибки
- Убедитесь, что файлы `index.html` и `auth.html` не повреждены
- Очистите кэш браузера
### Не работает кнопка "Premium"
- Это не баг 😄 — кнопка открывает пасхалку и информацию о Premium-версии
- Lite-версия полностью бесплатна и функциональна
💡 **Premium-версия** добавляет графики, экспорт, Telegram-уведомления и гибкие фильтры через UI.
Подробнее — в модальном окне по кнопке "Premium" в админке или у разработчика.
## 📞 Поддержка
По вопросам работы модуля, предложениям и багам:
- Telegram: [@Viacheslav_Sh1](https://t.me/Viacheslav_Sh1)
---
**LogUserRequest Lite** — бесплатный модуль для логирования запросов в Lampac NG.
-110
View File
@@ -1,110 +0,0 @@
<!DOCTYPE html>
<html lang="ru" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0c0e12" id="metaTheme">
<title>LogUserRequest — Вход</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root { --font-body: 'Inter', system-ui, -apple-system, sans-serif; }
[data-theme="dark"] { --bg: #0c0e12; --surface: #13161d; --surface-2: #1a1e28; --border: #2a3040; --text: #e2e6ef; --text-muted: #7c849a; --accent: #3ec9d1; --accent-soft: #2ea8b0; --err: #f87171; --err-dim: rgba(248, 113, 113, 0.12); --shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.45); }
[data-theme="light"] { --bg: #f4f5f8; --surface: #ffffff; --surface-2: #f0f1f5; --border: #d4d8e3; --text: #1a1e28; --text-muted: #6b7280; --accent: #0d9499; --accent-soft: #0b7d82; --err: #dc2626; --err-dim: rgba(220, 38, 38, 0.1); --shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.12); }
html, body { height: 100%; }
body { font-family: var(--font-body); background: var(--bg); display: flex; justify-content: center; align-items: center; color: var(--text); -webkit-font-smoothing: antialiased; }
.auth-container { background: var(--surface); border: 1px solid var(--border); border-radius: 24px; padding: 48px 40px; width: 100%; max-width: 420px; box-shadow: var(--shadow-lg); }
.logo { display: flex; align-items: center; justify-content: center; gap: 8px; margin-bottom: 32px; }
.logo svg { width: 36px; height: 36px; }
.logo span { font-size: 1.5rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text); }
.logo small { font-weight: 400; color: var(--text-muted); margin-left: 4px; }
h2 { font-size: 1.25rem; font-weight: 600; margin-bottom: 24px; text-align: center; color: var(--accent); }
.error { background: var(--err-dim); border: 1px solid var(--err); border-radius: 10px; padding: 12px 16px; margin-bottom: 20px; color: var(--err); font-size: 0.9rem; display: none; }
.error.show { display: block; }
input { width: 100%; background: var(--surface-2); border: 1px solid var(--border); border-radius: 12px; padding: 14px 18px; color: var(--text); font-size: 1rem; margin-bottom: 24px; transition: border 0.15s; }
input:focus { outline: none; border-color: var(--accent); }
button { width: 100%; background: var(--accent); border: none; border-radius: 12px; padding: 14px; color: #fff; font-size: 1rem; font-weight: 600; cursor: pointer; transition: background 0.15s; }
button:hover { background: var(--accent-soft); }
.theme-toggle { position: absolute; top: 20px; right: 20px; width: 40px; height: 40px; border-radius: 10px; background: var(--surface-2); border: 1px solid var(--border); color: var(--text-muted); cursor: pointer; display: flex; align-items: center; justify-content: center; }
.theme-toggle:hover { color: var(--text); }
.theme-toggle svg { width: 20px; height: 20px; }
</style>
</head>
<body>
<button class="theme-toggle" id="themeBtn" title="Сменить тему (Alt+T)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
</button>
<div class="auth-container">
<div class="logo">
<svg viewBox="0 0 28 28" fill="none" aria-hidden="true">
<rect x="2" y="2" width="24" height="24" rx="6" stroke="currentColor" stroke-width="1.5" fill="none" />
<rect x="7" y="16" width="3" height="6" rx="1" fill="var(--accent)" />
<rect x="12.5" y="11" width="3" height="11" rx="1" fill="var(--accent)" opacity="0.7" />
<rect x="18" y="6" width="3" height="16" rx="1" fill="var(--accent)" opacity="0.45" />
</svg>
<span>LogUserRequest <small>Lite</small></span>
</div>
<h2>🔐 Вход</h2>
<div class="error" id="errorMsg"></div>
<form method="post" action="/lite/logrequest/login">
<input type="password" name="password" placeholder="Пароль" autocomplete="off" autofocus>
<button type="submit">Войти</button>
</form>
</div>
<script>
const THEME_KEY = 'loguser-theme';
function setTheme(dark) {
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
try { localStorage.setItem(THEME_KEY, dark ? 'dark' : 'light'); } catch(e) {}
}
const saved = localStorage.getItem(THEME_KEY);
setTheme(saved === 'dark' || (saved === null && window.matchMedia('(prefers-color-scheme: dark)').matches));
document.getElementById('themeBtn').addEventListener('click', () => {
setTheme(document.documentElement.getAttribute('data-theme') !== 'dark');
});
document.addEventListener('keydown', e => {
if (e.altKey && (e.key === 't' || e.key === 'T')) {
e.preventDefault();
setTheme(document.documentElement.getAttribute('data-theme') !== 'dark');
}
});
const urlParams = new URLSearchParams(window.location.search);
const errorEl = document.getElementById('errorMsg');
if (urlParams.has('error')) {
const remaining = urlParams.get('remaining');
errorEl.innerText = remaining ? `Неверный пароль. Осталось попыток: ${remaining}` : 'Неверный пароль';
errorEl.classList.add('show');
}
if (urlParams.has('blocked')) {
errorEl.innerText = 'Доступ заблокирован на сутки';
errorEl.classList.add('show');
}
// Предотвращаем возврат на auth через кнопку "назад"
(function() {
if (window.history && window.history.replaceState) {
const url = new URL(window.location);
if (!url.searchParams.has('error') && !url.searchParams.has('blocked')) {
window.history.replaceState(null, '', '/lite/logrequest');
}
}
window.addEventListener('pageshow', function(event) {
if (event.persisted) {
fetch('/lite/logrequest/api?skip=0&take=1', {
credentials: 'include',
cache: 'no-cache'
}).then(r => {
if (r.ok) window.location.replace('/lite/logrequest');
}).catch(() => {});
}
});
})();
</script>
</body>
</html>
-487
View File
@@ -1,487 +0,0 @@
<!DOCTYPE html>
<html lang="ru" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LogUserRequest Lite</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root { --font-body: 'Inter', system-ui, sans-serif; --font-mono: 'JetBrains Mono', monospace; --text-xs: 0.75rem; --text-sm: 0.875rem; --text-base: 1rem; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; --sp-5: 20px; --sp-6: 24px; --r-md: 8px; --r-lg: 12px; --r-xl: 16px; }
[data-theme="dark"] { --bg: #0c0e12; --surface: #13161d; --surface-2: #1a1e28; --surface-3: #222733; --border: #2a3040; --text: #e2e6ef; --text-muted: #7c849a; --text-faint: #4d5568; --accent: #3ec9d1; --accent-dim: rgba(62, 201, 209, 0.12); --ok: #4ade80; --err: #f87171; --shadow-sm: 0 1px 2px rgba(0,0,0,0.3); --modal-overlay: rgba(0,0,0,0.7); --chart-grid: #2a3040; }
[data-theme="light"] { --bg: #f4f5f8; --surface: #ffffff; --surface-2: #f0f1f5; --surface-3: #e8eaf0; --border: #d4d8e3; --text: #1a1e28; --text-muted: #6b7280; --text-faint: #9ca3af; --accent: #0d9499; --accent-dim: rgba(13, 148, 153, 0.1); --ok: #16a34a; --err: #dc2626; --shadow-sm: 0 1px 2px rgba(0,0,0,0.06); --modal-overlay: rgba(0,0,0,0.5); --chart-grid: #d4d8e3; }
html, body { height: 100%; }
body { font-family: var(--font-body); font-size: var(--text-base); background: var(--bg); color: var(--text); line-height: 1.5; -webkit-font-smoothing: antialiased; padding: 20px; }
.container { max-width: 1400px; margin: 0 auto; }
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
.header-left { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.logo { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 1.2rem; color: var(--text); }
.logo svg { width: 28px; height: 28px; }
.logo small { font-weight: 400; color: var(--text-muted); margin-left: 4px; }
.actions { display: flex; gap: 8px; }
.btn { font: inherit; font-size: var(--text-xs); font-weight: 600; padding: 8px 16px; border-radius: var(--r-md); border: 1px solid var(--accent); background: var(--accent-dim); color: var(--accent); cursor: pointer; transition: all 0.15s; display: inline-flex; align-items: center; gap: 6px; }
.btn:hover { background: rgba(62, 201, 209, 0.22); transform: translateY(-1px); }
.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
.btn-primary:hover { background: var(--accent); opacity: 0.85; }
.btn-premium { background: var(--accent-dim); border-color: var(--accent); color: var(--accent); margin-left: 8px; }
.btn-premium:hover { background: rgba(62, 201, 209, 0.22); }
.btn-premium svg { width: 16px; height: 16px; }
.btn-ghost { border-color: var(--border); background: var(--surface-2); color: var(--text-muted); }
.btn-ghost:hover { color: var(--text); background: var(--surface-3); }
.btn-icon { width: 34px; height: 34px; padding: 0; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--surface-2); color: var(--text-muted); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
.btn-icon:hover { color: var(--text); background: var(--surface-3); }
.btn-icon svg { width: 16px; height: 16px; }
.filter-bar { display: flex; gap: 10px; margin-bottom: 24px; flex-wrap: wrap; }
.filter-bar input { background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--r-md); padding: 10px 16px; color: var(--text); font-size: 0.9rem; flex: 1; min-width: 200px; }
.filter-bar input:focus { outline: none; border-color: var(--accent); }
.stats-block { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-xl); padding: 20px; margin-bottom: 24px; }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 16px; margin-bottom: 24px; }
.stat-card { background: var(--surface-2); border-radius: var(--r-lg); padding: 16px; text-align: center; border: 1px solid rgba(255,255,255,0.06); }
.stat-value { font-size: 2rem; font-weight: 700; color: var(--accent); line-height: 1.2; }
.stat-label { font-size: var(--text-xs); color: var(--text-muted); margin-top: 6px; }
.top-section { margin-bottom: 20px; }
.top-section h3 { font-size: 0.9rem; font-weight: 600; margin-bottom: 12px; color: var(--text-muted); }
.scroll-grid { display: flex; flex-wrap: wrap; gap: 10px; }
.grid-item { background: var(--surface-2); border: 1px solid rgba(255,255,255,0.06); border-radius: var(--r-lg); padding: 8px 14px; min-width: 100px; display: inline-flex; align-items: center; gap: 8px; }
.grid-rank { font-size: var(--text-xs); font-weight: 700; color: var(--text-faint); background: var(--surface-3); width: 22px; height: 22px; display: inline-flex; align-items: center; justify-content: center; border-radius: 6px; }
.grid-name { font-size: var(--text-sm); font-weight: 500; }
.grid-count { font-size: var(--text-xs); color: var(--text-muted); background: var(--surface-3); padding: 2px 8px; border-radius: 6px; }
.requests-list { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-xl); overflow: hidden; }
.request-card { background: var(--surface-2); border-bottom: 1px solid var(--border); padding: 16px 20px; }
.request-card:last-child { border-bottom: none; }
.request-header { display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: 8px; font-size: 0.8rem; }
.request-time { color: var(--text-faint); }
.request-uid { color: var(--accent); font-weight: 600; }
.request-ip { color: var(--text-muted); }
.request-uri { font-family: var(--font-mono); font-size: 0.75rem; word-break: break-all; color: var(--text-muted); margin-top: 6px; }
.loading, .empty { text-align: center; padding: 40px; color: var(--text-faint); }
/* Модальное окно */
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: var(--modal-overlay); display: flex; align-items: flex-start; justify-content: center; z-index: 1000; padding: 20px; padding-top: 60px; backdrop-filter: blur(4px); overflow-y: auto; }
.modal { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-xl); max-width: 950px; width: 100%; box-shadow: 0 25px 50px -12px rgba(0,0,0,0.5); animation: modalSlide 0.2s ease; max-height: calc(100vh - 80px); overflow-y: auto; }
@keyframes modalSlide { from { opacity: 0; transform: translateY(-20px); } to { opacity: 1; transform: translateY(0); } }
.modal-header { display: flex; justify-content: space-between; align-items: center; padding: 20px 24px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--surface); z-index: 5; }
.modal-header h2 { font-size: 1.25rem; font-weight: 700; display: flex; align-items: center; gap: 8px; }
.modal-header h2 svg { width: 24px; height: 24px; color: var(--accent); }
.modal-close { width: 32px; height: 32px; border-radius: var(--r-md); border: 1px solid var(--border); background: var(--surface-2); color: var(--text-muted); cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 18px; }
.modal-close:hover { color: var(--text); background: var(--surface-3); }
.modal-body { padding: 24px; }
/* График в модалке */
.lite-chart-block { background: var(--surface-2); border-radius: var(--r-lg); padding: 20px; margin-bottom: 32px; }
.lite-chart-header { margin-bottom: 16px; }
.lite-chart-header h3 { font-size: 1rem; font-weight: 600; color: var(--text); display: flex; align-items: center; gap: 8px; }
.lite-chart-header h3 svg { width: 20px; height: 20px; }
.lite-chart-container { position: relative; height: 200px; }
.lite-scroll-hint { display: flex; justify-content: center; margin-top: 12px; }
.lite-scroll-arrow { color: var(--text-faint); font-size: var(--text-xs); display: flex; flex-direction: column; align-items: center; gap: 4px; opacity: 0.7; }
.lite-scroll-arrow svg { width: 20px; height: 20px; animation: bounce 2s infinite; }
@keyframes bounce { 0%, 20%, 50%, 80%, 100% { transform: translateY(0); } 40% { transform: translateY(6px); } 60% { transform: translateY(3px); } }
.premium-section { margin-top: 8px; }
.premium-header { display: flex; align-items: center; gap: 8px; margin-bottom: 24px; }
.premium-header h3 { font-size: 1.1rem; font-weight: 700; color: var(--accent); display: flex; align-items: center; gap: 8px; }
.premium-header svg { width: 24px; height: 24px; }
.comparison-table { width: 100%; border-collapse: collapse; margin: 0 0 24px; font-size: var(--text-sm); }
.comparison-table th { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--border); color: var(--text-muted); font-weight: 500; font-size: var(--text-xs); }
.comparison-table td { padding: 8px; border-bottom: 1px solid var(--border); text-align: left; }
.comparison-table tr:last-child td { border-bottom: none; }
.comparison-table .feature-col { font-weight: 500; color: var(--text); }
.comparison-table .lite-col { color: var(--text-muted); }
.comparison-table .premium-col { color: var(--ok); }
.comparison-table .both-col { color: var(--ok); }
.video-container { position: relative; padding-bottom: 56.25%; height: 0; margin: 24px 0; border-radius: var(--r-lg); overflow: hidden; border: 1px solid var(--border); }
.video-container iframe { position: absolute; top: 0; left: 0; width: 100%; height: 100%; border: 0; }
.telegram-section { background: var(--accent-dim); border-radius: var(--r-lg); padding: 24px; text-align: center; border: 1px solid var(--accent); }
.telegram-section h3 { color: var(--accent); margin-bottom: 8px; }
.telegram-section p { color: var(--text-muted); margin-bottom: 20px; }
.telegram-link { display: inline-flex; align-items: center; gap: 8px; background: var(--accent); color: #fff; padding: 12px 24px; border-radius: var(--r-lg); text-decoration: none; font-weight: 600; transition: all 0.15s; }
.telegram-link:hover { opacity: 0.85; transform: translateY(-2px); }
.telegram-link svg { width: 20px; height: 20px; fill: #fff; }
.hidden { display: none !important; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="header-left">
<div class="logo">
<svg viewBox="0 0 28 28" fill="none" aria-hidden="true">
<rect x="2" y="2" width="24" height="24" rx="6" stroke="currentColor" stroke-width="1.5" fill="none" />
<rect x="7" y="16" width="3" height="6" rx="1" fill="var(--accent)" />
<rect x="12.5" y="11" width="3" height="11" rx="1" fill="var(--accent)" opacity="0.7" />
<rect x="18" y="6" width="3" height="16" rx="1" fill="var(--accent)" opacity="0.45" />
</svg>
<span>LogUserRequest Lite</span>
</div>
<button class="btn btn-premium" id="premiumBtn">
Premium
</button>
</div>
<div class="actions">
<button class="btn-icon" id="themeBtn" title="Сменить тему (Alt+T)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
</button>
<button class="btn btn-ghost" id="logoutBtn">Выйти</button>
</div>
</div>
<div class="filter-bar">
<input type="text" id="uidInput" placeholder="UID пользователя (оставьте пустым для всех)">
<button class="btn btn-primary" onclick="applyFilter()">Показать</button>
<button class="btn btn-ghost" onclick="resetFilter()">Сбросить</button>
</div>
<div class="stats-block" id="statsBlock" style="display:none;">
<div class="stats-grid">
<div class="stat-card"><div class="stat-value" id="statToday">0</div><div class="stat-label">Запросов сегодня</div></div>
<div class="stat-card"><div class="stat-value" id="statMonth">0</div><div class="stat-label">За месяц</div></div>
<div class="stat-card"><div class="stat-value" id="statDevices">0</div><div class="stat-label">Устройств</div></div>
<div class="stat-card"><div class="stat-value" id="statIps">0</div><div class="stat-label">IP адресов</div></div>
</div>
<div class="top-section">
<h3>👥 Топ пользователей</h3>
<div class="scroll-grid" id="topUsers"></div>
</div>
<div class="top-section">
<h3>⚡ Топ балансиров</h3>
<div class="scroll-grid" id="topBalancers"></div>
</div>
</div>
<div class="requests-list" id="requestsList">
<div class="loading">Загрузка...</div>
</div>
</div>
<!-- Модальное окно Premium -->
<div class="modal-overlay hidden" id="premiumModal">
<div class="modal">
<div class="modal-header">
<h2>
<svg viewBox="0 0 28 28" fill="none" aria-hidden="true">
<rect x="2" y="2" width="24" height="24" rx="6" stroke="currentColor" stroke-width="1.5" fill="none" />
<rect x="7" y="16" width="3" height="6" rx="1" fill="currentColor" />
<rect x="12.5" y="11" width="3" height="11" rx="1" fill="currentColor" opacity="0.7" />
<rect x="18" y="6" width="3" height="16" rx="1" fill="currentColor" opacity="0.45" />
</svg>
LogUserRequest Lite
</h2>
<button class="modal-close" id="closeModalBtn">✕</button>
</div>
<div class="modal-body">
<!-- Блок Lite-версии с графиком -->
<div class="lite-chart-block">
<div class="lite-chart-header">
<h3>
<svg viewBox="0 0 28 28" fill="none" aria-hidden="true" width="20" height="20">
<rect x="2" y="2" width="24" height="24" rx="6" stroke="currentColor" stroke-width="1.5" fill="none" />
<rect x="7" y="16" width="3" height="6" rx="1" fill="currentColor" />
<rect x="12.5" y="11" width="3" height="11" rx="1" fill="currentColor" opacity="0.7" />
<rect x="18" y="6" width="3" height="16" rx="1" fill="currentColor" opacity="0.45" />
</svg>
Активность за 3 дня
</h3>
</div>
<div class="lite-chart-container">
<canvas id="premiumChart"></canvas>
</div>
<div class="lite-scroll-hint">
<span class="lite-scroll-arrow">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 5v14M19 12l-7 7-7-7"/>
</svg>
Прокрутите чтобы увидеть Premium-возможности
</span>
</div>
</div>
<!-- Разделитель / пустое пространство -->
<div style="height: 400px;"></div>
<!-- Premium секция -->
<div class="premium-section">
<div class="premium-header">
<svg viewBox="0 0 28 28" fill="none" aria-hidden="true">
<rect x="2" y="2" width="24" height="24" rx="6" stroke="var(--accent)" stroke-width="1.5" fill="none" />
<rect x="7" y="16" width="3" height="6" rx="1" fill="var(--accent)" />
<rect x="12.5" y="11" width="3" height="11" rx="1" fill="var(--accent)" opacity="0.7" />
<rect x="18" y="6" width="3" height="16" rx="1" fill="var(--accent)" opacity="0.45" />
</svg>
<h3>LogUserRequest Premium</h3>
</div>
<table class="comparison-table">
<thead>
<tr><th>Функция</th><th>Lite-версия</th><th>Premium-версия</th></tr>
</thead>
<tbody>
<tr><td class="feature-col">Логирование запросов</td><td class="both-col">✅ Все запросы</td><td class="both-col">✅ Все запросы</td></tr>
<tr><td class="feature-col">Фильтрация служебных путей</td><td class="both-col">✅ Хардкод-список</td><td class="premium-col">✅ Гибкий UI + regex + IP</td></tr>
<tr><td class="feature-col">Авторизация</td><td class="both-col">✅ Сессии + rate-limit</td><td class="both-col">✅ Сессии + rate-limit</td></tr>
<tr><td class="feature-col">База данных</td><td class="both-col">✅ SQLite</td><td class="premium-col">✅ SQLite с индексами</td></tr>
<tr><td class="feature-col">Очистка старых записей</td><td class="both-col">✅ По дням (logDay)</td><td class="premium-col">✅ По дням + по размеру БД</td></tr>
<tr><td class="feature-col">Статистика</td><td class="both-col">✅ Топы пользователей и балансиров</td><td class="premium-col">✅ Топы + графики + модальные окна</td></tr>
<tr><td class="feature-col">Тёмная/светлая тема</td><td class="both-col">✅ Есть</td><td class="both-col">✅ Есть</td></tr>
<tr><td class="feature-col">Мобильная адаптация</td><td class="both-col">✅ Есть</td><td class="both-col">✅ Есть</td></tr>
<tr><td class="feature-col">Поиск по UID</td><td class="both-col">✅ Поле ввода</td><td class="premium-col">✅ Поле ввода + модальное окно</td></tr>
<tr><td class="feature-col">Дашборд</td><td class="lite-col">❌ Простой список</td><td class="premium-col">✅ Полноценный SPA с графиками</td></tr>
<tr><td class="feature-col">Дедупликация перебора балансиров</td><td class="lite-col">❌ Нет</td><td class="premium-col">✅ First + Last (окно 30 сек)</td></tr>
<tr><td class="feature-col">Фильтр по дате</td><td class="lite-col">❌ Нет</td><td class="premium-col">✅ На дашборде</td></tr>
<tr><td class="feature-col">Активность в реальном времени</td><td class="lite-col">❌ Нет</td><td class="premium-col">✅ Live-обновления</td></tr>
<tr><td class="feature-col">Экспорт данных</td><td class="lite-col">❌ Нет</td><td class="premium-col">✅ CSV/JSON (ZIP)</td></tr>
<tr><td class="feature-col">Массовое удаление</td><td class="lite-col">❌ Нет</td><td class="premium-col">✅ С гибкими фильтрами</td></tr>
<tr><td class="feature-col">Архивация</td><td class="lite-col">❌ Нет</td><td class="premium-col">✅ По дате + по размеру БД</td></tr>
<tr><td class="feature-col">Telegram-уведомления</td><td class="lite-col">❌ Нет</td><td class="premium-col">✅ Мониторинг + архив + ошибки</td></tr>
<tr><td class="feature-col">Health-check</td><td class="lite-col">❌ Нет</td><td class="premium-col">✅ API для мониторинга</td></tr>
<tr><td class="feature-col">Настройка фильтров через UI</td><td class="lite-col">❌ Нет</td><td class="premium-col">✅ Модальное окно + авто-сохранение</td></tr>
<tr><td class="feature-col">Защита от ReDoS</td><td class="lite-col">❌ Нет</td><td class="premium-col">✅ Кэширование regex + таймаут</td></tr>
<tr><td class="feature-col">Очередь с защитой от переполнения</td><td class="lite-col">❌ Нет</td><td class="premium-col">✅ До 50000 записей</td></tr>
<tr><td class="feature-col">Потребление памяти</td><td class="both-col">~20-30 МБ</td><td class="both-col">~50-70 МБ</td></tr>
</tbody>
</table>
<!-- Видео-обзор -->
<div class="video-container">
<iframe src="https://drive.google.com/file/d/1VCgOGJIVy4CVjYV1wL47t_5Vb5dbs-Gg/preview" allow="autoplay; fullscreen" allowfullscreen></iframe>
</div>
<!-- Telegram -->
<div class="telegram-section">
<h3>💬 Заинтересовал Premium?</h3>
<p>Напишите автору в Telegram — отвечу на вопросы и дам доступ</p>
<a href="https://t.me/Viacheslav_Sh1" target="_blank" class="telegram-link">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.562 8.161c-.18 1.897-.962 6.502-1.359 8.627-.168.9-.5 1.201-.82 1.23-.697.064-1.226-.46-1.901-.903-1.056-.692-1.653-1.123-2.678-1.799-1.185-.781-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.015-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.479.329-.913.489-1.302.481-.428-.009-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.015 3.333-1.386 4.025-1.627 4.476-1.635.099-.002.321.023.465.141.145.118.185.276.204.388.018.112.013.23.01.356z"/>
</svg>
@Viacheslav_Sh1
</a>
</div>
</div>
</div>
</div>
</div>
<script>
const THEME_KEY = 'loguser-theme';
function setTheme(dark) {
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
try { localStorage.setItem(THEME_KEY, dark ? 'dark' : 'light'); } catch(e) {}
}
const saved = localStorage.getItem(THEME_KEY);
setTheme(saved === 'dark' || (saved === null && window.matchMedia('(prefers-color-scheme: dark)').matches));
document.getElementById('themeBtn').addEventListener('click', () => {
setTheme(document.documentElement.getAttribute('data-theme') !== 'dark');
});
document.addEventListener('keydown', e => {
if (e.altKey && (e.key === 't' || e.key === 'T')) {
e.preventDefault();
setTheme(document.documentElement.getAttribute('data-theme') !== 'dark');
}
});
let currentUid = '';
let skip = 0;
const take = 50;
let loading = false;
let allLoaded = false;
let premiumChart = null;
// Модальное окно
const modal = document.getElementById('premiumModal');
const premiumBtn = document.getElementById('premiumBtn');
const closeBtn = document.getElementById('closeModalBtn');
function openModal() {
modal.classList.remove('hidden');
loadPremiumChart();
}
premiumBtn.addEventListener('click', openModal);
closeBtn.addEventListener('click', () => modal.classList.add('hidden'));
modal.addEventListener('click', (e) => { if (e.target === modal) modal.classList.add('hidden'); });
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') modal.classList.add('hidden'); });
async function apiCall(path) {
const res = await fetch('/lite/logrequest' + path, { credentials: 'include' });
if (res.status === 401 || res.status === 403) {
window.location.href = '/lite/logrequest/auth';
throw new Error('Unauthorized');
}
const json = await res.json();
return json.success ? json.data : json;
}
async function loadPremiumChart() {
if (premiumChart) return;
try {
const result = await apiCall('/premium-chart?days=3');
const ctx = document.getElementById('premiumChart').getContext('2d');
const accentColor = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#3ec9d1';
const gridColor = getComputedStyle(document.documentElement).getPropertyValue('--chart-grid').trim() || '#2a3040';
let labels, values;
if (result && result.data && result.data.length > 0) {
labels = result.data.map(d => new Date(d.date).toLocaleDateString('ru', { day: 'numeric', month: 'short' }));
values = result.data.map(d => d.count);
} else {
labels = ['Нет данных'];
values = [0];
}
premiumChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'Запросы',
data: values,
borderColor: accentColor,
backgroundColor: accentColor + '20',
tension: 0.3,
fill: true,
pointBackgroundColor: accentColor
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, grid: { color: gridColor } },
x: { grid: { display: false } }
}
}
});
} catch(e) {
console.error('Chart error:', e);
}
}
async function loadStats() {
try {
const url = currentUid ? `/stats?uid=${encodeURIComponent(currentUid)}` : '/stats';
const stats = await apiCall(url);
if (!stats) return;
document.getElementById('statsBlock').style.display = 'block';
document.getElementById('statToday').innerText = stats.today || 0;
document.getElementById('statMonth').innerText = stats.month || 0;
document.getElementById('statDevices').innerText = stats.uniqueUserAgent || 0;
document.getElementById('statIps').innerText = stats.uniqueIp || 0;
const topUsers = document.getElementById('topUsers');
const users = stats.topUsers || [];
topUsers.innerHTML = users.map((u, idx) => `
<div class="grid-item">
<span class="grid-rank">${idx + 1}</span>
<span class="grid-name">${escapeHtml(u.name)}</span>
<span class="grid-count">${u.count}</span>
</div>
`).join('') || '<div class="empty">Нет данных</div>';
const topBalancers = document.getElementById('topBalancers');
const balancers = stats.topBalancers || [];
topBalancers.innerHTML = balancers.map((b, idx) => `
<div class="grid-item">
<span class="grid-rank">${idx + 1}</span>
<span class="grid-name">${escapeHtml(b.name)}</span>
<span class="grid-count">${b.count}</span>
</div>
`).join('') || '<div class="empty">Нет данных</div>';
} catch(e) {
console.error('Stats error:', e);
}
}
async function loadRequests(reset = true) {
if (reset) { skip = 0; allLoaded = false; document.getElementById('requestsList').innerHTML = ''; }
if (loading || allLoaded) return;
loading = true;
try {
let url = `/api?skip=${skip}&take=${take}`;
if (currentUid) url += `&uid=${encodeURIComponent(currentUid)}`;
const data = await apiCall(url);
if (!data || (Array.isArray(data) && data.length === 0)) {
allLoaded = true;
if (skip === 0) document.getElementById('requestsList').innerHTML = '<div class="empty">Нет записей</div>';
loading = false;
return;
}
if (reset) document.getElementById('requestsList').innerHTML = '';
data.forEach(r => {
const card = document.createElement('div');
card.className = 'request-card';
card.innerHTML = `
<div class="request-header">
<span class="request-time">${new Date(r.time).toLocaleString()}</span>
<span class="request-uid">${escapeHtml(r.userUid || r.uid || 'unknown')}</span>
<span class="request-ip">${escapeHtml(r.ip || 'unknown')}</span>
</div>
<div class="request-uri">${escapeHtml(r.uri || '')}</div>
`;
document.getElementById('requestsList').appendChild(card);
});
skip += data.length;
if (data.length < take) allLoaded = true;
loading = false;
} catch(e) {
console.error('Load error:', e);
loading = false;
}
}
function escapeHtml(str) {
if (!str) return '';
return String(str).replace(/[&<>]/g, m => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[m] || m));
}
function applyFilter() {
currentUid = document.getElementById('uidInput').value.trim();
loadStats();
loadRequests(true);
}
function resetFilter() {
document.getElementById('uidInput').value = '';
currentUid = '';
loadStats();
loadRequests(true);
}
document.getElementById('logoutBtn').addEventListener('click', () => {
window.location.href = '/lite/logrequest/logout';
});
window.addEventListener('scroll', () => {
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 500) {
if (!loading && !allLoaded) loadRequests(false);
}
});
loadStats();
loadRequests(true);
</script>
</body>
</html>
-6
View File
@@ -1,6 +0,0 @@
{
"enable": true,
"version": 1,
"namespace": "LogUserRequest",
"logDay": 90
}
-48
View File
@@ -1,48 +0,0 @@
using System;
using System.IO;
using Microsoft.EntityFrameworkCore;
using Telemetry.Models;
namespace Telemetry;
public class AppDbContext : DbContext
{
public DbSet<LogModelSql> Logs { get; set; } = null!;
public DbSet<UserInfoModelSql> Users { get; set; } = null!;
public AppDbContext() : base() { }
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
if (!optionsBuilder.IsConfigured)
{
var dbPath = ModInit.DbPath;
var dbDir = Path.GetDirectoryName(dbPath);
if (dbDir != null && !Directory.Exists(dbDir)) Directory.CreateDirectory(dbDir);
optionsBuilder.UseSqlite($"Data Source={dbPath};Cache=Shared");
optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
}
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<LogModelSql>()
.HasIndex(l => l.Time);
modelBuilder.Entity<LogModelSql>()
.HasIndex(l => new { l.Uid, l.Time });
modelBuilder.Entity<LogModelSql>()
.HasIndex(l => new { l.Balancer, l.Time });
modelBuilder.Entity<LogModelSql>()
.HasIndex(l => l.UnfoId);
modelBuilder.Entity<UserInfoModelSql>()
.HasKey(u => u.Id);
modelBuilder.Entity<UserInfoModelSql>()
.HasIndex(u => u.Ip);
}
}
@@ -1,539 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Shared;
using Shared.Attributes;
using System;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Telemetry.Models;
using System.Collections.Generic;
namespace Telemetry.Controllers;
[Authorization(redirectUri: "/adminpanel/auth")]
[Route("/telemetry")]
public class TelemetryController : BaseController
{
private readonly IDbContextFactory<AppDbContext> _contextFactory;
public TelemetryController(IDbContextFactory<AppDbContext> contextFactory)
{
_contextFactory = contextFactory;
}
private class MovieRawDto
{
public string? Title { get; set; }
public string? Tmdb { get; set; }
public string? Kp { get; set; }
public string? Imdb { get; set; }
public bool IsTv { get; set; }
public int Count { get; set; }
}
private static (DateTime start, DateTime end) GetPeriod(int days)
{
var now = DateTime.UtcNow.Date;
var periodStart = days == 0 ? now : (days == -1 ? now.AddDays(-1) : now.AddDays(-days));
var periodEnd = days == -1 ? now : DateTime.UtcNow;
return (periodStart, periodEnd);
}
private static List<object> MapMovies(List<MovieRawDto> raw)
{
return raw.Select(m => new
{
title = m.Title,
id = m.Tmdb ?? m.Kp ?? m.Imdb ?? m.Title,
tmdb = m.Tmdb,
kp = m.Kp,
imdb = m.Imdb,
count = m.Count,
source = m.Tmdb != null ? "tmdb" : m.Kp != null ? "kinopoisk" : m.Imdb != null ? "imdb" : "unknown",
isTv = m.IsTv
}).Cast<object>().ToList();
}
private IQueryable<LogModelSql> ApplySearchFilter(AppDbContext db, IQueryable<LogModelSql> query, string? search)
{
if (string.IsNullOrEmpty(search)) return query;
var rawSearch = search;
var encodedSearch = Uri.EscapeDataString(search);
var encodedSearchPlus = encodedSearch.Replace("%20", "+");
return query.Where(l =>
l.Uid.Contains(rawSearch) ||
l.Uri.Contains(rawSearch) ||
l.Uri.Contains(encodedSearch) ||
l.Uri.Contains(encodedSearchPlus) ||
(l.Balancer != null && l.Balancer.Contains(rawSearch)) ||
db.Users.Any(u => u.Id == l.UnfoId && (u.Ip.Contains(rawSearch) || u.UserAgent.Contains(rawSearch)))
);
}
[HttpGet]
[Route("")]
public ActionResult Index()
{
var path = Path.Combine(ModInit.modpath, "index.html");
if (!System.IO.File.Exists(path)) return Content("UI missing", "text/plain");
var html = System.IO.File.ReadAllText(path, Encoding.UTF8);
return Content(html, "text/html; charset=utf-8");
}
[HttpGet("api/stats")]
public async Task<IActionResult> Stats(string? search, int days = 30)
{
await using var db = await _contextFactory.CreateDbContextAsync();
var query = db.Logs.AsNoTracking();
query = ApplySearchFilter(db, query, search);
var (periodStart, periodEnd) = GetPeriod(days);
var now = DateTime.UtcNow.Date;
int total;
int today;
if (days == 0)
{
today = await query.Where(l => l.Time >= now.Date && l.Time < periodEnd).CountAsync();
total = today;
}
else if (days == -1)
{
total = await query.Where(l => l.Time >= periodStart && l.Time < periodEnd).CountAsync();
today = 0;
}
else
{
total = await query.Where(l => l.Time >= periodStart && l.Time < periodEnd).CountAsync();
today = await query.Where(l => l.Time >= now.Date && l.Time < periodEnd).CountAsync();
}
var uids = await query.Where(l => l.Time >= periodStart && l.Time < periodEnd && l.Uid != "anonymous" && !string.IsNullOrEmpty(l.Uid)).Select(l => l.Uid).Distinct().CountAsync();
var validLogs = query.Where(l => l.Time >= periodStart && l.Time < periodEnd && l.UnfoId != null);
var ips = await validLogs.Join(db.Users, l => l.UnfoId, u => u.Id, (l, u) => u.Ip).Where(ip => ip != null && ip != "").Distinct().CountAsync();
var uas = await validLogs.Join(db.Users, l => l.UnfoId, u => u.Id, (l, u) => u.UserAgent).Where(ua => ua != null && ua != "").Distinct().CountAsync();
return new JsonResult(new { success = true, data = new { today, total, uids, ips, uas } });
}
[HttpGet("api/logs")]
public async Task<IActionResult> Logs(string? search, int days = 30, int skip = 0, int take = 100)
{
take = Math.Min(take, 1000);
await using var db = await _contextFactory.CreateDbContextAsync();
var query = db.Logs.AsNoTracking();
var (periodStart, periodEnd) = GetPeriod(days);
query = query.Where(l => l.Time >= periodStart && l.Time < periodEnd);
query = ApplySearchFilter(db, query, search);
var logs = await query.OrderByDescending(l => l.Time).Skip(skip).Take(take)
.GroupJoin(db.Users, l => l.UnfoId, u => u.Id, (l, users) => new { Log = l, Users = users })
.SelectMany(x => x.Users.DefaultIfEmpty(), (x, u) => new
{
id = x.Log.Id,
time = x.Log.Time,
uri = x.Log.Uri,
uid = x.Log.Uid,
ip = u != null ? u.Ip : "unknown",
userAgent = u != null ? u.UserAgent : "unknown",
balancer = x.Log.Balancer,
statusCode = x.Log.StatusCode,
duration = x.Log.DurationMs
})
.ToListAsync();
return new JsonResult(new { success = true, data = logs });
}
[HttpGet("api/chart")]
public async Task<IActionResult> Chart(string? search, int days = 30)
{
var (periodStart, periodEnd) = GetPeriod(days);
await using var db = await _contextFactory.CreateDbContextAsync();
var query = db.Logs.AsNoTracking();
query = ApplySearchFilter(db, query, search);
var data = await query
.Where(l => l.Time >= periodStart && l.Time < periodEnd)
.GroupBy(l => l.Time.Date)
.Select(g => new { date = g.Key, count = g.Count() })
.OrderBy(x => x.date)
.ToListAsync();
return new JsonResult(new { success = true, data = data });
}
[HttpGet("api/tops")]
public async Task<IActionResult> GetTops(string? search, [FromQuery] int days = 7)
{
await using var db = await _contextFactory.CreateDbContextAsync();
var (periodStart, periodEnd) = GetPeriod(days);
var query = db.Logs.AsNoTracking();
query = ApplySearchFilter(db, query, search);
var baseQuery = query.Where(l => l.Time >= periodStart && l.Time < periodEnd);
var topBalancers = await baseQuery
.Where(l => l.Balancer != null && l.Balancer != "" && !l.Balancer.StartsWith("18+ "))
.GroupBy(l => l.Balancer)
.Select(g => new { balancer = g.Key, count = g.Count() })
.OrderByDescending(x => x.count)
.Take(10)
.ToListAsync();
var topAdult = await baseQuery
.Where(l => l.Balancer != null && l.Balancer.StartsWith("18+ "))
.GroupBy(l => l.Balancer)
.Select(g => new { balancer = g.Key!.Substring(4), count = g.Count() })
.OrderByDescending(x => x.count)
.Take(10)
.ToListAsync();
var topUsers = await baseQuery
.Where(l => l.Uid != "anonymous" && l.Uid != null && l.Uid != "")
.GroupBy(l => l.Uid)
.Select(g => new { uid = g.Key, count = g.Count() })
.OrderByDescending(x => x.count)
.Take(10)
.ToListAsync();
var topMoviesRaw = await baseQuery
.Where(l => l.MovieTitle != null && l.MovieTitle != "")
.GroupBy(l => l.MovieTitle)
.Select(g => new MovieRawDto
{
Title = g.Key,
Tmdb = g.Max(l => l.TmdbId),
Kp = g.Max(l => l.KpId),
Imdb = g.Max(l => l.ImdbId),
IsTv = g.Max(l => l.IsTv ? 1 : 0) == 1,
Count = g.Count()
})
.OrderByDescending(x => x.Count)
.Take(10)
.ToListAsync();
var topMovies = MapMovies(topMoviesRaw);
var userAgents = await baseQuery
.Where(l => l.UnfoId != null)
.Join(db.Users, l => l.UnfoId, u => u.Id, (l, u) => u.UserAgent)
.GroupBy(ua => ua)
.Select(g => new { ua = g.Key, count = g.Count() })
.OrderByDescending(x => x.count)
.Take(1000)
.ToListAsync();
var devices = userAgents
.Select(x => new { name = ParseDevice(x.ua), count = x.count })
.GroupBy(x => x.name)
.Select(g => new { name = g.Key, count = g.Sum(x => x.count) })
.OrderByDescending(x => x.count)
.ToList();
var hours = await baseQuery
.GroupBy(l => l.Time.Hour)
.Select(g => new { hour = g.Key, count = g.Count() })
.OrderBy(x => x.hour)
.ToListAsync();
var seriesCount = await baseQuery.Where(l => l.IsTv).CountAsync();
var moviesCount = await baseQuery.Where(l => l.MovieTitle != null && l.MovieTitle != "" && !l.IsTv).CountAsync();
var animeCount = await baseQuery.Where(l => l.Uri.Contains("anime=1") || l.Uri.Contains("anime=true")).CountAsync();
var contentTypes = new[] {
new { name = "Фильмы", count = moviesCount },
new { name = "Сериалы", count = seriesCount },
new { name = "Аниме", count = animeCount }
}.Where(x => x.count > 0).ToList();
return new JsonResult(new { success = true, data = new { balancers = topBalancers, adult = topAdult, users = topUsers, movies = topMovies, devices, hours, contentTypes } });
}
private string ParseDevice(string? ua)
{
if (ua == null) return "Другие";
ua = ua.ToLower();
if (ua.Contains("tizen") || ua.Contains("samsung")) return "Tizen";
if (ua.Contains("web0s") || ua.Contains("webos")) return "WebOS";
if (ua.Contains("android")) return "Android";
if (ua.Contains("iphone") || ua.Contains("ipad") || ua.Contains("mac os")) return "Apple";
if (ua.Contains("windows")) return "Windows";
if (ua.Contains("linux")) return "Linux";
return "Другие";
}
[HttpGet("api/user")]
public async Task<IActionResult> UserInfo(string uid, int days = 30)
{
if (string.IsNullOrEmpty(uid)) return BadRequest();
await using var db = await _contextFactory.CreateDbContextAsync();
var (periodStart, periodEnd) = GetPeriod(days);
var baseQuery = db.Logs.AsNoTracking().Where(l => l.Uid == uid && l.Time >= periodStart && l.Time < periodEnd);
var count = await baseQuery.CountAsync();
if (count == 0) return new JsonResult(new { success = false });
var totalRequests = count;
var firstSeen = await baseQuery.MinAsync(l => l.Time);
var lastSeen = await baseQuery.MaxAsync(l => l.Time);
var ips = await baseQuery.Where(l => l.UnfoId != null).Join(db.Users, l => l.UnfoId, u => u.Id, (l, u) => u.Ip).Where(ip => ip != null && ip != "").Distinct().ToListAsync();
var userAgents = await baseQuery.Where(l => l.UnfoId != null).Join(db.Users, l => l.UnfoId, u => u.Id, (l, u) => u.UserAgent).Where(ua => ua != null && ua != "").Distinct().ToListAsync();
var balancers = await baseQuery.Where(l => l.Balancer != null && l.Balancer != "")
.GroupBy(l => l.Balancer)
.Select(g => new { name = g.Key, count = g.Count() })
.OrderByDescending(x => x.count)
.ToListAsync();
var isHourly = days <= 1;
var timeline = await (isHourly
? baseQuery.GroupBy(l => new DateTime(l.Time.Year, l.Time.Month, l.Time.Day, l.Time.Hour, 0, 0)).Select(g => new { date = g.Key, count = g.Count() }).OrderBy(x => x.date).ToListAsync()
: baseQuery.GroupBy(l => l.Time.Date).Select(g => new { date = g.Key, count = g.Count() }).OrderBy(x => x.date).ToListAsync());
var moviesRaw = await baseQuery
.Where(l => l.MovieTitle != null && l.MovieTitle != "")
.GroupBy(l => l.MovieTitle)
.Select(g => new MovieRawDto
{
Title = g.Key,
Tmdb = g.Max(l => l.TmdbId),
Kp = g.Max(l => l.KpId),
Imdb = g.Max(l => l.ImdbId),
IsTv = g.Max(l => l.IsTv ? 1 : 0) == 1,
Count = g.Count()
})
.OrderByDescending(x => x.Count)
.Take(10)
.ToListAsync();
var movies = MapMovies(moviesRaw);
return new JsonResult(new
{
success = true,
data = new
{
totalRequests,
firstSeen,
lastSeen,
ips,
userAgents,
balancers,
timeline,
movies
}
});
}
[HttpGet("api/ip")]
public async Task<IActionResult> IpInfo(string ip, int days = 30)
{
if (string.IsNullOrEmpty(ip)) return BadRequest();
await using var db = await _contextFactory.CreateDbContextAsync();
var (periodStart, periodEnd) = GetPeriod(days);
var baseQuery = db.Logs.AsNoTracking().Where(l => l.Time >= periodStart && l.Time < periodEnd && l.UnfoId != null && db.Users.Any(u => u.Id == l.UnfoId && u.Ip == ip));
var count = await baseQuery.CountAsync();
if (count == 0) return new JsonResult(new { success = false });
var totalRequests = count;
var firstSeen = await baseQuery.MinAsync(l => l.Time);
var lastSeen = await baseQuery.MaxAsync(l => l.Time);
var uids = await baseQuery.Where(l => l.Uid != "anonymous" && l.Uid != null && l.Uid != "").Select(l => l.Uid).Distinct().ToListAsync();
var userAgents = await baseQuery.Join(db.Users, l => l.UnfoId, u => u.Id, (l, u) => u.UserAgent).Where(ua => ua != null && ua != "").Distinct().ToListAsync();
var balancers = await baseQuery.Where(l => l.Balancer != null && l.Balancer != "")
.GroupBy(l => l.Balancer)
.Select(g => new { name = g.Key, count = g.Count() })
.OrderByDescending(x => x.count)
.ToListAsync();
var isHourly = days <= 1;
var timeline = await (isHourly
? baseQuery.GroupBy(l => new DateTime(l.Time.Year, l.Time.Month, l.Time.Day, l.Time.Hour, 0, 0)).Select(g => new { date = g.Key, count = g.Count() }).OrderBy(x => x.date).ToListAsync()
: baseQuery.GroupBy(l => l.Time.Date).Select(g => new { date = g.Key, count = g.Count() }).OrderBy(x => x.date).ToListAsync());
var moviesRaw = await baseQuery
.Where(l => l.MovieTitle != null && l.MovieTitle != "")
.GroupBy(l => l.MovieTitle)
.Select(g => new MovieRawDto
{
Title = g.Key,
Tmdb = g.Max(l => l.TmdbId),
Kp = g.Max(l => l.KpId),
Imdb = g.Max(l => l.ImdbId),
IsTv = g.Max(l => l.IsTv ? 1 : 0) == 1,
Count = g.Count()
})
.OrderByDescending(x => x.Count)
.Take(10)
.ToListAsync();
var movies = MapMovies(moviesRaw);
return new JsonResult(new
{
success = true,
data = new
{
totalRequests,
firstSeen,
lastSeen,
uids,
userAgents,
balancers,
timeline,
movies
}
});
}
[HttpGet("api/settings")]
public IActionResult GetSettings()
{
return new JsonResult(new
{
success = true,
data = new
{
prefixes = string.Join("\n", RequestListener.SkipPrefixes),
extensions = string.Join("\n", RequestListener.SkipExtensions),
regexes = string.Join("\n", RequestListener.RegexFilters),
ips = string.Join("\n", RequestListener.BlacklistIps),
rateLimit = RequestListener.RateLimitMs
}
});
}
[HttpPost("api/settings")]
public IActionResult UpdateSettings([FromForm] string? prefixes, [FromForm] string? extensions, [FromForm] string? regexes, [FromForm] string? ips, [FromForm] int? rateLimit)
{
if (prefixes != null) RequestListener.SkipPrefixes = prefixes.Split(new[] { '\n', ',' }, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray();
if (extensions != null) RequestListener.SkipExtensions = extensions.Split(new[] { '\n', ',' }, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray();
if (regexes != null)
{
var patterns = regexes.Split(new[] { '\n', ',' }, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray();
var invalid = new List<string>();
foreach (var p in patterns)
{
try { System.Text.RegularExpressions.Regex.Match("", p, System.Text.RegularExpressions.RegexOptions.None, TimeSpan.FromMilliseconds(50)); }
catch { invalid.Add(p); }
}
if (invalid.Count > 0)
return new JsonResult(new { success = false, error = $"Невалидные regex: {string.Join(", ", invalid)}" });
RequestListener.RegexFilters = patterns;
}
if (ips != null) RequestListener.BlacklistIps = ips.Split(new[] { '\n', ',' }, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray();
if (rateLimit.HasValue) RequestListener.RateLimitMs = rateLimit.Value;
RequestListener.SaveSettings();
return new JsonResult(new { success = true });
}
[HttpGet("api/balancer")]
public async Task<IActionResult> BalancerInfo(string balancer, int days = 30)
{
if (string.IsNullOrEmpty(balancer)) return BadRequest();
await using var db = await _contextFactory.CreateDbContextAsync();
var (periodStart, periodEnd) = GetPeriod(days);
var now = DateTime.UtcNow.Date;
var baseQuery = db.Logs.AsNoTracking().Where(l => l.Balancer == balancer && l.Time >= periodStart && l.Time < periodEnd);
var total = await baseQuery.CountAsync();
var today = await baseQuery.Where(l => l.Time >= now.Date).CountAsync();
var topUids = await baseQuery.Where(l => l.Uid != "anonymous" && l.Uid != null && l.Uid != "")
.GroupBy(l => l.Uid)
.Select(g => new { uid = g.Key, count = g.Count() })
.OrderByDescending(x => x.count).Take(10).ToListAsync();
var topIps = await baseQuery.Where(l => l.UnfoId != null)
.Join(db.Users, l => l.UnfoId, u => u.Id, (l, u) => u.Ip)
.Where(ip => ip != null && ip != "")
.GroupBy(ip => ip)
.Select(g => new { ip = g.Key, count = g.Count() })
.OrderByDescending(x => x.count).Take(10).ToListAsync();
var isHourly = days <= 1;
var timeline = await (isHourly
? baseQuery.GroupBy(l => new DateTime(l.Time.Year, l.Time.Month, l.Time.Day, l.Time.Hour, 0, 0)).Select(g => new { date = g.Key, count = g.Count() }).OrderBy(x => x.date).ToListAsync()
: baseQuery.GroupBy(l => l.Time.Date).Select(g => new { date = g.Key, count = g.Count() }).OrderBy(x => x.date).ToListAsync());
var moviesRaw = await baseQuery
.Where(l => l.MovieTitle != null && l.MovieTitle != "")
.GroupBy(l => l.MovieTitle)
.Select(g => new MovieRawDto
{
Title = g.Key,
Tmdb = g.Max(l => l.TmdbId),
Kp = g.Max(l => l.KpId),
Imdb = g.Max(l => l.ImdbId),
IsTv = g.Max(l => l.IsTv ? 1 : 0) == 1,
Count = g.Count()
})
.OrderByDescending(x => x.Count)
.Take(10)
.ToListAsync();
var movies = MapMovies(moviesRaw);
return new JsonResult(new { success = true, data = new { total, today, uids = topUids, ips = topIps, timeline, movies } });
}
[HttpPost("api/logs/delete")]
public async Task<IActionResult> DeleteLogs([FromForm] string? search, [FromForm] int days = 30)
{
await using var db = await _contextFactory.CreateDbContextAsync();
var now = DateTime.UtcNow.Date;
var periodStart = days == 0 ? now : (days == -1 ? now.AddDays(-1) : now.AddDays(-days));
var periodEnd = days == -1 ? now : DateTime.UtcNow;
var query = db.Logs.Where(l => l.Time >= periodStart && l.Time < periodEnd);
query = ApplySearchFilter(db, query, search);
var deleted = await query.ExecuteDeleteAsync();
return new JsonResult(new { success = true, deleted });
}
[HttpGet("api/export")]
public async Task<IActionResult> ExportData()
{
await using var db = await _contextFactory.CreateDbContextAsync();
var result = await db.Logs.AsNoTracking()
.OrderByDescending(l => l.Id)
.Take(50000)
.GroupJoin(db.Users, l => l.UnfoId, u => u.Id, (l, users) => new { Log = l, Users = users })
.SelectMany(x => x.Users.DefaultIfEmpty(), (x, u) => new
{
time = x.Log.Time,
uri = x.Log.Uri,
uid = x.Log.Uid,
ip = u != null ? u.Ip : "",
userAgent = u != null ? u.UserAgent : "",
balancer = x.Log.Balancer,
statusCode = x.Log.StatusCode
})
.ToListAsync();
return new JsonResult(result);
}
}
-148
View File
@@ -1,148 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Shared;
using Shared.Models;
using Shared.Models.Base;
using Shared.Models.Events;
using Shared.Models.Module;
using Shared.Models.Module.Interfaces;
using Telemetry.Models;
namespace Telemetry;
public class ModInit : IModuleLoaded, IModuleConfigure
{
public static string modpath { get; private set; } = string.Empty;
public static readonly string DbPath = Path.Combine(AppContext.BaseDirectory, "database", "Telemetry", "stats.db");
private static Timer? _updateDbTimer;
private static Timer? _cleanupTimer;
private static int _updatingDb = 0;
public void Configure(ConfigureModel app)
{
app.services.AddDbContextFactory<AppDbContext>(options =>
{
options.UseSqlite($"Data Source={DbPath};Cache=Shared");
options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
});
}
public void Loaded(InitspaceModel initspace)
{
modpath = initspace.path;
var dbDir = Path.GetDirectoryName(DbPath);
if (dbDir != null) Directory.CreateDirectory(dbDir);
using (var sqlDb = new AppDbContext())
{
sqlDb.Database.EnsureCreated();
try
{
sqlDb.Database.ExecuteSqlRaw("PRAGMA journal_mode = WAL;");
sqlDb.Database.ExecuteSqlRaw("PRAGMA synchronous = NORMAL;");
sqlDb.Database.ExecuteSqlRaw("PRAGMA cache_size = -64000;");
sqlDb.Database.ExecuteSqlRaw("PRAGMA temp_store = MEMORY;");
sqlDb.Database.ExecuteSqlRaw("PRAGMA mmap_size = 33554432;");
}
catch { }
}
_updateDbTimer?.Dispose();
_cleanupTimer?.Dispose();
_updateDbTimer = new Timer(UpdateDb, null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15));
_cleanupTimer = new Timer(CleanupDb, null, TimeSpan.FromMinutes(1), TimeSpan.FromHours(12));
RequestListener.LoadSettings();
EventListener.MiddlewareAsync -= RequestListener.InvokeAsync;
EventListener.MiddlewareAsync += RequestListener.InvokeAsync;
Console.WriteLine("[Telemetry] Module loaded.");
}
private static void UpdateDb(object? state)
{
if (Interlocked.Exchange(ref _updatingDb, 1) == 1) return;
try
{
using var sqlDb = new AppDbContext();
sqlDb.ChangeTracker.AutoDetectChangesEnabled = false;
var batch = new List<(LogModelSql log, UserInfoModelSql user)>();
while (batch.Count < 2000 && RequestListener.Queue.TryDequeue(out var item))
{
RequestListener.DequeueItem();
batch.Add(item);
}
if (batch.Count == 0) return;
var userIds = batch.Select(b => b.user.Id).Distinct().ToList();
var existingUsers = sqlDb.Users.Where(u => userIds.Contains(u.Id)).Select(u => u.Id).ToHashSet();
foreach (var item in batch.OrderBy(i => i.log.Time))
{
if (!existingUsers.Contains(item.user.Id))
{
sqlDb.Users.Add(item.user);
existingUsers.Add(item.user.Id);
}
sqlDb.Logs.Add(item.log);
}
sqlDb.SaveChanges();
}
catch (Exception ex)
{
Console.WriteLine($"[Telemetry] DB Update Error: {ex.Message}");
}
finally { Interlocked.Exchange(ref _updatingDb, 0); }
}
private static void CleanupDb(object? state)
{
try
{
// Settings should be configurable, hardcoded for now: 90 days or max 500MB DB
int logDay = 90;
var cutoff = DateTime.UtcNow.AddDays(-logDay);
using var sqlDb = new AppDbContext();
int batchSize = 5000;
// Delete old records
while (true)
{
var ids = sqlDb.Logs.Where(l => l.Time < cutoff).Select(l => l.Id).Take(batchSize).ToList();
if (ids.Count == 0) break;
sqlDb.Logs.Where(l => ids.Contains(l.Id)).ExecuteDelete();
if (ids.Count < batchSize) break;
}
// Delete orphaned users who have no logs left
sqlDb.Users.Where(u => !sqlDb.Logs.Any(l => l.UnfoId == u.Id)).ExecuteDelete();
// Run VACUUM to reclaim deleted disk space
try { sqlDb.Database.ExecuteSqlRaw("VACUUM;"); } catch { }
}
catch (Exception ex)
{
Console.WriteLine($"[Telemetry] DB Cleanup Error: {ex.Message}");
}
}
public void Dispose()
{
EventListener.MiddlewareAsync -= RequestListener.InvokeAsync;
_updateDbTimer?.Dispose();
_cleanupTimer?.Dispose();
Console.WriteLine("[Telemetry] Module disposed.");
}
}
-56
View File
@@ -1,56 +0,0 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Telemetry.Models;
public class LogModelSql
{
[Key]
public long Id { get; set; }
public DateTime Time { get; set; }
[MaxLength(2048)]
public string Uri { get; set; } = string.Empty;
[MaxLength(256)]
public string Uid { get; set; } = string.Empty;
[MaxLength(64)]
public string? UnfoId { get; set; }
public int DurationMs { get; set; }
[MaxLength(128)]
public string? Balancer { get; set; }
public int StatusCode { get; set; }
[MaxLength(256)]
public string? MovieTitle { get; set; }
[MaxLength(32)]
public string? TmdbId { get; set; }
[MaxLength(32)]
public string? KpId { get; set; }
[MaxLength(32)]
public string? ImdbId { get; set; }
public bool IsTv { get; set; }
}
public class UserInfoModelSql
{
[Key]
[MaxLength(64)]
public string Id { get; set; } = string.Empty;
[MaxLength(64)]
public string Ip { get; set; } = string.Empty;
[MaxLength(1024)]
public string UserAgent { get; set; } = string.Empty;
}
-273
View File
@@ -1,273 +0,0 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Caching.Memory;
using Shared;
using Shared.Models;
using Shared.Models.Base;
using Shared.Models.Events;
using Shared.Models.Module;
using Shared.Models.Module.Interfaces;
using Telemetry.Models;
namespace Telemetry;
public static class RequestListener
{
public static readonly ConcurrentQueue<(LogModelSql log, UserInfoModelSql user)> Queue = new();
private static int _queueSize = 0;
private const int MaxQueueSize = 50000;
private static readonly MemoryCache _rateLimitCache = new(new MemoryCacheOptions { SizeLimit = 100_000 });
private static readonly MemoryCache _balancerDedupCache = new(new MemoryCacheOptions { SizeLimit = 50_000 });
private static readonly MemoryCache _userIdCache = new(new MemoryCacheOptions { SizeLimit = 50_000 });
private static readonly string _settingsPath = Path.Combine(AppContext.BaseDirectory, "database", "Telemetry", "settings.json");
public static volatile string[] SkipPrefixes = {
"/.well-known", "/admin/health", "/admin/ping",
"/lite/telemetry", "/telemetry", "/adminpanel", "/stats", "/weblog",
"/storage/", "/timecode/", "/bookmark/", "/tmdb/api/", "/lite/events", "/externalids", "/sisi"
};
public static volatile string[] SkipExtensions = { ".js", ".css", ".svg", ".png", ".jpg", ".woff", ".ico" };
public static volatile string[] RegexFilters = { };
private static volatile HashSet<string> _blacklistIps = new();
public static volatile int RateLimitMs = 33;
public static string[] BlacklistIps
{
get => _blacklistIps.ToArray();
set => _blacklistIps = new HashSet<string>(value, StringComparer.OrdinalIgnoreCase);
}
public static bool IsBlacklistedIp(string ip) => _blacklistIps.Contains(ip);
public static void DequeueItem() => Interlocked.Decrement(ref _queueSize);
public static Task<bool> InvokeAsync(bool first, EventMiddleware e)
{
if (first)
{
e.httpContext.Items["Telemetry_StartTime"] = DateTime.UtcNow;
return Task.FromResult(true);
}
var httpContext = e.httpContext;
var requestInfo = httpContext.Features.Get<RequestModel>();
if (requestInfo == null || requestInfo.IsLocalRequest || requestInfo.IsAnonymousRequest)
return Task.FromResult(true);
try
{
var path = httpContext.Request.Path.Value ?? "";
// Configurable Regex filters with ReDoS protection (timeout 100ms)
try
{
if (RegexFilters.Any(pattern => Regex.IsMatch(path, pattern, RegexOptions.None, TimeSpan.FromMilliseconds(100))))
return Task.FromResult(true);
}
catch (RegexMatchTimeoutException)
{
// ReDoS protection triggered, skip this request and log warning
return Task.FromResult(true);
}
// Extension filter
if (SkipExtensions.Any(ext => path.EndsWith(ext, StringComparison.OrdinalIgnoreCase)))
return Task.FromResult(true);
// Prefix filter
if (SkipPrefixes.Any(prefix => path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)))
return Task.FromResult(true);
var realIP = requestInfo.IP;
if (string.IsNullOrEmpty(realIP) || realIP == "127.0.0.1" || realIP == "::1")
return Task.FromResult(true);
// IP Blacklist check
if (IsBlacklistedIp(realIP))
return Task.FromResult(true);
// Rate limit
if (RateLimitMs > 0)
{
var rateLimitKey = $"{realIP}:{path}";
if (_rateLimitCache.TryGetValue(rateLimitKey, out _))
return Task.FromResult(true);
_rateLimitCache.Set(rateLimitKey, true, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMilliseconds(RateLimitMs),
Size = 1
});
}
string userAgent = httpContext.Request.Headers["User-Agent"].FirstOrDefault() ?? "unknown";
if (userAgent.Length > 1024) userAgent = userAgent[..1024];
string userUid = httpContext.Request.Query["uid"].FirstOrDefault() ?? httpContext.Request.Cookies["uid"] ?? "anonymous";
if (userUid.Length > 256) userUid = userUid[..256];
var cacheKey = $"{realIP}:{userAgent}";
if (!_userIdCache.TryGetValue(cacheKey, out string? userId))
{
userId = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(cacheKey))).ToLower();
_userIdCache.Set(cacheKey, userId, new MemoryCacheEntryOptions { SlidingExpiration = TimeSpan.FromHours(2), Size = 1 });
}
var user = new UserInfoModelSql
{
Id = userId!,
Ip = realIP,
UserAgent = userAgent
};
var fullUri = path + httpContext.Request.QueryString;
if (fullUri.Length > 2048) fullUri = fullUri[..2048];
string balancer = "";
if (path.StartsWith("/lite/")) balancer = path[6..].Split('/', '?')[0];
else if (path.StartsWith("/rc/")) balancer = path[4..].Split('/', '?')[0];
else
{
// Parse 18+ (sisi) modules which sit at the root
string[] adultRoutes = { "bgs", "chu", "elo", "epr", "hqr", "phub", "phubgay", "phubsml", "phubprem", "ptx", "runetki", "sbg", "tizam", "xmr", "xmrgay", "xmrsml", "xnx", "xds", "xdsgay", "xdssml", "xdsred", "nexthub" };
var rootSegment = path.TrimStart('/').Split('/', '?')[0].ToLowerInvariant();
if (adultRoutes.Contains(rootSegment))
{
balancer = "18+ " + rootSegment;
}
}
// Filter out system endpoints that are not actual movie balancers
string[] systemEndpoints = { "events", "sync", "auth", "ws", "subs", "vtt", "search", "suggest", "torrent", "tracker", "info", "mdl" };
if (systemEndpoints.Contains(balancer.ToLowerInvariant()))
{
balancer = "";
}
// Deduplication for balancers (First + Last within 30 seconds)
if (!string.IsNullOrEmpty(balancer))
{
var dedupKey = $"{userUid}:{balancer}";
var lastSeen = _balancerDedupCache.Get<DateTime?>(dedupKey);
if (lastSeen.HasValue && (DateTime.UtcNow - lastSeen.Value).TotalSeconds < 30)
{
return Task.FromResult(true);
}
else
{
_balancerDedupCache.Set(dedupKey, DateTime.UtcNow, new MemoryCacheEntryOptions
{
SlidingExpiration = TimeSpan.FromSeconds(30),
Size = 1
});
}
}
int durationMs = 0;
if (httpContext.Items.TryGetValue("Telemetry_StartTime", out var startTimeObj) && startTimeObj is DateTime startTime)
{
durationMs = (int)(DateTime.UtcNow - startTime).TotalMilliseconds;
}
string? mTitle = null, mTmdb = null, mKp = null, mImdb = null;
bool mIsTv = false;
var reqQuery = httpContext.Request.Query;
if (reqQuery.TryGetValue("title", out var titleVal)) mTitle = titleVal.ToString();
if (reqQuery.TryGetValue("id", out var idVal)) mTmdb = idVal.ToString();
if (reqQuery.TryGetValue("kinopoisk_id", out var kpVal)) mKp = kpVal.ToString();
if (reqQuery.TryGetValue("imdb_id", out var imdbVal)) mImdb = imdbVal.ToString();
if (reqQuery.TryGetValue("serial", out var serialVal)) mIsTv = serialVal.ToString() == "1" || serialVal.ToString().Equals("true", StringComparison.OrdinalIgnoreCase);
var log = new LogModelSql
{
Time = DateTime.UtcNow,
Uri = fullUri,
Uid = userUid,
UnfoId = user.Id,
DurationMs = durationMs,
Balancer = balancer,
StatusCode = httpContext.Response?.StatusCode ?? 0,
MovieTitle = mTitle,
TmdbId = mTmdb,
KpId = mKp,
ImdbId = mImdb,
IsTv = mIsTv
};
if (Interlocked.Increment(ref _queueSize) <= MaxQueueSize)
{
try { Queue.Enqueue((log, user)); }
catch { Interlocked.Decrement(ref _queueSize); }
}
else
{
Interlocked.Decrement(ref _queueSize);
// Dropped
}
}
catch { }
return Task.FromResult(true);
}
public static void SaveSettings()
{
try
{
var dir = Path.GetDirectoryName(_settingsPath);
if (dir != null) Directory.CreateDirectory(dir);
var data = new Dictionary<string, object>
{
["prefixes"] = SkipPrefixes,
["extensions"] = SkipExtensions,
["regexes"] = RegexFilters,
["ips"] = BlacklistIps,
["rateLimit"] = RateLimitMs
};
File.WriteAllText(_settingsPath, System.Text.Json.JsonSerializer.Serialize(data, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }));
}
catch (Exception ex)
{
Console.WriteLine($"[Telemetry] SaveSettings Error: {ex.Message}");
}
}
public static void LoadSettings()
{
try
{
if (!File.Exists(_settingsPath)) return;
var json = File.ReadAllText(_settingsPath);
var doc = System.Text.Json.JsonDocument.Parse(json);
var root = doc.RootElement;
if (root.TryGetProperty("prefixes", out var p)) SkipPrefixes = p.EnumerateArray().Select(e => e.GetString()!).ToArray();
if (root.TryGetProperty("extensions", out var e2)) SkipExtensions = e2.EnumerateArray().Select(e => e.GetString()!).ToArray();
if (root.TryGetProperty("regexes", out var r)) RegexFilters = r.EnumerateArray().Select(e => e.GetString()!).ToArray();
if (root.TryGetProperty("ips", out var i)) BlacklistIps = i.EnumerateArray().Select(e => e.GetString()!).ToArray();
if (root.TryGetProperty("rateLimit", out var rl)) RateLimitMs = rl.GetInt32();
Console.WriteLine("[Telemetry] Settings loaded from disk.");
}
catch (Exception ex)
{
Console.WriteLine($"[Telemetry] LoadSettings Error: {ex.Message}");
}
}
}
-13
View File
@@ -1,13 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<RootNamespace>Telemetry</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Shared\Shared.csproj" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.7" />
</ItemGroup>
</Project>
File diff suppressed because it is too large Load Diff
-11
View File
@@ -1,11 +0,0 @@
{
"enable": true,
"dynamic": true,
"tree": [
"Controllers",
"Models",
"AppDbContext.cs",
"ModInit.cs",
"RequestListener.cs"
]
}
+16 -2
View File
@@ -5,6 +5,7 @@ using Shared.Attributes;
using Shared.Services;
using Shared.Services.Utilities;
using System;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
@@ -49,8 +50,21 @@ public class TracksController : BaseController
{
media = Regex.Replace(media, "[^a-z0-9_:\\-\\/\\.\\=\\?\\&\\%\\@]+", "", RegexOptions.IgnoreCase);
if (media.Contains("/stream/") && ModInit.conf.tsuri != null)
media = Regex.Replace(media, "^https?://[^/]+", ModInit.conf.tsuri, RegexOptions.IgnoreCase);
if (media.Contains("/stream/"))
{
if (ModInit.conf.tsuri != null)
media = Regex.Replace(media, "^https?://[^/]+", ModInit.conf.tsuri, RegexOptions.IgnoreCase);
// TorrServer returns an empty body for preload/stat/m3u; ffprobe needs the play stream
int q = media.IndexOf('?');
if (q > 0)
{
var args = media[(q + 1)..].Split('&', StringSplitOptions.RemoveEmptyEntries)
.Where(a => !Regex.IsMatch(a, "^(preload|stat|m3u|play)(=|$)", RegexOptions.IgnoreCase));
media = media[..q] + "?" + string.Join("&", args.Append("play"));
}
}
}
else if (media.Contains("/proxy/") && media.Contains(".mkv"))
{
+379 -364
View File
@@ -1,401 +1,416 @@
(function () {
'use strict';
'use strict';
var connect_host = '{localhost}';
var list_opened = false;
var unic_id = Lampa.Storage.get('lampac_unic_id', '');
if (!unic_id) {
unic_id = Lampa.Utils.uid(8).toLowerCase();
Lampa.Storage.set('lampac_unic_id', unic_id);
}
var connect_host = '{localhost}';
var list_opened = false;
function reguest(params, callback) {
if (params.ffprobe) {
setTimeout(function () {
callback({
streams: params.ffprobe
});
}, 200);
} else {
var net = new Lampa.Reguest();
net.timeout(1000 * 15);
var url = connect_host + '/ffprobe?media=' + encodeURIComponent(params.url);
if (url.indexOf('account_email=') == -1) {
var email = Lampa.Storage.get('account_email');
if (email) url = Lampa.Utils.addUrlComponent(url, 'account_email=' + encodeURIComponent(email));
}
var unic_id = Lampa.Storage.get('lampac_unic_id', '');
if (!unic_id) {
unic_id = Lampa.Utils.uid(8).toLowerCase();
Lampa.Storage.set('lampac_unic_id', unic_id);
}
if (url.indexOf('uid=') == -1) {
var uid = Lampa.Storage.get('lampac_unic_id', '');
if (uid) url = Lampa.Utils.addUrlComponent(url, 'uid=' + encodeURIComponent(uid));
}
if (url.indexOf('token=') == -1) {
var token = '{token}';
if (token != '') url = Lampa.Utils.addUrlComponent(url, 'token={token}');
}
net["native"](url, function (str) {
var json = {};
try {
json = JSON.parse(str);
} catch (e) {}
if (json.streams) callback(json);
}, false, false, {
dataType: 'text'
function reguest(params, callback) {
if (params.ffprobe) {
setTimeout(function () {
callback({
streams: params.ffprobe
});
}, 200);
} else {
var net = new Lampa.Reguest();
net.timeout(1000 * 15);
var url = connect_host + '/ffprobe?media=' + encodeURIComponent(params.url);
if (url.indexOf('account_email=') == -1) {
var email = Lampa.Storage.get('account_email');
if (email) url = Lampa.Utils.addUrlComponent(url, 'account_email=' + encodeURIComponent(email));
}
if (url.indexOf('uid=') == -1) {
var uid = Lampa.Storage.get('lampac_unic_id', '');
if (uid) url = Lampa.Utils.addUrlComponent(url, 'uid=' + encodeURIComponent(uid));
}
if (url.indexOf('token=') == -1) {
var token = '{token}';
if (token != '') url = Lampa.Utils.addUrlComponent(url, 'token={token}');
}
net["native"](url, function (str) {
var json = {};
try {
json = JSON.parse(str);
} catch (e) { }
callback(json.streams && json.streams.length ? json : null);
}, function () {
callback(null);
}, false, {
dataType: 'text'
});
}
}
function subscribeTracks(data) {
var inited = false;
var inited_parse = false;
var webos_replace = {};
function log() {
console.log.apply(console.log, arguments);
}
function getTracks() {
var video = Lampa.PlayerVideo.video();
return video.audioTracks || [];
}
function getSubs() {
var video = Lampa.PlayerVideo.video();
return video.textTracks || [];
}
log('Tracks', 'start');
function setTracks() {
if (inited_parse) {
var new_tracks = [];
var video_tracks = getTracks();
var parse_tracks = inited_parse.streams.filter(function (a) {
return a.codec_type == 'audio';
});
var minus = 1;
log('Tracks', 'set tracks:', video_tracks.length);
if (parse_tracks.length !== video_tracks.length) parse_tracks = parse_tracks.filter(function (a) {
return a.codec_name !== 'dts';
});
parse_tracks = parse_tracks.filter(function (a) {
return a.tags;
});
log('Tracks', 'filtred tracks:', parse_tracks.length);
parse_tracks.forEach(function (track) {
var orig = video_tracks[track.index - minus];
var elem = {
index: track.index - minus,
language: track.tags.language,
label: track.tags.title || track.tags.handler_name,
ghost: orig ? false : true,
selected: orig ? orig.selected == true || orig.enabled == true : false
};
console.log('Tracks', 'tracks original', orig);
Object.defineProperty(elem, "enabled", {
set: function set(v) {
if (v) {
var aud = getTracks();
var trk = aud[elem.index];
for (var i = 0; i < aud.length; i++) {
aud[i].enabled = false;
aud[i].selected = false;
}
if (trk) {
trk.enabled = true;
trk.selected = true;
}
}
},
get: function get() { }
});
new_tracks.push(elem);
});
if (parse_tracks.length) Lampa.PlayerPanel.setTracks(new_tracks);
}
}
function subscribeTracks(data) {
var inited = false;
var inited_parse = false;
var webos_replace = {};
function setSubs() {
if (inited_parse) {
var new_subs = [];
var video_subs = getSubs();
var parse_subs = inited_parse.streams.filter(function (a) {
return a.codec_type == 'subtitle';
});
var minus = inited_parse.streams.filter(function (a) {
return a.codec_type == 'audio';
}).length + 1;
log('Tracks', 'set subs:', video_subs.length);
parse_subs = parse_subs.filter(function (a) {
return a.tags;
});
log('Tracks', 'filtred subs:', parse_subs.length);
parse_subs.forEach(function (track) {
var orig = video_subs[track.index - minus];
var elem = {
index: track.index - minus,
language: track.tags.language,
label: track.tags.title || track.tags.handler_name,
ghost: video_subs[track.index - minus] ? false : true,
selected: orig ? orig.selected == true || orig.mode == 'showing' : false
};
console.log('Tracks', 'subs original', orig);
Object.defineProperty(elem, "mode", {
set: function set(v) {
if (v) {
var txt = getSubs();
var sub = txt[elem.index];
function log() {
console.log.apply(console.log, arguments);
}
for (var i = 0; i < txt.length; i++) {
txt[i].mode = 'disabled';
txt[i].selected = false;
}
function getTracks() {
var video = Lampa.PlayerVideo.video();
return video.audioTracks || [];
}
function getSubs() {
var video = Lampa.PlayerVideo.video();
return video.textTracks || [];
}
log('Tracks', 'start');
function setTracks() {
if (inited_parse) {
var new_tracks = [];
var video_tracks = getTracks();
var parse_tracks = inited_parse.streams.filter(function (a) {
return a.codec_type == 'audio';
});
var minus = 1;
log('Tracks', 'set tracks:', video_tracks.length);
if (parse_tracks.length !== video_tracks.length) parse_tracks = parse_tracks.filter(function (a) {
return a.codec_name !== 'dts';
if (sub) {
sub.mode = 'showing';
sub.selected = true;
}
}
},
get: function get() { }
});
new_subs.push(elem);
});
if (parse_subs.length) Lampa.PlayerPanel.setSubs(new_subs);
}
}
function listenTracks() {
log('Tracks', 'tracks video event');
setTracks();
Lampa.PlayerVideo.listener.remove('tracks', listenTracks);
}
function listenSubs() {
log('Tracks', 'subs video event');
setSubs();
Lampa.PlayerVideo.listener.remove('subs', listenSubs);
}
function canPlay() {
log('Tracks', 'canplay video event');
if (webos_replace.tracks) setWebosTracks(webos_replace.tracks); else setTracks();
if (webos_replace.subs) setWebosSubs(webos_replace.subs); else setSubs();
Lampa.PlayerVideo.listener.remove('canplay', canPlay);
}
function setWebosTracks(video_tracks) {
if (inited_parse) {
var parse_tracks = inited_parse.streams.filter(function (a) {
return a.codec_type == 'audio';
});
log('Tracks', 'webos set tracks:', video_tracks.length);
if (parse_tracks.length !== video_tracks.length) {
parse_tracks = parse_tracks.filter(function (a) {
return a.tags;
return a.codec_name !== 'truehd';
});
log('Tracks', 'filtred tracks:', parse_tracks.length);
parse_tracks.forEach(function (track) {
var orig = video_tracks[track.index - minus];
var elem = {
index: track.index - minus,
language: track.tags.language,
label: track.tags.title || track.tags.handler_name,
ghost: orig ? false : true,
selected: orig ? orig.selected == true || orig.enabled == true : false
};
console.log('Tracks', 'tracks original', orig);
Object.defineProperty(elem, "enabled", {
set: function set(v) {
if (v) {
var aud = getTracks();
var trk = aud[elem.index];
for (var i = 0; i < aud.length; i++) {
aud[i].enabled = false;
aud[i].selected = false;
}
if (trk) {
trk.enabled = true;
trk.selected = true;
}
}
},
get: function get() {}
});
new_tracks.push(elem);
});
if (parse_tracks.length) Lampa.PlayerPanel.setTracks(new_tracks);
}
}
function setSubs() {
if (inited_parse) {
var new_subs = [];
var video_subs = getSubs();
var parse_subs = inited_parse.streams.filter(function (a) {
return a.codec_type == 'subtitle';
});
var minus = inited_parse.streams.filter(function (a) {
return a.codec_type == 'audio';
}).length + 1;
log('Tracks', 'set subs:', video_subs.length);
parse_subs = parse_subs.filter(function (a) {
return a.tags;
});
log('Tracks', 'filtred subs:', parse_subs.length);
parse_subs.forEach(function (track) {
var orig = video_subs[track.index - minus];
var elem = {
index: track.index - minus,
language: track.tags.language,
label: track.tags.title || track.tags.handler_name,
ghost: video_subs[track.index - minus] ? false : true,
selected: orig ? orig.selected == true || orig.mode == 'showing' : false
};
console.log('Tracks', 'subs original', orig);
Object.defineProperty(elem, "mode", {
set: function set(v) {
if (v) {
var txt = getSubs();
var sub = txt[elem.index];
for (var i = 0; i < txt.length; i++) {
txt[i].mode = 'disabled';
txt[i].selected = false;
}
if (sub) {
sub.mode = 'showing';
sub.selected = true;
}
}
},
get: function get() {}
});
new_subs.push(elem);
});
if (parse_subs.length) Lampa.PlayerPanel.setSubs(new_subs);
}
}
function listenTracks() {
log('Tracks', 'tracks video event');
setTracks();
Lampa.PlayerVideo.listener.remove('tracks', listenTracks);
}
function listenSubs() {
log('Tracks', 'subs video event');
setSubs();
Lampa.PlayerVideo.listener.remove('subs', listenSubs);
}
function canPlay() {
log('Tracks', 'canplay video event');
if (webos_replace.tracks) setWebosTracks(webos_replace.tracks);else setTracks();
if (webos_replace.subs) setWebosSubs(webos_replace.subs);else setSubs();
Lampa.PlayerVideo.listener.remove('canplay', canPlay);
}
function setWebosTracks(video_tracks) {
if (inited_parse) {
var parse_tracks = inited_parse.streams.filter(function (a) {
return a.codec_type == 'audio';
});
log('Tracks', 'webos set tracks:', video_tracks.length);
if (parse_tracks.length !== video_tracks.length) {
parse_tracks = parse_tracks.filter(function (a) {
return a.codec_name !== 'truehd';
return a.codec_name !== 'dts';
});
if (parse_tracks.length !== video_tracks.length) {
parse_tracks = parse_tracks.filter(function (a) {
return a.codec_name !== 'dts';
});
}
}
parse_tracks = parse_tracks.filter(function (a) {
return a.tags;
});
log('Tracks', 'webos tracks', video_tracks);
parse_tracks.forEach(function (track, i) {
if (video_tracks[i]) {
video_tracks[i].language = track.tags.language;
video_tracks[i].label = track.tags.title || track.tags.handler_name;
}
});
}
}
function setWebosSubs(video_subs) {
if (inited_parse) {
var parse_subs = inited_parse.streams.filter(function (a) {
return a.codec_type == 'subtitle';
});
log('Tracks', 'webos set subs:', video_subs.length);
if (parse_subs.length !== video_subs.length - 1) parse_subs = parse_subs.filter(function (a) {
return a.codec_name !== 'hdmv_pgs_subtitle';
});
parse_subs = parse_subs.filter(function (a) {
return a.tags;
});
parse_subs.forEach(function (track, a) {
var i = a + 1;
if (video_subs[i]) {
video_subs[i].language = track.tags.language;
video_subs[i].label = track.tags.title || track.tags.handler_name;
}
});
}
}
function listenWebosSubs(_data) {
log('Tracks', 'webos subs event');
webos_replace.subs = _data.subs;
if (inited_parse) setWebosSubs(_data.subs);
}
function listenWebosTracks(_data) {
log('Tracks', 'webos tracks event');
webos_replace.tracks = _data.tracks;
if (inited_parse) setWebosTracks(_data.tracks);
}
function listenStart() {
inited = true;
reguest(data, function (result) {
log('Tracks', 'parsed', inited_parse);
inited_parse = result;
if (inited) {
if (webos_replace.subs) setWebosSubs(webos_replace.subs);else setSubs();
if (webos_replace.tracks) setWebosTracks(webos_replace.tracks);else setTracks();
parse_tracks = parse_tracks.filter(function (a) {
return a.tags;
});
log('Tracks', 'webos tracks', video_tracks);
parse_tracks.forEach(function (track, i) {
if (video_tracks[i]) {
video_tracks[i].language = track.tags.language;
video_tracks[i].label = track.tags.title || track.tags.handler_name;
}
});
}
function listenDestroy() {
inited = false;
Lampa.Player.listener.remove('destroy', listenDestroy);
Lampa.PlayerVideo.listener.remove('tracks', listenTracks);
Lampa.PlayerVideo.listener.remove('subs', listenSubs);
Lampa.PlayerVideo.listener.remove('webos_subs', listenWebosSubs);
Lampa.PlayerVideo.listener.remove('webos_tracks', listenWebosTracks);
Lampa.PlayerVideo.listener.remove('canplay', canPlay);
log('Tracks', 'end');
}
Lampa.Player.listener.follow('destroy', listenDestroy);
Lampa.PlayerVideo.listener.follow('tracks', listenTracks);
Lampa.PlayerVideo.listener.follow('subs', listenSubs);
Lampa.PlayerVideo.listener.follow('webos_subs', listenWebosSubs);
Lampa.PlayerVideo.listener.follow('webos_tracks', listenWebosTracks);
Lampa.PlayerVideo.listener.follow('canplay', canPlay);
listenStart();
}
function parseMetainfo(data) {
var loading = Lampa.Template.get('tracks_loading');
data.item.after(loading);
reguest(data.element, function (result) {
if (list_opened) {
var append = function append(name, fields) {
if (fields.length) {
var block = Lampa.Template.get('tracks_metainfo_block', {});
block.find('.tracks-metainfo__label').text(Lampa.Lang.translate(name == 'video' ? 'extensions_hpu_video' : name == 'audio' ? 'player_tracks' : 'player_' + name));
fields.forEach(function (data) {
var item = $('<div class="tracks-metainfo__item tracks-metainfo__item--' + name + ' selector"></div>');
item.on('hover:focus', function (e) {
Lampa.Modal.scroll().update(item);
});
function setWebosSubs(video_subs) {
if (inited_parse) {
var parse_subs = inited_parse.streams.filter(function (a) {
return a.codec_type == 'subtitle';
});
log('Tracks', 'webos set subs:', video_subs.length);
if (parse_subs.length !== video_subs.length - 1) parse_subs = parse_subs.filter(function (a) {
return a.codec_name !== 'hdmv_pgs_subtitle';
});
parse_subs = parse_subs.filter(function (a) {
return a.tags;
});
parse_subs.forEach(function (track, a) {
var i = a + 1;
for (var i in data) {
var div = $('<div class="tracks-metainfo__column--' + i + '"></div>');
div.text(data[i]);
item.append(div);
}
block.find('.tracks-metainfo__info').append(item);
});
html.append(block);
}
};
var video = [];
var audio = [];
var subs = [];
var codec_video = result.streams.filter(function (a) {
return a.codec_type == 'video';
});
var codec_audio = result.streams.filter(function (a) {
return a.codec_type == 'audio';
});
var codec_subs = result.streams.filter(function (a) {
return a.codec_type == 'subtitle';
});
codec_video.slice(0, 1).forEach(function (v) {
var line = {};
if (v.width && v.height) line.video = v.width + 'х' + v.height;
if (v.codec_name) line.codec = v.codec_name.toUpperCase();
if (Boolean(v.is_avc)) line.avc = 'AVC';
if (Lampa.Arrays.getKeys(line).length) video.push(line);
});
codec_audio.forEach(function (a, i) {
var line = {
num: i + 1
};
if (a.tags) {
line.lang = (a.tags.language || '').toUpperCase();
}
line.name = a.tags ? a.tags.title || a.tags.handler_name : '';
if (a.codec_name) line.codec = a.codec_name.toUpperCase();
if (a.channel_layout) line.channels = a.channel_layout.replace('(side)', '').replace('stereo', '2.0');
var bit = a.bit_rate ? a.bit_rate : a.tags && (a.tags.BPS || a.tags["BPS-eng"]) ? a.tags.BPS || a.tags["BPS-eng"] : '--';
line.rate = bit == '--' ? bit : Math.round(bit / 1000) + ' ' + Lampa.Lang.translate('speed_kb');
if (Lampa.Arrays.getKeys(line).length) audio.push(line);
});
codec_subs.forEach(function (a, i) {
var line = {
num: i + 1
};
if (a.tags) {
line.lang = (a.tags.language || '').toUpperCase();
}
line.name = a.tags ? a.tags.title || a.tags.handler_name : '';
if (Lampa.Arrays.getKeys(line).length) subs.push(line);
});
var html = Lampa.Template.get('tracks_metainfo', {});
append('video', video);
append('audio', audio);
append('subs', subs);
loading.remove();
if (video.length || audio.length || subs.length) {
data.item.after(html);
if (video_subs[i]) {
video_subs[i].language = track.tags.language;
video_subs[i].label = track.tags.title || track.tags.handler_name;
}
});
}
}
if (Lampa.Controller.enabled().name == 'modal') Lampa.Controller.toggle('modal');
function listenWebosSubs(_data) {
log('Tracks', 'webos subs event');
webos_replace.subs = _data.subs;
if (inited_parse) setWebosSubs(_data.subs);
}
function listenWebosTracks(_data) {
log('Tracks', 'webos tracks event');
webos_replace.tracks = _data.tracks;
if (inited_parse) setWebosTracks(_data.tracks);
}
function listenStart() {
inited = true;
reguest(data, function (result) {
if (!result) return log('Tracks', 'no metadata');
log('Tracks', 'parsed', inited_parse);
inited_parse = result;
if (inited) {
if (webos_replace.subs) setWebosSubs(webos_replace.subs); else setSubs();
if (webos_replace.tracks) setWebosTracks(webos_replace.tracks); else setTracks();
}
});
}
Lampa.Player.listener.follow('start', function (data) {
if (data.torrent_hash) subscribeTracks(data);
});
Lampa.Listener.follow('torrent_file', function (data) {
if (data.type == 'list_open') list_opened = true;
if (data.type == 'list_close') list_opened = false;
function listenDestroy() {
inited = false;
Lampa.Player.listener.remove('destroy', listenDestroy);
Lampa.PlayerVideo.listener.remove('tracks', listenTracks);
Lampa.PlayerVideo.listener.remove('subs', listenSubs);
Lampa.PlayerVideo.listener.remove('webos_subs', listenWebosSubs);
Lampa.PlayerVideo.listener.remove('webos_tracks', listenWebosTracks);
Lampa.PlayerVideo.listener.remove('canplay', canPlay);
log('Tracks', 'end');
}
if (data.type == 'render' && data.items.length == 1 && list_opened) {
parseMetainfo(data);
Lampa.Player.listener.follow('destroy', listenDestroy);
Lampa.PlayerVideo.listener.follow('tracks', listenTracks);
Lampa.PlayerVideo.listener.follow('subs', listenSubs);
Lampa.PlayerVideo.listener.follow('webos_subs', listenWebosSubs);
Lampa.PlayerVideo.listener.follow('webos_tracks', listenWebosTracks);
Lampa.PlayerVideo.listener.follow('canplay', canPlay);
listenStart();
}
function parseMetainfo(data) {
var loading = Lampa.Template.get('tracks_loading');
data.item.after(loading);
reguest(data.element, function (result) {
if (!result) {
loading.addClass('tracks-loading--error').find('span').text(Lampa.Lang.translate('tracks_nometa'));
return;
}
if (list_opened) {
var append = function append(name, fields) {
if (fields.length) {
var block = Lampa.Template.get('tracks_metainfo_block', {});
block.find('.tracks-metainfo__label').text(Lampa.Lang.translate(name == 'video' ? 'extensions_hpu_video' : name == 'audio' ? 'player_tracks' : 'player_' + name));
fields.forEach(function (data) {
var item = $('<div class="tracks-metainfo__item tracks-metainfo__item--' + name + ' selector"></div>');
item.on('hover:focus', function (e) {
Lampa.Modal.scroll().update(item);
});
for (var i in data) {
var div = $('<div class="tracks-metainfo__column--' + i + '"></div>');
div.text(data[i]);
item.append(div);
}
block.find('.tracks-metainfo__info').append(item);
});
html.append(block);
}
};
var video = [];
var audio = [];
var subs = [];
var codec_video = result.streams.filter(function (a) {
return a.codec_type == 'video';
});
var codec_audio = result.streams.filter(function (a) {
return a.codec_type == 'audio';
});
var codec_subs = result.streams.filter(function (a) {
return a.codec_type == 'subtitle';
});
codec_video.slice(0, 1).forEach(function (v) {
var line = {};
if (v.width && v.height) line.video = v.width + 'х' + v.height;
if (v.codec_name) line.codec = v.codec_name.toUpperCase();
if (Boolean(v.is_avc)) line.avc = 'AVC';
if (Lampa.Arrays.getKeys(line).length) video.push(line);
});
codec_audio.forEach(function (a, i) {
var line = {
num: i + 1
};
if (a.tags) {
line.lang = (a.tags.language || '').toUpperCase();
}
line.name = a.tags ? a.tags.title || a.tags.handler_name : '';
if (a.codec_name) line.codec = a.codec_name.toUpperCase();
if (a.channel_layout) line.channels = a.channel_layout.replace('(side)', '').replace('stereo', '2.0');
var bit = a.bit_rate ? a.bit_rate : a.tags && (a.tags.BPS || a.tags["BPS-eng"]) ? a.tags.BPS || a.tags["BPS-eng"] : '--';
line.rate = bit == '--' ? bit : Math.round(bit / 1000) + ' ' + Lampa.Lang.translate('speed_kb');
if (Lampa.Arrays.getKeys(line).length) audio.push(line);
});
codec_subs.forEach(function (a, i) {
var line = {
num: i + 1
};
if (a.tags) {
line.lang = (a.tags.language || '').toUpperCase();
}
line.name = a.tags ? a.tags.title || a.tags.handler_name : '';
if (Lampa.Arrays.getKeys(line).length) subs.push(line);
});
var html = Lampa.Template.get('tracks_metainfo', {});
append('video', video);
append('audio', audio);
append('subs', subs);
loading.remove();
if (video.length || audio.length || subs.length) {
data.item.after(html);
}
if (Lampa.Controller.enabled().name == 'modal') Lampa.Controller.toggle('modal');
}
});
Lampa.Template.add('tracks_loading', "\n <div class=\"tracks-loading\">\n <span>#{loading}...</span>\n </div>\n");
Lampa.Template.add('tracks_metainfo', "\n <div class=\"tracks-metainfo\"></div>\n");
Lampa.Template.add('tracks_metainfo_block', "\n <div class=\"tracks-metainfo__line\">\n <div class=\"tracks-metainfo__label\"></div>\n <div class=\"tracks-metainfo__info\"></div>\n </div>\n");
Lampa.Template.add('tracks_css', "\n <style>\n .tracks-loading{margin-top:1em;display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-ms-flexbox;display:flex;-webkit-box-align:start;-webkit-align-items:flex-start;-moz-box-align:start;-ms-flex-align:start;align-items:flex-start}.tracks-loading:before{content:'';display:inline-block;width:1.3em;height:1.3em;background:url('./img/loader.svg') no-repeat 50% 50%;-webkit-background-size:contain;-moz-background-size:contain;-o-background-size:contain;background-size:contain;margin-right:.4em}.tracks-loading>span{font-size:1.1em;line-height:1.1}.tracks-metainfo{margin-top:1em}.tracks-metainfo__line+.tracks-metainfo__line{margin-top:2em}.tracks-metainfo__label{opacity:.5;font-weight:600}.tracks-metainfo__info{padding-top:1em;line-height:1.2}.tracks-metainfo__info>div{background-color:rgba(0,0,0,0.22);display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-ms-flexbox;display:flex;-webkit-border-radius:.3em;-moz-border-radius:.3em;border-radius:.3em;-webkit-flex-wrap:wrap;-ms-flex-wrap:wrap;flex-wrap:wrap}.tracks-metainfo__info>div.focus{background-color:rgba(255,255,255,0.06)}.tracks-metainfo__info>div>div{padding:1em;-webkit-flex-shrink:0;-ms-flex-negative:0;flex-shrink:0}.tracks-metainfo__info>div>div:not(:last-child){padding-right:1.5em}.tracks-metainfo__info>div+div{margin-top:1em}.tracks-metainfo__column--video,.tracks-metainfo__column--name{margin-right:auto}.tracks-metainfo__column--num{min-width:3em;padding-right:0}.tracks-metainfo__column--rate{min-width:7em;text-align:right}.tracks-metainfo__column--channels{min-width:5em;text-align:right}\n </style>\n");
$('body').append(Lampa.Template.get('tracks_css', {}, true));
}
Lampa.Player.listener.follow('start', function (data) {
if (data.torrent_hash) subscribeTracks(data);
});
Lampa.Listener.follow('torrent_file', function (data) {
if (data.type == 'list_open') list_opened = true;
if (data.type == 'list_close') list_opened = false;
if (data.type == 'render' && data.items.length == 1 && list_opened) {
parseMetainfo(data);
}
});
Lampa.Lang.add({
tracks_nometa: {
ru: 'Не удалось получить сведения о дорожках',
uk: 'Не вдалося отримати відомості про доріжки',
en: 'Failed to get track info'
}
});
Lampa.Template.add('tracks_loading', "\n <div class=\"tracks-loading\">\n <span>#{loading}...</span>\n </div>\n");
Lampa.Template.add('tracks_metainfo', "\n <div class=\"tracks-metainfo\"></div>\n");
Lampa.Template.add('tracks_metainfo_block', "\n <div class=\"tracks-metainfo__line\">\n <div class=\"tracks-metainfo__label\"></div>\n <div class=\"tracks-metainfo__info\"></div>\n </div>\n");
Lampa.Template.add('tracks_css', "\n <style>\n .tracks-loading{margin-top:1em;display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-ms-flexbox;display:flex;-webkit-box-align:start;-webkit-align-items:flex-start;-moz-box-align:start;-ms-flex-align:start;align-items:flex-start}.tracks-loading:before{content:'';display:inline-block;width:1.3em;height:1.3em;background:url('./img/loader.svg') no-repeat 50% 50%;-webkit-background-size:contain;-moz-background-size:contain;-o-background-size:contain;background-size:contain;margin-right:.4em}.tracks-loading>span{font-size:1.1em;line-height:1.1}.tracks-loading--error{opacity:.5}.tracks-loading--error:before{display:none}.tracks-metainfo{margin-top:1em}.tracks-metainfo__line+.tracks-metainfo__line{margin-top:2em}.tracks-metainfo__label{opacity:.5;font-weight:600}.tracks-metainfo__info{padding-top:1em;line-height:1.2}.tracks-metainfo__info>div{background-color:rgba(0,0,0,0.22);display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-ms-flexbox;display:flex;-webkit-border-radius:.3em;-moz-border-radius:.3em;border-radius:.3em;-webkit-flex-wrap:wrap;-ms-flex-wrap:wrap;flex-wrap:wrap}.tracks-metainfo__info>div.focus{background-color:rgba(255,255,255,0.06)}.tracks-metainfo__info>div>div{padding:1em;-webkit-flex-shrink:0;-ms-flex-negative:0;flex-shrink:0}.tracks-metainfo__info>div>div:not(:last-child){padding-right:1.5em}.tracks-metainfo__info>div+div{margin-top:1em}.tracks-metainfo__column--video,.tracks-metainfo__column--name{margin-right:auto}.tracks-metainfo__column--num{min-width:3em;padding-right:0}.tracks-metainfo__column--rate{min-width:7em;text-align:right}.tracks-metainfo__column--channels{min-width:5em;text-align:right}\n </style>\n");
$('body').append(Lampa.Template.get('tracks_css', {}, true));
})();
-594
View File
@@ -1,594 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.StaticFiles;
using Newtonsoft.Json;
using Shared;
using Shared.Services;
using Shared.Services.Pools;
using Shared.Models.Templates;
using System;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Web;
using Shared.Services.Utilities;
using Shared.Services.Pools.Json;
namespace Transcoding;
[ApiController]
[Route("transcoding")]
public sealed class TranscodingController : Controller
{
#region static
static readonly Serilog.ILogger Log = Serilog.Log.ForContext<TranscodingController>();
readonly TranscodingService _service = TranscodingService.Instance;
static readonly FileExtensionContentTypeProvider provider = new FileExtensionContentTypeProvider()
{
Mappings = {
[".m4s"] = "video/mp4",
[".ts"] = "video/mp2t",
[".mp4"] = "video/mp4",
[".m2ts"] = "video/MP2T"
}
};
#endregion
#region transcoding.js
[AllowAnonymous]
[HttpGet("/transcoding.js")]
[HttpGet("js/{token}")]
public ActionResult TranscodingJs(string token)
{
HttpContext.Response.Headers["Cache-Control"] = "no-cache, no-store, must-revalidate"; // HTTP 1.1.
HttpContext.Response.Headers["Pragma"] = "no-cache"; // HTTP 1.0.
HttpContext.Response.Headers["Expires"] = "0"; // Proxies.
var plugin = FileCache.ReadAllText($"{ModInit.modpath}/plugin.js", "transcoding.js")
.Replace("{localhost}", CoreInit.Host(HttpContext))
.Replace("{token}", HttpUtility.UrlEncode(token));
return Content(plugin, "application/javascript; charset=utf-8");
}
#endregion
#region Start
[HttpGet("start.m3u8")]
public async Task<IActionResult> StartM3u8(string src, int a, int s, bool subtitles, bool live)
{
if (string.IsNullOrEmpty(src))
return BadRequest(new { error = "src" });
if (src.Contains("/proxy/") && src.Contains(".mkv"))
{
string hash = Regex.Match(src, "/proxy/([^\n\r]+\\.mkv)").Groups[1].Value;
src = ProxyLink.Decrypt(hash.AsSpan(), null)?.uri;
if (string.IsNullOrWhiteSpace(src))
return BadRequest(new { error = "src decrypt" });
}
var defaults = ModInit.conf;
var (job, error) = await _service.Start(new TranscodingStartRequest()
{
src = src,
live = live,
subtitles = subtitles,
audio = new TranscodingAudioOptions() { index = a },
hls = new TranscodingHlsOptions() { seek = s }
});
if (job == null)
return BadRequest(new { error });
string uri = $"{CoreInit.Host(HttpContext)}/transcoding/{job.StreamId}/{(live ? "live" : "main")}.m3u8";
return Redirect(AccsDbInvk.Args(uri, HttpContext));
}
[HttpPost("start")]
public async Task<IActionResult> Start([FromBody] TranscodingStartRequest request)
{
if (request == null)
return BadRequest(new { error = "Request body is required" });
var (job, error) = await _service.Start(request);
if (job == null)
return BadRequest(new { error });
return Ok(new
{
job.StreamId,
playlistUrl = AccsDbInvk.Args($"{CoreInit.Host(HttpContext)}/transcoding/{job.StreamId}/{(job.Context.live ? "live" : "main")}.m3u8", HttpContext),
subtitlesUrl = job.Context.subtitles ? AccsDbInvk.Args($"{CoreInit.Host(HttpContext)}/transcoding/{job.StreamId}/subtitles", HttpContext) : null,
hls_timeout_seconds = 60
});
}
#endregion
#region Seek
[HttpGet("{streamId}/seek/{ss}")]
public async Task<IActionResult> Seek(string streamId, int ss)
{
if (!_service.TryResolveJob(streamId, out var job))
return NotFound();
if (!job.Context.live)
return BadRequest(new { error = "Context not live" });
if (ss < 0)
return BadRequest(new { error = "ss must be greater or equal 0" });
var (success, error) = await _service.SeekAsync(streamId, ss);
if (!success)
return BadRequest(new { error });
return Ok();
}
#endregion
#region Live
[HttpGet("{streamId}/live.m3u8")]
public async Task<IActionResult> Live(string streamId)
{
if (!_service.TryResolveJob(streamId, out var job))
return NotFound();
if (!job.Context.live)
return BadRequest(new { error = "Context not live" });
_service.Touch(job);
string path = Path.Combine(job.Context.OutputDirectory, "index.m3u8");
var fileExistsTimeout = TimeSpan.FromSeconds(60);
var sw = System.Diagnostics.Stopwatch.StartNew();
bool fileExists = System.IO.File.Exists(path);
while (!fileExists && sw.Elapsed < fileExistsTimeout)
{
await Task.Delay(250);
fileExists = System.IO.File.Exists(path);
if (fileExists)
break;
}
if (!fileExists)
return NotFound();
string m3u8 = null;
sw.Restart();
while (sw.Elapsed < fileExistsTimeout)
{
try
{
m3u8 = System.IO.File.ReadAllText(path);
}
catch
{
m3u8 = null;
}
if (!string.IsNullOrEmpty(m3u8) && Regex.IsMatch(m3u8, "seg_[0-9]+\\.(m4s|ts)"))
break;
await Task.Delay(250);
}
if (string.IsNullOrEmpty(m3u8))
return NotFound();
m3u8 = Regex.Replace(m3u8, "#EXT-X-MAP:URI=[^\n\r]+", $"#EXT-X-MAP:URI=\"{AccsDbInvk.Args("init.mp4", HttpContext)}\"");
m3u8 = Regex.Replace(m3u8, "(seg_[0-9]+\\.(m4s|ts))", r =>
{
string file = r.Groups[1].Value;
return AccsDbInvk.Args(file, HttpContext);
});
return Content(m3u8, "application/vnd.apple.mpegurl");
}
#endregion
#region Playlist
[HttpGet("{streamId}/main.m3u8")]
public async Task<IActionResult> Playlist(string streamId)
{
if (!_service.TryResolveJob(streamId, out var job))
return NotFound();
if (job.Context.live)
return BadRequest(new { error = "Context not playlist" });
if (!int.TryParse(job.Context.ffprobe["format"].Value<string>("duration").Split('.')[0].Split(',')[0], out int duration) || duration == 0)
return BadRequest(new { error = "duration" });
_service.Touch(job);
var fileExistsTimeout = TimeSpan.FromSeconds(60);
var sw = System.Diagnostics.Stopwatch.StartNew();
while (sw.Elapsed < fileExistsTimeout)
{
if (Directory.GetFiles(job.Context.OutputDirectory).Length > 2)
break;
await Task.Delay(250);
}
int segDur = job.Context.HlsOptions.segDur;
var builder = new StringBuilder();
builder.AppendLine("#EXTM3U");
builder.AppendLine("#EXT-X-PLAYLIST-TYPE:VOD");
builder.AppendLine($"#EXT-X-VERSION:{(job.Context.HlsOptions.fmp4 ? 7 : 3)}");
builder.AppendLine($"#EXT-X-TARGETDURATION:{segDur}");
builder.AppendLine("#EXT-X-MEDIA-SEQUENCE:0");
if (job.Context.HlsOptions.fmp4)
builder.AppendLine($"#EXT-X-MAP:URI=\"{AccsDbInvk.Args("init.mp4", HttpContext)}\"");
for (int i = 0; i < (duration / segDur); i++)
{
builder.AppendLine($"#EXTINF:{segDur}.0,");
builder.AppendLine(AccsDbInvk.Args($"seg_{i:d5}.{(job.Context.HlsOptions.fmp4 ? "m4s" : "ts")}", HttpContext));
}
builder.AppendLine("#EXT-X-ENDLIST");
return Content(builder.ToString(), "application/vnd.apple.mpegurl");
}
#endregion
#region Segment
[HttpGet("{streamId}/{file}")]
public async Task<IActionResult> Segment(string streamId, string file)
{
if (!_service.TryResolveJob(streamId, out var job))
return NotFound();
_service.Touch(job);
int? segmentIndex = null;
if (!job.Context.live && file != null)
{
var matchSegment = Regex.Match(file, @"seg_(\d+)\.(m4s|ts)$", RegexOptions.IgnoreCase);
if (matchSegment.Success && int.TryParse(matchSegment.Groups[1].Value, out var idx))
segmentIndex = idx;
}
var fileExistsTimeout = TimeSpan.FromSeconds(60);
var sw = System.Diagnostics.Stopwatch.StartNew();
string resolved = _service.GetFilePath(job, file);
if (job.Context.live == false && resolved == null && !file.Contains(".vtt"))
{
#region SeekAsync
if (segmentIndex.HasValue)
{
int segDur = job.Context.HlsOptions.segDur;
int ss = segmentIndex.Value * segDur;
if (job.Context.HlsOptions.seek == 0 && 30 > ss)
{
// первые 30 секунд без seek-а - не трогаем
}
else if (job.Context.HlsOptions.seek == ss)
{
// ffmpeg на текущем сегменте
}
else
{
// работает дальше чем текущий сегмент
bool goSeek = job.Context.HlsOptions.seek > ss;
string extension = Path.GetExtension(file);
int segmentsPerMinute = (int)Math.Ceiling(30.0 / segDur);
int startIndex = Math.Max(0, segmentIndex.Value - segmentsPerMinute);
if (goSeek == false)
{
goSeek = true;
for (int i = startIndex; i < segmentIndex.Value; i++)
{
string candidate = $"seg_{i:d5}{extension}";
if (_service.GetFilePath(job, candidate) != null)
{
// есть сегменты в пределах последних 30 секунд
goSeek = false;
break;
}
}
}
if (goSeek)
await _service.SeekAsync(streamId, ss, segmentIndex.Value);
}
}
#endregion
do
{
try
{
await Task.Delay(200);
resolved = _service.GetFilePath(job, file);
if (resolved != null)
break;
}
catch (System.Exception ex)
{
Log.Error(ex, "CatchId={CatchId}", "id_om7pdvok");
}
}
while (sw.Elapsed < fileExistsTimeout);
}
if (resolved == null)
return NotFound();
#region ждем появления следующего сегмента
while (segmentIndex.HasValue && sw.Elapsed < fileExistsTimeout)
{
try
{
string seg = Path.Combine(job.OutputDirectory, $"seg_{segmentIndex.Value + 1:d5}");
if (System.IO.File.Exists($"{seg}.{(job.Context.HlsOptions.fmp4 ? "m4s" : "ts")}"))
break;
}
catch (System.Exception ex)
{
Log.Error(ex, "CatchId={CatchId}", "id_4pxipm5i");
}
await Task.Delay(200);
}
#endregion
#region FileStream
FileStream fs = null;
do
{
try
{
fs = file.Contains(".vtt")
? new FileStream(resolved, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)
: System.IO.File.OpenRead(resolved);
break;
}
catch (IOException)
{
await Task.Delay(200);
}
catch (UnauthorizedAccessException)
{
await Task.Delay(200);
}
}
while (sw.Elapsed < fileExistsTimeout);
if (fs == null)
return NotFound();
#endregion
if (!provider.TryGetContentType(resolved, out var contentType))
contentType = "application/octet-stream";
if (segmentIndex.HasValue)
_service.ReportSegmentAccess(job, segmentIndex.Value);
return File(fs, contentType, enableRangeProcessing: true);
}
#endregion
#region Subtitles
[HttpGet("{streamId}/subtitles")]
public IActionResult Subtitles(string streamId)
{
if (!_service.TryResolveJob(streamId, out var job))
return NotFound();
_service.Touch(job);
var subsTpl = new SubtitleTpl();
if (job.Context.subtitles && job.Context.ffprobe.ContainsKey("streams"))
{
foreach (var s in job.Context.ffprobe["streams"])
{
if (s.Value<string>("codec_type") != "subtitle")
continue;
if (s.Value<string>("codec_name") is "subrip" or "webvtt" or "ass" or "ssa" or "mov_text" or "ttml" or "sami")
{
int subIndex = s.Value<int>("index");
if (subIndex == 0)
continue;
string uri = $"{CoreInit.Host(HttpContext)}/transcoding/{streamId}/{AccsDbInvk.Args($"subs_{subIndex}.vtt", HttpContext)}";
string name = s["tags"].Value<string>("title")
?? s["language"].Value<string>("title")
?? $"sub_{subIndex}";
subsTpl.Append(name, uri);
}
}
}
return Ok(subsTpl.ToObject());
}
#endregion
#region Heartbeat
[HttpGet("{streamId}/heartbeat")]
public IActionResult Heartbeat(string streamId)
{
if (!_service.TryResolveJob(streamId, out var job))
return NotFound();
_service.Touch(job);
return Ok();
}
#endregion
#region StopAsync
[HttpGet("{streamId}/stop")]
public async Task<IActionResult> StopAsync(string streamId)
{
var stopped = await _service.StopAsync(streamId);
return stopped ? Ok() : NotFound();
}
#endregion
#region Status
[HttpGet("{streamId}/status")]
public IActionResult Status(string streamId)
{
if (!_service.TryResolveJob(streamId, out var job))
return NotFound();
var now = DateTime.UtcNow;
var state = job.Process.HasExited ? TranscodingJobState.Stopped : TranscodingJobState.Running;
var uptime = now - job.StartedUtc;
int? exitCode = null;
try
{
if (job.Process.HasExited)
exitCode = job.Process.ExitCode;
}
catch (System.Exception ex)
{
Log.Error(ex, "CatchId={CatchId}", "id_iuxl2cac");
}
var snapshotLog = job.SnapshotLog();
ulong time_ms = 0;
foreach (string line in snapshotLog.Reverse())
{
if (!line.Contains("out_time_ms="))
continue;
if (!ulong.TryParse(Regex.Match(line, "out_time_ms=([0-9]+)").Groups[1].Value, out time_ms))
continue;
break;
}
return Content(JsonConvertPool.SerializeObject(new
{
job.StreamId,
state = state.ToString(),
job.OutputDirectory,
ffmpeg = string.Join(" ", job.Process.StartInfo.ArgumentList),
startedUtc = job.StartedUtc,
lastAccessUtc = job.LastAccessUtc,
uptime = uptime.TotalSeconds,
positionSec = (ulong)(job.Context.HlsOptions.seek + (time_ms > 0 ? (time_ms / 1000000.0) : 0)),
exitCode,
job.Context.ffprobe,
log = snapshotLog
}), "application/json; charset=utf-8");
}
#endregion
#region DOC
[HttpGet("")]
public IActionResult DOC()
{
var endpoints = new object[]
{
new {
path = "/transcoding/start.m3u8",
method = "GET",
query = new object[] {
new { name = "src", type = "string", required = true, description = "Source URL or local path to media" },
new { name = "a", type = "int", required = false, description = "Audio index (optional)" },
new { name = "s", type = "int", required = false, description = "Seek position in seconds (optional)" },
new { name = "subtitles", type = "bool", required = false, description = "subtitles on/off" },
new { name = "live", type = "bool", required = false, description = "Context live/playlist" }
},
description = "Start transcoding with query parameters and redirect to the generated HLS playlist"
},
new {
path = "/transcoding/start",
method = "POST",
contentType = "application/json",
body = new {
src = "https://example.com/media.mp4",
videoFormat = "",
live = false,
subtitles = false,
headers = new { referer = "https://example.com", userAgent = "HlsProxy/1.0" },
audio = ModInit.conf.audioOptions,
hls = ModInit.conf.hlsOptions
},
description = "Start transcoding by POSTing a JSON body. Returns StreamId and playlist URL"
},
new {
path = "/transcoding/{streamId}/live.m3u8",
method = "GET",
route = new { name = "streamId", type = "string", required = true },
description = "Returns the HLS live for the given transcoding job"
},
new {
path = "/transcoding/{streamId}/main.m3u8",
method = "GET",
route = new { name = "streamId", type = "string", required = true },
description = "Returns the HLS master/variant playlist for the given transcoding job"
},
new {
path = "/transcoding/{streamId}/{file}",
method = "GET",
route = new object[] {
new { name = "streamId", type = "string", required = true },
new { name = "file", type = "string", required = true, description = "Requested segment or asset (e.g. init.mp4, seg_1.m4s, index.m3u8)" }
},
description = "Serves individual segment files, init files and playlists produced by the transcoder. Supports range requests."
},
new {
path = "/transcoding/{streamId}/seek/{ss}",
method = "GET",
route = new object[] {
new { name = "streamId", type = "string", required = true },
new { name = "ss", type = "int", required = true, description = "Seek position in seconds (>= 0)" }
},
description = "Request the transcoder to seek to the specified position (async). Returns 200 on success."
},
new {
path = "/transcoding/{streamId}/heartbeat",
method = "GET",
route = new { name = "streamId", type = "string", required = true },
description = "Touch the job to keep it alive. Returns 200 if job exists."
},
new {
path = "/transcoding/{streamId}/stop",
method = "GET",
route = new { name = "streamId", type = "string", required = true },
description = "Stop the transcoding job. Returns 200 if stopped or 404 if job not found."
},
new {
path = "/transcoding/{streamId}/status",
method = "GET",
query = new { name = "log", type = "bool", required = false, description = "Include job log snapshot when true" },
route = new { name = "streamId", type = "string", required = true },
description = "Return current job status, uptime, position and optional log snapshot."
}
};
return Ok(JsonConvert.SerializeObject(endpoints, Formatting.Indented));
}
#endregion
}
-54
View File
@@ -1,54 +0,0 @@
using Shared;
using Shared.Services;
using Shared.Models.AppConf;
using Shared.Models.Module;
using Shared.Models.Module.Interfaces;
using System.Collections.Generic;
using System.IO;
using Shared.Models.Events;
namespace Transcoding;
public class ModInit : IModuleLoaded
{
public static string modpath;
public static ModuleConf conf;
public void Loaded(InitspaceModel initspace)
{
modpath = initspace.path;
updateConf();
EventListener.UpdateInitFile += updateConf;
foreach (var m in conf.limit_map)
CoreInit.conf.WAF.limit_map.Insert(0, m);
Directory.CreateDirectory("database/tracks");
Directory.CreateDirectory(conf.tempRoot);
TranscodingService.Instance.Configure(conf);
}
public void Dispose()
{
EventListener.UpdateInitFile -= updateConf;
TranscodingService.Instance.StopAll();
}
void updateConf()
{
conf = ModuleInvoke.Init("transcoding", new ModuleConf()
{
tempRoot = Path.Combine("cache", "transcoding"),
idleTimeoutSec = 60 * 5,
idleTimeoutSec_live = 120,
maxConcurrentJobs = 5,
limit_map = new List<WafLimitRootMap>()
{
new("^/transcoding/", new WafLimitMap { limit = 50, second = 1 })
}
});
}
}
-153
View File
@@ -1,153 +0,0 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Shared.Models.Module;
using System;
using System.Collections.Generic;
namespace Transcoding;
public class ModuleConf : ModuleBaseConf
{
public string ffmpeg { get; set; }
public string tsuri { get; set; }
public string tempRoot { get; set; }
public int idleTimeoutSec { get; set; }
public int idleTimeoutSec_live { get; set; }
public bool defaultSubtitles { get; set; }
public int maxConcurrentJobs { get; set; }
public string[] allowHosts { get; set; } = Array.Empty<string>();
public TranscodingHlsOptions hlsOptions { get; set; } = new();
public TranscodingAudioOptions audioOptions { get; set; } = new();
public TranscodingSubtitleOptions subtitleOptions { get; set; } = new();
public TranscodingPlaylistOptions playlistOptions { get; set; } = new();
public TranscodingConvertOptions convertOptions { get; set; } = new();
[JsonProperty("comand", ObjectCreationHandling = ObjectCreationHandling.Replace, NullValueHandling = NullValueHandling.Ignore)]
public Dictionary<string, string[]> comand { get; set; } = new Dictionary<string, string[]>()
{
["demuxer"] = ["-threads 0", "-fflags +genpts"],
["input"] = ["-avoid_negative_ts disabled"],
["output"] = [
"-map 0:v:0", "-map 0:a:{audio_index}",
"-dn", "-sn",
"-map_metadata -1", "-map_chapters -1", "-max_muxing_queue_size 2048"
],
};
}
public class TranscodingHlsOptions
{
[JsonIgnore]
public int seek { get; set; }
/// <summary>
/// hls_time
/// </summary>
public int segDur { get; set; } = 6;
/// <summary>
/// hls_list_size
/// </summary>
public int winSize { get; set; } = 10;
/// <summary>
/// hls_segment_type fmp4 / mpegts
/// </summary>
public bool fmp4 { get; set; } = true;
[JsonProperty("comand", ObjectCreationHandling = ObjectCreationHandling.Replace, NullValueHandling = NullValueHandling.Ignore)]
public Dictionary<string, string[]> comand { get; set; } = new Dictionary<string, string[]>()
{
["output"] = ["-max_delay 5000000"],
["segment_mpegts"] = ["-bsf:v h264_mp4toannexb"],
};
}
public class TranscodingAudioOptions
{
[JsonIgnore]
public int index { get; set; }
public int bitrateKbps { get; set; } = 192;
public bool stereo { get; set; } = true;
[JsonProperty("codec_copy", ObjectCreationHandling = ObjectCreationHandling.Replace, NullValueHandling = NullValueHandling.Ignore)]
public string[] codec_copy { get; set; } = Array.Empty<string>();
[JsonProperty("comand_transcode", ObjectCreationHandling = ObjectCreationHandling.Replace, NullValueHandling = NullValueHandling.Ignore)]
public Dictionary<string, string[]> comand_transcode { get; set; } = new Dictionary<string, string[]>()
{
["default"] = ["-c:a aac", "-ac {stereo}", "-b:a {bitrateKbps}", "-profile:a aac_low"]
};
}
public class TranscodingPlaylistOptions
{
/// <summary>
/// sped
/// </summary>
public double readrate { get; set; } = 1.6;
/// <summary>
/// 10 MB
/// </summary>
public int burst { get; set; } = 10485760;
public bool delete_segments { get; set; } = true;
}
public class TranscodingConvertOptions
{
public bool transcodeVideo { get; set; }
[JsonProperty("codec", ObjectCreationHandling = ObjectCreationHandling.Replace, NullValueHandling = NullValueHandling.Ignore)]
public string[] codec { get; set; } = { "mpeg4", "msmpeg4v3", "flv1", "av1" };
[JsonProperty("comand", ObjectCreationHandling = ObjectCreationHandling.Replace, NullValueHandling = NullValueHandling.Ignore)]
public Dictionary<string, string[]> comand { get; set; } = new Dictionary<string, string[]>()
{
["default"] = ["-c:v libx264", "-preset veryfast", "-tune zerolatency", "-pix_fmt yuv420p"],
["h264_yuv420p10le"] = [
"-vf", "scale=in_color_matrix=bt2020nc:out_color_matrix=bt709:in_range=pc:out_range=tv,format=yuv420p",
"-c:v libx264", "-preset veryfast", "-tune zerolatency", "-pix_fmt yuv420p",
"-x264-params", "colorprim=bt709:transfer=bt709:colormatrix=bt709",
"-color_primaries bt709", "-color_trc bt709", "-colorspace bt709", "-color_range tv"
]
};
}
public class TranscodingSubtitleOptions
{
[JsonProperty("codec", ObjectCreationHandling = ObjectCreationHandling.Replace, NullValueHandling = NullValueHandling.Ignore)]
public string[] codec { get; set; } = { "subrip", "webvtt", "ass", "ssa", "mov_text", "ttml", "sami" };
[JsonProperty("comand", ObjectCreationHandling = ObjectCreationHandling.Replace, NullValueHandling = NullValueHandling.Ignore)]
public string[] comand { get; set; } = ["-map 0:{subIndex}", "-an -vn", "-c:s webvtt", "-flush_packets 1", "-max_interleave_delta 0", "-muxpreload 0", "-muxdelay 0", "-f webvtt", "subs_{subIndex}.vtt"];
}
public record TranscodingStartContext(
Uri Source,
string UserAgent,
string Referer,
TranscodingHlsOptions HlsOptions,
TranscodingAudioOptions Audio,
bool live,
bool subtitles,
string OutputDirectory,
int? startNumber,
JObject ffprobe
);
-35
View File
@@ -1,35 +0,0 @@
<b>Актуальная реализация</b>:<br>
https://github.com/lampac-nextgen/lampac/blob/main/Modules/GStreamer/README.md
# Transcoding (legacy)
Серверное **перекодирование потоков** (FFmpeg и связанная логика): очередь задач, каталог временных файлов, ограничение параллельных заданий и HTTP API под **`/transcoding`**.
## Назначение
- Путь модуля сохраняется в **`Transcoding.modpath`**.
- **`TranscodingService`** конфигурируется при загрузке модуля и останавливает все задачи при выгрузке (`StopAll`).
- Создаются каталоги **`database/tracks`** (общий паттерн с Tracks) и **`tempRoot`** для промежуточных файлов (по умолчанию `cache/transcoding`).
## Конфигурация
Секция в `init.conf`: **`transcoding`** (имя ключа в нижнем регистре в `ModuleInvoke.Init`).
Параметры по умолчанию в **`ModInit`**:
| Параметр | Значение в коде |
|----------|-----------------|
| **`tempRoot`** | **`cache/transcoding`** |
| **`idleTimeoutSec`** | **300** с |
| **`idleTimeoutSec_live`** | **120** с |
| **`maxConcurrentJobs`** | **5** |
| **`limit_map`** | **`^/transcoding/`** — **50** запросов / **1** с |
## HTTP
Базовый префикс контроллера: **`transcoding`** — дочерние маршруты см. **`Controller.cs`**.
## Зависимости
- Доступность **ffmpeg** на машине (или иной исполнитель, зашитый в `TranscodingService`).
-130
View File
@@ -1,130 +0,0 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
namespace Transcoding;
internal sealed class TranscodingJob : IDisposable
{
static readonly Serilog.ILogger Log = Serilog.Log.ForContext<TranscodingJob>();
private const int MaxLogLines = 200;
private readonly LinkedList<string> _log = new();
private readonly object _logSync = new();
private readonly CancellationTokenSource _cts = new();
private int _lastSegmentIndex = -1;
public TranscodingJob(string id, string streamId, string outputDirectory, Process process, TranscodingStartContext context)
{
Id = id;
StreamId = streamId;
OutputDirectory = outputDirectory;
Process = process;
Context = context;
StartedUtc = DateTime.UtcNow;
LastAccessUtc = StartedUtc;
}
public string Id { get; }
public string StreamId { get; }
public string OutputDirectory { get; }
public Process Process { get; }
public TranscodingStartContext Context { get; }
public DateTime StartedUtc { get; }
public DateTime LastAccessUtc { get; private set; }
public int? ExitCode { get; private set; }
public bool HasExited => Process.HasExited;
public CancellationToken CancellationToken => _cts.Token;
public void UpdateLastAccess() => LastAccessUtc = DateTime.UtcNow;
public int LastSegmentIndex => Volatile.Read(ref _lastSegmentIndex);
public void UpdateLastSegmentIndex(int segmentIndex)
{
if (segmentIndex < 0)
return;
while (true)
{
var current = Volatile.Read(ref _lastSegmentIndex);
if (segmentIndex <= current)
return;
if (Interlocked.CompareExchange(ref _lastSegmentIndex, segmentIndex, current) == current)
return;
}
}
public void AppendLog(string line)
{
if (string.IsNullOrWhiteSpace(line))
return;
lock (_logSync)
{
foreach (var part in line.Split('\n'))
{
string trimmed = part.TrimEnd('\r');
if (string.IsNullOrWhiteSpace(trimmed))
continue;
if (trimmed.Length > 2000)
trimmed = trimmed[..2000];
_log.AddLast(trimmed);
if (_log.Count > MaxLogLines)
_log.RemoveFirst();
}
}
}
public string[] SnapshotLog()
{
lock (_logSync)
return _log.ToArray();
}
public void SignalExit()
{
if (Process.HasExited)
ExitCode = Process.ExitCode;
}
public void StopBackground()
=> _cts.Cancel();
public void Dispose()
{
try
{
StopBackground();
}
catch (System.Exception ex)
{
Log.Error(ex, "CatchId={CatchId}", "id_p5i63hde");
}
try
{
Process.Dispose();
}
catch (System.Exception ex)
{
Log.Error(ex, "CatchId={CatchId}", "id_6fb2ahbb");
}
}
}
@@ -1,25 +0,0 @@
using System.Collections.Generic;
namespace Transcoding;
public sealed class TranscodingStartRequest
{
public string src { get; set; } = string.Empty;
public TranscodingAudioOptions audio { get; set; }
public TranscodingHlsOptions hls { get; set; }
public Dictionary<string, string> headers { get; set; }
public bool live { get; set; }
public bool? subtitles { get; set; }
}
public enum TranscodingJobState
{
Running,
Idle,
Stopped
}
File diff suppressed because it is too large Load Diff
-19
View File
@@ -1,19 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>library</OutputType>
<IsPackable>true</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Shared\Shared.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="plugin.js">
<CopyToPublishDirectory>Always</CopyToPublishDirectory>
</None>
</ItemGroup>
</Project>
-9
View File
@@ -1,9 +0,0 @@
{
"enable": true,
"tree": [
"Services",
"Controller.cs",
"ModInit.cs",
"ModuleConf.cs"
]
}
-356
View File
@@ -1,356 +0,0 @@
(function () {
var API_BASE = '{localhost}';
var activeJob = null;
var heartbeatTimer = null;
var waitHandle = null;
function account(url) {
url = url + '';
if (url.indexOf('account_email=') == -1) {
var email = Lampa.Storage.get('account_email');
if (email) url = Lampa.Utils.addUrlComponent(url, 'account_email=' + encodeURIComponent(email));
}
if (url.indexOf('uid=') == -1) {
var uid = Lampa.Storage.get('lampac_unic_id', '');
if (uid) url = Lampa.Utils.addUrlComponent(url, 'uid=' + encodeURIComponent(uid));
}
if (url.indexOf('token=') == -1) {
var token = '{token}';
if (token != '') url = Lampa.Utils.addUrlComponent(url, 'token={token}');
}
return url;
}
function showWait(message) {
Lampa.Loading.start(function(){}, message);
}
function hideWait() {
Lampa.Loading.stop();
}
function log() {
try {
var args = Array.prototype.slice.call(arguments);
args.unshift('Transcoder');
console.log.apply(console, args);
}
catch (err) {
/* noop */
}
}
function notify(message) {
if (window.Lampa && Lampa.Noty && Lampa.Noty.show) {
Lampa.Noty.show(message);
}
else {
log(message);
}
}
function resolveMediaUrl(data) {
if (data && data.url) return data.url;
return '';
}
function isMkvSource(data) {
var url = resolveMediaUrl(data);
if (!url) return false;
return /\.(mkv|avi|flv)($|\?|#)/i.test(url) || /\/lite\/pidtor\//i.test(url);
}
function requestFfprobe(mediaUrl, onSuccess, onError) {
if (!mediaUrl) {
if (onError) onError(new Error('Empty media url'));
return;
}
var net = new Lampa.Reguest();
var target = account(API_BASE + '/ffprobe?media=' + encodeURIComponent(mediaUrl));
log('request ffprobe', target);
net.native(target, function (response) {
try {
var json = typeof response === 'string' ? JSON.parse(response) : response;
try {
var streams = json && Array.isArray(json.streams) ? json.streams : [];
var v = streams.find(function(s){ return s.codec_type === 'video'; });
var rawCodec = (v && (v.codec_name || (v.tags && v.tags.codec_name))) || '';
} catch(e) { /* noop */ }
if (onSuccess) onSuccess(json || {});
} catch(e) {
if (onError) onError(e);
}
}, function (error) {
hideWait();
if (onError) onError(error || new Error('ffprobe request failed'));
}, null, {
dataType: 'text',
timeout: 1000 * 40
});
}
function formatAudioItem(track, index) {
var tags = track && track.tags ? track.tags : {};
var title = tags.title || tags.handler_name || ('Audio #' + (index + 1));
var lang = (tags.language || '').toUpperCase();
var codec = (track.codec_name || '').toUpperCase();
var channels = (track.channel_layout || '').replace('(side)', '').replace('stereo', '2.0');
var rate = track.bit_rate ? Math.round(track.bit_rate / 1000) + ' kbps' : '';
var subtitleParts = [];
if (lang) subtitleParts.push(lang);
if (codec) subtitleParts.push(codec);
if (channels) subtitleParts.push(channels);
if (rate) subtitleParts.push(rate);
return {
title: title,
subtitle: subtitleParts.join(' • '),
track: track
};
}
function selectAutoAudioTrack(audioTracks) {
for (var i = 0; i < audioTracks.length; i++) {
var disposition = audioTracks[i] && audioTracks[i].disposition;
if (disposition && (disposition.default === 1 || disposition.default === true)) {
return audioTracks[i];
}
}
var language = String(Lampa.Storage.get('language', 'ru') || 'ru').toLowerCase().split('-')[0];
var aliases = {
ru: ['ru', 'rus'],
en: ['en', 'eng'],
uk: ['uk', 'ukr'],
be: ['be', 'bel'],
bg: ['bg', 'bul'],
he: ['he', 'heb'],
cs: ['cs', 'ces', 'cze'],
ro: ['ro', 'ron', 'rum'],
de: ['de', 'deu', 'ger'],
fr: ['fr', 'fra', 'fre'],
es: ['es', 'spa'],
it: ['it', 'ita'],
pt: ['pt', 'por'],
pl: ['pl', 'pol'],
tr: ['tr', 'tur'],
ja: ['ja', 'jpn'],
ko: ['ko', 'kor'],
zh: ['zh', 'zho', 'chi']
};
var preferred = aliases[language] || (language ? [language] : []);
for (var j = 0; j < audioTracks.length; j++) {
var tags = audioTracks[j] && audioTracks[j].tags ? audioTracks[j].tags : {};
var trackLanguage = String(tags.language || '').toLowerCase();
if (preferred.indexOf(trackLanguage) !== -1) {
return audioTracks[j];
}
}
return audioTracks[0];
}
function showAudioSelector(data, audioTracks) {
if (!audioTracks.length) {
notify('Не удалось найти аудиодорожки для MKV файла');
return;
}
var manualSelection = Lampa.Storage.get('transcoding_audio_prompt', false);
if (!(manualSelection === true || manualSelection === 'true')) {
startTranscoding(data, selectAutoAudioTrack(audioTracks));
return;
}
var items = audioTracks.map(function (track, index) {
return formatAudioItem(track, index);
});
var last_controller = Lampa.Controller.enabled().name
Lampa.Select.show({
title: 'Выберите аудиодорожку',
items: items,
onSelect: function (item) {
Lampa.Select.close();
if (!item || !item.track) {
notify('Выбрана пустая дорожка');
return;
}
startTranscoding(data, item.track);
},
onBack: function () {
Lampa.Controller.toggle(last_controller)
}
});
}
function startTranscoding(data, track) {
stopHeartbeat();
ensureJobStopped(true);
var payload = {
src: resolveMediaUrl(data),
audio: { index: track.index -1 },
live: false
};
/*if (data && data.subtitles) {
payload.subtitles = !!data.subtitles;
}*/
var net = new Lampa.Reguest();
net.native(account(API_BASE + '/transcoding/start'), function (response) {
var json = typeof response === 'string' ? JSON.parse(response) : response;
if (!json || !json.streamId || !json.playlistUrl) {
notify('Некорректный ответ от сервера перекодирования');
log('invalid start response', response);
return;
}
activeJob = {
streamId: json.streamId,
playlistUrl: json.playlistUrl
};
var playback = data || {};
playback.transcoding = true;
playback.ffprobe = null;
playback.url = json.playlistUrl;
playback.subtitles_call = json.subtitlesUrl;
playback.hls_manifest_timeout = json.hls_timeout_seconds * 1000;
Lampa.Player.play(playback);
}, function (error) {
hideWait();
notify('Не удалось запустить перекодирование MKV');
log('start error', error);
}, JSON.stringify(payload), {
dataType: 'json',
type: 'POST',
timeout: 1000 * 20,
headers: {
'Content-Type': 'application/json'
}
});
}
function sendHeartbeat() {
if (!activeJob) return;
var net = new Lampa.Reguest();
net.native(account(API_BASE + '/transcoding/' + activeJob.streamId + '/heartbeat'), function () {
log('heartbeat sent');
}, function (error) {
hideWait();
log('heartbeat error', error);
}, null, {
dataType: 'text',
timeout: 1000 * 10
});
}
function stopHeartbeat() {
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
heartbeatTimer = null;
}
}
function ensureJobStopped(sendRemote) {
if (!activeJob) return;
var job = activeJob;
activeJob = null;
stopHeartbeat();
if (sendRemote) {
var net = new Lampa.Reguest();
net.native(account(API_BASE + '/transcoding/' + job.streamId + '/stop'), function () {
log('stop sent');
}, function (error) {
hideWait();
log('stop error', error);
}, null, {
dataType: 'text',
timeout: 1000 * 10
});
}
}
function registerSettings() {
if (window.lampac_transcoding_audio_setting || !Lampa.SettingsApi || !Lampa.SettingsApi.addParam) return;
window.lampac_transcoding_audio_setting = true;
Lampa.SettingsApi.addParam({
component: 'player',
param: {
name: 'transcoding_audio_prompt',
type: 'trigger',
values: '',
default: false
},
field: {
name: 'Спрашивать аудиодорожку при транскодировании',
description: 'Включите для ручного выбора дорожки перед запуском MKV'
}
});
}
function handlePlayerStart(e) {
if (!isMkvSource(e.data) || e.data.transcoding || /\/transcoding\//i.test(e.data.url)) return;
e.abort();
e.data.url = e.data.url.replace(/&(preload|stat|m3u)/g, '&play');
log('intercept mkv playback', e.data.url);
showWait('Получение списка аудио дорожек...');
requestFfprobe(resolveMediaUrl(e.data), function (info) {
hideWait();
var streams = info && Array.isArray(info.streams) ? info.streams : [];
var audioTracks = streams.filter(function (track) {
return track.codec_type === 'audio';
});
showAudioSelector(e.data, audioTracks);
}, function (error) {
hideWait();
notify('Ошибка анализа MKV файла');
log('ffprobe error', error);
});
}
function handlePlayerDestroy() {
ensureJobStopped(true);
}
function handleVideoPause() {
if (!activeJob) return;
stopHeartbeat();
heartbeatTimer = setInterval(sendHeartbeat, 1000 * 10);
}
function handleVideoPlay() {
if (!activeJob) return;
stopHeartbeat();
}
if (!window.lampac_transcoding_plugin) {
window.lampac_transcoding_plugin = true;
registerSettings();
Lampa.Player.listener.follow('create', handlePlayerStart);
Lampa.Player.listener.follow('destroy', handlePlayerDestroy);
Lampa.PlayerVideo.listener.follow('pause', handleVideoPause);
Lampa.PlayerVideo.listener.follow('play', handleVideoPlay);
}
})();
-72
View File
@@ -1,72 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
using Shared;
using Shared.Models.AppConf;
using Shared.Models.Events;
using Shared.Models.Module;
using Shared.Models.Module.Interfaces;
using System.Collections.Generic;
using Shared.Services;
namespace WatchTogether
{
public class WatchTogetherConf
{
public bool enable { get; set; } = true;
public bool allow_anonymous { get; set; } = true;
public int relay_max_channels { get; set; } = 500;
public int relay_max_clients_per_channel { get; set; } = 32;
public int gc_empty_timeout_minutes { get; set; } = 30;
public int gc_max_lifetime_hours { get; set; } = 12;
public List<WafLimitRootMap> limit_map { get; set; } = new()
{
new("^/watchtogether/", new WafLimitMap { limit = 30, second = 1 }),
new("^/wt/c/", new WafLimitMap { limit = 10, second = 1 })
};
}
/// <summary>
/// WatchTogether: a lampac module that acts as both the lparty relay and a
/// shadow host keeping rooms alive when their creator disconnects.
/// </summary>
public class ModInit : IModuleLoaded, IModuleConfigure
{
public static string modpath;
public static WatchTogetherConf conf;
public void Configure(ConfigureModel app)
{
app.services.AddTransient<Microsoft.AspNetCore.Hosting.IStartupFilter, RelayStartupFilter>();
}
public void Loaded(InitspaceModel baseconf)
{
modpath = baseconf.path;
UpdateConf();
EventListener.UpdateInitFile += UpdateConf;
ShadowHost.Start();
}
public void Dispose()
{
EventListener.UpdateInitFile -= UpdateConf;
ShadowHost.Stop();
}
private static bool isWafInjected = false;
private void UpdateConf()
{
conf = ModuleInvoke.Init("WatchTogether", new WatchTogetherConf());
if (!isWafInjected && conf.limit_map != null)
{
foreach (var m in conf.limit_map)
{
CoreInit.conf.WAF.limit_map.Insert(0, m);
}
isWafInjected = true;
}
}
}
}
-44
View File
@@ -1,44 +0,0 @@
# WatchTogether
Модуль синхронизированного просмотра для Lampac. Работает одновременно как **хранитель состояния** и как **тихий lparty-клиент**: комната живёт в канале реле, а сервер незаметно подхватывает роль хоста, если создатель отключился.
Клиентский плагин основан на [lparty.js](https://nrsua.github.io/lampa-nrs/lparty.js) (nrsua, levende) и полностью wire-совместим с его экосистемой — JustPlus Player, lparty-web и стоковый lparty.js подключаются к тем же комнатам без правок.
## Как это работает
1. **Создание комнаты.** Человек создаёт комнату в любом lparty-клиенте — его клиент открывает канал `wss://<lampac>/wt/c/lparty-r-<sha256(КОД|пароль)[0:24]>` и является хостом.
2. **Тень.** Сервер видит адрес подключения и начинает пассивно слушать кадры канала: копирует url, метаданные, позицию, скорость и список участников. В списке зрителей он виден как `Lampac`, но ничего не рассылает.
3. **Подмена.** Как только соединение хоста рвётся, сервер мгновенно шлёт кадр `host` и продолжает timeline с последнего хартбита (лаг ≤ 2с, пауза — точно). Попытки других клиентов перехватить хостство отклоняются повторным `host=сервер`.
4. **Возврат.** Создатель переподключился — сервер отдаёт ему роль и снова уходит в тень.
5. **GC.** Пустые комнаты удаляются через `gc_empty_timeout_minutes`, любые — через `gc_max_lifetime_hours`.
## HTTP (обзор)
| Маршрут | Описание |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `GET /watchtogether.js` | Плагин для клиента Lampa (адаптированный lparty.js). |
| `GET /watch_together/` | Веб-плеер для анонимных гостей (адаптированный lparty-web). Поддерживает `?room=<base64(КОД:пароль)>` и `?relay=`. |
| `WS /wt/c/{channel}` | LParty-совместимый релей: каналы, кадры join/leave/message, эхо с серверным временем. |
## Скорость воспроизведения
Наши клиенты передают скорость поле `sp` в кадрах `state`/`sync`/`act`/`hold`/`go` и применяют её к `playbackRate`. Стоковые клиенты поле молча игнорируют — обратная совместимость не нарушается. Если автор JustPlus добавит поддержку `sp`, синхронизация заработает у него автоматически.
## Конфигурация
Секция в `init.conf`: **`WatchTogether`**.
| Поле | Смысл |
| ------------------------------- | ----------------------------------------------------------------------------------------------------- |
| `enable` | Включение модуля (по умолчанию **true**). |
| `allow_anonymous` | Доступ к плагину `/watchtogether.js` и веб-плееру lparty-web без авторизации (по умолчанию **true**). |
| `relay_max_channels` | Лимит одновременных каналов реле (по умолчанию **500**). |
| `relay_max_clients_per_channel` | Лимит участников одного канала (по умолчанию **32**). |
| `gc_empty_timeout_minutes` | Удаление пустой комнаты через N минут (по умолчанию **30**). |
| `gc_max_lifetime_hours` | Удаление любой комнаты через N часов (по умолчанию **12**). |
## Подключение клиентов
- Плагин Lampa: `/watchtogether.js` — реле уже настроено на собственный сервер lampac.
- JustPlus Player / lparty.js: в настройках клиента указать адрес реле `ws(s)://<адрес-lampac>/wt/c/`.
- Веб-гость: открыть `https://<lampac>/watch_together/?room=<invite-код>` — ссылку формирует плагин.
-456
View File
@@ -1,456 +0,0 @@
using Core.Middlewares;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Net.WebSockets;
using System.Text.Json;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace WatchTogether
{
/// <summary>
/// Maps the lparty-compatible relay endpoint (/wt/c/{channel}) at app startup.
/// Modules cannot map endpoints themselves, so this hooks the pipeline
/// through an IStartupFilter registered in ModInit.Configure. The branch
/// runs before the global pipeline (like /nws) so auth middlewares cannot
/// gate raw websocket clients; RequestInfo is applied in-branch because
/// the WAF reads its output.
/// </summary>
public class RelayStartupFilter : IStartupFilter
{
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
{
return app =>
{
app.Map("/wt/c", relayApp =>
{
relayApp.UseRequestInfo();
relayApp.UseWAF();
relayApp.UseWebSockets();
relayApp.Run(RelayServer.HandleWebSocketAsync);
});
next(app);
};
}
}
public class RelayMember
{
public string Uid { get; set; }
public string Alias { get; set; }
public bool Echo { get; set; }
public WebSocket Socket { get; set; }
public SemaphoreSlim SendLock { get; } = new(1, 1);
/// <summary>LParty peer id (the u field of the member's messages).</summary>
public string Pid { get; set; }
public string Name { get; set; }
public long RateSecond;
public int RateCount;
}
public class RelayChannel
{
public string Name { get; set; }
public readonly object Lock = new();
public readonly Dictionary<string, RelayMember> Members = new();
}
/// <summary>
/// Wire-compatible emulation of the itty.ws channel relay used by lparty
/// clients: stateless broadcast groups with join/leave announcements, a
/// member list and a server date on every frame (the date is what lparty
/// uses for clock sync via message echo).
/// </summary>
public static class RelayServer
{
static long uidSeq = 0;
static readonly ConcurrentDictionary<string, RelayChannel> Channels = new();
public static RelayChannel TryGetChannel(string name) =>
Channels.TryGetValue(name, out var channel) ? channel : null;
/// <summary>Member uids excluding the server's virtual member.</summary>
public static List<string> HumanUids(RelayChannel channel)
{
lock (channel.Lock)
return channel.Members.Keys
.Where(u => !string.Equals(u, ShadowHost.ServerUid, StringComparison.Ordinal))
.ToList();
}
const int MaxMessageBytes = 16 * 1024;
const int MaxMessagesPerSecond = 50;
public static async Task HandleWebSocketAsync(HttpContext context)
{
if (!ModInit.conf.enable)
{
context.Response.StatusCode = 404;
return;
}
if (!context.WebSockets.IsWebSocketRequest)
{
context.Response.StatusCode = 400;
return;
}
// Inside Map("/wt/c") the request path is already without the prefix:
// "/{channel}", so only the leading slash is stripped here.
string path = context.Request.Path.Value ?? "/";
string channelName = path.Length > 1 ? Uri.UnescapeDataString(path[1..]) : string.Empty;
if (string.IsNullOrWhiteSpace(channelName) || channelName.Length > 128)
{
context.Response.StatusCode = 404;
return;
}
bool echo = string.Equals(context.Request.Query["echo"].ToString(), "true", StringComparison.OrdinalIgnoreCase);
string alias = context.Request.Query["as"].ToString() ?? string.Empty;
if (alias.Length > 64) alias = alias[..64];
int maxChannels = Math.Max(1, ModInit.conf.relay_max_channels);
if (!Channels.ContainsKey(channelName) && Channels.Count >= maxChannels)
{
context.Response.StatusCode = 429;
return;
}
var socket = await context.WebSockets.AcceptWebSocketAsync();
var member = new RelayMember
{
Uid = "u" + Interlocked.Increment(ref uidSeq),
Alias = alias,
Echo = echo,
Socket = socket
};
var channel = Channels.GetOrAdd(channelName, _ => new RelayChannel { Name = channelName });
// A server-hosted room keeps its virtual member across channel
// recreation: joiners must see total >= 2 or lparty clients
// declare the room dead on arrival.
if (ShadowHost.ShouldHostChannel(channelName))
EnsureServerMember(channel);
bool full = false;
List<RelayMember> users;
int total;
lock (channel.Lock)
{
int maxClients = Math.Max(2, ModInit.conf.relay_max_clients_per_channel);
if (channel.Members.Count >= maxClients)
{
full = true;
users = new List<RelayMember>();
total = 0;
}
else
{
channel.Members[member.Uid] = member;
users = new List<RelayMember>(channel.Members.Values);
total = users.Count;
}
}
if (full)
{
_ = SendAsync(member, BuildErrorFrame(ServerNowMs(), "channel_full"));
try { await socket.CloseAsync(WebSocketCloseStatus.PolicyViolation, "channel_full", CancellationToken.None); } catch { }
return;
}
long now = ServerNowMs();
_ = SendAsync(member, BuildSelfJoinFrame(member.Uid, now, total, users));
byte[] joinFrame = BuildForeignJoinFrame(member.Uid, member.Alias, now, total);
Broadcast(channel, member.Uid, joinFrame);
ShadowHost.OnMemberJoined(channelName, channel, member);
try
{
await ReceiveLoop(channelName, channel, member);
}
catch { }
finally
{
RemoveMember(channelName, channel, member);
}
}
/// <summary>The server's virtual lparty member inside a channel: a
/// silent observer while a real host is alive, the acting host after
/// the host is gone.</summary>
public static void EnsureServerMember(RelayChannel channel)
{
bool added = false;
int total;
lock (channel.Lock)
{
if (!channel.Members.ContainsKey(ShadowHost.ServerUid))
{
channel.Members[ShadowHost.ServerUid] = new RelayMember { Uid = ShadowHost.ServerUid, Alias = "Lampac" };
added = true;
}
total = channel.Members.Count;
}
if (added)
Broadcast(channel, null, BuildForeignJoinFrame(ShadowHost.ServerUid, "Lampac", ServerNowMs(), total));
}
static async Task ReceiveLoop(string channelName, RelayChannel channel, RelayMember member)
{
var socket = member.Socket;
var buffer = new byte[8192];
while (socket.State == WebSocketState.Open)
{
var result = await socket.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
if (result.MessageType == WebSocketMessageType.Close) break;
using var ms = new MemoryStream();
ms.Write(buffer, 0, result.Count);
while (!result.EndOfMessage)
{
if (ms.Length > MaxMessageBytes) throw new IOException("relay message too large");
result = await socket.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
if (result.MessageType == WebSocketMessageType.Close) return;
ms.Write(buffer, 0, result.Count);
}
if (ms.Length == 0 || ms.Length > MaxMessageBytes) continue;
if (!CheckRate(member))
{
_ = SendAsync(member, BuildErrorFrame(ServerNowMs(), "rate_limited"));
continue;
}
JsonElement message;
try
{
using var doc = JsonDocument.Parse(ms.ToArray());
if (doc.RootElement.ValueKind != JsonValueKind.Object) continue;
message = doc.RootElement.Clone();
}
catch (JsonException)
{
continue;
}
long now = ServerNowMs();
// The echo feeds clock sync: it happens for every message.
// Frames are broadcast verbatim; the shadow host only listens.
if (member.Echo)
{
byte[] echoFrame = BuildMessageFrame(member.Uid, member.Alias, ServerNowMs(), message);
_ = SendAsync(member, echoFrame);
}
byte[] frame = BuildMessageFrame(member.Uid, member.Alias, now, message);
Broadcast(channel, member.Uid, frame);
ShadowHost.OnMessage(channelName, channel, member, message, now);
}
}
static bool CheckRate(RelayMember member)
{
long second = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (Interlocked.CompareExchange(ref member.RateSecond, second, second) != second)
{
member.RateSecond = second;
member.RateCount = 0;
}
member.RateCount++;
return member.RateCount <= MaxMessagesPerSecond;
}
public static void Broadcast(RelayChannel channel, string excludeUid, byte[] frame)
{
RelayMember[] targets;
lock (channel.Lock)
{
if (channel.Members.Count == 0) return;
targets = new RelayMember[channel.Members.Count];
channel.Members.Values.CopyTo(targets, 0);
}
foreach (var target in targets)
{
if (string.Equals(target.Uid, excludeUid, StringComparison.Ordinal)) continue;
_ = SendAsync(target, frame);
}
}
static void RemoveMember(string channelName, RelayChannel channel, RelayMember member)
{
int total;
bool tornDown = false;
lock (channel.Lock)
{
if (!channel.Members.Remove(member.Uid, out _)) return;
total = channel.Members.Count;
bool onlyServer = total == 1 && channel.Members.ContainsKey(ShadowHost.ServerUid);
if (total == 0 || onlyServer)
{
tornDown = true;
if (Channels.TryRemove(channel.Name, out var stale) && !ReferenceEquals(stale, channel))
{
// A newer instance took the name; put it back.
Channels.TryAdd(channel.Name, stale);
}
if (onlyServer)
channel.Members.Remove(ShadowHost.ServerUid, out _);
}
}
byte[] leaveFrame = BuildLeaveFrame(member.Uid, member.Alias, ServerNowMs(), total);
Broadcast(channel, member.Uid, leaveFrame);
ShadowHost.OnMemberLeft(channelName, channel, member, tornDown);
try { member.Socket.Dispose(); } catch { }
}
public static async Task SendAsync(RelayMember member, byte[] payload)
{
try
{
await member.SendLock.WaitAsync();
try
{
if (member.Socket.State != WebSocketState.Open) return;
await member.Socket.SendAsync(new ArraySegment<byte>(payload), WebSocketMessageType.Text, true, CancellationToken.None);
}
finally
{
member.SendLock.Release();
}
}
catch { }
}
static long ServerNowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
static byte[] BuildSelfJoinFrame(string uid, long date, int total, List<RelayMember> users)
{
using var ms = new MemoryStream();
using (var w = new Utf8JsonWriter(ms))
{
w.WriteStartObject();
w.WriteString("type", "join");
w.WriteBoolean("self", true);
w.WriteString("uid", uid);
w.WriteNumber("date", date);
w.WriteNumber("total", total);
w.WriteStartArray("users");
foreach (var u in users)
{
w.WriteStartObject();
w.WriteString("uid", u.Uid);
w.WriteString("alias", u.Alias ?? string.Empty);
w.WriteEndObject();
}
w.WriteEndArray();
w.WriteEndObject();
}
return ms.ToArray();
}
static byte[] BuildForeignJoinFrame(string uid, string alias, long date, int total)
{
using var ms = new MemoryStream();
using (var w = new Utf8JsonWriter(ms))
{
w.WriteStartObject();
w.WriteString("type", "join");
w.WriteBoolean("self", false);
w.WriteString("uid", uid);
w.WriteString("alias", alias ?? string.Empty);
w.WriteNumber("date", date);
w.WriteNumber("total", total);
w.WriteEndObject();
}
return ms.ToArray();
}
static byte[] BuildLeaveFrame(string uid, string alias, long date, int total)
{
using var ms = new MemoryStream();
using (var w = new Utf8JsonWriter(ms))
{
w.WriteStartObject();
w.WriteString("type", "leave");
w.WriteString("uid", uid);
w.WriteString("alias", alias ?? string.Empty);
w.WriteNumber("date", date);
w.WriteNumber("total", total);
w.WriteEndObject();
}
return ms.ToArray();
}
static byte[] BuildErrorFrame(long date, string message)
{
using var ms = new MemoryStream();
using (var w = new Utf8JsonWriter(ms))
{
w.WriteStartObject();
w.WriteString("type", "error");
w.WriteNumber("date", date);
w.WriteString("message", message);
w.WriteEndObject();
}
return ms.ToArray();
}
public static byte[] BuildMessageFrame(string uid, string alias, long date, JsonElement message)
{
using var ms = new MemoryStream();
using (var w = new Utf8JsonWriter(ms))
{
w.WriteStartObject();
w.WriteString("type", "message");
w.WriteString("uid", uid);
w.WriteString("alias", alias ?? string.Empty);
w.WriteNumber("date", date);
w.WritePropertyName("message");
message.WriteTo(w);
w.WriteEndObject();
}
return ms.ToArray();
}
public static byte[] BuildMessageFrame(string uid, string alias, long date, object message)
{
using var ms = new MemoryStream();
using (var w = new Utf8JsonWriter(ms))
{
w.WriteStartObject();
w.WriteString("type", "message");
w.WriteString("uid", uid);
w.WriteString("alias", alias ?? string.Empty);
w.WriteNumber("date", date);
w.WritePropertyName("message");
JsonSerializer.Serialize(w, message, message.GetType());
w.WriteEndObject();
}
return ms.ToArray();
}
}
}
-826
View File
@@ -1,826 +0,0 @@
using Shared;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace WatchTogether
{
/// <summary>
/// Server-side state of a room the server eavesdrops on. The server never
/// creates rooms: a room exists because a client opened its channel and a
/// host announced a stream. Until the host vanishes, the server is a
/// silent member; when the host is gone, it seamlessly takes over.
/// </summary>
public class ShadowRoom
{
public readonly object Lock = new();
public string Channel;
// host bookkeeping
public string HostUid;
public string HostPid;
public string CreatorPid;
public long HostSeen;
public bool Hosting;
public bool Stalled;
// timeline
public string State = "paused";
public double Position;
public long AtServerTime;
public double Speed = 1.0;
// metadata
public string RoomName;
public string Url;
public string Title;
public string Poster;
public string Source;
public string Type;
public int Tmdb;
/// <summary>Room code as announced by the host in the lobby (ad frames);
/// lets the server list and re-announce rooms it keeps alive.</summary>
public string Code;
// server-side hold barrier (only while Hosting)
public HashSet<string> HoldWaiting;
public double HoldPosition;
public Timer HoldTimer;
public DateTime CreatedUtc = DateTime.UtcNow;
public DateTime LastActivityUtc = DateTime.UtcNow;
/// <summary>Display name of the member that announced the room.</summary>
public string OwnerName;
}
/// <summary>A room advertisement overheard in the lobby channel.</summary>
public class LobbyAd
{
public string SenderUid;
public long Seen;
public string Id;
public string Name;
public string Title;
public string Poster;
public string Owner;
public int Members;
public int Tmdb;
public string Type;
public bool Pwd;
}
/// <summary>
/// The shadow host: the WatchTogether module acting as a state-keeping
/// lparty client. While a real host is alive it silently listens and keeps
/// a snapshot of the room (url, metadata, timeline). The moment the host's
/// connection drops, the server steps in as the host on the wire - the
/// members see one "host changed" toast and playback continues without a
/// gap. When the original creator returns, the role is handed back.
/// </summary>
public static class ShadowHost
{
public const string ServerUid = "lampac";
const string LobbyChannel = "lparty-lobby-v1";
const string RoomPrefix = "lparty-r-";
const int MetronomeMs = 2000;
const int GcEveryMs = 60000;
const double MaxAdvancableSeconds = 3600;
const int HoldMaxMs = 15000;
const long ReconnectGraceMs = 60000;
const int AdTtlMs = 120000;
static Timer metronomeTimer;
static Timer gcTimer;
static int initialized;
static readonly ConcurrentDictionary<string, ShadowRoom> rooms = new();
/// <summary>Room advertisements overheard in the lobby channel: the
/// host announces its code publicly, so the server can keep listing
/// rooms (including ones it keeps alive) without knowing passwords.</summary>
static readonly ConcurrentDictionary<string, LobbyAd> lobbyAds = new();
/// <summary>pid -> last hello, per channel; used to skip the join hold
/// for members that are reconnecting rather than new.</summary>
static readonly ConcurrentDictionary<string, ConcurrentDictionary<string, long>> knownPids = new();
/// <summary>The wire channel name for a room code. With an empty
/// password this is computable server-side, which lets the server link
/// a lobby advertisement to the room it belongs to.</summary>
static string RoomChannelHash(string roomId, string password)
{
if (string.IsNullOrEmpty(roomId)) return null;
string input = roomId.ToUpperInvariant() + "|" + (password ?? string.Empty);
using var sha = SHA256.Create();
byte[] hashBytes = sha.ComputeHash(Encoding.UTF8.GetBytes(input));
return RoomPrefix + Convert.ToHexString(hashBytes).ToLowerInvariant()[..24];
}
// ---------------------------------------------------------------- lifecycle
public static void Start()
{
if (Interlocked.Exchange(ref initialized, 1) == 1) return;
metronomeTimer = new Timer(Metronome, null, MetronomeMs, MetronomeMs);
gcTimer = new Timer(Gc, null, GcEveryMs, GcEveryMs);
}
public static void Stop()
{
if (Interlocked.Exchange(ref initialized, 0) == 0) return;
metronomeTimer?.Dispose();
gcTimer?.Dispose();
metronomeTimer = gcTimer = null;
rooms.Clear();
knownPids.Clear();
}
// ---------------------------------------------------------------- relay hooks
/// <summary>Whether the server's virtual member must exist in this
/// channel right now (it is the acting host).</summary>
public static bool ShouldHostChannel(string channelName)
{
if (channelName == LobbyChannel) return false;
return rooms.TryGetValue(channelName, out var room) && room.Hosting;
}
public static void OnMemberJoined(string channelName, RelayChannel channel, RelayMember member)
{
if (channelName == LobbyChannel) return;
if (!rooms.TryGetValue(channelName, out var room)) return;
room.LastActivityUtc = DateTime.UtcNow;
RelayServer.EnsureServerMember(channel);
// Server-hosted room: answer the joiner like a host would.
if (room.Hosting)
{
var pids = knownPids.GetOrAdd(channelName, _ => new ConcurrentDictionary<string, long>());
AnswerHelloAsHost(room, channel, member, pids, 0, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
}
}
public static void OnMemberLeft(string channelName, RelayChannel channel, RelayMember member, bool tornDown)
{
if (channelName == LobbyChannel)
{
// A lobby agent disconnected: its ads die with it.
foreach (var key in lobbyAds.Keys.ToList())
if (lobbyAds.TryGetValue(key, out var ad) && string.Equals(ad.SenderUid, member.Uid, StringComparison.Ordinal))
lobbyAds.TryRemove(key, out var dead);
return;
}
if (!rooms.TryGetValue(channelName, out var room)) return;
if (!room.Hosting && !string.IsNullOrEmpty(room.HostUid) && string.Equals(room.HostUid, member.Uid, StringComparison.Ordinal))
BecomeHost(room, channel, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
}
public static void OnMessage(string channelName, RelayChannel channel, RelayMember member, JsonElement msg, long now)
{
if (channelName == LobbyChannel)
{
HandleLobby(member, msg, now);
return;
}
var room = rooms.GetOrAdd(channelName, _ => new ShadowRoom { Channel = channelName });
room.LastActivityUtc = DateTime.UtcNow;
string t = GetString(msg, "t");
string pid = GetString(msg, "u");
string name = GetString(msg, "n");
if (!string.IsNullOrEmpty(pid)) member.Pid = pid;
if (!string.IsNullOrEmpty(name)) member.Name = name;
switch (t)
{
case "state": ObserveState(room, channel, member, msg, now); break;
case "sync": ObserveSync(room, member, msg, now); break;
case "act": ObserveAct(room, msg, now); break;
case "url": ObserveUrl(room, member, msg); break;
case "buf": ObserveBuf(room, member, msg); break;
case "host": HandleHostClaim(room, channel, member, msg, now); break;
case "hello": HandleHello(room, channel, member, msg, now); break;
case "ready": HandleReady(room, channel, member, now); break;
case "bye": HandleBye(room, channel, member, now); break;
}
}
/// <summary>The lobby is server-answered: hosts advertise their code
/// in `ad` frames, the server caches them and re-announces rooms it
/// keeps alive - so the list survives a host handover.</summary>
static void HandleLobby(RelayMember member, JsonElement msg, long now)
{
string t = GetString(msg, "t");
if (t == "ad")
{
if (msg.TryGetProperty("r", out var r) && r.ValueKind == JsonValueKind.Object)
{
string id = GetString(r, "id");
if (!string.IsNullOrWhiteSpace(id) && id.Length <= 16)
{
lobbyAds[id] = new LobbyAd
{
SenderUid = member.Uid,
Seen = now,
Id = id,
Name = GetString(r, "name") ?? id,
Title = GetString(r, "title") ?? string.Empty,
Poster = GetString(r, "poster") ?? string.Empty,
Owner = GetString(r, "owner") ?? string.Empty,
Members = GetInt(r, "members"),
Tmdb = GetInt(r, "tmdb"),
Type = GetString(r, "type") ?? "movie",
Pwd = GetInt(r, "pwd") != 0,
};
}
}
return;
}
if (t != "who") return;
var listed = new HashSet<string>(StringComparer.Ordinal);
// Server-kept rooms first, with live member counts.
foreach (var room in rooms.Values)
{
string code, name, title, poster, owner, type;
int tmdb;
bool hasUrl;
lock (room.Lock)
{
code = room.Code;
hasUrl = !string.IsNullOrEmpty(room.Url);
name = room.RoomName;
title = room.Title;
poster = room.Poster;
owner = room.OwnerName;
type = room.Type;
tmdb = room.Tmdb;
}
if (string.IsNullOrWhiteSpace(code) || listed.Contains(code) || !hasUrl) continue;
var channel = RelayServer.TryGetChannel(room.Channel);
int members = channel == null ? 0 : RelayServer.HumanUids(channel).Count;
SendLobbyAd(member, now, code, name, title, poster, owner, members, true, tmdb, type);
listed.Add(code);
}
// Cached ads from hosts. Provably alive (passwordless) rooms
// outlive the TTL; the rest go stale with their announcer.
foreach (var ad in lobbyAds.Values.OrderByDescending(a => a.Seen))
{
if (listed.Contains(ad.Id)) continue;
string hash = RoomChannelHash(ad.Id, string.Empty);
bool tracked = hash != null && rooms.ContainsKey(hash);
if (now - ad.Seen > AdTtlMs && !tracked) continue;
int members = ad.Members;
if (tracked)
{
var channel = RelayServer.TryGetChannel(hash);
if (channel != null) members = RelayServer.HumanUids(channel).Count;
}
SendLobbyAd(member, now, ad.Id, ad.Name, ad.Title, ad.Poster, ad.Owner, members, true, ad.Tmdb, ad.Type);
listed.Add(ad.Id);
}
}
static void SendLobbyAd(RelayMember target, long now, string id, string name, string title, string poster, string owner, int members, bool pwd, int tmdb, string type)
{
var frame = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new
{
t = "ad",
r = new { id, name, title, poster, owner, members, pwd = pwd ? 1 : 0, tmdb, type }
});
_ = RelayServer.SendAsync(target, frame);
}
// ---------------------------------------------------------------- observation
static void ObserveState(ShadowRoom room, RelayChannel channel, RelayMember member, JsonElement msg, long now)
{
// While hosting, only the creator may take the room back.
if (room.Hosting)
{
if (!string.Equals(member.Pid, room.CreatorPid, StringComparison.Ordinal))
{
AssertHost(room, channel, now);
return;
}
Handback(room, channel, member, now);
}
string url = GetString(msg, "url");
if (string.IsNullOrWhiteSpace(url)) return;
lock (room.Lock)
{
room.HostUid = member.Uid;
room.HostPid = GetString(msg, "own") ?? member.Pid ?? member.Uid;
room.CreatorPid = room.HostPid;
room.HostSeen = now;
room.Hosting = false;
room.Stalled = false;
room.RoomName = GetString(msg, "rn") ?? room.RoomName;
room.Url = url.Trim();
room.Title = GetString(msg, "ti") ?? room.Title;
room.Poster = GetString(msg, "po") ?? room.Poster;
room.Tmdb = GetInt(msg, "tm");
room.Source = GetString(msg, "src") ?? room.Source;
room.Type = GetString(msg, "ty") ?? room.Type;
room.Code = GetString(msg, "cd") ?? room.Code;
room.OwnerName = member.Alias ?? room.OwnerName;
room.State = GetString(msg, "s") == "playing" ? "playing" : "paused";
double p = GetDouble(msg, "p");
if (!double.IsNaN(p) && !double.IsInfinity(p) && p >= 0 && p <= 2592000) room.Position = p;
room.Speed = GetSpeedOr(msg, room.Speed);
room.AtServerTime = now;
}
RelayServer.EnsureServerMember(channel);
}
static void ObserveSync(ShadowRoom room, RelayMember member, JsonElement msg, long now)
{
string s = GetString(msg, "s") == "playing" ? "playing" : "paused";
double p = GetDouble(msg, "p");
double sp = GetSpeedOr(msg, room.Speed);
lock (room.Lock)
{
if (room.Hosting) return;
bool fromHost = string.Equals(room.HostUid, member.Uid, StringComparison.Ordinal) ||
(!string.IsNullOrEmpty(room.HostPid) && string.Equals(room.HostPid, member.Pid, StringComparison.Ordinal));
if (!fromHost && string.IsNullOrEmpty(room.HostUid))
{
// No known host yet: adopt the sender.
room.HostUid = member.Uid;
room.HostPid = member.Pid;
room.CreatorPid = member.Pid;
fromHost = true;
}
if (!fromHost) return;
room.State = s;
if (!double.IsNaN(p) && !double.IsInfinity(p) && p >= 0 && p <= 2592000) room.Position = p;
room.Speed = sp;
room.AtServerTime = now;
room.HostSeen = now;
}
}
static void ObserveAct(ShadowRoom room, JsonElement msg, long now)
{
// Acts from anyone move the room; the host's next sync re-asserts.
if (room.Hosting) return;
string s = GetString(msg, "s") == "playing" ? "playing" : "paused";
double p = GetDouble(msg, "p");
lock (room.Lock)
{
room.State = s;
if (!double.IsNaN(p) && !double.IsInfinity(p) && p >= 0 && p <= 2592000) room.Position = p;
room.Speed = GetSpeedOr(msg, room.Speed);
room.AtServerTime = now;
}
}
static void ObserveUrl(ShadowRoom room, RelayMember member, JsonElement msg)
{
lock (room.Lock)
{
bool fromHost = string.Equals(room.HostUid, member.Uid, StringComparison.Ordinal) ||
(!string.IsNullOrEmpty(room.HostPid) && string.Equals(room.HostPid, member.Pid, StringComparison.Ordinal));
if (!fromHost || room.Hosting) return;
string url = GetString(msg, "url");
if (string.IsNullOrWhiteSpace(url)) return;
room.Url = url.Trim();
string ti = GetString(msg, "ti");
if (!string.IsNullOrEmpty(ti)) room.Title = ti.Trim();
room.State = "paused";
room.Position = 0;
room.AtServerTime = 0;
room.Stalled = false;
}
}
static void ObserveBuf(ShadowRoom room, RelayMember member, JsonElement msg)
{
lock (room.Lock)
{
bool fromHost = string.Equals(room.HostUid, member.Uid, StringComparison.Ordinal) ||
(!string.IsNullOrEmpty(room.HostPid) && string.Equals(room.HostPid, member.Pid, StringComparison.Ordinal));
if (!fromHost) return;
room.Stalled = GetBool(msg, "v");
}
}
static void HandleHostClaim(ShadowRoom room, RelayChannel channel, RelayMember member, JsonElement msg, long now)
{
if (room.Hosting)
{
// Claims are dropped; the server re-asserts itself.
AssertHost(room, channel, now);
return;
}
string claimer = GetString(msg, "u");
if (string.IsNullOrEmpty(claimer)) return;
lock (room.Lock)
{
room.HostUid = member.Uid;
room.HostPid = claimer;
room.CreatorPid = claimer;
room.HostSeen = now;
room.Hosting = false;
}
}
static void HandleHello(ShadowRoom room, RelayChannel channel, RelayMember member, JsonElement msg, long now)
{
string pid = GetString(msg, "u") ?? member.Pid;
string name = GetString(msg, "n");
string cd = GetString(msg, "cd");
var pids = knownPids.GetOrAdd(channel.Name, _ => new ConcurrentDictionary<string, long>());
long seenBefore = 0;
if (!string.IsNullOrEmpty(pid))
{
pids.TryGetValue(pid, out seenBefore);
pids[pid] = now;
}
// The first participant stating the code links the room to its listing.
if (!string.IsNullOrWhiteSpace(cd))
{
lock (room.Lock)
{
if (string.IsNullOrEmpty(room.Code)) room.Code = cd.Trim().ToUpperInvariant();
}
}
if (!room.Hosting) return;
if (!string.IsNullOrEmpty(pid) && string.Equals(pid, room.CreatorPid, StringComparison.Ordinal))
{
Handback(room, channel, member, now);
return;
}
AnswerHelloAsHost(room, channel, member, pids, seenBefore, now);
}
/// <summary>Server-hosting room, newcomer arrived: introduce the room
/// and, like a real host, freeze it while they buffer.</summary>
static void AnswerHelloAsHost(ShadowRoom room, RelayChannel channel, RelayMember member, ConcurrentDictionary<string, long> pids, long seenBefore, long now)
{
if (!room.Hosting) return;
RelayServer.EnsureServerMember(channel);
SendStateFrame(room, member);
bool reconnected = now - seenBefore < ReconnectGraceMs;
bool playing;
lock (room.Lock) playing = room.State == "playing" && !string.IsNullOrEmpty(room.Url);
if (!playing) return;
if (reconnected) return;
StartHold(room, channel, now);
}
static void HandleReady(ShadowRoom room, RelayChannel channel, RelayMember member, long now)
{
lock (room.Lock)
{
if (!room.Hosting || room.HoldWaiting == null) return;
room.HoldWaiting.Remove(member.Uid);
if (room.HoldWaiting.Count > 0) return;
}
ReleaseHold(room, channel, now);
}
static void HandleBye(ShadowRoom room, RelayChannel channel, RelayMember member, long now)
{
bool wasHost;
lock (room.Lock)
wasHost = !room.Hosting && !string.IsNullOrEmpty(room.HostUid) && string.Equals(room.HostUid, member.Uid, StringComparison.Ordinal);
if (wasHost) BecomeHost(room, channel, now);
}
// ---------------------------------------------------------------- host transitions
static void BecomeHost(ShadowRoom room, RelayChannel channel, long now)
{
if (room.Hosting) return;
lock (room.Lock)
{
room.Hosting = true;
room.HostUid = ServerUid;
room.HostSeen = now;
room.Stalled = false;
if (room.State == "playing" && room.AtServerTime > 0)
{
// Continue exactly where the dead host was last heard.
double elapsed = (now - room.AtServerTime) / 1000.0;
if (elapsed > 0 && elapsed <= MaxAdvancableSeconds)
room.Position += elapsed * room.Speed;
room.AtServerTime = now;
}
}
RelayServer.EnsureServerMember(channel);
AssertHost(room, channel, now);
double pos;
string state;
double speed;
lock (room.Lock) { state = room.State; pos = room.Position; speed = room.Speed; }
var sync = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new { t = "sync", u = ServerUid, s = state, p = pos, sp = speed });
RelayServer.Broadcast(channel, null, sync);
}
static void Handback(ShadowRoom room, RelayChannel channel, RelayMember member, long now)
{
lock (room.Lock)
{
room.Hosting = false;
room.HostUid = member.Uid;
room.HostPid = member.Pid;
room.HostSeen = now;
room.Stalled = false;
}
var frame = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new { t = "host", u = member.Pid, n = member.Name ?? member.Alias ?? string.Empty });
RelayServer.Broadcast(channel, null, frame);
}
static void AssertHost(ShadowRoom room, RelayChannel channel, long now)
{
var frame = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new { t = "host", u = ServerUid, n = "Lampac" });
RelayServer.Broadcast(channel, null, frame);
}
// ---------------------------------------------------------------- hold barrier (server as host)
static void StartHold(ShadowRoom room, RelayChannel channel, long now)
{
lock (room.Lock)
{
if (room.HoldWaiting != null) return; // already holding
AdvanceClock(room, now);
room.HoldPosition = room.Position;
room.HoldWaiting = new HashSet<string>(RelayServer.HumanUids(channel), StringComparer.Ordinal);
if (room.HoldWaiting.Count == 0) return;
}
var holdFrame = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new { t = "hold", u = ServerUid, p = room.HoldPosition, sp = room.Speed });
RelayServer.Broadcast(channel, null, holdFrame);
lock (room.Lock)
{
room.HoldTimer?.Dispose();
room.HoldTimer = new Timer(_ =>
{
var ch = RelayServer.TryGetChannel(room.Channel);
if (ch != null) ReleaseHold(room, ch, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
}, null, HoldMaxMs, Timeout.Infinite);
}
}
/// <summary>Freeze the clock base at the current position.</summary>
static void AdvanceClock(ShadowRoom room, long now)
{
if (room.State == "playing" && room.AtServerTime > 0)
{
double elapsed = (now - room.AtServerTime) / 1000.0;
if (elapsed > 0 && elapsed <= MaxAdvancableSeconds) room.Position += elapsed * room.Speed;
}
room.AtServerTime = now;
}
static void ReleaseHold(ShadowRoom room, RelayChannel channel, long now)
{
double position;
lock (room.Lock)
{
room.HoldWaiting = null;
room.HoldTimer?.Dispose();
room.HoldTimer = null;
room.State = "playing";
room.Position = room.HoldPosition;
room.AtServerTime = now;
position = room.HoldPosition;
}
var goFrame = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new { t = "go", u = ServerUid, p = position, sp = room.Speed });
RelayServer.Broadcast(channel, null, goFrame);
}
// ---------------------------------------------------------------- metronome
/// <summary>While the server hosts, nobody's player drives the clock:
/// the metronome advances the room by wall time and beats every couple
/// of seconds, exactly like the host heartbeats it replaces.</summary>
static void Metronome(object _)
{
try
{
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
foreach (var room in rooms.Values)
{
if (!room.Hosting) continue;
var channel = RelayServer.TryGetChannel(room.Channel);
if (channel == null) continue;
bool playing;
lock (room.Lock)
{
playing = room.State == "playing" && !room.Stalled;
if (playing && room.AtServerTime > 0)
{
double elapsed = (now - room.AtServerTime) / 1000.0;
if (elapsed > 0 && elapsed <= MaxAdvancableSeconds)
room.Position += elapsed * room.Speed;
room.AtServerTime = now;
}
}
if (!playing) continue;
double pos, speed;
string state;
lock (room.Lock) { pos = room.Position; speed = room.Speed; state = room.State; }
var frame = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new { t = "sync", u = ServerUid, s = state, p = pos, sp = speed });
RelayServer.Broadcast(channel, null, frame);
}
}
catch { }
}
// ---------------------------------------------------------------- gc
static void Gc(object _)
{
try
{
var emptyLimit = DateTime.UtcNow.AddMinutes(-Math.Max(5, ModInit.conf.gc_empty_timeout_minutes));
var lifeLimit = DateTime.UtcNow.AddHours(-Math.Max(1, ModInit.conf.gc_max_lifetime_hours));
foreach (var room in rooms.Values.ToArray())
{
bool hasHumans = false;
var channel = RelayServer.TryGetChannel(room.Channel);
if (channel != null)
{
lock (channel.Lock)
hasHumans = channel.Members.Keys.Any(u => !string.Equals(u, ServerUid, StringComparison.Ordinal));
}
bool expired = room.CreatedUtc < lifeLimit ||
(!hasHumans && room.LastActivityUtc < emptyLimit);
if (expired)
{
rooms.TryRemove(room.Channel, out var deadRoom);
knownPids.TryRemove(room.Channel, out var deadPids);
}
}
long cutoff = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - ReconnectGraceMs;
foreach (var pids in knownPids.Values)
{
foreach (var key in pids.Keys.ToList())
{
if (!pids.TryGetValue(key, out var seen) || seen < cutoff)
pids.TryRemove(key, out var deadPid);
}
}
long adCutoff = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - AdTtlMs;
foreach (var key in lobbyAds.Keys.ToList())
{
if (!lobbyAds.TryGetValue(key, out var ad) || ad.Seen < adCutoff)
lobbyAds.TryRemove(key, out var deadAd);
}
}
catch { }
}
// ---------------------------------------------------------------- helpers
static void SendStateFrame(ShadowRoom room, RelayMember target)
{
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
double pos;
string state;
double speed;
lock (room.Lock)
{
pos = room.Position;
speed = room.Speed;
state = room.State;
if (state == "playing" && room.AtServerTime > 0)
{
double elapsed = (now - room.AtServerTime) / 1000.0;
if (elapsed > 0 && elapsed <= MaxAdvancableSeconds) pos += elapsed * room.Speed;
}
}
var frame = RelayServer.BuildMessageFrame(ServerUid, "Lampac", now, new
{
t = "state",
rn = room.RoomName ?? string.Empty,
own = ServerUid,
url = room.Url ?? string.Empty,
ti = room.Title ?? string.Empty,
po = room.Poster ?? string.Empty,
tm = room.Tmdb,
src = room.Source ?? string.Empty,
ty = room.Type ?? "movie",
s = state,
p = pos,
sp = speed,
});
_ = RelayServer.SendAsync(target, frame);
}
static string GetString(JsonElement obj, string name)
{
if (obj.ValueKind != JsonValueKind.Object) return null;
if (!obj.TryGetProperty(name, out var el)) return null;
if (el.ValueKind == JsonValueKind.String) return el.GetString();
if (el.ValueKind == JsonValueKind.Null) return null;
return el.ToString();
}
static double GetDouble(JsonElement obj, string name)
{
if (obj.ValueKind != JsonValueKind.Object) return 0;
if (obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.Number && el.TryGetDouble(out var val))
return val;
return 0;
}
static double GetSpeedOr(JsonElement obj, double fallback)
{
double v = GetDouble(obj, "sp");
return v >= 0.25 && v <= 4.0 ? v : fallback;
}
static int GetInt(JsonElement obj, string name)
{
if (obj.ValueKind != JsonValueKind.Object) return 0;
if (obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.Number && el.TryGetInt32(out var val))
return val;
return 0;
}
static bool GetBool(JsonElement obj, string name)
{
if (obj.ValueKind != JsonValueKind.Object) return false;
return obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.True;
}
}
}
-26
View File
@@ -1,26 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Shared\Shared.csproj" />
<ProjectReference Include="..\..\Core\Core.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="manifest.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="plugin.js">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="web\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -1,92 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using Shared;
using System;
using System.IO;
namespace WatchTogether
{
/// <summary>
/// Static file service: the Lampa plugin (adapted lparty.js) and the web
/// guest page (adapted lparty-web), with lampac hosts injected as
/// placeholders. The relay itself lives at /c/ (RelayStartupFilter).
/// </summary>
[Route("watchtogether")]
public class WatchTogetherController : BaseController
{
private bool CheckAuth()
{
if (ModInit.conf.allow_anonymous) return true;
if (!CoreInit.conf.accsdb.enable) return true;
return requestInfo.user != null;
}
[AllowAnonymous]
[HttpGet]
[Route("/watchtogether.js")]
public ActionResult GetPlugin()
{
if (!CheckAuth()) return StatusCode(401);
return ServeFile("plugin.js", "application/javascript; charset=utf-8", js =>
js.Replace("{relay}", RelayAddress())
.Replace("{invite}", InviteAddress()));
}
[AllowAnonymous]
[HttpGet]
[Route("/watch_together")]
[Route("/watch_together/{file}")]
public ActionResult GetWebPlayer(string file)
{
if (!CheckAuth()) return StatusCode(401);
switch (file ?? string.Empty)
{
case "":
case "index.html":
return ServeFile("web/index.html", "text/html; charset=utf-8", html =>
html.Replace("{relay}", RelayAddress()));
case "lparty-web.js":
return ServeFile("web/lparty-web.js", "application/javascript; charset=utf-8", js =>
js.Replace("{relay}", RelayAddress()));
case "i18n.js":
return ServeFile("web/i18n.js", "application/javascript; charset=utf-8", null);
case "names.js":
return ServeFile("web/names.js", "application/javascript; charset=utf-8", null);
default:
return NotFound();
}
}
string RelayAddress()
{
return (host.StartsWith("https", StringComparison.Ordinal) ? "wss://" : "ws://") +
host.Replace("https://", "").Replace("http://", "").TrimEnd('/') + "/wt/c/";
}
string InviteAddress()
{
return host.TrimEnd('/') + "/watch_together/";
}
ActionResult ServeFile(string name, string contentType, Func<string, string> transform)
{
string path = Path.Combine(ModInit.modpath, name);
if (!System.IO.File.Exists(path))
return Content("<html><body><h2>" + name + " not found</h2></body></html>", "text/html; charset=utf-8");
long mtime = System.IO.File.GetLastWriteTimeUtc(path).Ticks;
string memKey = "watchtogether:file:" + name + ":" + mtime;
if (!memoryCache.TryGetValue(memKey, out string raw))
{
raw = System.IO.File.ReadAllText(path);
memoryCache.Set(memKey, raw, TimeSpan.FromMinutes(10));
}
string result = transform != null ? transform(raw) : raw;
return Content(result, contentType);
}
}
}
-10
View File
@@ -1,10 +0,0 @@
{
"enable": true,
"dynamic": true,
"tree": [
"ModInit.cs",
"RelayServer.cs",
"ShadowHost.cs",
"WatchTogetherController.cs"
]
}
File diff suppressed because it is too large Load Diff
-324
View File
@@ -1,324 +0,0 @@
/**
* Three languages, no libraries. Auto-detection picks between Ukrainian and English
* only: Russian is in the list but is never selected without an explicit choice.
*
* A dictionary value is either a string or an object of plural forms
* (one/few/many/other) resolved through Intl.PluralRules.
*/
export const LANGS = [
{ code: "uk", name: "Українська" },
{ code: "en", name: "English" },
{ code: "ru", name: "Русский" },
];
const DICT = {
uk: {
tagline: "Спільний перегляд із Lampa",
lang_label: "Мова",
join_heading: "Увійти за кодом",
code_ph: "Код кімнати",
pwd_ph: "Пароль",
join_btn: "Увійти",
rooms_heading: "Відкриті кімнати",
refresh_btn: "Оновити",
searching: "Шукаємо…",
empty_title: "Поки жодної кімнати",
empty_hint:
"Кімната з’явиться тут, щойно хтось у Lampa створить її з увімкненою публікацією. Якщо код уже є — введіть його вище.",
create_heading: "Створити кімнату",
url_ph: "Посилання на потік (.m3u8 або .mp4)",
title_ph: "Назва",
create_btn: "Створити",
url_hint: "Потрібне https-посилання, і сервер має дозволяти CORS.",
cancel_btn: "Скасувати",
done_btn: "Готово",
settings_heading: "Налаштування",
name_ph: "Ваше ім’я",
regenerate_name: "Згенерувати інше ім’я",
relay_label: "Реле",
publish_label: "Показувати мою кімнату у списку",
leave_btn: "Вийти",
host_badge: "ви хост",
switch_ph: "Нове посилання на потік",
switch_btn: "Перемкнути всім",
status_connecting: "З’єднання…",
status_online: "На зв’язку",
status_offline: "Зв’язок втрачено",
viewers: {
one: "{n} глядач",
few: "{n} глядачі",
many: "{n} глядачів",
other: "{n} глядача",
},
room_created: "Кімнату створено · код {id}",
join_ok: "Ви в кімнаті {name}",
joined: "{name} приєднався",
left_room: "{name} вийшов",
host_changed: "Новий хост: {name}",
you_are_host: "Тепер ви хост",
notice_hold: "Пауза — чекаємо, поки всі завантажать буфер",
notice_go: "Усі готові — продовжуємо",
act_resumed: "{name} продовжив",
act_paused: "{name} поставив на паузу",
act_seeked: "{name} перемотав",
someone: "учасник",
connecting: "Підключення…",
password_prompt: "Пароль кімнати {name}",
need_code: "Введіть код кімнати",
need_url: "Потрібне посилання на потік",
need_pwd_public:
"Кімната у списку потребує пароля — задайте його або вимкніть публікацію",
already_in_room: "Ви вже в кімнаті {name}",
create_failed: "Не вдалося створити кімнату",
join_failed: "Кімнату не знайдено або пароль невірний",
kicked: "Ви підключилися з іншого місця",
no_stream: "У кімнаті немає потоку",
relay_error: "Реле: {text}",
autoplay_blocked: "Браузер заблокував автозапуск — натисніть play",
mixed_content:
"Потік віддається по http, а сторінка по https — браузер це блокує",
cors_error:
"Потік не завантажується: схоже, сервер не віддає Access-Control-Allow-Origin",
playback_error: "Помилка відтворення: {details}",
media_error:
"Браузер не зміг відкрити потік (формат, CORS або mixed content)",
},
en: {
tagline: "Watch together with Lampa",
lang_label: "Language",
join_heading: "Join with a code",
code_ph: "Room code",
pwd_ph: "Password",
join_btn: "Join",
rooms_heading: "Open rooms",
refresh_btn: "Refresh",
searching: "Searching…",
empty_title: "No rooms right now",
empty_hint:
"A room appears here as soon as someone in Lampa creates one with publishing enabled. If you already have a code, enter it above.",
create_heading: "Create a room",
url_ph: "Stream link (.m3u8 or .mp4)",
title_ph: "Title",
create_btn: "Create",
url_hint: "Needs an https link from a server that allows CORS.",
cancel_btn: "Cancel",
done_btn: "Done",
settings_heading: "Settings",
name_ph: "Your name",
regenerate_name: "Generate another name",
relay_label: "Relay",
publish_label: "List my room publicly",
leave_btn: "Leave",
host_badge: "you host",
switch_ph: "New stream link",
switch_btn: "Switch for everyone",
status_connecting: "Connecting…",
status_online: "Connected",
status_offline: "Connection lost",
viewers: { one: "{n} viewer", other: "{n} viewers" },
room_created: "Room created · code {id}",
join_ok: "You are in {name}",
joined: "{name} joined",
left_room: "{name} left",
host_changed: "New host: {name}",
you_are_host: "You are the host now",
notice_hold: "Paused — waiting for everyone to buffer",
notice_go: "Everyone is ready — resuming",
act_resumed: "{name} resumed",
act_paused: "{name} paused",
act_seeked: "{name} skipped",
someone: "someone",
connecting: "Connecting…",
password_prompt: "Password for {name}",
need_code: "Enter a room code",
need_url: "A stream link is required",
need_pwd_public:
"A publicly listed room needs a password — set one or turn off publishing",
already_in_room: "You are already in {name}",
create_failed: "Could not create the room",
join_failed: "Room not found, or the password is wrong",
kicked: "You connected from somewhere else",
no_stream: "The room has no stream",
relay_error: "Relay: {text}",
autoplay_blocked: "The browser blocked autoplay — press play",
mixed_content:
"The stream is served over http while this page is https — the browser blocks that",
cors_error:
"The stream will not load: the server likely omits Access-Control-Allow-Origin",
playback_error: "Playback error: {details}",
media_error:
"The browser could not open the stream (format, CORS or mixed content)",
},
ru: {
tagline: "Совместный просмотр с Lampa",
lang_label: "Язык",
join_heading: "Войти по коду",
code_ph: "Код комнаты",
pwd_ph: "Пароль",
join_btn: "Войти",
rooms_heading: "Открытые комнаты",
refresh_btn: "Обновить",
searching: "Ищем…",
empty_title: "Пока ни одной комнаты",
empty_hint:
"Комната появится здесь, как только кто-то в Lampa создаст её с включённой публикацией. Если код уже есть — введите его выше.",
create_heading: "Создать комнату",
url_ph: "Ссылка на поток (.m3u8 или .mp4)",
title_ph: "Название",
create_btn: "Создать",
url_hint: "Нужна https-ссылка, и сервер должен разрешать CORS.",
cancel_btn: "Отмена",
done_btn: "Готово",
settings_heading: "Настройки",
name_ph: "Ваше имя",
regenerate_name: "Сгенерировать другое имя",
relay_label: "Реле",
publish_label: "Показывать мою комнату в списке",
leave_btn: "Выйти",
host_badge: "вы хост",
switch_ph: "Новая ссылка на поток",
switch_btn: "Переключить всем",
status_connecting: "Соединение…",
status_online: "На связи",
status_offline: "Связь потеряна",
viewers: {
one: "{n} зритель",
few: "{n} зрителя",
many: "{n} зрителей",
other: "{n} зрителя",
},
room_created: "Комната создана · код {id}",
join_ok: "Вы в комнате {name}",
joined: "{name} присоединился",
left_room: "{name} вышел",
host_changed: "Новый хост: {name}",
you_are_host: "Теперь вы хост",
notice_hold: "Пауза — ждём, пока все загрузят буфер",
notice_go: "Все готовы — продолжаем",
act_resumed: "{name} продолжил",
act_paused: "{name} поставил на паузу",
act_seeked: "{name} перемотал",
someone: "участник",
connecting: "Подключение…",
password_prompt: "Пароль комнаты {name}",
need_code: "Введите код комнаты",
need_url: "Нужна ссылка на поток",
need_pwd_public:
"Комната в списке требует пароль — задайте его или отключите публикацию",
already_in_room: "Вы уже в комнате {name}",
create_failed: "Не удалось создать комнату",
join_failed: "Комната не найдена или пароль неверный",
kicked: "Вы подключились из другого места",
no_stream: "В комнате нет потока",
relay_error: "Реле: {text}",
autoplay_blocked: "Браузер заблокировал автозапуск — нажмите play",
mixed_content:
"Поток отдаётся по http, а страница по https — браузер это блокирует",
cors_error:
"Поток не загружается: похоже, сервер не отдаёт Access-Control-Allow-Origin",
playback_error: "Ошибка воспроизведения: {details}",
media_error:
"Браузер не смог открыть поток (формат, CORS или mixed content)",
},
};
export const DICTS = DICT; // exposed so tests can check the dictionaries are complete
const STORE_KEY = "lparty_lang";
const listeners = new Set();
let current = "uk";
/** Russian is deliberately not reachable here: it is a manual choice only. */
export function detectLang() {
for (const tag of navigator.languages || [navigator.language || ""]) {
const code = String(tag).toLowerCase().split("-")[0];
if (code === "uk") return "uk";
if (code === "en") return "en";
}
return "uk";
}
export function getLang() {
return current;
}
export function setLang(code) {
if (!DICT[code]) return;
current = code;
try {
localStorage.setItem(STORE_KEY, code);
} catch (err) {}
document.documentElement.lang = code;
listeners.forEach((fn) => fn(code));
}
export function initLang() {
let saved = null;
try {
saved = localStorage.getItem(STORE_KEY);
} catch (err) {}
current = DICT[saved] ? saved : detectLang();
document.documentElement.lang = current;
return current;
}
export function onLangChange(fn) {
listeners.add(fn);
}
export function t(key, params) {
let value = DICT[current][key];
if (value === undefined) return key; // visible immediately; silence would be worse
if (typeof value === "object") {
const n = Number(params && params.n);
const form = new Intl.PluralRules(current).select(n);
value = value[form] ?? value.other;
}
return params
? value.replace(/\{(\w+)\}/g, (m, name) =>
params[name] === undefined ? m : params[name],
)
: value;
}
/**
* Repaints static nodes. data-i18n sets textContent, data-i18n-ph sets the
* placeholder, data-i18n-label sets aria-label.
*/
export function applyTranslations(root = document) {
root.querySelectorAll("[data-i18n]").forEach((el) => {
el.textContent = t(el.dataset.i18n);
});
root.querySelectorAll("[data-i18n-ph]").forEach((el) => {
el.placeholder = t(el.dataset.i18nPh);
});
root.querySelectorAll("[data-i18n-label]").forEach((el) => {
el.setAttribute("aria-label", t(el.dataset.i18nLabel));
});
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-386
View File
@@ -1,386 +0,0 @@
/**
* Friendly names for a participant — "Clever Mango" rather than a random id.
*
* The idea, the adjective-then-greengrocer shape and the word lists come from LocalSend
* (Apache License 2.0) by way of the JAPP player, which solves the same problem: something
* a stranger can read off a screen and repeat out loud, that says nothing about the device
* or its owner. 222 x 137 is 30414, which puts two people in a room of five
* drawing the same name at around one chance in three thousand.
*
* Deliberately not localised, exactly as LocalSend is not, so a name generated here reads
* the same as one generated in the Android player.
*/
const ADJECTIVES = [
"Able",
"Active",
"Adorable",
"Agile",
"Alert",
"Amber",
"Amused",
"Ancient",
"Artful",
"Autumn",
"Balanced",
"Beautiful",
"Big",
"Bold",
"Brave",
"Breezy",
"Bright",
"Brisk",
"Calm",
"Candid",
"Careful",
"Cheerful",
"Chosen",
"Civil",
"Classic",
"Clean",
"Clear",
"Clever",
"Cool",
"Cosmic",
"Crafty",
"Crimson",
"Crisp",
"Cunning",
"Curious",
"Cute",
"Dapper",
"Daring",
"Dazzling",
"Deep",
"Deft",
"Determined",
"Devoted",
"Diligent",
"Distant",
"Divine",
"Eager",
"Early",
"Earnest",
"Easy",
"Efficient",
"Elder",
"Electric",
"Elegant",
"Endless",
"Energetic",
"Epic",
"Equal",
"Eternal",
"Exact",
"Fabled",
"Faithful",
"Famous",
"Fantastic",
"Fast",
"Fearless",
"Feisty",
"Fertile",
"Festive",
"Fiery",
"Fine",
"Firm",
"Fond",
"Frank",
"Free",
"Fresh",
"Friendly",
"Frosty",
"Gallant",
"Gentle",
"Genuine",
"Gifted",
"Glad",
"Gleaming",
"Glossy",
"Golden",
"Good",
"Gorgeous",
"Graceful",
"Grand",
"Grateful",
"Great",
"Handsome",
"Happy",
"Hardy",
"Hearty",
"Helpful",
"Heroic",
"Hidden",
"Honest",
"Hopeful",
"Hot",
"Humble",
"Ideal",
"Immense",
"Inner",
"Iron",
"Jolly",
"Joyful",
"Keen",
"Kind",
"Lively",
"Lovely",
"Loyal",
"Lucid",
"Lucky",
"Lunar",
"Magic",
"Major",
"Merry",
"Mighty",
"Mild",
"Modern",
"Modest",
"Mystic",
"Neat",
"Nice",
"Nimble",
"Noble",
"Northern",
"Novel",
"Open",
"Orbital",
"Original",
"Patient",
"Peaceful",
"Perfect",
"Playful",
"Pleasant",
"Polished",
"Polite",
"Powerful",
"Precise",
"Pretty",
"Prime",
"Proud",
"Prudent",
"Quick",
"Quiet",
"Radiant",
"Rapid",
"Ready",
"Refined",
"Regal",
"Reliable",
"Rich",
"Robust",
"Royal",
"Ruby",
"Rustic",
"Sage",
"Scarlet",
"Secret",
"Serene",
"Sharp",
"Shining",
"Silent",
"Silver",
"Sincere",
"Skilled",
"Sleek",
"Smart",
"Smooth",
"Snowy",
"Solar",
"Solid",
"Southern",
"Special",
"Spirited",
"Splendid",
"Spry",
"Stately",
"Steady",
"Sterling",
"Stormy",
"Strategic",
"Strong",
"Sturdy",
"Subtle",
"Sunny",
"Superb",
"Supreme",
"Swift",
"Tactful",
"Tender",
"Thankful",
"Thoughtful",
"Tidy",
"Timely",
"Tranquil",
"True",
"Trusty",
"Unique",
"Upbeat",
"Valiant",
"Velvet",
"Vibrant",
"Vigilant",
"Violet",
"Vivid",
"Warm",
"Watchful",
"Welcome",
"Whole",
"Wild",
"Willing",
"Wise",
"Witty",
"Wondrous",
"Worthy",
"Zealous",
"Zesty",
];
const FRUITS = [
"Almond",
"Apple",
"Apricot",
"Artichoke",
"Arugula",
"Asparagus",
"Aubergine",
"Avocado",
"Banana",
"Barley",
"Basil",
"Beetroot",
"Blackberry",
"Blackcurrant",
"Blueberry",
"Broccoli",
"Cabbage",
"Cantaloupe",
"Caper",
"Cardamom",
"Carrot",
"Cashew",
"Cauliflower",
"Celery",
"Chard",
"Cherry",
"Chestnut",
"Chickpea",
"Chicory",
"Chili",
"Chive",
"Cilantro",
"Clementine",
"Cloudberry",
"Clove",
"Coconut",
"Cranberry",
"Cucumber",
"Currant",
"Damson",
"Date",
"Dill",
"Durian",
"Eggplant",
"Elderberry",
"Endive",
"Fennel",
"Fig",
"Garlic",
"Ginger",
"Gooseberry",
"Grape",
"Grapefruit",
"Guava",
"Hazelnut",
"Honeydew",
"Horseradish",
"Jackfruit",
"Jicama",
"Kale",
"Kiwi",
"Kohlrabi",
"Kumquat",
"Leek",
"Lemon",
"Lentil",
"Lettuce",
"Lime",
"Longan",
"Loquat",
"Lychee",
"Mandarin",
"Mango",
"Marrow",
"Melon",
"Millet",
"Mint",
"Mulberry",
"Mushroom",
"Nectarine",
"Nutmeg",
"Oat",
"Okra",
"Olive",
"Onion",
"Orange",
"Oregano",
"Papaya",
"Paprika",
"Parsley",
"Parsnip",
"Passionfruit",
"Pea",
"Peach",
"Peanut",
"Pear",
"Pecan",
"Pepper",
"Persimmon",
"Pineapple",
"Pistachio",
"Plantain",
"Plum",
"Pomegranate",
"Pomelo",
"Poppy",
"Potato",
"Pumpkin",
"Quince",
"Radish",
"Raisin",
"Raspberry",
"Rhubarb",
"Rosemary",
"Rutabaga",
"Saffron",
"Sesame",
"Shallot",
"Sorrel",
"Soybean",
"Spinach",
"Sprout",
"Squash",
"Strawberry",
"Tamarind",
"Tangerine",
"Tarragon",
"Thyme",
"Tomato",
"Turnip",
"Vanilla",
"Walnut",
"Wasabi",
"Watercress",
"Watermelon",
"Yam",
"Zucchini",
];
const pick = (list) =>
list[crypto.getRandomValues(new Uint32Array(1))[0] % list.length];
export function randomAlias() {
return pick(ADJECTIVES) + " " + pick(FRUITS);
}
export const ALIAS_WORDS = { ADJECTIVES, FRUITS };