Files
iptvc/app/web/views.go
T
2026-07-19 19:14:55 +08:00

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")
}