Каталог плагинов: полный локальный 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:
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"enable": true,
|
||||
"references": [
|
||||
"Microsoft.Data.Sqlite"
|
||||
]
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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`; контроллеров и статики в модуле нет.
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"enable": true,
|
||||
"dynamic": true,
|
||||
"tree": [
|
||||
"ModInit.cs"
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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 => ({ '&': '&', '<': '<', '>': '>' }[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>
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"enable": true,
|
||||
"version": 1,
|
||||
"namespace": "LogUserRequest",
|
||||
"logDay": 90
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"enable": true,
|
||||
"dynamic": true,
|
||||
"tree": [
|
||||
"Controllers",
|
||||
"Models",
|
||||
"AppDbContext.cs",
|
||||
"ModInit.cs",
|
||||
"RequestListener.cs"
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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));
|
||||
|
||||
})();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -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`).
|
||||
@@ -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
@@ -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>
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"enable": true,
|
||||
"tree": [
|
||||
"Services",
|
||||
"Controller.cs",
|
||||
"ModInit.cs",
|
||||
"ModuleConf.cs"
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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-код>` — ссылку формирует плагин.
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
Reference in New Issue
Block a user