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