wip6
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
{{define "base"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru" class="h-100">
|
||||
<head>
|
||||
<title>{{block "title" .}}{{.Title}}{{end}}</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="description" content="{{block "metadescription" .}}{{end}}">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="keywords" content="{{block "metakeywords" .}}{{end}}">
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
||||
<style>.cursor-pointer{cursor:pointer}.cursor-help{cursor:help}</style>
|
||||
<script async type="module" src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.esm.js"></script>
|
||||
<script async nomodule src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.js"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
{{block "head" .}}{{end}}
|
||||
</head>
|
||||
<body class="d-flex flex-column h-100 bg-dark text-light">
|
||||
<header class="sticky-top bg-dark border-bottom border-secondary">
|
||||
<nav class="navbar navbar-expand-lg navbar-dark container px-2">
|
||||
<a class="navbar-brand d-flex align-items-center gap-2" href="/" title="На главную">
|
||||
<span>{{.Title}}</span>
|
||||
</a>
|
||||
<button class="navbar-toggler"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#navbarNav"
|
||||
aria-controls="navbarNav"
|
||||
>
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
{{range .Navigation}}
|
||||
{{if .Children}}
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle d-flex align-items-center"
|
||||
href="#"
|
||||
role="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
{{if .Icon}}<ion-icon name="{{.Icon}}" class="me-1"></ion-icon> {{end}}{{.Title}}
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-dark">
|
||||
{{range .Children}}
|
||||
<li>
|
||||
<a class="dropdown-item d-flex align-items-center gap-2" target="_blank" href="{{.Url}}">
|
||||
{{if .Icon}}<ion-icon name="{{.Icon}}"></ion-icon> {{end}}{{.Title}}
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</li>
|
||||
{{else}}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link d-flex align-items-center" target="_blank" href="{{.Url}}">
|
||||
{{if .Icon}}<ion-icon name="{{.Icon}}" class="me-1"></ion-icon> {{end}}{{.Title}}
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="flex-grow-1 container py-4">
|
||||
{{block "header" .}}{{end}}
|
||||
<div class="content-wrapper">
|
||||
{{block "content" .}}{{end}}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="bg-dark border-top border-secondary py-4">
|
||||
<div class="container text-center">
|
||||
<div class="d-flex flex-wrap justify-content-center gap-3 mb-3">
|
||||
{{range .FooterLinks}}
|
||||
<a target="_blank" href="{{.Url}}" class="text-light text-decoration-none d-flex align-items-center gap-1">
|
||||
{{if .Icon}}<ion-icon name="{{.Icon}}"></ion-icon> {{end}}{{.Title}}
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="small text-secondary d-inline-flex align-items-center gap-1">
|
||||
<a class="text-secondary" href="{{.RepoUrl}}/releases/tag/v{{.Version}}" target="_blank">
|
||||
<ion-icon name="pricetag-outline"></ion-icon> iptvc v{{.Version}}
|
||||
</a> Антон Аксенов © 2025-2026 MIT License
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||
<div class="toast align-items-center text-bg-success border-0" role="alert" id="clipboardToast">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body" id="clipboardToastBody"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
{{block "footer" .}}{{end}}
|
||||
|
||||
<script>
|
||||
function showToast(message) {
|
||||
const toastEl = document.getElementById('clipboardToast');
|
||||
const toastBodyEl = document.getElementById('clipboardToastBody');
|
||||
toastBodyEl.innerHTML = message;
|
||||
const toast = new bootstrap.Toast(toastEl, {delay: 5000});
|
||||
toast.show();
|
||||
}
|
||||
|
||||
function copyPlaylistUrl(code) {
|
||||
const url = '{{.BaseUrl}}/' + code;
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard
|
||||
.writeText(url)
|
||||
.then(() => showToast(`Ссылка на плейлист '${code}' скопирована в буфер обмена`))
|
||||
.catch(err => console.error('Failed to copy:', err));
|
||||
} else {
|
||||
try {
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = url;
|
||||
textArea.style.position = "fixed";
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
const successful = document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
if (successful) {
|
||||
showToast(`Ссылка на плейлист '${code}' скопирована в буфер обмена`);
|
||||
} else {
|
||||
showToast('Ошибка при копировании ссылки', true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Fallback copy failed:', err);
|
||||
showToast('Ошибка при копировании ссылки', true);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,493 @@
|
||||
{{define "title"}}[{{.Playlist.Code}}] {{.Playlist.Name}} - {{.Title}}{{end}}
|
||||
|
||||
{{define "metadescription"}}Смотреть бесплатный самообновляемый плейлист {{.Playlist.Name}}, проверить статус, {{.Playlist.Description}}{{end}}
|
||||
|
||||
{{define "head"}}
|
||||
<style>
|
||||
img.tvg-logo{max-width:80px;max-height:80px;padding:2px;border-radius:5px}
|
||||
tr.chrow td{padding:3px}
|
||||
td.chindex{width:1%}
|
||||
td.chlogo{width:100px}
|
||||
div.chlist-table{max-height:550px}
|
||||
textarea.m3u-raw{font-size:.7rem}
|
||||
</style>
|
||||
<script>
|
||||
const DEFAULT_LOGO = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" fill="#333"/><text x="50" y="55" text-anchor="middle" fill="#999" font-size="14">no logo</text></svg>')
|
||||
|
||||
function setDefaultLogo(imgtag) {
|
||||
imgtag.onerror = null
|
||||
imgtag.src = DEFAULT_LOGO
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
{{define "header"}}
|
||||
<h2>О плейлисте: {{.Playlist.Name}}</h2>
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
{{$pls := .Playlist}}
|
||||
<div class="row">
|
||||
<div class="col-lg-7">
|
||||
<ul class="nav nav-tabs">
|
||||
<li class="nav-item small">
|
||||
<a class="nav-link active"
|
||||
type="button"
|
||||
href="#tab-data"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#tab-data"
|
||||
>
|
||||
<ion-icon name="radio-outline"></ion-icon> Основные данные
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item small">
|
||||
<a class="nav-link"
|
||||
type="button"
|
||||
href="#tab-raw"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#tab-raw"
|
||||
>
|
||||
<ion-icon name="document-text-outline"></ion-icon> Исходный текст
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item small">
|
||||
<a class="nav-link"
|
||||
type="button"
|
||||
href="#tab-abuse"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#tab-abuse"
|
||||
>
|
||||
<ion-icon name="wallet-outline"></ion-icon> Правообладателям
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active" id="tab-data" tabindex="0">
|
||||
<table class="table table-dark table-hover small mb-lg-5">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th class="w-25" scope="row">Код</th>
|
||||
<th class="text-break">
|
||||
<span class="pe-3 font-monospace">{{$pls.Code}}</span>
|
||||
{{if not $pls.IsKnown}}
|
||||
<span class="cursor-help badge small text-dark bg-secondary"
|
||||
title="Не проверялся"
|
||||
>unknown</span>
|
||||
{{else if $pls.IsOnline}}
|
||||
<span class="cursor-help badge small text-dark bg-success"
|
||||
title="Вероятно, работает"
|
||||
>online</span>
|
||||
{{else}}
|
||||
<span class="cursor-help badge small text-dark bg-danger"
|
||||
title="Вероятно, не работает"
|
||||
>offline</span>
|
||||
{{end}}
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Описание</th>
|
||||
<td class="text-break"><p class="mb-0">{{$pls.Description}}</p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Короткая ссылка</th>
|
||||
<td>
|
||||
<span onclick="copyPlaylistUrl('{{$pls.Code}}')"
|
||||
class="cursor-pointer"
|
||||
title="Нажми на ссылку, чтобы скопировать её в буфер обмена"
|
||||
>
|
||||
<b class="cursor-pointer font-monospace text-break">{{baseUrl $pls.Code}}</b>
|
||||
<ion-icon name="copy-outline"></ion-icon>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Источник</th>
|
||||
<td class="text-break">{{$pls.Source}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Наполнение</th>
|
||||
<td class="text-break">
|
||||
{{if and $pls.IsKnown $pls.IsOnline}}
|
||||
{{if $pls.HasTokens}}
|
||||
<span class="cursor-help badge bg-info text-dark">
|
||||
<ion-icon name="paw"></ion-icon>
|
||||
</span> могут быть нестабильные каналы<br>
|
||||
{{end}}
|
||||
|
||||
{{if hasTag $pls.Tags "adult"}}
|
||||
<span class="cursor-help badge small bg-warning text-dark">18+</span> есть каналы для взрослых<br>
|
||||
{{end}}
|
||||
|
||||
<ion-icon name="folder-open-outline"></ion-icon> группы: {{groupCount $pls}}<br>
|
||||
<ion-icon name="videocam-outline"></ion-icon> каналы:
|
||||
<span class="cursor-help text-success" title="Возможно, рабочие каналы">
|
||||
{{$pls.OnlineCount}} ({{$pls.OnlinePercent}}%)
|
||||
</span>
|
||||
+
|
||||
<span class="cursor-help text-danger" title="Возможно, НЕрабочие каналы">
|
||||
{{$pls.OfflineCount}} ({{$pls.OfflinePercent}}%)
|
||||
</span>
|
||||
= {{channelCount $pls}}
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Возможности</th>
|
||||
<td class="text-break">
|
||||
{{if and $pls.IsKnown $pls.IsOnline}}
|
||||
<ion-icon name="newspaper-outline"></ion-icon> Программа передач: {{if $pls.HasTvg}}есть{{else}}нет{{end}}<br>
|
||||
<ion-icon name="play-back"></ion-icon> Перемотка (архив): {{if $pls.HasCatchup}}есть{{else}}нет{{end}}
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="text-secondary">
|
||||
<th scope="row">M3U</th>
|
||||
<td class="text-break">{{$pls.Url}}</td>
|
||||
</tr>
|
||||
<tr class="text-secondary">
|
||||
<th class="w-25" scope="row">Проверка плейлиста</th>
|
||||
<td class="text-break">
|
||||
<span title="Фактическая метка времени окончания проверки плейлиста">
|
||||
{{formatDate $pls.CheckedAt}}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{{if and $pls.IsKnown (not $pls.IsOnline)}}
|
||||
<tr class="text-secondary">
|
||||
<th class="w-25" scope="row">Ошибка проверки</th>
|
||||
<td class="text-break">{{$pls.Content}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{{if $pls.Attributes}}
|
||||
<h4>Дополнительные атрибуты</h4>
|
||||
<table class="table table-dark table-hover small font-monospace">
|
||||
<tbody>
|
||||
{{range $attr, $val := $pls.Attributes}}
|
||||
<tr>
|
||||
<th class="w-25" scope="row">{{$attr}}</th>
|
||||
<td class="text-break">{{$val}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="tab-pane fade" id="tab-raw" tabindex="1">
|
||||
<button class="btn btn-sm btn-success my-3"
|
||||
id="saveM3UBtn"
|
||||
onclick="savePlaylist()"
|
||||
>
|
||||
<ion-icon name="download-outline"></ion-icon> {{$pls.Code}}.m3u8
|
||||
</button>
|
||||
<textarea class="form-control bg-dark text-light font-monospace mb-3 mb-md-0 m3u-raw"
|
||||
rows="40"
|
||||
id="m3u-raw"
|
||||
readonly
|
||||
>{{$pls.Content}}</textarea>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="tab-abuse" tabindex="2">
|
||||
<p class="my-3">
|
||||
Данные, представленные на данной странице, получены автоматически из открыто доступных в
|
||||
интернете IPTV-плейлистов, опубликованных третьими лицами.
|
||||
При наличии технической возможности, источник плейлиста может быть указан на вкладке "Основные
|
||||
данные".
|
||||
</p>
|
||||
<p class="my-3">
|
||||
Сервис {{baseUrl ""}} не размещает и не транслирует медиаконтент, не создаёт, не призывает
|
||||
использовать и распространять плейлисты третьих лиц, а также не оказывает услуг по ретрансляции
|
||||
телепрограмм.
|
||||
</p>
|
||||
<p class="my-3">
|
||||
Информация о телеканалах (наименования, логотипы, технический статус и другие сведения)
|
||||
формируется исключительно путём обработки содержимого самого плейлиста.
|
||||
Вся информация носит технический и ознакомительный характер, и её достоверность не гарантируется.
|
||||
</p>
|
||||
<p class="my-3">
|
||||
Все права на торговые марки и графические изображения принадлежат их законным владельцам.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-5">
|
||||
<h4>Список каналов: <span id="chcount">{{channelCount $pls}}</span></h4>
|
||||
{{if gt (channelCount $pls) 0}}
|
||||
{{if gt (groupCount $pls) 1}}
|
||||
<div class="row my-3">
|
||||
<div class="col-12">
|
||||
{{if gt (channelCount $pls) 100}}
|
||||
<div class="alert alert-warning small" role="alert" id="chListLoading">
|
||||
<div class="spinner-border text-success spinner-border-sm" role="status"></div>
|
||||
Загрузка...
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="input-group">
|
||||
<select id="groupSelector"
|
||||
class="form-select form-select-sm border-secondary bg-dark text-light"
|
||||
onchange="updateFilter()"
|
||||
>
|
||||
<option selected value="all">Все группы</option>
|
||||
{{range $pls.Groups}}
|
||||
<option value="{{.Id}}">{{.Name}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
|
||||
<button type="button"
|
||||
onclick="resetGroup()"
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
title="Сбросить группу"
|
||||
>
|
||||
<ion-icon name="close-outline"></ion-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="row my-3">
|
||||
<div class="col-12">
|
||||
<div class="input-group">
|
||||
<input type="text"
|
||||
id="search-field"
|
||||
class="cursor-help form-control form-control-sm border-secondary bg-dark text-light fuzzy-search"
|
||||
placeholder="Поиск каналов..."
|
||||
title="Начни вводить название"
|
||||
/>
|
||||
|
||||
<input type="radio"
|
||||
class="btn-check"
|
||||
name="chFilter"
|
||||
id="chfAll"
|
||||
autocomplete="off"
|
||||
onclick="updateFilter()"
|
||||
checked
|
||||
>
|
||||
<label class="btn btn-sm btn-outline-secondary"
|
||||
for="chfAll"
|
||||
title="Выбрать все каналы"
|
||||
>
|
||||
<ion-icon name="radio-button-on-outline"></ion-icon>
|
||||
</label>
|
||||
|
||||
<input type="radio"
|
||||
class="btn-check"
|
||||
name="chFilter"
|
||||
id="chfOnline"
|
||||
autocomplete="off"
|
||||
onclick="updateFilter()"
|
||||
>
|
||||
<label class="btn btn-sm btn-outline-success"
|
||||
for="chfOnline"
|
||||
title="Выбрать только онлайн каналы"
|
||||
>
|
||||
<ion-icon name="radio-button-on-outline"></ion-icon>{{$pls.OnlineCount}}
|
||||
</label>
|
||||
|
||||
<input type="radio"
|
||||
class="btn-check"
|
||||
name="chFilter"
|
||||
id="chfOffline"
|
||||
autocomplete="off"
|
||||
onclick="updateFilter()"
|
||||
>
|
||||
<label class="btn btn-sm btn-outline-danger"
|
||||
for="chfOffline"
|
||||
title="Выбрать только оффлайн каналы"
|
||||
>
|
||||
<ion-icon name="radio-button-on-outline"></ion-icon>{{$pls.OfflineCount}}
|
||||
</label>
|
||||
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
onclick="resetSearch()"
|
||||
title="Сбросить фильтрацию"
|
||||
>
|
||||
<ion-icon name="close-outline"></ion-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="my-3">
|
||||
{{range $pls.Tags}}
|
||||
<input type="checkbox"
|
||||
class="btn-check"
|
||||
id="btn-tag-{{.}}"
|
||||
data-tag="{{.}}"
|
||||
autocomplete="off"
|
||||
onclick="updateFilter()"
|
||||
>
|
||||
<label class="badge btn btn-sm btn-outline-secondary rounded-pill"
|
||||
for="btn-tag-{{.}}"
|
||||
title="Нажми для фильтрации каналов по тегу, нажми ещё раз чтобы снять фильтр"
|
||||
>#{{.}}</label>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chlist-table overflow-auto">
|
||||
<table id="chlist" class="table table-dark table-hover small">
|
||||
<tbody class="list">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else if and $pls.IsKnown (not $pls.IsOnline)}}
|
||||
<div class="alert alert-danger small" role="alert">
|
||||
Ошибка плейлиста: {{$pls.Content}}
|
||||
</div>
|
||||
{{else if not $pls.IsKnown}}
|
||||
<div class="alert alert-warning small" role="alert" id="chListLoading">
|
||||
Список каналов сейчас неизвестен: плейлист ещё не проверялся, либо данные о последней проверке потеряли актуальность.
|
||||
<br><br>
|
||||
Вернитесь на эту страницу позже. Каналы будут известны, когда плейлист будет в статусе online.
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "footer"}}
|
||||
{{if and .Playlist.IsKnown .Playlist.IsOnline}}
|
||||
<script src="https://cdn.jsdelivr.net/npm/list.js@2.3.1/dist/list.min.js"></script>
|
||||
<script>
|
||||
function getChannelTemplate(channel) {
|
||||
const httpCode = channel.status ?? '(неизвестно)'
|
||||
const errText = !!channel.error
|
||||
? channel.error.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
: '(нет)'
|
||||
|
||||
const logoUrl = channel.attributes['tvg-logo'] ?? DEFAULT_LOGO
|
||||
const logoHint = !!channel.attributes['tvg-logo']
|
||||
? `Логотип канала '${channel.title}'`
|
||||
: `Нет логотипа для канала '${channel.title}'`
|
||||
|
||||
const statusIconColor = channel.isOnline ? 'text-success' : 'text-danger'
|
||||
const statusIconHint = channel.isOnline
|
||||
? 'Состояние: онлайн (возможно, работает прямо сейчас)"'
|
||||
: 'Состояние: оффлайн (не работал в момент проверки или ошибка проверки)"'
|
||||
|
||||
const adultIcon = channel.tags.indexOf('adult') !== -1
|
||||
? '<span class="badge small bg-warning text-dark" title="Канал для взрослых!">18+</span>'
|
||||
: ''
|
||||
|
||||
const pawIcon = channel.hasToken
|
||||
? '<span class="cursor-help badge small bg-info text-dark" title="Может быть нестабилен"><ion-icon name="paw"></ion-icon></span>'
|
||||
: ''
|
||||
|
||||
const tvgId = !!channel.attributes['tvg-id']
|
||||
? `<div title="Идентификатор канала для телепрограммы (tvg-id)" class="cursor-help"><ion-icon name="star-outline" class="me-1"></ion-icon> ${channel.attributes['tvg-id']}</div>`
|
||||
: ``
|
||||
|
||||
const mimeType = !!channel.contentType
|
||||
? `<div title="Тип контента (mime-type)" class="cursor-help"><ion-icon name="eye-outline" class="me-1"></ion-icon> ${channel.contentType}</div>`
|
||||
: ``
|
||||
|
||||
const tags = channel.tags.length > 0
|
||||
? `<ion-icon name="pricetag-outline" class="cursor-help me-1" title="Теги"></ion-icon> `
|
||||
+ channel.tags.map((tag) => `<span class="chtag">#${tag}</span>`).join(' ')
|
||||
: ``
|
||||
|
||||
return `<tr class="chrow" title="
HTTP: ${httpCode}
Error: ${errText}">
|
||||
<td class="chlogo text-center">
|
||||
<img class="tvg-logo" alt="${logoHint}" title="${logoHint}" src="${logoUrl}" onerror="setDefaultLogo(this)" />
|
||||
</td>
|
||||
<td class="text-break">
|
||||
<ion-icon name="radio-button-on-outline" class="cursor-help me-1 ${statusIconColor}" title="${statusIconHint}"></ion-icon>
|
||||
${adultIcon}
|
||||
${pawIcon}
|
||||
${channel.title}
|
||||
<div class="text-secondary small">
|
||||
${tvgId}
|
||||
${mimeType}
|
||||
${tags}
|
||||
</div>
|
||||
</td>
|
||||
</tr>`
|
||||
}
|
||||
|
||||
const options = {
|
||||
searchColumns: ['title'],
|
||||
item: (channel) => getChannelTemplate(channel),
|
||||
};
|
||||
const values = {{jsonChannels .Playlist.ViewChannels}}
|
||||
|
||||
const list = new List('chlist', options, values)
|
||||
list.on('updated', (data) => document.getElementById('chcount').innerText = data.visibleItems.length)
|
||||
document.getElementById('search-field').addEventListener('keyup', (e) => list.search(e.target.value))
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const alert = document.getElementById("chListLoading")
|
||||
!!alert && alert.remove()
|
||||
});
|
||||
|
||||
function savePlaylist() {
|
||||
const link = document.createElement("a");
|
||||
const content = document.getElementById("m3u-raw").value
|
||||
const file = new Blob([content], { type: 'text/plain' });
|
||||
link.href = URL.createObjectURL(file);
|
||||
link.download = "{{.Playlist.Code}}.m3u8";
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
}
|
||||
|
||||
function resetGroup() {
|
||||
document.getElementById('groupSelector').value = 'all'
|
||||
updateFilter()
|
||||
}
|
||||
|
||||
function resetSearch() {
|
||||
list.search('')
|
||||
document.getElementById('search-field').value = ''
|
||||
document.getElementById('chfAll').checked = true
|
||||
document.querySelectorAll('input[id*="btn-tag-"]:checked').forEach(tag => tag.checked = false)
|
||||
updateFilter()
|
||||
}
|
||||
|
||||
function updateFilter() {
|
||||
const selectedGroupId = document.getElementById('groupSelector')?.value ?? 'all';
|
||||
const tagsElements = document.querySelectorAll('input[id*="btn-tag-"]:checked')
|
||||
const tagsSelected = []
|
||||
tagsElements.forEach(tag => tagsSelected.push(tag.attributes['data-tag'].value));
|
||||
const activeType = document.querySelector('input[name="chFilter"]:checked').id;
|
||||
switch (activeType) {
|
||||
case 'chfAll':
|
||||
list.filter(item => {
|
||||
const chTags = item.values().tags
|
||||
const isGroupValid = item.values().groupId === selectedGroupId || selectedGroupId === 'all';
|
||||
const tagsIntersection = tagsSelected.filter(tagSelected => chTags.includes(tagSelected));
|
||||
const hasValidTags = tagsIntersection.length > 0 || tagsSelected.length === 0;
|
||||
return isGroupValid && hasValidTags;
|
||||
})
|
||||
break
|
||||
|
||||
case 'chfOnline':
|
||||
list.filter(item => {
|
||||
const isOnline = item.values().isOnline
|
||||
const chTags = item.values().tags
|
||||
const isGroupValid = item.values().groupId === selectedGroupId || selectedGroupId === 'all';
|
||||
const tagsIntersection = tagsSelected.filter(tagSelected => chTags.includes(tagSelected));
|
||||
const hasValidTags = tagsIntersection.length > 0 || tagsSelected.length === 0;
|
||||
return isGroupValid && isOnline && hasValidTags
|
||||
})
|
||||
break
|
||||
|
||||
case 'chfOffline':
|
||||
list.filter(item => {
|
||||
const isOffline = !item.values().isOnline
|
||||
const chTags = item.values().tags
|
||||
const isGroupValid = item.values().groupId === selectedGroupId || selectedGroupId === 'all';
|
||||
const tagsIntersection = tagsSelected.filter(tagSelected => chTags.includes(tagSelected));
|
||||
const hasValidTags = tagsIntersection.length > 0 || tagsSelected.length === 0;
|
||||
return isGroupValid && isOffline && hasValidTags
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,151 @@
|
||||
{{define "title"}}{{.Title}}{{end}}
|
||||
|
||||
{{define "metadescription"}}Самообновляемые бесплатные IPTV-плейлисты для домашнего просмотра по коротким ссылкам, списки каналов, проверка доступности{{end}}
|
||||
|
||||
{{define "metakeywords"}}самообновляемые,бесплатные,iptv-плейлисты,iptv,плейлисты{{end}}
|
||||
|
||||
{{define "head"}}
|
||||
<style>
|
||||
.card {transition: box-shadow .2s, transform .2s}
|
||||
.card.hover-success:hover {transform: translateY(-7px); box-shadow: rgba(var(--bs-success-rgb), 1) 0 5px 20px -5px}
|
||||
.card.hover-danger:hover {transform: translateY(-7px); box-shadow: rgba(var(--bs-danger-rgb), 1) 0 5px 20px -5px}
|
||||
.card.hover-secondary:hover {transform: translateY(-7px); box-shadow: rgba(var(--bs-secondary-rgb), 1) 0 5px 20px -5px}
|
||||
</style>
|
||||
<script>
|
||||
const DEFAULT_LOGO = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" fill="#333"/><text x="50" y="55" text-anchor="middle" fill="#999" font-size="14">no logo</text></svg>')
|
||||
|
||||
function setDefaultLogo(imgtag) {
|
||||
imgtag.onerror = null
|
||||
imgtag.src = DEFAULT_LOGO
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
{{define "header"}}
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center mb-4">
|
||||
<div class="mb-2">
|
||||
<h2 class="mb-0">Список плейлистов ({{.Count}})</h2>
|
||||
<div class="text-muted small">Изменён {{.UpdatedAt}} МСК</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
{{if .Error}}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<ion-icon name="alert-circle-outline" class="me-1"></ion-icon>{{.Error}}
|
||||
</div>
|
||||
{{else if eq (len .Playlists) 0}}
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<ion-icon name="information-circle-outline" class="me-1"></ion-icon>Список плейлистов пуст.
|
||||
Вернитесь на эту страницу позже — возможно, плейлисты ещё не добавлены или не проверены.
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="row g-4">
|
||||
{{range .Playlists}}
|
||||
{{$sc := statusClass .}}
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="card bg-dark text-light h-100 border border-{{$sc}} hover-{{$sc}} position-relative">
|
||||
<a href="/{{.Code}}/details" class="text-decoration-none">
|
||||
<div class="card-header d-flex align-items-center gap-2">
|
||||
<span class="font-monospace text-{{$sc}}">{{.Code}}</span>
|
||||
|
||||
{{if not .IsKnown}}
|
||||
<span class="cursor-help badge bg-{{$sc}} ms-auto"
|
||||
title="Плейлист ещё не проверялся, придётся подождать"
|
||||
>unknown</span>
|
||||
{{else if .IsOnline}}
|
||||
<span class="cursor-help badge bg-{{$sc}} ms-auto"
|
||||
title="Возможно, этот плейлист рабочий"
|
||||
>online</span>
|
||||
{{else}}
|
||||
<span class="cursor-help badge bg-{{$sc}} ms-auto"
|
||||
title="Этот плейлист нерабочий или его не удалось проверить"
|
||||
>offline</span>
|
||||
{{end}}
|
||||
|
||||
{{if and .IsKnown .IsOnline}}
|
||||
<span class="cursor-help badge border border-success"
|
||||
title="Процент рабочих каналов"
|
||||
>{{.OnlinePercent}}%</span>
|
||||
{{end}}
|
||||
|
||||
{{if hasTag .Tags "adult"}}
|
||||
<span class="cursor-help badge bg-warning text-dark"
|
||||
title="Есть каналы для взрослых!"
|
||||
>18+</span>
|
||||
{{end}}
|
||||
|
||||
{{if .HasTokens}}
|
||||
<span class="cursor-help badge bg-info text-dark"
|
||||
title="В плейлисте есть каналы, которые могут быть нестабильны"
|
||||
><ion-icon name="paw"></ion-icon></span>
|
||||
{{end}}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="card-body position-relative z-2">
|
||||
<a href="/{{.Code}}/details" class="text-decoration-none">
|
||||
<h5 class="card-title text-light">{{.Name}}</h5>
|
||||
</a>
|
||||
{{if .Description}}
|
||||
<p class="card-text small text-secondary d-none d-md-block">{{.Description}}</p>
|
||||
{{end}}
|
||||
<div class="d-flex flex-wrap gap-2 mb-1">
|
||||
{{if and .IsKnown .IsOnline}}
|
||||
<span class="badge border border-secondary">
|
||||
<ion-icon name="videocam-outline" class="me-1"></ion-icon> {{channelCount .}}<span class="d-none d-xl-inline-block"> каналов</span>
|
||||
</span>
|
||||
{{if gt (groupCount .) 0}}
|
||||
<span class="badge border border-secondary">
|
||||
<ion-icon name="folder-open-outline" class="me-1"></ion-icon> {{groupCount .}}<span class="d-none d-xl-inline-block"> групп</span>
|
||||
</span>
|
||||
{{end}}
|
||||
{{if .HasTvg}}
|
||||
<span class="badge border border-secondary">
|
||||
<ion-icon name="newspaper-outline" class="me-1"></ion-icon><span class="d-none d-xl-inline-block"> ТВ-программа</span>
|
||||
</span>
|
||||
{{end}}
|
||||
{{if .HasCatchup}}
|
||||
<span class="badge border border-secondary">
|
||||
<ion-icon name="play-back-outline" class="me-1"></ion-icon><span class="d-none d-xl-inline-block"> Архив</span>
|
||||
</span>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-footer cursor-pointer"
|
||||
onclick="copyPlaylistUrl('{{.Code}}')"
|
||||
title="Нажми на ссылку, чтобы скопировать её в буфер обмена"
|
||||
>
|
||||
<div class="d-flex justify-content-between align-items-center small">
|
||||
<span class="font-monospace text-truncate">
|
||||
{{baseUrl .Code}}
|
||||
</span>
|
||||
<ion-icon name="copy-outline"></ion-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if gt .PageCount 1}}
|
||||
<nav class="mt-4">
|
||||
<ul class="pagination justify-content-center">
|
||||
{{range $page := seq 1 .PageCount}}
|
||||
{{if eq $page $.PageCurrent}}
|
||||
<li class="page-item active" aria-current="page">
|
||||
<span class="page-link">{{$page}}</span>
|
||||
</li>
|
||||
{{else}}
|
||||
<li class="page-item">
|
||||
<a class="page-link bg-dark text-light" href="/page/{{$page}}">{{$page}}</a>
|
||||
</li>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</ul>
|
||||
</nav>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,23 @@
|
||||
{{define "title"}}Плейлист не найден - {{.Title}}{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8 col-lg-6 text-center">
|
||||
<div class="card bg-dark border-secondary">
|
||||
<div class="card-body">
|
||||
<ion-icon name="warning-outline" class="display-1 text-warning mb-3"></ion-icon>
|
||||
<h2 class="card-title">Плейлист <code>{{.Code}}</code> не найден</h2>
|
||||
<p class="card-text">
|
||||
Возможно, его здесь никогда не было, либо он уже был удалён.
|
||||
</p>
|
||||
<p class="text-muted small">
|
||||
Если хочешь, чтобы здесь был плейлист, предложи его к добавлению.
|
||||
</p>
|
||||
<a class="btn btn-outline-light" href="/" title="На главную">
|
||||
<ion-icon name="list-outline" class="me-1"></ion-icon>Перейти к списку плейлистов
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user