wip6
This commit is contained in:
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user