323 lines
8.8 KiB
Go
323 lines
8.8 KiB
Go
/*
|
|
* 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")
|
|
}
|