wip6
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"axenov/iptv-checker/app/config"
|
||||
"axenov/iptv-checker/app/playlist"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// makeTestServer создаёт HTTP-сервер с тестовыми плейлистами в memCache.
|
||||
// Возвращает сервер и обёртку для добавления плейлистов в memCache.
|
||||
func makeTestServer(t *testing.T) (*httptest.Server, *Server) {
|
||||
t.Helper()
|
||||
cfg := config.Init("")
|
||||
cfg.App.Playlists = "testdata/playlists.ini"
|
||||
|
||||
srv := NewServer(cfg, nil)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
// API routes
|
||||
mux.HandleFunc("GET /api/playlists", srv.handleAPIGetAll)
|
||||
mux.HandleFunc("GET /api/playlists/{code}", srv.handleAPIGetOne)
|
||||
mux.HandleFunc("GET /api/playlists/{code}/channels", srv.handleAPIGetChannels)
|
||||
mux.HandleFunc("GET /api/version", srv.handleAPIVersion)
|
||||
mux.HandleFunc("GET /api/health", srv.handleAPIHealth)
|
||||
mux.HandleFunc("GET /api/stats", srv.handleAPIStats)
|
||||
mux.HandleFunc("GET /api/openapi.json", srv.handleOpenAPISpec)
|
||||
mux.HandleFunc("GET /api", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/api/", http.StatusMovedPermanently)
|
||||
})
|
||||
mux.Handle("GET /api/", http.StripPrefix("/api/", swaggerFileServer))
|
||||
// page routes
|
||||
mux.HandleFunc("GET /{$}", srv.handleHome)
|
||||
mux.HandleFunc("GET /{path...}", srv.handleRoute)
|
||||
ts := httptest.NewServer(mux)
|
||||
t.Cleanup(ts.Close)
|
||||
return ts, srv
|
||||
}
|
||||
|
||||
// putPlaylist кладёт плейлист в in-memory кеш сервера
|
||||
func (s *Server) putPlaylist(pls playlist.Playlist) {
|
||||
s.memMu.Lock()
|
||||
defer s.memMu.Unlock()
|
||||
if s.memCache == nil {
|
||||
s.memCache = make(map[string]playlist.Playlist)
|
||||
}
|
||||
s.memCache[pls.Code] = pls
|
||||
}
|
||||
|
||||
// makeOnlinePlaylist создаёт тестовый плейлист с онлайн/оффлайн каналами
|
||||
func makeOnlinePlaylist() playlist.Playlist {
|
||||
return playlist.Playlist{
|
||||
Code: "test1",
|
||||
Name: "Test Playlist 1",
|
||||
Description: "A test playlist",
|
||||
Url: "http://example.com/test1.m3u8",
|
||||
Source: "test source",
|
||||
IsOnline: true,
|
||||
CheckedAt: 1700000000,
|
||||
Attributes: map[string]string{"url-tvg": "http://example.com/epg.xml"},
|
||||
Groups: map[string]playlist.Group{
|
||||
"group1": {Id: "group1", Name: "Group One"},
|
||||
"group2": {Id: "group2", Name: "Group Two"},
|
||||
},
|
||||
Channels: map[string]playlist.Channel{
|
||||
"id1": {
|
||||
Id: "id1",
|
||||
Title: "Channel One",
|
||||
URL: "http://example.com/stream1.m3u8",
|
||||
GroupId: "group1",
|
||||
Attributes: map[string]string{"tvg-id": "ch1", "tvg-logo": "http://example.com/logo1.png"},
|
||||
Status: 200,
|
||||
IsOnline: true,
|
||||
Tags: []string{"hd"},
|
||||
},
|
||||
"id2": {
|
||||
Id: "id2",
|
||||
Title: "Channel Two",
|
||||
URL: "http://example.com/stream2.m3u8",
|
||||
GroupId: "group2",
|
||||
Attributes: map[string]string{"tvg-id": "ch2", "tvg-logo": "http://example.com/logo2.png"},
|
||||
Status: 404,
|
||||
IsOnline: false,
|
||||
Tags: []string{"untagged"},
|
||||
},
|
||||
"id3": {
|
||||
Id: "id3",
|
||||
Title: "Channel Three",
|
||||
URL: "http://example.com/stream3.m3u8",
|
||||
GroupId: "group1",
|
||||
Attributes: map[string]string{"tvg-id": "ch3", "tvg-logo": "http://example.com/logo3.png"},
|
||||
Status: 200,
|
||||
IsOnline: true,
|
||||
Tags: []string{"hd", "news"},
|
||||
},
|
||||
},
|
||||
OnlineCount: 2,
|
||||
OfflineCount: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// fetchBody читает тело HTTP-ответа целиком и возвращает его как строку
|
||||
func fetchBody(t *testing.T, resp *http.Response) string {
|
||||
t.Helper()
|
||||
defer resp.Body.Close()
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %s", err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// TestDetailsPageRendersChannelsTable проверяет, что на странице деталей
|
||||
// онлайн-плейлиста присутствует таблица с каналами и данными для list.js
|
||||
func TestDetailsPageRendersChannelsTable(t *testing.T) {
|
||||
ts, srv := makeTestServer(t)
|
||||
srv.putPlaylist(makeOnlinePlaylist())
|
||||
|
||||
// Главная страница должна быть доступна
|
||||
resp, err := http.Get(ts.URL + "/")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /: %s", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("GET /: expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// Страница деталей — основной сценарий
|
||||
resp, err = http.Get(ts.URL + "/test1/details")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /test1/details: %s", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("GET /test1/details: expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
body := fetchBody(t, resp)
|
||||
|
||||
checks := []struct {
|
||||
name string
|
||||
want bool
|
||||
}{
|
||||
// Заголовок и базовая структура
|
||||
{"заголовок 'Список каналов'", strings.Contains(body, "Список каналов")},
|
||||
{"id таблицы 'chlist'", strings.Contains(body, `id="chlist"`)},
|
||||
{"класс 'list' у tbody", strings.Contains(body, `class="list"`)},
|
||||
{"chcount = 3", strings.Contains(body, `<span id="chcount">3</span>`)},
|
||||
|
||||
// Данные каналов в JSON
|
||||
{"values JSON", strings.Contains(body, "const values =")},
|
||||
{"Channel One в JSON", strings.Contains(body, "Channel One")},
|
||||
{"Channel Two в JSON", strings.Contains(body, "Channel Two")},
|
||||
{"Channel Three в JSON", strings.Contains(body, "Channel Three")},
|
||||
|
||||
// Группы (отображаются когда > 1)
|
||||
{"id группы group1", strings.Contains(body, `value="group1"`)},
|
||||
{"id группы group2", strings.Contains(body, `value="group2"`)},
|
||||
|
||||
// Теги
|
||||
{"тег #hd", strings.Contains(body, "btn-tag-hd")},
|
||||
{"тег #news", strings.Contains(body, "btn-tag-news")},
|
||||
{"тег #untagged", strings.Contains(body, "btn-tag-untagged")},
|
||||
|
||||
// list.js
|
||||
{"list.js подключён", strings.Contains(body, "list.js@2.3.1")},
|
||||
{"getChannelTemplate функция", strings.Contains(body, "function getChannelTemplate")},
|
||||
{"опции list.js", strings.Contains(body, "const options =")},
|
||||
{"new List('chlist')", strings.Contains(body, "new List('chlist'")},
|
||||
{"getChannelTemplate возвращает <tr>", strings.Contains(body, "chrow")},
|
||||
|
||||
// Счётчики
|
||||
{"OnlineCount отображается (~66%)", strings.Contains(body, "2 (66%)") || strings.Contains(body, "2 (67%)")},
|
||||
{"OfflineCount отображается (~33%)", strings.Contains(body, "1 (33%)") || strings.Contains(body, "1 (34%)")},
|
||||
}
|
||||
|
||||
for _, c := range checks {
|
||||
if !c.want {
|
||||
t.Errorf("page missing: %s", c.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetailsPageOfflinePlaylist проверяет, что для оффлайн-плейлиста
|
||||
// (IsKnown=true, IsOnline=false) вместо таблицы каналов показывается
|
||||
// сообщение об ошибке
|
||||
func TestDetailsPageOfflinePlaylist(t *testing.T) {
|
||||
ts, srv := makeTestServer(t)
|
||||
srv.putPlaylist(playlist.Playlist{
|
||||
Code: "test1",
|
||||
Name: "Test Playlist 1",
|
||||
Url: "http://example.com/test1.m3u8",
|
||||
IsOnline: false,
|
||||
Content: "HTTP status 404",
|
||||
CheckedAt: 1700000000,
|
||||
})
|
||||
|
||||
resp, err := http.Get(ts.URL + "/test1/details")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /test1/details: %s", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
body := fetchBody(t, resp)
|
||||
|
||||
if !strings.Contains(body, "Ошибка плейлиста") {
|
||||
t.Error("offline playlist should show error message")
|
||||
}
|
||||
if !strings.Contains(body, "HTTP status 404") {
|
||||
t.Error("offline playlist should show error text from Content")
|
||||
}
|
||||
if strings.Contains(body, `id="chlist"`) {
|
||||
t.Error("offline playlist should NOT render channels table")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetailsPageUnknownPlaylist проверяет, что для непроверенного плейлиста
|
||||
// (IsKnown=false) вместо таблицы каналов показывается предупреждение
|
||||
func TestDetailsPageUnknownPlaylist(t *testing.T) {
|
||||
ts, _ := makeTestServer(t)
|
||||
|
||||
resp, err := http.Get(ts.URL + "/test1/details")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /test1/details: %s", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
body := fetchBody(t, resp)
|
||||
|
||||
if !strings.Contains(body, "Список каналов сейчас неизвестен") {
|
||||
t.Error("unknown playlist should show 'list unknown' warning")
|
||||
}
|
||||
if strings.Contains(body, `id="chlist"`) {
|
||||
t.Error("unknown playlist should NOT render channels table")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetailsPageNonexistentPlaylist проверяет, что несуществующий код
|
||||
// возвращает 404
|
||||
func TestDetailsPageNonexistentPlaylist(t *testing.T) {
|
||||
ts, _ := makeTestServer(t)
|
||||
|
||||
resp, err := http.Get(ts.URL + "/nonexistent/details")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /nonexistent/details: %s", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026, Антон Аксенов
|
||||
* This file is part of iptvc project
|
||||
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
|
||||
*/
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"axenov/iptv-checker/app"
|
||||
"axenov/iptv-checker/app/playlist"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// handleHome — главная страница со списком плейлистов
|
||||
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
|
||||
playlists, updatedAt, err := s.getPlaylistsView()
|
||||
errorMsg := ""
|
||||
if err != nil {
|
||||
log.Printf("Error loading playlists: %s", err)
|
||||
errorMsg = "Не удалось загрузить список плейлистов. Проверьте наличие файла playlists.ini."
|
||||
playlists = []PlaylistView{}
|
||||
updatedAt = ""
|
||||
}
|
||||
|
||||
count := len(playlists)
|
||||
pageSize := int(s.cfg.Site.PageSize)
|
||||
pageCurrent := 1
|
||||
pageCount := 1
|
||||
|
||||
// pagination
|
||||
if pageSize > 0 && count > pageSize {
|
||||
pageCount = int(math.Ceil(float64(count) / float64(pageSize)))
|
||||
if pageArg := r.PathValue("page"); pageArg != "" {
|
||||
if p, err := strconv.Atoi(pageArg); err == nil && p >= 1 && p <= pageCount {
|
||||
pageCurrent = p
|
||||
}
|
||||
}
|
||||
offset := (pageCurrent - 1) * pageSize
|
||||
if offset >= count {
|
||||
offset = 0
|
||||
pageCurrent = 1
|
||||
}
|
||||
end := offset + pageSize
|
||||
if end > count {
|
||||
end = count
|
||||
}
|
||||
playlists = playlists[offset:end]
|
||||
}
|
||||
|
||||
data := &PageData{
|
||||
Title: s.cfg.Site.Header.Title,
|
||||
BaseUrl: s.cfg.Site.BaseUrl,
|
||||
RepoUrl: s.cfg.Site.RepoUrl,
|
||||
Version: app.VERSION,
|
||||
UpdatedAt: updatedAt,
|
||||
Navigation: s.cfg.Site.Header.Navigation,
|
||||
FooterLinks: s.cfg.Site.FooterLinks,
|
||||
Playlists: playlists,
|
||||
Count: count,
|
||||
PageCount: pageCount,
|
||||
PageCurrent: pageCurrent,
|
||||
Error: errorMsg,
|
||||
}
|
||||
|
||||
s.templates.Render(w, "list", data)
|
||||
}
|
||||
|
||||
// handleDetails — страница с описанием плейлиста
|
||||
func (s *Server) handleDetails(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.PathValue("code")
|
||||
if code == "" {
|
||||
s.renderNotFound(w, r, "")
|
||||
return
|
||||
}
|
||||
|
||||
view, ok := s.getPlaylistView(code)
|
||||
if !ok {
|
||||
s.renderNotFound(w, r, code)
|
||||
return
|
||||
}
|
||||
|
||||
data := &PageData{
|
||||
Title: s.cfg.Site.Header.Title,
|
||||
BaseUrl: s.cfg.Site.BaseUrl,
|
||||
RepoUrl: s.cfg.Site.RepoUrl,
|
||||
Version: app.VERSION,
|
||||
Navigation: s.cfg.Site.Header.Navigation,
|
||||
FooterLinks: s.cfg.Site.FooterLinks,
|
||||
Playlist: view,
|
||||
}
|
||||
|
||||
s.templates.Render(w, "details", data)
|
||||
}
|
||||
|
||||
// handleRoute — единый обработчик для не-API маршрутов
|
||||
// Разбирает путь и направляет на нужный обработчик:
|
||||
// /page/N → главная с пагинацией
|
||||
// /CODE/details → страница плейлиста
|
||||
// /CODE или /CODE.m3u[8] → редирект на URL плейлиста
|
||||
// остальное → 404
|
||||
func (s *Server) handleRoute(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.PathValue("path")
|
||||
if path == "" {
|
||||
s.handleHome(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
segments := strings.Split(path, "/")
|
||||
|
||||
switch len(segments) {
|
||||
case 1:
|
||||
// /page/N → пагинация
|
||||
if segments[0] == "page" {
|
||||
s.renderNotFound(w, r, "")
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(segments[0], "page/") {
|
||||
// shouldn't happen with single segment, but just in case
|
||||
pageStr := strings.TrimPrefix(segments[0], "page/")
|
||||
if _, err := strconv.Atoi(pageStr); err == nil {
|
||||
r.SetPathValue("page", pageStr)
|
||||
s.handleHome(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
// /CODE or /CODE.m3u[8] → redirect
|
||||
s.handleRedirect(w, r, segments[0])
|
||||
|
||||
case 2:
|
||||
// /page/N → пагинация
|
||||
if segments[0] == "page" {
|
||||
if _, err := strconv.Atoi(segments[1]); err == nil {
|
||||
r.SetPathValue("page", segments[1])
|
||||
s.handleHome(w, r)
|
||||
return
|
||||
}
|
||||
s.renderNotFound(w, r, "")
|
||||
return
|
||||
}
|
||||
// /CODE/details → страница плейлиста
|
||||
if segments[1] == "details" {
|
||||
r.SetPathValue("code", segments[0])
|
||||
s.handleDetails(w, r)
|
||||
return
|
||||
}
|
||||
s.renderNotFound(w, r, segments[0])
|
||||
|
||||
default:
|
||||
s.renderNotFound(w, r, "")
|
||||
}
|
||||
}
|
||||
|
||||
// handleRedirect — перенаправляет на URL плейлиста по коду
|
||||
func (s *Server) handleRedirect(w http.ResponseWriter, r *http.Request, path string) {
|
||||
// strip .m3u / .m3u8 suffix
|
||||
code := path
|
||||
code = strings.TrimSuffix(code, ".m3u8")
|
||||
code = strings.TrimSuffix(code, ".m3u")
|
||||
|
||||
// validate: alphanumeric
|
||||
if !isAlphanumeric(code) {
|
||||
s.renderNotFound(w, r, code)
|
||||
return
|
||||
}
|
||||
|
||||
// look up in ini
|
||||
ini, err := s.loadIni()
|
||||
if err != nil {
|
||||
s.renderNotFound(w, r, code)
|
||||
return
|
||||
}
|
||||
pls, exists := ini.Lists[code]
|
||||
if !exists || pls.Url == "" {
|
||||
s.renderNotFound(w, r, code)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, pls.Url, http.StatusFound)
|
||||
}
|
||||
|
||||
// handleAPIGetAll — API: список плейлистов
|
||||
func (s *Server) handleAPIGetAll(w http.ResponseWriter, r *http.Request) {
|
||||
playlists, _, err := s.getPlaylistsView()
|
||||
if err != nil {
|
||||
s.jsonError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]map[string]interface{}, 0, len(playlists))
|
||||
for _, view := range playlists {
|
||||
items = append(items, buildAPIPlaylist(view))
|
||||
}
|
||||
|
||||
s.jsonResponse(w, http.StatusOK, map[string]interface{}{
|
||||
"items": items,
|
||||
})
|
||||
}
|
||||
|
||||
// handleAPIGetOne — API: информация о плейлисте
|
||||
func (s *Server) handleAPIGetOne(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.PathValue("code")
|
||||
if code == "" {
|
||||
s.jsonError(w, http.StatusBadRequest, fmt.Errorf("code is required"))
|
||||
return
|
||||
}
|
||||
|
||||
view, ok := s.getPlaylistView(code)
|
||||
if !ok {
|
||||
s.jsonError(w, http.StatusNotFound, fmt.Errorf("playlist '%s' not found", code))
|
||||
return
|
||||
}
|
||||
|
||||
s.jsonResponse(w, http.StatusOK, buildAPIPlaylist(*view))
|
||||
}
|
||||
|
||||
// handleAPIGetChannels — API: каналы плейлиста
|
||||
func (s *Server) handleAPIGetChannels(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.PathValue("code")
|
||||
if code == "" {
|
||||
s.jsonError(w, http.StatusBadRequest, fmt.Errorf("code is required"))
|
||||
return
|
||||
}
|
||||
|
||||
view, ok := s.getPlaylistView(code)
|
||||
if !ok {
|
||||
s.jsonError(w, http.StatusNotFound, fmt.Errorf("playlist '%s' not found", code))
|
||||
return
|
||||
}
|
||||
|
||||
channels := make([]map[string]interface{}, 0, len(view.Channels))
|
||||
for _, ch := range view.ViewChannels {
|
||||
channels = append(channels, map[string]interface{}{
|
||||
"id": ch.Id,
|
||||
"title": ch.Title,
|
||||
"url": ch.URL,
|
||||
"groupId": ch.GroupId,
|
||||
"attributes": mapToAttributeArray(ch.Attributes),
|
||||
"status": ch.Status,
|
||||
"isOnline": ch.IsOnline,
|
||||
"error": ch.Error,
|
||||
"contentType": ch.ContentType,
|
||||
"tags": ch.Tags,
|
||||
"checkedAt": ch.CheckedAt,
|
||||
"hasToken": ch.HasToken,
|
||||
})
|
||||
}
|
||||
|
||||
s.jsonResponse(w, http.StatusOK, map[string]interface{}{
|
||||
"items": channels,
|
||||
})
|
||||
}
|
||||
|
||||
// buildAPIPlaylist формирует JSON-представление плейлиста для API
|
||||
func buildAPIPlaylist(view PlaylistView) map[string]interface{} {
|
||||
resp := map[string]interface{}{
|
||||
"code": view.Code,
|
||||
"name": view.Name,
|
||||
"description": view.Description,
|
||||
"url": view.Url,
|
||||
"source": view.Source,
|
||||
"isOnline": nil,
|
||||
"onlineCount": view.OnlineCount,
|
||||
"offlineCount": view.OfflineCount,
|
||||
"checkedAt": view.CheckedAt,
|
||||
"attributes": mapToAttributeArray(view.Attributes),
|
||||
"groups": groupsToArray(view.Groups),
|
||||
"tags": view.Tags,
|
||||
}
|
||||
|
||||
if view.IsKnown {
|
||||
resp["isOnline"] = view.IsOnline
|
||||
if view.IsOnline {
|
||||
delete(resp, "content")
|
||||
} else {
|
||||
resp["content"] = view.Content
|
||||
}
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
// mapToAttributeArray преобразует map атрибутов в массив {name, value}
|
||||
func mapToAttributeArray(attrs map[string]string) []map[string]string {
|
||||
result := make([]map[string]string, 0, len(attrs))
|
||||
for name, value := range attrs {
|
||||
result = append(result, map[string]string{
|
||||
"name": name,
|
||||
"value": value,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// groupsToArray преобразует map групп в массив объектов
|
||||
func groupsToArray(groups map[string]playlist.Group) []map[string]interface{} {
|
||||
result := make([]map[string]interface{}, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
result = append(result, map[string]interface{}{
|
||||
"id": g.Id,
|
||||
"name": g.Name,
|
||||
"attributes": mapToAttributeArray(g.Attributes),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// handleAPIVersion — API: версия
|
||||
func (s *Server) handleAPIVersion(w http.ResponseWriter, r *http.Request) {
|
||||
resp := map[string]interface{}{
|
||||
"iptvc": app.VERSION,
|
||||
}
|
||||
|
||||
if s.cache != nil {
|
||||
ctx := r.Context()
|
||||
info, err := s.cache.Info(ctx, "server").Result()
|
||||
if err == nil {
|
||||
resp["keydb"] = parseRedisVersion(info)
|
||||
}
|
||||
}
|
||||
|
||||
s.jsonResponse(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// handleAPIHealth — API: состояние
|
||||
func (s *Server) handleAPIHealth(w http.ResponseWriter, r *http.Request) {
|
||||
health := map[string]interface{}{
|
||||
"timestamp": time.Now().Unix(),
|
||||
}
|
||||
|
||||
if s.cache != nil {
|
||||
ctx := r.Context()
|
||||
health["redis"] = map[string]interface{}{
|
||||
"isConnected": s.cache.Ping(ctx).Err() == nil,
|
||||
}
|
||||
} else {
|
||||
health["redis"] = map[string]interface{}{
|
||||
"isConnected": false,
|
||||
}
|
||||
}
|
||||
|
||||
health["iniFile"] = map[string]interface{}{
|
||||
"path": s.cfg.App.Playlists,
|
||||
}
|
||||
|
||||
s.jsonResponse(w, http.StatusOK, health)
|
||||
}
|
||||
|
||||
// handleAPIStats — API: статистика
|
||||
func (s *Server) handleAPIStats(w http.ResponseWriter, r *http.Request) {
|
||||
playlists, _, err := s.getPlaylistsView()
|
||||
if err != nil {
|
||||
s.jsonError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
stats := computeStats(playlists)
|
||||
s.jsonResponse(w, http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// renderNotFound — отображает страницу 404
|
||||
func (s *Server) renderNotFound(w http.ResponseWriter, r *http.Request, code string) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
data := &PageData{
|
||||
Title: s.cfg.Site.Header.Title,
|
||||
BaseUrl: s.cfg.Site.BaseUrl,
|
||||
RepoUrl: s.cfg.Site.RepoUrl,
|
||||
Version: app.VERSION,
|
||||
Navigation: s.cfg.Site.Header.Navigation,
|
||||
FooterLinks: s.cfg.Site.FooterLinks,
|
||||
Code: code,
|
||||
}
|
||||
s.templates.Render(w, "notfound", data)
|
||||
}
|
||||
|
||||
// jsonResponse отправляет JSON-ответ
|
||||
func (s *Server) jsonResponse(w http.ResponseWriter, status int, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
resp := map[string]interface{}{
|
||||
"timestamp": time.Now().Unix(),
|
||||
}
|
||||
if m, ok := data.(map[string]interface{}); ok {
|
||||
for k, v := range m {
|
||||
resp[k] = v
|
||||
}
|
||||
} else {
|
||||
resp["data"] = data
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// jsonError отправляет JSON-ошибку
|
||||
func (s *Server) jsonError(w http.ResponseWriter, status int, err error) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"timestamp": time.Now().Unix(),
|
||||
"error": map[string]string{
|
||||
"message": err.Error(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// isAlphanumeric проверяет, состоит ли строка только из букв и цифр
|
||||
func isAlphanumeric(s string) bool {
|
||||
for _, c := range s {
|
||||
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return len(s) > 0
|
||||
}
|
||||
|
||||
// parseRedisVersion извлекает версию Redis из INFO-ответа
|
||||
func parseRedisVersion(info string) string {
|
||||
for _, line := range strings.Split(info, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "redis_version:") {
|
||||
return strings.TrimPrefix(line, "redis_version:")
|
||||
}
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// computeStats вычисляет статистику по плейлистам и каналам
|
||||
func computeStats(playlists []PlaylistView) map[string]interface{} {
|
||||
plsOnline := 0
|
||||
plsOffline := 0
|
||||
plsUnknown := 0
|
||||
plsAdult := 0
|
||||
plsCatchup := 0
|
||||
plsTvg := 0
|
||||
plsGroupped := 0
|
||||
|
||||
channelsAll := 0
|
||||
channelsOnline := 0
|
||||
channelsOffline := 0
|
||||
channelsAdult := 0
|
||||
|
||||
var latestCode string
|
||||
var latestTime int64
|
||||
|
||||
for _, pls := range playlists {
|
||||
if !pls.IsKnown {
|
||||
plsUnknown++
|
||||
} else if pls.IsOnline {
|
||||
plsOnline++
|
||||
} else {
|
||||
plsOffline++
|
||||
}
|
||||
|
||||
if containsTag(pls.Tags, "adult") {
|
||||
plsAdult++
|
||||
}
|
||||
if pls.HasCatchup {
|
||||
plsCatchup++
|
||||
}
|
||||
if pls.HasTvg {
|
||||
plsTvg++
|
||||
}
|
||||
if len(pls.Groups) > 0 {
|
||||
plsGroupped++
|
||||
}
|
||||
|
||||
if pls.CheckedAt > latestTime {
|
||||
latestTime = pls.CheckedAt
|
||||
latestCode = pls.Code
|
||||
}
|
||||
|
||||
for _, ch := range pls.Channels {
|
||||
channelsAll++
|
||||
if ch.IsOnline {
|
||||
channelsOnline++
|
||||
} else {
|
||||
channelsOffline++
|
||||
}
|
||||
if containsTag(ch.Tags, "adult") {
|
||||
channelsAdult++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
latest := map[string]interface{}{
|
||||
"code": latestCode,
|
||||
"time": latestTime,
|
||||
}
|
||||
if latestTime > 0 {
|
||||
latest["timeFmt"] = time.Unix(latestTime, 0).Format("15:04:05 02.01.2006")
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"playlists": map[string]interface{}{
|
||||
"all": len(playlists),
|
||||
"online": plsOnline,
|
||||
"offline": plsOffline,
|
||||
"unknown": plsUnknown,
|
||||
"adult": plsAdult,
|
||||
"hasCatchup": plsCatchup,
|
||||
"hasTvg": plsTvg,
|
||||
"groupped": plsGroupped,
|
||||
"latest": latest,
|
||||
},
|
||||
"channels": map[string]interface{}{
|
||||
"all": channelsAll,
|
||||
"online": channelsOnline,
|
||||
"offline": channelsOffline,
|
||||
"adult": channelsAdult,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// containsTag проверяет наличие тега в списке
|
||||
func containsTag(tags []string, tag string) bool {
|
||||
for _, t := range tags {
|
||||
if t == tag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026, Антон Аксенов
|
||||
* This file is part of iptvc project
|
||||
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
|
||||
*/
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"axenov/iptv-checker/app"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
//go:embed swagger
|
||||
var swaggerFS embed.FS
|
||||
|
||||
// handleOpenAPISpec — отдаёт динамически сгенерированную OpenAPI 3.0 спецификацию
|
||||
func (s *Server) handleOpenAPISpec(w http.ResponseWriter, r *http.Request) {
|
||||
spec := s.generateOpenAPISpec()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(spec)
|
||||
}
|
||||
|
||||
// swaggerFileServer раздаёт встроенные файлы Swagger UI (CSS, JS, HTML)
|
||||
var swaggerFileServer http.Handler
|
||||
|
||||
func init() {
|
||||
sub, err := fs.Sub(swaggerFS, "swagger")
|
||||
if err != nil {
|
||||
panic("cannot create sub-FS for swagger: " + err.Error())
|
||||
}
|
||||
swaggerFileServer = http.FileServer(http.FS(sub))
|
||||
}
|
||||
// generateOpenAPISpec строит OpenAPI 3.0.3 спецификацию динамически
|
||||
func (s *Server) generateOpenAPISpec() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"openapi": "3.0.3",
|
||||
"info": map[string]interface{}{
|
||||
"title": s.cfg.Site.Header.Title + " API",
|
||||
"version": app.VERSION,
|
||||
"description": "API для проверки IPTV-плейлистов и доступности каналов.",
|
||||
"license": map[string]interface{}{
|
||||
"name": "MIT",
|
||||
"url": "https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE",
|
||||
},
|
||||
},
|
||||
"servers": []map[string]interface{}{
|
||||
{
|
||||
"url": s.cfg.Site.BaseUrl,
|
||||
"description": "Текущий сервер",
|
||||
},
|
||||
},
|
||||
"paths": map[string]interface{}{
|
||||
"/api/playlists": map[string]interface{}{
|
||||
"get": map[string]interface{}{
|
||||
"tags": []string{"Playlists"},
|
||||
"summary": "Список плейлистов",
|
||||
"description": "Возвращает список всех плейлистов из ini-файла с результатами проверки.",
|
||||
"responses": map[string]interface{}{
|
||||
"200": map[string]interface{}{
|
||||
"description": "Список плейлистов",
|
||||
"content": map[string]interface{}{
|
||||
"application/json": map[string]interface{}{
|
||||
"schema": map[string]interface{}{"$ref": "#/components/schemas/PlaylistListResponse"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"500": map[string]interface{}{
|
||||
"description": "Ошибка загрузки плейлистов",
|
||||
"content": map[string]interface{}{
|
||||
"application/json": map[string]interface{}{
|
||||
"schema": map[string]interface{}{"$ref": "#/components/schemas/ErrorResponse"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/playlists/{code}": map[string]interface{}{
|
||||
"get": map[string]interface{}{
|
||||
"tags": []string{"Playlists"},
|
||||
"summary": "Информация о плейлисте",
|
||||
"description": "Возвращает информацию о плейлисте по его коду, включая группы, атрибуты и результаты проверки.",
|
||||
"parameters": []map[string]interface{}{
|
||||
{
|
||||
"name": "code",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "Код плейлиста (из ini-файла)",
|
||||
"schema": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
},
|
||||
"responses": map[string]interface{}{
|
||||
"200": map[string]interface{}{
|
||||
"description": "Информация о плейлисте",
|
||||
"content": map[string]interface{}{
|
||||
"application/json": map[string]interface{}{
|
||||
"schema": map[string]interface{}{"$ref": "#/components/schemas/PlaylistResponse"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"400": map[string]interface{}{
|
||||
"description": "Код плейлиста не передан",
|
||||
"content": map[string]interface{}{
|
||||
"application/json": map[string]interface{}{
|
||||
"schema": map[string]interface{}{"$ref": "#/components/schemas/ErrorResponse"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": map[string]interface{}{
|
||||
"description": "Плейлист не найден",
|
||||
"content": map[string]interface{}{
|
||||
"application/json": map[string]interface{}{
|
||||
"schema": map[string]interface{}{"$ref": "#/components/schemas/ErrorResponse"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/playlists/{code}/channels": map[string]interface{}{
|
||||
"get": map[string]interface{}{
|
||||
"tags": []string{"Playlists"},
|
||||
"summary": "Каналы плейлиста",
|
||||
"description": "Возвращает каналы плейлиста по его коду.",
|
||||
"parameters": []map[string]interface{}{
|
||||
{
|
||||
"name": "code",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "Код плейлиста (из ini-файла)",
|
||||
"schema": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
},
|
||||
"responses": map[string]interface{}{
|
||||
"200": map[string]interface{}{
|
||||
"description": "Каналы плейлиста",
|
||||
"content": map[string]interface{}{
|
||||
"application/json": map[string]interface{}{
|
||||
"schema": map[string]interface{}{"$ref": "#/components/schemas/ChannelListResponse"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"400": map[string]interface{}{
|
||||
"description": "Код плейлиста не передан",
|
||||
"content": map[string]interface{}{
|
||||
"application/json": map[string]interface{}{
|
||||
"schema": map[string]interface{}{"$ref": "#/components/schemas/ErrorResponse"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": map[string]interface{}{
|
||||
"description": "Плейлист не найден",
|
||||
"content": map[string]interface{}{
|
||||
"application/json": map[string]interface{}{
|
||||
"schema": map[string]interface{}{"$ref": "#/components/schemas/ErrorResponse"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/version": map[string]interface{}{
|
||||
"get": map[string]interface{}{
|
||||
"tags": []string{"System"},
|
||||
"summary": "Версии компонентов",
|
||||
"description": "Возвращает версию iptvc и (при наличии) версию KeyDB/Redis.",
|
||||
"responses": map[string]interface{}{
|
||||
"200": map[string]interface{}{
|
||||
"description": "Информация о версиях",
|
||||
"content": map[string]interface{}{
|
||||
"application/json": map[string]interface{}{
|
||||
"schema": map[string]interface{}{"$ref": "#/components/schemas/VersionResponse"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/health": map[string]interface{}{
|
||||
"get": map[string]interface{}{
|
||||
"tags": []string{"System"},
|
||||
"summary": "Состояние сервиса",
|
||||
"description": "Возвращает состояние подключения к KeyDB/Redis и путь к ini-файлу.",
|
||||
"responses": map[string]interface{}{
|
||||
"200": map[string]interface{}{
|
||||
"description": "Состояние сервиса",
|
||||
"content": map[string]interface{}{
|
||||
"application/json": map[string]interface{}{
|
||||
"schema": map[string]interface{}{"$ref": "#/components/schemas/HealthResponse"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/stats": map[string]interface{}{
|
||||
"get": map[string]interface{}{
|
||||
"tags": []string{"Statistics"},
|
||||
"summary": "Статистика",
|
||||
"description": "Возвращает агрегированную статистику по плейлистам и каналам.",
|
||||
"responses": map[string]interface{}{
|
||||
"200": map[string]interface{}{
|
||||
"description": "Статистика",
|
||||
"content": map[string]interface{}{
|
||||
"application/json": map[string]interface{}{
|
||||
"schema": map[string]interface{}{"$ref": "#/components/schemas/StatsResponse"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"500": map[string]interface{}{
|
||||
"description": "Ошибка загрузки плейлистов",
|
||||
"content": map[string]interface{}{
|
||||
"application/json": map[string]interface{}{
|
||||
"schema": map[string]interface{}{"$ref": "#/components/schemas/ErrorResponse"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"components": map[string]interface{}{
|
||||
"schemas": map[string]interface{}{
|
||||
"PlaylistListResponse": map[string]interface{}{
|
||||
"type": "object",
|
||||
"description": "Список плейлистов",
|
||||
"properties": map[string]interface{}{
|
||||
"timestamp": map[string]interface{}{"type": "integer", "description": "UNIX timestamp ответа"},
|
||||
"items": map[string]interface{}{
|
||||
"type": "array",
|
||||
"items": map[string]interface{}{"$ref": "#/components/schemas/Playlist"},
|
||||
"description": "Массив плейлистов",
|
||||
},
|
||||
},
|
||||
},
|
||||
"PlaylistResponse": map[string]interface{}{
|
||||
"type": "object",
|
||||
"description": "Информация о плейлисте с результатами проверки",
|
||||
"allOf": []map[string]interface{}{
|
||||
{"$ref": "#/components/schemas/Playlist"},
|
||||
},
|
||||
},
|
||||
"Playlist": map[string]interface{}{
|
||||
"type": "object",
|
||||
"description": "Информация о плейлисте",
|
||||
"properties": map[string]interface{}{
|
||||
"code": map[string]interface{}{"type": "string", "description": "Код плейлиста"},
|
||||
"name": map[string]interface{}{"type": "string", "description": "Название плейлиста"},
|
||||
"description": map[string]interface{}{"type": "string", "description": "Описание плейлиста"},
|
||||
"url": map[string]interface{}{"type": "string", "description": "URL плейлиста"},
|
||||
"source": map[string]interface{}{"type": "string", "description": "Источник плейлиста"},
|
||||
"isOnline": map[string]interface{}{
|
||||
"type": "boolean",
|
||||
"nullable": true,
|
||||
"description": "Доступность плейлиста (null если не проверялся)",
|
||||
},
|
||||
"onlineCount": map[string]interface{}{"type": "integer", "description": "Количество рабочих каналов"},
|
||||
"offlineCount": map[string]interface{}{"type": "integer", "description": "Количество нерабочих каналов"},
|
||||
"checkedAt": map[string]interface{}{"type": "integer", "description": "UNIX timestamp последней проверки"},
|
||||
"content": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "Содержимое плейлиста (только для оффлайн-плейлистов)",
|
||||
},
|
||||
"attributes": map[string]interface{}{
|
||||
"type": "array",
|
||||
"items": map[string]interface{}{"$ref": "#/components/schemas/Attribute"},
|
||||
"description": "Атрибуты тега #EXTM3U",
|
||||
},
|
||||
"groups": map[string]interface{}{
|
||||
"type": "array",
|
||||
"items": map[string]interface{}{"$ref": "#/components/schemas/Group"},
|
||||
"description": "Группы каналов",
|
||||
},
|
||||
"tags": map[string]interface{}{
|
||||
"type": "array",
|
||||
"items": map[string]interface{}{"type": "string"},
|
||||
"description": "Теги плейлиста",
|
||||
},
|
||||
},
|
||||
},
|
||||
"ChannelListResponse": map[string]interface{}{
|
||||
"type": "object",
|
||||
"description": "Список каналов плейлиста",
|
||||
"properties": map[string]interface{}{
|
||||
"timestamp": map[string]interface{}{"type": "integer", "description": "UNIX timestamp ответа"},
|
||||
"items": map[string]interface{}{
|
||||
"type": "array",
|
||||
"items": map[string]interface{}{"$ref": "#/components/schemas/Channel"},
|
||||
"description": "Массив каналов",
|
||||
},
|
||||
},
|
||||
},
|
||||
"Channel": map[string]interface{}{
|
||||
"type": "object",
|
||||
"description": "Информация о канале и статусе его проверки",
|
||||
"properties": map[string]interface{}{
|
||||
"id": map[string]interface{}{"type": "string", "description": "MD5-хэш URL канала"},
|
||||
"title": map[string]interface{}{"type": "string", "description": "Название канала"},
|
||||
"url": map[string]interface{}{"type": "string", "description": "URL потока канала"},
|
||||
"groupId": map[string]interface{}{"type": "string", "description": "MD5-хэш названия группы"},
|
||||
"hasToken": map[string]interface{}{"type": "boolean", "description": "Признак наличия токена/ключа в URL"},
|
||||
"attributes": map[string]interface{}{
|
||||
"type": "array",
|
||||
"items": map[string]interface{}{"$ref": "#/components/schemas/Attribute"},
|
||||
"description": "Атрибуты тега #EXTINF",
|
||||
},
|
||||
"status": map[string]interface{}{"type": "integer", "description": "HTTP-код статуса"},
|
||||
"isOnline": map[string]interface{}{"type": "boolean", "description": "Доступность канала (status < 400)"},
|
||||
"error": map[string]interface{}{"type": "string", "description": "Текст ошибки"},
|
||||
"contentType": map[string]interface{}{"type": "string", "description": "MIME-тип ответа"},
|
||||
"tags": map[string]interface{}{
|
||||
"type": "array",
|
||||
"items": map[string]interface{}{"type": "string"},
|
||||
"description": "Теги канала",
|
||||
},
|
||||
"checkedAt": map[string]interface{}{"type": "integer", "description": "UNIX timestamp проверки"},
|
||||
},
|
||||
},
|
||||
"Group": map[string]interface{}{
|
||||
"type": "object",
|
||||
"description": "Группа каналов",
|
||||
"properties": map[string]interface{}{
|
||||
"id": map[string]interface{}{"type": "string", "description": "MD5-хэш названия группы"},
|
||||
"name": map[string]interface{}{"type": "string", "description": "Название группы"},
|
||||
"attributes": map[string]interface{}{
|
||||
"type": "array",
|
||||
"items": map[string]interface{}{"$ref": "#/components/schemas/Attribute"},
|
||||
"description": "Атрибуты тега #EXTGRP",
|
||||
},
|
||||
},
|
||||
},
|
||||
"Attribute": map[string]interface{}{
|
||||
"type": "object",
|
||||
"description": "Именованный атрибут тега",
|
||||
"properties": map[string]interface{}{
|
||||
"name": map[string]interface{}{"type": "string", "description": "Имя атрибута"},
|
||||
"value": map[string]interface{}{"type": "string", "description": "Значение атрибута"},
|
||||
},
|
||||
},
|
||||
"VersionResponse": map[string]interface{}{
|
||||
"type": "object",
|
||||
"description": "Информация о версиях компонентов",
|
||||
"properties": map[string]interface{}{
|
||||
"timestamp": map[string]interface{}{"type": "integer", "description": "UNIX timestamp ответа"},
|
||||
"iptvc": map[string]interface{}{"type": "string", "description": "Версия iptvc"},
|
||||
"keydb": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "Версия KeyDB/Redis (если подключён)",
|
||||
},
|
||||
},
|
||||
},
|
||||
"HealthResponse": map[string]interface{}{
|
||||
"type": "object",
|
||||
"description": "Состояние сервиса",
|
||||
"properties": map[string]interface{}{
|
||||
"timestamp": map[string]interface{}{"type": "integer", "description": "UNIX timestamp ответа"},
|
||||
"redis": map[string]interface{}{
|
||||
"type": "object",
|
||||
"description": "Состояние подключения к KeyDB/Redis",
|
||||
"properties": map[string]interface{}{
|
||||
"isConnected": map[string]interface{}{"type": "boolean", "description": "Подключение установлено"},
|
||||
},
|
||||
},
|
||||
"iniFile": map[string]interface{}{
|
||||
"type": "object",
|
||||
"description": "Информация об ini-файле",
|
||||
"properties": map[string]interface{}{
|
||||
"path": map[string]interface{}{"type": "string", "description": "Путь к playlists.ini"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"StatsResponse": map[string]interface{}{
|
||||
"type": "object",
|
||||
"description": "Агрегированная статистика",
|
||||
"properties": map[string]interface{}{
|
||||
"timestamp": map[string]interface{}{"type": "integer", "description": "UNIX timestamp ответа"},
|
||||
"playlists": map[string]interface{}{
|
||||
"type": "object",
|
||||
"description": "Статистика по плейлистам",
|
||||
"properties": map[string]interface{}{
|
||||
"all": map[string]interface{}{"type": "integer"},
|
||||
"online": map[string]interface{}{"type": "integer"},
|
||||
"offline": map[string]interface{}{"type": "integer"},
|
||||
"unknown": map[string]interface{}{"type": "integer"},
|
||||
"adult": map[string]interface{}{"type": "integer"},
|
||||
"hasCatchup": map[string]interface{}{"type": "integer"},
|
||||
"hasTvg": map[string]interface{}{"type": "integer"},
|
||||
"groupped": map[string]interface{}{"type": "integer"},
|
||||
"latest": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"code": map[string]interface{}{"type": "string"},
|
||||
"time": map[string]interface{}{"type": "integer"},
|
||||
"timeFmt": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"channels": map[string]interface{}{
|
||||
"type": "object",
|
||||
"description": "Статистика по каналам",
|
||||
"properties": map[string]interface{}{
|
||||
"all": map[string]interface{}{"type": "integer"},
|
||||
"online": map[string]interface{}{"type": "integer"},
|
||||
"offline": map[string]interface{}{"type": "integer"},
|
||||
"adult": map[string]interface{}{"type": "integer"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"ErrorResponse": map[string]interface{}{
|
||||
"type": "object",
|
||||
"description": "Ошибка",
|
||||
"properties": map[string]interface{}{
|
||||
"timestamp": map[string]interface{}{"type": "integer", "description": "UNIX timestamp ответа"},
|
||||
"error": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"message": map[string]interface{}{"type": "string", "description": "Текст ошибки"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestOpenAPISpec проверяет, что /api/openapi.json отдаёт валидную спецификацию
|
||||
func TestOpenAPISpec(t *testing.T) {
|
||||
ts, _ := makeTestServer(t)
|
||||
|
||||
resp, err := http.Get(ts.URL + "/api/openapi.json")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /api/openapi.json: %s", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
|
||||
t.Errorf("expected application/json content-type, got %s", ct)
|
||||
}
|
||||
|
||||
var spec map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&spec); err != nil {
|
||||
t.Fatalf("cannot decode spec: %s", err)
|
||||
}
|
||||
|
||||
// базовые поля OpenAPI 3.0
|
||||
if v, ok := spec["openapi"].(string); !ok || !strings.HasPrefix(v, "3.0") {
|
||||
t.Errorf("expected openapi 3.0.x, got %v", spec["openapi"])
|
||||
}
|
||||
|
||||
info, ok := spec["info"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("missing 'info' section")
|
||||
}
|
||||
if _, ok := info["title"].(string); !ok {
|
||||
t.Error("missing info.title")
|
||||
}
|
||||
if _, ok := info["version"].(string); !ok {
|
||||
t.Error("missing info.version")
|
||||
}
|
||||
|
||||
// сервер должен быть указан
|
||||
servers, ok := spec["servers"].([]interface{})
|
||||
if !ok || len(servers) == 0 {
|
||||
t.Error("expected at least one server")
|
||||
}
|
||||
|
||||
// пути
|
||||
paths, ok := spec["paths"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("missing 'paths' section")
|
||||
}
|
||||
|
||||
expectedPaths := []string{
|
||||
"/api/playlists",
|
||||
"/api/playlists/{code}",
|
||||
"/api/playlists/{code}/channels",
|
||||
"/api/version",
|
||||
"/api/health",
|
||||
"/api/stats",
|
||||
}
|
||||
for _, p := range expectedPaths {
|
||||
if _, ok := paths[p]; !ok {
|
||||
t.Errorf("missing path %s in spec", p)
|
||||
}
|
||||
}
|
||||
|
||||
// компоненты/схемы
|
||||
components, ok := spec["components"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("missing 'components' section")
|
||||
}
|
||||
schemas, ok := components["schemas"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("missing 'components.schemas' section")
|
||||
}
|
||||
|
||||
expectedSchemas := []string{
|
||||
"PlaylistListResponse",
|
||||
"PlaylistResponse",
|
||||
"Playlist",
|
||||
"ChannelListResponse",
|
||||
"Channel",
|
||||
"Group",
|
||||
"Attribute",
|
||||
"VersionResponse",
|
||||
"HealthResponse",
|
||||
"StatsResponse",
|
||||
"ErrorResponse",
|
||||
}
|
||||
for _, s := range expectedSchemas {
|
||||
if _, ok := schemas[s]; !ok {
|
||||
t.Errorf("missing schema %s in components.schemas", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSwaggerUI проверяет, что /api/ отдаёт HTML-страницу Swagger UI
|
||||
// и что CSS/JS-ассеты доступны без внешних зависимостей
|
||||
func TestSwaggerUI(t *testing.T) {
|
||||
ts, _ := makeTestServer(t)
|
||||
|
||||
// /api → редирект на /api/
|
||||
noRedirect := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}}
|
||||
resp, err := noRedirect.Get(ts.URL + "/api")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /api: %s", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusMovedPermanently {
|
||||
t.Fatalf("expected 301 redirect, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// /api/ → HTML-страница
|
||||
resp, err = http.Get(ts.URL + "/api/")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /api/: %s", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
|
||||
t.Errorf("expected text/html content-type, got %s", ct)
|
||||
}
|
||||
|
||||
body := fetchBody(t, resp)
|
||||
if !strings.Contains(body, "swagger-ui") {
|
||||
t.Error("Swagger UI page should contain 'swagger-ui' reference")
|
||||
}
|
||||
if !strings.Contains(body, "/api/openapi.json") {
|
||||
t.Error("Swagger UI page should reference /api/openapi.json")
|
||||
}
|
||||
// проверяем, что нет ссылок на CDN
|
||||
if strings.Contains(body, "cdn.jsdelivr.net") || strings.Contains(body, "unpkg.com") {
|
||||
t.Error("Swagger UI page should not reference any CDN")
|
||||
}
|
||||
|
||||
// CSS-ассет
|
||||
respCSS, err := http.Get(ts.URL + "/api/swagger-ui.css")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /api/swagger-ui.css: %s", err)
|
||||
}
|
||||
defer respCSS.Body.Close()
|
||||
if respCSS.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200 for CSS, got %d", respCSS.StatusCode)
|
||||
}
|
||||
if ct := respCSS.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/css") {
|
||||
t.Errorf("expected text/css content-type, got %s", ct)
|
||||
}
|
||||
|
||||
// JS-ассет
|
||||
respJS, err := http.Get(ts.URL + "/api/swagger-ui-bundle.js")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /api/swagger-ui-bundle.js: %s", err)
|
||||
}
|
||||
defer respJS.Body.Close()
|
||||
if respJS.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200 for JS, got %d", respJS.StatusCode)
|
||||
}
|
||||
if ct := respJS.Header.Get("Content-Type"); !strings.Contains(ct, "javascript") {
|
||||
t.Errorf("expected javascript content-type, got %s", ct)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026, Антон Аксенов
|
||||
* This file is part of iptvc project
|
||||
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
|
||||
*/
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"axenov/iptv-checker/app/checker"
|
||||
"axenov/iptv-checker/app/config"
|
||||
"axenov/iptv-checker/app/inifile"
|
||||
"axenov/iptv-checker/app/playlist"
|
||||
"axenov/iptv-checker/app/utils"
|
||||
"log"
|
||||
"maps"
|
||||
"net/http"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// Server — веб-сервер для отображения плейлистов
|
||||
type Server struct {
|
||||
cfg *config.Config
|
||||
cache *redis.Client
|
||||
templates *TemplateManager
|
||||
iniCache inifile.IniFile
|
||||
iniLoaded time.Time
|
||||
memMu sync.RWMutex
|
||||
memCache map[string]playlist.Playlist
|
||||
}
|
||||
|
||||
// NewServer создаёт новый экземпляр веб-сервера
|
||||
func NewServer(cfg *config.Config, cache *redis.Client) *Server {
|
||||
s := &Server{
|
||||
cfg: cfg,
|
||||
cache: cache,
|
||||
memCache: make(map[string]playlist.Playlist),
|
||||
}
|
||||
|
||||
tm, err := NewTemplateManager(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load templates: %s", err)
|
||||
}
|
||||
s.templates = tm
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// Start запускает веб-сервер
|
||||
func (s *Server) Start() {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// API routes (specific, no conflicts)
|
||||
mux.HandleFunc("GET /api/playlists", s.handleAPIGetAll)
|
||||
mux.HandleFunc("GET /api/playlists/{code}", s.handleAPIGetOne)
|
||||
mux.HandleFunc("GET /api/playlists/{code}/channels", s.handleAPIGetChannels)
|
||||
mux.HandleFunc("GET /api/version", s.handleAPIVersion)
|
||||
mux.HandleFunc("GET /api/health", s.handleAPIHealth)
|
||||
mux.HandleFunc("GET /api/stats", s.handleAPIStats)
|
||||
|
||||
// OpenAPI spec + Swagger UI
|
||||
mux.HandleFunc("GET /api/openapi.json", s.handleOpenAPISpec)
|
||||
mux.HandleFunc("GET /api", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/api/", http.StatusMovedPermanently)
|
||||
})
|
||||
mux.Handle("GET /api/", http.StripPrefix("/api/", swaggerFileServer))
|
||||
|
||||
// All other routes handled by catch-all to avoid mux pattern conflicts
|
||||
mux.HandleFunc("GET /{$}", s.handleHome)
|
||||
mux.HandleFunc("GET /{path...}", s.handleRoute)
|
||||
|
||||
handler := s.withMiddleware(mux)
|
||||
|
||||
addr := s.cfg.Server.Host + ":" + strconv.FormatUint(uint64(s.cfg.Server.Port), 10)
|
||||
log.Printf("Starting web server on %s", addr)
|
||||
if err := http.ListenAndServe(addr, handler); err != nil {
|
||||
log.Fatalf("Server failed: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// withMiddleware добавляет middleware: логирование, восстановление после паники
|
||||
func (s *Server) withMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
log.Printf("PANIC: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
log.Printf("%s %s %s %s", r.Method, r.URL.Path, time.Since(start), r.RemoteAddr)
|
||||
})
|
||||
}
|
||||
|
||||
// loadIni загружает ini-файл (с кешированием на 30 секунд)
|
||||
func (s *Server) loadIni() (inifile.IniFile, error) {
|
||||
// перезагружаем раз в 30 секунд
|
||||
if !s.iniLoaded.IsZero() && time.Since(s.iniLoaded) < 30*time.Second && len(s.iniCache.Lists) > 0 {
|
||||
return s.iniCache, nil
|
||||
}
|
||||
|
||||
ini, err := inifile.Init(s.cfg.App.Playlists)
|
||||
if err != nil {
|
||||
return inifile.IniFile{}, err
|
||||
}
|
||||
s.iniCache = ini
|
||||
s.iniLoaded = time.Now()
|
||||
return ini, nil
|
||||
}
|
||||
|
||||
// getUpdatedAt возвращает время последнего изменения ini-файла
|
||||
func (s *Server) getUpdatedAt() string {
|
||||
path, err := utils.ExpandPath(s.cfg.App.Playlists)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return info.ModTime().Format("02.01.2006 15:04")
|
||||
}
|
||||
|
||||
// CheckOptions — параметры фоновой проверки плейлистов
|
||||
type CheckOptions struct {
|
||||
Repeat uint
|
||||
Random uint
|
||||
Files []string
|
||||
Urls []string
|
||||
Codes []string
|
||||
}
|
||||
|
||||
// StartBackgroundChecker запускает фоновую периодическую проверку плейлистов.
|
||||
// Метод блокирует вызывающую горутину, поэтому должен запускаться в отдельной goroutine.
|
||||
func (s *Server) StartBackgroundChecker(opts CheckOptions) {
|
||||
// каждый проверенный плейлист сразу попадает в in-memory кеш
|
||||
checker.OnPlaylistChecked = func(pls playlist.Playlist) {
|
||||
s.memMu.Lock()
|
||||
s.memCache[pls.Code] = pls
|
||||
s.memMu.Unlock()
|
||||
// сбрасываем кеш ini, чтобы веб-часть подхватила свежие данные
|
||||
s.iniLoaded = time.Time{}
|
||||
}
|
||||
|
||||
// AllCooldown хранится в миллисекундах, поэтому переводим в time.Millisecond
|
||||
cooldown := time.Duration(s.cfg.Check.Playlists.AllCooldown.Value()) * time.Millisecond
|
||||
log.Printf("Background checker started: cooldown=%s repeat=%d", cooldown, opts.Repeat)
|
||||
|
||||
iteration := uint(0)
|
||||
for {
|
||||
iteration++
|
||||
log.Printf("@ Background checker iteration=%d repeat=%d", iteration, opts.Repeat)
|
||||
|
||||
s.runCheckerOnce(opts)
|
||||
|
||||
if opts.Repeat > 0 && iteration >= opts.Repeat {
|
||||
log.Println("Background checker finished: reached repeat count")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Background checker waiting... cooldown=%s", cooldown)
|
||||
// cooldown уже вычислен в миллисекундах выше
|
||||
time.Sleep(cooldown)
|
||||
}
|
||||
}
|
||||
|
||||
// runCheckerOnce выполняет один цикл проверки плейлистов
|
||||
func (s *Server) runCheckerOnce(opts CheckOptions) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
log.Printf("Background checker panic: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
var lists []playlist.Playlist
|
||||
|
||||
if len(opts.Files) > 0 || len(opts.Urls) > 0 || len(opts.Codes) > 0 {
|
||||
// проверка конкретных файлов/URL/кодов
|
||||
lists = checker.PrepareListsToCheck(opts.Files, opts.Urls, opts.Codes)
|
||||
} else {
|
||||
// проверка всех плейлистов из ini
|
||||
ini, err := inifile.Init(s.cfg.App.Playlists)
|
||||
if err != nil {
|
||||
log.Printf("Background checker: cannot load ini: %s", err)
|
||||
return
|
||||
}
|
||||
lists = slices.Collect(maps.Values(ini.Lists))
|
||||
}
|
||||
|
||||
if len(lists) == 0 {
|
||||
log.Println("Background checker: no playlists to check")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Background checker: %d playlists will be checked", len(lists))
|
||||
startTime := time.Now()
|
||||
onlineCount, offlineCount := checker.CheckPlaylists(lists)
|
||||
log.Printf(
|
||||
"Background checker done! online=%d offline=%d elapsed=%.2fs",
|
||||
onlineCount,
|
||||
offlineCount,
|
||||
time.Since(startTime).Seconds(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Swagger UI — IPTV Checker API</title>
|
||||
<link rel="stylesheet" href="./swagger-ui.css">
|
||||
<style>
|
||||
body { margin: 0 }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="./swagger-ui-bundle.js"></script>
|
||||
<script src="./swagger-ui-standalone-preset.js"></script>
|
||||
<script>
|
||||
window.onload = function() {
|
||||
window.ui = SwaggerUIBundle({
|
||||
url: window.location.origin + '/api/openapi.json',
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
|
||||
layout: 'BaseLayout',
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026, Антон Аксенов
|
||||
* This file is part of iptvc project
|
||||
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
|
||||
*/
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"axenov/iptv-checker/app/config"
|
||||
"bytes"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed views/*.html
|
||||
var viewsFS embed.FS
|
||||
|
||||
// TemplateManager управляет загрузкой и рендерингом шаблонов
|
||||
type TemplateManager struct {
|
||||
templates map[string]*template.Template
|
||||
funcMap template.FuncMap
|
||||
}
|
||||
|
||||
// NewTemplateManager создаёт новый менеджер шаблонов
|
||||
func NewTemplateManager(cfg *config.Config) (*TemplateManager, error) {
|
||||
funcMap := template.FuncMap{
|
||||
"baseUrl": func(path string) string {
|
||||
base := cfg.Site.BaseUrl
|
||||
if path == "" {
|
||||
return base
|
||||
}
|
||||
return base + "/" + path
|
||||
},
|
||||
"formatDate": formatDate,
|
||||
"formatTimeAgo": formatTimeAgo,
|
||||
"statusClass": func(v PlaylistView) string {
|
||||
return statusClass(&v)
|
||||
},
|
||||
"statusBadge": func(v PlaylistView) string {
|
||||
return statusBadge(&v)
|
||||
},
|
||||
"jsonChannels": func(channels map[string]ChannelView) template.JS {
|
||||
arr := make([]ChannelView, 0, len(channels))
|
||||
for _, ch := range channels {
|
||||
arr = append(arr, ch)
|
||||
}
|
||||
b, _ := json.Marshal(arr)
|
||||
return template.JS(b)
|
||||
},
|
||||
"seq": func(start, end int) []int {
|
||||
result := make([]int, 0, end-start+1)
|
||||
for i := start; i <= end; i++ {
|
||||
result = append(result, i)
|
||||
}
|
||||
return result
|
||||
},
|
||||
"sub": func(a, b int) int {
|
||||
return a - b
|
||||
},
|
||||
"add": func(a, b int) int {
|
||||
return a + b
|
||||
},
|
||||
"channelCount": func(v PlaylistView) int {
|
||||
return len(v.Channels)
|
||||
},
|
||||
"groupCount": func(v PlaylistView) int {
|
||||
return len(v.Groups)
|
||||
},
|
||||
"hasTag": containsTag,
|
||||
}
|
||||
|
||||
tm := &TemplateManager{
|
||||
templates: make(map[string]*template.Template),
|
||||
funcMap: funcMap,
|
||||
}
|
||||
|
||||
// parse base template
|
||||
baseContent, err := viewsFS.ReadFile("views/base.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read base.html: %w", err)
|
||||
}
|
||||
|
||||
pages := []string{"list", "details", "notfound"}
|
||||
for _, page := range pages {
|
||||
pageContent, err := viewsFS.ReadFile(fmt.Sprintf("views/%s.html", page))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read %s.html: %w", page, err)
|
||||
}
|
||||
|
||||
tmpl := template.New("base").Funcs(funcMap)
|
||||
tmpl, err = tmpl.Parse(string(baseContent))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot parse base template: %w", err)
|
||||
}
|
||||
tmpl, err = tmpl.Parse(string(pageContent))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot parse %s template: %w", page, err)
|
||||
}
|
||||
|
||||
tm.templates[page] = tmpl
|
||||
}
|
||||
|
||||
return tm, nil
|
||||
}
|
||||
|
||||
// Render рендерит шаблон страницы
|
||||
func (tm *TemplateManager) Render(w io.Writer, page string, data *PageData) {
|
||||
tmpl, ok := tm.templates[page]
|
||||
if !ok {
|
||||
http.Error(w.(http.ResponseWriter), "Template not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.ExecuteTemplate(&buf, "base", data); err != nil {
|
||||
log.Printf("Template error (%s): %s", page, err)
|
||||
http.Error(w.(http.ResponseWriter), "Template error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Write(buf.Bytes())
|
||||
}
|
||||
|
||||
// formatTimeAgo возвращает относительное время на русском
|
||||
func formatTimeAgo(ts int64) string {
|
||||
if ts == 0 {
|
||||
return "неизвестно"
|
||||
}
|
||||
diff := time.Now().Unix() - ts
|
||||
if diff < 0 {
|
||||
diff = 0
|
||||
}
|
||||
if diff < 60 {
|
||||
return "только что"
|
||||
}
|
||||
if diff < 3600 {
|
||||
minutes := diff / 60
|
||||
return strconv.FormatInt(minutes, 10) + " " + getNounForm(int(minutes), "минуту", "минуты", "минут") + " назад"
|
||||
}
|
||||
if diff < 86400 {
|
||||
hours := diff / 3600
|
||||
return strconv.FormatInt(hours, 10) + " " + getNounForm(int(hours), "час", "часа", "часов") + " назад"
|
||||
}
|
||||
if diff < 604800 {
|
||||
days := diff / 86400
|
||||
return strconv.FormatInt(days, 10) + " " + getNounForm(int(days), "день", "дня", "дней") + " назад"
|
||||
}
|
||||
return formatDate(ts)
|
||||
}
|
||||
|
||||
// getNounForm возвращает правильную форму существительного для числительного
|
||||
func getNounForm(number int, form1, form2, form5 string) string {
|
||||
abs := int(math.Abs(float64(number)))
|
||||
lastDigit := abs % 10
|
||||
lastTwoDigits := abs % 100
|
||||
|
||||
if lastTwoDigits >= 11 && lastTwoDigits <= 14 {
|
||||
return form5
|
||||
}
|
||||
if lastDigit == 1 {
|
||||
return form1
|
||||
}
|
||||
if lastDigit >= 2 && lastDigit <= 4 {
|
||||
return form2
|
||||
}
|
||||
return form5
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGetNounForm(t *testing.T) {
|
||||
cases := []struct {
|
||||
n int
|
||||
f1 string
|
||||
f2 string
|
||||
f5 string
|
||||
want string
|
||||
}{
|
||||
{1, "минуту", "минуты", "минут", "минуту"},
|
||||
{2, "минуту", "минуты", "минут", "минуты"},
|
||||
{5, "минуту", "минуты", "минут", "минут"},
|
||||
{11, "минуту", "минуты", "минут", "минут"},
|
||||
{21, "минуту", "минуты", "минут", "минуту"},
|
||||
{22, "минуту", "минуты", "минут", "минуты"},
|
||||
{25, "минуту", "минуты", "минут", "минут"},
|
||||
{101, "минуту", "минуты", "минут", "минуту"},
|
||||
{111, "минуту", "минуты", "минут", "минут"},
|
||||
{0, "минуту", "минуты", "минут", "минут"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := getNounForm(c.n, c.f1, c.f2, c.f5)
|
||||
if got != c.want {
|
||||
t.Errorf("getNounForm(%d) = %q, want %q", c.n, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTimeAgo_Zero(t *testing.T) {
|
||||
if formatTimeAgo(0) != "неизвестно" {
|
||||
t.Errorf("expected 'неизвестно' for ts=0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTimeAgo_JustNow(t *testing.T) {
|
||||
now := time.Now().Unix()
|
||||
got := formatTimeAgo(now)
|
||||
if got != "только что" {
|
||||
t.Errorf("expected 'только что', got '%s'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTimeAgo_Minutes(t *testing.T) {
|
||||
ts := time.Now().Unix() - 120 // 2 minutes ago
|
||||
got := formatTimeAgo(ts)
|
||||
if got == "неизвестно" || got == "только что" {
|
||||
t.Errorf("expected minutes format, got '%s'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTimeAgo_Hours(t *testing.T) {
|
||||
ts := time.Now().Unix() - 7200 // 2 hours ago
|
||||
got := formatTimeAgo(ts)
|
||||
if got == "неизвестно" || got == "только что" {
|
||||
t.Errorf("expected hours format, got '%s'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTimeAgo_Days(t *testing.T) {
|
||||
ts := time.Now().Unix() - 172800 // 2 days ago
|
||||
got := formatTimeAgo(ts)
|
||||
if got == "неизвестно" || got == "только что" {
|
||||
t.Errorf("expected days format, got '%s'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTimeAgo_Future(t *testing.T) {
|
||||
ts := time.Now().Unix() + 100
|
||||
got := formatTimeAgo(ts)
|
||||
// future → diff clamped to 0 → "только что"
|
||||
if got != "только что" {
|
||||
t.Errorf("expected 'только что' for future, got '%s'", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026, Антон Аксенов
|
||||
* This file is part of iptvc project
|
||||
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
|
||||
*/
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"axenov/iptv-checker/app/config"
|
||||
"axenov/iptv-checker/app/playlist"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PlaylistView — расширенное представление плейлиста для шаблонов
|
||||
type PlaylistView struct {
|
||||
playlist.Playlist
|
||||
IsKnown bool `json:"-"` // true если есть данные в кеше
|
||||
OnlinePercent int `json:"-"`
|
||||
OfflinePercent int `json:"-"`
|
||||
HasCatchup bool `json:"-"`
|
||||
HasTvg bool `json:"-"`
|
||||
HasTokens bool `json:"-"`
|
||||
Tags []string `json:"-"`
|
||||
ViewChannels map[string]ChannelView `json:"-"`
|
||||
}
|
||||
|
||||
// ChannelView — расширенное представление канала
|
||||
type ChannelView struct {
|
||||
playlist.Channel
|
||||
HasToken bool `json:"hasToken"`
|
||||
}
|
||||
|
||||
// PageData — данные для рендеринга шаблонов
|
||||
type PageData struct {
|
||||
Title string
|
||||
BaseUrl string
|
||||
RepoUrl string
|
||||
Version string
|
||||
UpdatedAt string
|
||||
Navigation []config.Link
|
||||
FooterLinks []config.Link
|
||||
Playlists []PlaylistView
|
||||
Playlist *PlaylistView
|
||||
Count int
|
||||
PageCount int
|
||||
PageCurrent int
|
||||
Code string
|
||||
Error string
|
||||
}
|
||||
|
||||
// tokenPatterns — паттерны для определения нестабильных каналов
|
||||
var tokenPatterns = []string{
|
||||
`[?&]token=`,
|
||||
`[?&]drmreq=`,
|
||||
`[?&]u=`,
|
||||
`[?&]user=`,
|
||||
`[?&]username=`,
|
||||
`[?&]p=`,
|
||||
`[?&]pwd=`,
|
||||
`[?&]password=`,
|
||||
}
|
||||
|
||||
// enrichPlaylist подготавливает данные о плейлисте в расширенном формате,
|
||||
// аналогично PHP IniFile::initPlaylist
|
||||
func enrichPlaylist(view *PlaylistView) {
|
||||
// приколы golang: nil maps
|
||||
if view.Attributes == nil {
|
||||
view.Attributes = make(map[string]string)
|
||||
}
|
||||
if view.Groups == nil {
|
||||
view.Groups = make(map[string]playlist.Group)
|
||||
}
|
||||
if view.Channels == nil {
|
||||
view.Channels = make(map[string]playlist.Channel)
|
||||
}
|
||||
|
||||
// проценты онлайн/оффлайн
|
||||
view.OnlinePercent = 0
|
||||
view.OfflinePercent = 0
|
||||
if view.IsKnown && view.IsOnline && len(view.Channels) > 0 {
|
||||
view.OnlinePercent = int(math.Round(float64(view.OnlineCount) / float64(len(view.Channels)) * 100))
|
||||
view.OfflinePercent = int(math.Round(float64(view.OfflineCount) / float64(len(view.Channels)) * 100))
|
||||
}
|
||||
|
||||
// наличие catchup
|
||||
view.HasCatchup = strings.Contains(view.Content, "catchup")
|
||||
|
||||
// наличие TVG
|
||||
view.HasTvg = view.Attributes["url-tvg"] != "" || view.Attributes["x-tvg-url"] != ""
|
||||
|
||||
// наличие токенов в плейлисте
|
||||
view.HasTokens = hasTokensString(view.Url + " " + view.Content)
|
||||
|
||||
// сбор тегов и токенов каналов
|
||||
view.ViewChannels = make(map[string]ChannelView, len(view.Channels))
|
||||
tagSet := make(map[string]bool)
|
||||
for id, ch := range view.Channels {
|
||||
cv := ChannelView{Channel: ch}
|
||||
cv.HasToken = hasTokensString(ch.URL)
|
||||
view.ViewChannels[id] = cv
|
||||
for _, tag := range ch.Tags {
|
||||
tagSet[tag] = true
|
||||
}
|
||||
}
|
||||
view.Tags = make([]string, 0, len(tagSet))
|
||||
for tag := range tagSet {
|
||||
view.Tags = append(view.Tags, tag)
|
||||
}
|
||||
sort.Strings(view.Tags)
|
||||
}
|
||||
|
||||
// hasTokensString проверяет наличие токенов/ключей/логинов в строке
|
||||
func hasTokensString(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, pattern := range tokenPatterns {
|
||||
if matched, _ := regexp.MatchString(pattern, s); matched {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// getPlaylistFromCache возвращает плейлист из кеша Redis по коду
|
||||
func (s *Server) getPlaylistFromCache(code string) (*PlaylistView, bool) {
|
||||
if s.cache == nil {
|
||||
return nil, false
|
||||
}
|
||||
ctx := context.Background()
|
||||
result, err := s.cache.Get(ctx, code).Result()
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
var pls playlist.Playlist
|
||||
if err := json.Unmarshal([]byte(result), &pls); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
view := &PlaylistView{Playlist: pls, IsKnown: true}
|
||||
enrichPlaylist(view)
|
||||
return view, true
|
||||
}
|
||||
|
||||
// getPlaylistView возвращает представление плейлиста: из in-memory кеша, Redis или из ini
|
||||
func (s *Server) getPlaylistView(code string) (*PlaylistView, bool) {
|
||||
// сначала пробуем in-memory кеш (результаты фоновой проверки)
|
||||
s.memMu.RLock()
|
||||
if pls, ok := s.memCache[code]; ok {
|
||||
s.memMu.RUnlock()
|
||||
view := &PlaylistView{Playlist: pls, IsKnown: true}
|
||||
enrichPlaylist(view)
|
||||
return view, true
|
||||
}
|
||||
s.memMu.RUnlock()
|
||||
|
||||
// затем пробуем Redis
|
||||
if view, ok := s.getPlaylistFromCache(code); ok {
|
||||
return view, true
|
||||
}
|
||||
|
||||
// если нет в кеше — берём метаданные из ini
|
||||
ini, err := s.loadIni()
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
iniPls, exists := ini.Lists[code]
|
||||
if !exists {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
view := &PlaylistView{
|
||||
Playlist: playlist.Playlist{
|
||||
Code: code,
|
||||
Name: iniPls.Name,
|
||||
Description: iniPls.Description,
|
||||
Url: iniPls.Url,
|
||||
Source: iniPls.Source,
|
||||
IsOnline: false,
|
||||
Attributes: make(map[string]string),
|
||||
Groups: make(map[string]playlist.Group),
|
||||
Channels: make(map[string]playlist.Channel),
|
||||
},
|
||||
IsKnown: false,
|
||||
Tags: []string{},
|
||||
ViewChannels: make(map[string]ChannelView),
|
||||
}
|
||||
return view, true
|
||||
}
|
||||
|
||||
// getPlaylistsView возвращает все плейлисты для списка (отсортированные по коду)
|
||||
func (s *Server) getPlaylistsView() ([]PlaylistView, string, error) {
|
||||
ini, err := s.loadIni()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
updatedAt := s.getUpdatedAt()
|
||||
result := make([]PlaylistView, 0, len(ini.Lists))
|
||||
|
||||
// собираем коды в порядке из ini-файла
|
||||
codes := make([]string, 0, len(ini.Lists))
|
||||
for _, section := range ini.File.Sections() {
|
||||
if section.Name() == "DEFAULT" {
|
||||
continue
|
||||
}
|
||||
if _, ok := ini.Lists[section.Name()]; ok {
|
||||
codes = append(codes, section.Name())
|
||||
}
|
||||
}
|
||||
|
||||
// сначала проверяем in-memory кеш (результаты фоновой проверки)
|
||||
s.memMu.RLock()
|
||||
memHasData := len(s.memCache) > 0
|
||||
s.memMu.RUnlock()
|
||||
|
||||
if memHasData {
|
||||
for _, code := range codes {
|
||||
iniPls := ini.Lists[code]
|
||||
s.memMu.RLock()
|
||||
pls, ok := s.memCache[code]
|
||||
s.memMu.RUnlock()
|
||||
if ok {
|
||||
view := PlaylistView{Playlist: pls, IsKnown: true}
|
||||
enrichPlaylist(&view)
|
||||
result = append(result, view)
|
||||
} else {
|
||||
result = append(result, makeUnknownView(code, iniPls))
|
||||
}
|
||||
}
|
||||
return result, updatedAt, nil
|
||||
}
|
||||
|
||||
// если in-memory пуст — пробуем Redis
|
||||
if s.cache != nil {
|
||||
ctx := context.Background()
|
||||
cached, err := s.cache.MGet(ctx, codes...).Result()
|
||||
if err == nil {
|
||||
for i, code := range codes {
|
||||
iniPls := ini.Lists[code]
|
||||
if i < len(cached) {
|
||||
strVal, ok := cached[i].(string)
|
||||
if ok && strVal != "" {
|
||||
var pls playlist.Playlist
|
||||
if json.Unmarshal([]byte(strVal), &pls) == nil {
|
||||
view := PlaylistView{Playlist: pls, IsKnown: true}
|
||||
enrichPlaylist(&view)
|
||||
result = append(result, view)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
result = append(result, makeUnknownView(code, iniPls))
|
||||
}
|
||||
return result, updatedAt, nil
|
||||
}
|
||||
}
|
||||
|
||||
// без кеша — все плейлисты unknown
|
||||
for _, code := range codes {
|
||||
result = append(result, makeUnknownView(code, ini.Lists[code]))
|
||||
}
|
||||
return result, updatedAt, nil
|
||||
}
|
||||
|
||||
// makeUnknownView создаёт представление непроверенного плейлиста
|
||||
func makeUnknownView(code string, iniPls playlist.Playlist) PlaylistView {
|
||||
return PlaylistView{
|
||||
Playlist: playlist.Playlist{
|
||||
Code: code,
|
||||
Name: iniPls.Name,
|
||||
Description: iniPls.Description,
|
||||
Url: iniPls.Url,
|
||||
Source: iniPls.Source,
|
||||
IsOnline: false,
|
||||
Attributes: make(map[string]string),
|
||||
Groups: make(map[string]playlist.Group),
|
||||
Channels: make(map[string]playlist.Channel),
|
||||
},
|
||||
IsKnown: false,
|
||||
Tags: []string{},
|
||||
ViewChannels: make(map[string]ChannelView),
|
||||
}
|
||||
}
|
||||
|
||||
// statusClass возвращает CSS-класс для статуса плейлиста
|
||||
func statusClass(view *PlaylistView) string {
|
||||
if !view.IsKnown {
|
||||
return "secondary"
|
||||
}
|
||||
if view.IsOnline {
|
||||
return "success"
|
||||
}
|
||||
return "danger"
|
||||
}
|
||||
|
||||
// statusBadge возвращает текст бейджа статуса
|
||||
func statusBadge(view *PlaylistView) string {
|
||||
if !view.IsKnown {
|
||||
return "unknown"
|
||||
}
|
||||
if view.IsOnline {
|
||||
return "online"
|
||||
}
|
||||
return "offline"
|
||||
}
|
||||
|
||||
// formatDate форматирует unix timestamp в строку
|
||||
func formatDate(ts int64) string {
|
||||
if ts == 0 {
|
||||
return ""
|
||||
}
|
||||
return time.Unix(ts, 0).Format("02.01.2006 15:04:05")
|
||||
}
|
||||
@@ -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}}
|
||||
@@ -0,0 +1,360 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"axenov/iptv-checker/app/playlist"
|
||||
)
|
||||
|
||||
func TestHasTokensString(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"http://example.com/stream.m3u8?token=abc", true},
|
||||
{"http://example.com/stream?user=test&pwd=secret", true},
|
||||
{"http://example.com/stream?drmreq=xyz", true},
|
||||
{"http://example.com/stream?u=foo", true},
|
||||
{"http://example.com/stream?password=123", true},
|
||||
{"http://example.com/stream.m3u8", false},
|
||||
{"", false},
|
||||
{"plain text without tokens", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := hasTokensString(c.in)
|
||||
if got != c.want {
|
||||
t.Errorf("hasTokensString(%q) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusClass(t *testing.T) {
|
||||
cases := []struct {
|
||||
view *PlaylistView
|
||||
want string
|
||||
}{
|
||||
{&PlaylistView{IsKnown: false}, "secondary"},
|
||||
{&PlaylistView{IsKnown: true, Playlist: playlist.Playlist{IsOnline: true}}, "success"},
|
||||
{&PlaylistView{IsKnown: true, Playlist: playlist.Playlist{IsOnline: false}}, "danger"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := statusClass(c.view)
|
||||
if got != c.want {
|
||||
t.Errorf("statusClass() = %s, want %s", got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusBadge(t *testing.T) {
|
||||
cases := []struct {
|
||||
view *PlaylistView
|
||||
want string
|
||||
}{
|
||||
{&PlaylistView{IsKnown: false}, "unknown"},
|
||||
{&PlaylistView{IsKnown: true, Playlist: playlist.Playlist{IsOnline: true}}, "online"},
|
||||
{&PlaylistView{IsKnown: true, Playlist: playlist.Playlist{IsOnline: false}}, "offline"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := statusBadge(c.view)
|
||||
if got != c.want {
|
||||
t.Errorf("statusBadge() = %s, want %s", got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatDate(t *testing.T) {
|
||||
if formatDate(0) != "" {
|
||||
t.Error("expected empty string for ts=0")
|
||||
}
|
||||
result := formatDate(1700000000)
|
||||
if result == "" {
|
||||
t.Error("expected non-empty date string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAlphanumeric(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"ABC123", true},
|
||||
{"abc", true},
|
||||
{"123", true},
|
||||
{"", false},
|
||||
{"ABC-123", false},
|
||||
{"ABC_123", false},
|
||||
{"ABC 123", false},
|
||||
{"Привет", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := isAlphanumeric(c.in)
|
||||
if got != c.want {
|
||||
t.Errorf("isAlphanumeric(%q) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRedisVersion(t *testing.T) {
|
||||
info := "# Server\nredis_version:6.2.6\nredis_mode:standalone\n"
|
||||
got := parseRedisVersion(info)
|
||||
if got != "6.2.6" {
|
||||
t.Errorf("expected '6.2.6', got '%s'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRedisVersion_NotFound(t *testing.T) {
|
||||
info := "# Server\nsome_other_field:foo\n"
|
||||
got := parseRedisVersion(info)
|
||||
if got != "unknown" {
|
||||
t.Errorf("expected 'unknown', got '%s'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainsTag(t *testing.T) {
|
||||
tags := []string{"hd", "adult", "russian"}
|
||||
if !containsTag(tags, "adult") {
|
||||
t.Error("expected true for 'adult'")
|
||||
}
|
||||
if containsTag(tags, "movies") {
|
||||
t.Error("expected false for 'movies'")
|
||||
}
|
||||
if containsTag([]string{}, "x") {
|
||||
t.Error("expected false for empty list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakeUnknownView(t *testing.T) {
|
||||
iniPls := playlist.Playlist{
|
||||
Name: "Test",
|
||||
Description: "Desc",
|
||||
Url: "http://example.com",
|
||||
Source: "public",
|
||||
}
|
||||
view := makeUnknownView("CODE", iniPls)
|
||||
if view.Code != "CODE" {
|
||||
t.Errorf("expected Code='CODE', got '%s'", view.Code)
|
||||
}
|
||||
if view.IsKnown {
|
||||
t.Error("expected IsKnown=false")
|
||||
}
|
||||
if view.Name != "Test" {
|
||||
t.Errorf("expected Name='Test', got '%s'", view.Name)
|
||||
}
|
||||
if len(view.Tags) != 0 {
|
||||
t.Errorf("expected 0 tags, got %d", len(view.Tags))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichPlaylist_OnlinePercent(t *testing.T) {
|
||||
view := &PlaylistView{
|
||||
Playlist: playlist.Playlist{
|
||||
IsOnline: true,
|
||||
OnlineCount: 8,
|
||||
OfflineCount: 2,
|
||||
Channels: map[string]playlist.Channel{
|
||||
"1": {},
|
||||
"2": {},
|
||||
"3": {},
|
||||
"4": {},
|
||||
"5": {},
|
||||
"6": {},
|
||||
"7": {},
|
||||
"8": {},
|
||||
"9": {},
|
||||
"10": {},
|
||||
},
|
||||
Attributes: map[string]string{},
|
||||
Groups: map[string]playlist.Group{},
|
||||
Content: "#EXTM3U",
|
||||
},
|
||||
IsKnown: true,
|
||||
}
|
||||
enrichPlaylist(view)
|
||||
if view.OnlinePercent != 80 {
|
||||
t.Errorf("expected 80%%, got %d%%", view.OnlinePercent)
|
||||
}
|
||||
if view.OfflinePercent != 20 {
|
||||
t.Errorf("expected 20%%, got %d%%", view.OfflinePercent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichPlaylist_Unknown(t *testing.T) {
|
||||
view := &PlaylistView{
|
||||
Playlist: playlist.Playlist{
|
||||
Channels: map[string]playlist.Channel{"1": {}},
|
||||
Attributes: map[string]string{},
|
||||
Groups: map[string]playlist.Group{},
|
||||
Content: "",
|
||||
},
|
||||
IsKnown: false,
|
||||
}
|
||||
enrichPlaylist(view)
|
||||
if view.OnlinePercent != 0 || view.OfflinePercent != 0 {
|
||||
t.Errorf("expected 0%% for unknown, got online=%d%% offline=%d%%",
|
||||
view.OnlinePercent, view.OfflinePercent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichPlaylist_HasCatchup(t *testing.T) {
|
||||
view := &PlaylistView{
|
||||
Playlist: playlist.Playlist{
|
||||
Content: "#EXTM3U catchup=default",
|
||||
Channels: map[string]playlist.Channel{},
|
||||
Attributes: map[string]string{},
|
||||
Groups: map[string]playlist.Group{},
|
||||
},
|
||||
IsKnown: true,
|
||||
}
|
||||
enrichPlaylist(view)
|
||||
if !view.HasCatchup {
|
||||
t.Error("expected HasCatchup=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichPlaylist_HasTvg(t *testing.T) {
|
||||
view := &PlaylistView{
|
||||
Playlist: playlist.Playlist{
|
||||
Attributes: map[string]string{"url-tvg": "http://example.com/epg.xml"},
|
||||
Channels: map[string]playlist.Channel{},
|
||||
Groups: map[string]playlist.Group{},
|
||||
Content: "",
|
||||
},
|
||||
IsKnown: true,
|
||||
}
|
||||
enrichPlaylist(view)
|
||||
if !view.HasTvg {
|
||||
t.Error("expected HasTvg=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichPlaylist_HasTokens(t *testing.T) {
|
||||
view := &PlaylistView{
|
||||
Playlist: playlist.Playlist{
|
||||
Url: "http://example.com/list.m3u8?token=abc",
|
||||
Content: "#EXTM3U",
|
||||
Channels: map[string]playlist.Channel{},
|
||||
Attributes: map[string]string{},
|
||||
Groups: map[string]playlist.Group{},
|
||||
},
|
||||
IsKnown: true,
|
||||
}
|
||||
enrichPlaylist(view)
|
||||
if !view.HasTokens {
|
||||
t.Error("expected HasTokens=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichPlaylist_TagsCollection(t *testing.T) {
|
||||
view := &PlaylistView{
|
||||
Playlist: playlist.Playlist{
|
||||
Channels: map[string]playlist.Channel{
|
||||
"1": {Tags: []string{"hd", "russian"}},
|
||||
"2": {Tags: []string{"adult", "hd"}},
|
||||
},
|
||||
Attributes: map[string]string{},
|
||||
Groups: map[string]playlist.Group{},
|
||||
Content: "",
|
||||
},
|
||||
IsKnown: true,
|
||||
}
|
||||
enrichPlaylist(view)
|
||||
if len(view.Tags) != 3 {
|
||||
t.Errorf("expected 3 unique tags, got %d: %v", len(view.Tags), view.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichPlaylist_ChannelHasToken(t *testing.T) {
|
||||
view := &PlaylistView{
|
||||
Playlist: playlist.Playlist{
|
||||
Channels: map[string]playlist.Channel{
|
||||
"1": {URL: "http://example.com/stream?token=abc"},
|
||||
},
|
||||
Attributes: map[string]string{},
|
||||
Groups: map[string]playlist.Group{},
|
||||
Content: "",
|
||||
},
|
||||
IsKnown: true,
|
||||
}
|
||||
enrichPlaylist(view)
|
||||
for _, cv := range view.ViewChannels {
|
||||
if !cv.HasToken {
|
||||
t.Error("expected channel HasToken=true")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeStats(t *testing.T) {
|
||||
playlists := []PlaylistView{
|
||||
{
|
||||
Playlist: playlist.Playlist{
|
||||
Code: "RU",
|
||||
IsOnline: true,
|
||||
CheckedAt: 1700000000,
|
||||
Channels: map[string]playlist.Channel{"1": {IsOnline: true}, "2": {IsOnline: false}},
|
||||
Groups: map[string]playlist.Group{"g1": {}},
|
||||
Attributes: map[string]string{"url-tvg": "http://epg.xml"},
|
||||
Content: "#EXTM3U catchup=default",
|
||||
},
|
||||
IsKnown: true,
|
||||
HasCatchup: true,
|
||||
HasTvg: true,
|
||||
Tags: []string{"adult"},
|
||||
},
|
||||
{
|
||||
Playlist: playlist.Playlist{
|
||||
Code: "EN",
|
||||
IsOnline: false,
|
||||
CheckedAt: 1700000100,
|
||||
Channels: map[string]playlist.Channel{"1": {IsOnline: false}},
|
||||
},
|
||||
IsKnown: false,
|
||||
},
|
||||
}
|
||||
stats := computeStats(playlists)
|
||||
plsStats := stats["playlists"].(map[string]interface{})
|
||||
if plsStats["all"].(int) != 2 {
|
||||
t.Errorf("expected 2 playlists, got %d", plsStats["all"])
|
||||
}
|
||||
if plsStats["online"].(int) != 1 {
|
||||
t.Errorf("expected 1 online, got %d", plsStats["online"])
|
||||
}
|
||||
if plsStats["unknown"].(int) != 1 {
|
||||
t.Errorf("expected 1 unknown, got %d", plsStats["unknown"])
|
||||
}
|
||||
if plsStats["adult"].(int) != 1 {
|
||||
t.Errorf("expected 1 adult, got %d", plsStats["adult"])
|
||||
}
|
||||
if plsStats["hasCatchup"].(int) != 1 {
|
||||
t.Errorf("expected 1 catchup, got %d", plsStats["hasCatchup"])
|
||||
}
|
||||
if plsStats["hasTvg"].(int) != 1 {
|
||||
t.Errorf("expected 1 tvg, got %d", plsStats["hasTvg"])
|
||||
}
|
||||
if plsStats["groupped"].(int) != 1 {
|
||||
t.Errorf("expected 1 groupped, got %d", plsStats["groupped"])
|
||||
}
|
||||
|
||||
chStats := stats["channels"].(map[string]interface{})
|
||||
if chStats["all"].(int) != 3 {
|
||||
t.Errorf("expected 3 channels, got %d", chStats["all"])
|
||||
}
|
||||
if chStats["online"].(int) != 1 {
|
||||
t.Errorf("expected 1 online channel, got %d", chStats["online"])
|
||||
}
|
||||
if chStats["offline"].(int) != 2 {
|
||||
t.Errorf("expected 2 offline channels, got %d", chStats["offline"])
|
||||
}
|
||||
|
||||
latest := plsStats["latest"].(map[string]interface{})
|
||||
if latest["code"] != "EN" {
|
||||
t.Errorf("expected latest code 'EN', got '%v'", latest["code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeStats_Empty(t *testing.T) {
|
||||
stats := computeStats([]PlaylistView{})
|
||||
plsStats := stats["playlists"].(map[string]interface{})
|
||||
if plsStats["all"].(int) != 0 {
|
||||
t.Errorf("expected 0, got %d", plsStats["all"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user