Files
iptvc/app/web/views/details.html
2026-07-21 12:37:30 +08:00

546 lines
31 KiB
HTML

{{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}
#qrContainer > img {margin: 0 auto;border: 10px solid white}
</style>
<script>
const DEFAULT_LOGO = 'data:image/png;base64,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'
function setDefaultLogo(imgtag) {
imgtag.onerror = null
imgtag.src = DEFAULT_LOGO
}
function showQrCode(code) {
const url = '{{.BaseUrl}}/' + code;
const container = document.getElementById('qrContainer');
container.innerHTML = '';
new QRCode(container, {
text: url,
width: 256,
height: 256,
colorDark: '#000000',
colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.H,
});
const modal = new bootstrap.Modal(document.getElementById('qrModal'));
modal.show();
}
</script>
<script src="/static/js/qrcode.min.js"></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"
>
<span class="mdi mdi-television-classic"></span>&nbsp;Основные&nbsp;данные
</a>
</li>
{{if .Tabs.Raw.Visible}}
<li class="nav-item small">
<a class="nav-link"
type="button"
href="#tab-raw"
data-bs-toggle="tab"
data-bs-target="#tab-raw"
>
<span class="mdi mdi-text-long"></span>&nbsp;Исходный&nbsp;текст
</a>
</li>
{{end}}
{{if .Tabs.Legal.Visible}}
<li class="nav-item small">
<a class="nav-link"
type="button"
href="#tab-abuse"
data-bs-toggle="tab"
data-bs-target="#tab-abuse"
>
<span class="mdi mdi-wallet-outline"></span>&nbsp;Правообладателям
</a>
</li>
{{end}}
</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 class="w-25" scope="row">Описание</th>
<td class="text-break"><p class="mb-0">{{$pls.Description}}</p></td>
</tr>
<tr>
<th class="w-25" 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>
<span class="mdi mdi-content-copy"></span>
</span>
</td>
</tr>
<tr>
<th class="w-25" scope="row">Источник</th>
<td class="text-break">{{$pls.Source}}</td>
</tr>
<tr>
<th class="w-25" 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">
<span class="mdi mdi-paw"></span>
</span>&nbsp;могут быть нестабильные каналы<br>
{{end}}
{{if hasTag $pls.Tags "adult"}}
<span class="cursor-help badge small bg-warning text-dark">18+</span>&nbsp;есть каналы для взрослых<br>
{{end}}
<span class="mdi mdi-folder-open-outline"></span>&nbsp;группы: {{groupCount $pls}}<br>
<span class="mdi mdi-video-outline"></span>&nbsp;каналы:
<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}}
<span class="mdi mdi-newspaper-variant-outline"></span>&nbsp;Программа передач:&nbsp;{{if $pls.HasTvg}}есть{{else}}нет{{end}}<br>
<span class="mdi mdi-skip-backward"></span>&nbsp;Перемотка&nbsp;(архив):&nbsp;{{if $pls.HasCatchup}}есть{{else}}нет{{end}}
{{end}}
</td>
</tr>
<tr class="">
<th class="text-secondary w-25" scope="row">M3U</th>
<td class="text-secondary text-break">{{$pls.Url}}</td>
</tr>
<tr class="">
<th class="text-secondary w-25" scope="row">Проверка плейлиста</th>
<td class="text-secondary 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>
{{if .Tabs.Raw.Visible}}
<div class="tab-pane fade" id="tab-raw" tabindex="1">
<button class="btn btn-sm btn-success my-3"
id="saveM3UBtn"
onclick="savePlaylist()"
>
<span class="mdi mdi-download"></span>&nbsp;{{$pls.Code}}.m3u
</button>
<button type="button"
class="btn btn-sm btn-outline-light ms-2"
onclick="showQrCode('{{$pls.Code}}')"
title="Показать QR-код"
>
<span class="mdi mdi-qrcode-scan"></span>&nbsp;QR-код
</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>
{{end}}
{{if .Tabs.Legal.Visible}}
<div class="tab-pane fade" id="tab-abuse" tabindex="2">
{{if .Tabs.Legal.Text}}
{{.Tabs.Legal.Text}}
{{else}}
<p class="my-3">
Данные, представленные на данной странице, получены автоматически из открыто доступных в
интернете IPTV-плейлистов, опубликованных третьими лицами.
При наличии технической возможности, источник плейлиста может быть указан на вкладке "Основные
данные".
</p>
<p class="my-3">
Сервис {{baseUrl ""}} не размещает и не транслирует медиаконтент, не создаёт, не призывает
использовать и распространять плейлисты третьих лиц, а также не оказывает услуг по ретрансляции
телепрограмм.
</p>
<p class="my-3">
Информация о телеканалах (наименования, логотипы, технический статус и другие сведения)
формируется исключительно путём обработки содержимого самого плейлиста.
Вся информация носит технический и ознакомительный характер, и её достоверность не гарантируется.
</p>
<p class="my-3">
Все права на торговые марки и графические изображения принадлежат их законным владельцам.
</p>
{{end}}
</div>
{{end}}
</div>
</div>
<div class="col-lg-5">
<h4>Список каналов:&nbsp;<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="Сбросить группу"
>
<span class="mdi mdi-close"></span>
</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="Выбрать все каналы"
>
<span class="mdi mdi-radiobox-marked"></span>
</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="Выбрать только онлайн каналы"
>
<span class="mdi mdi-radiobox-marked"></span>{{$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="Выбрать только оффлайн каналы"
>
<span class="mdi mdi-radiobox-marked"></span>{{$pls.OfflineCount}}
</label>
<button type="button"
class="btn btn-sm btn-outline-secondary"
onclick="resetSearch()"
title="Сбросить фильтрацию"
>
<span class="mdi mdi-close"></span>
</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"}}
<div class="modal fade" id="qrModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content bg-dark text-light border-secondary">
<div class="modal-header border-secondary">
<h5 class="modal-title">QR-код плейлиста</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body text-center">
<div id="qrContainer"></div>
</div>
</div>
</div>
</div>
{{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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
: '(нет)'
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="Может быть нестабилен"><span class="mdi mdi-paw"></span></span>'
: ''
const tvgId = !!channel.attributes['tvg-id']
? `<div title="Идентификатор канала для телепрограммы (tvg-id)" class="cursor-help"><span class="mdi mdi-star-outline me-1"></span>&nbsp;${channel.attributes['tvg-id']}</div>`
: ``
const mimeType = !!channel.contentType
? `<div title="Тип контента (mime-type)" class="cursor-help"><span class="mdi mdi-eye-outline me-1"></span>&nbsp;${channel.contentType}</div>`
: ``
const tags = channel.tags.length > 0
? `<span class="mdi mdi-tag-outline cursor-help me-1" title="Теги"></span>&nbsp;`
+ channel.tags.map((tag) => `<span class="chtag">#${tag}</span>`).join(' ')
: ``
return `<tr class="chrow" title="&#010;HTTP: ${httpCode}&#010;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">
<span class="mdi mdi-radiobox-marked cursor-help me-1 ${statusIconColor}" title="${statusIconHint}"></span>
${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}}.m3u";
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}}