529 lines
13 KiB
Go
529 lines
13 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"
|
|
"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
|
|
}
|