wip7
This commit is contained in:
@@ -0,0 +1,535 @@
|
||||
/*
|
||||
* 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,
|
||||
UpdatedAtTZ: s.getTimezoneLabel(),
|
||||
Meta: s.cfg.Site.Meta,
|
||||
Menu: s.cfg.Site.Header.Menu,
|
||||
FooterLinks: s.cfg.Site.Footer.Links,
|
||||
Tabs: s.cfg.Site.Tabs,
|
||||
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,
|
||||
Meta: s.cfg.Site.Meta,
|
||||
Menu: s.cfg.Site.Header.Menu,
|
||||
FooterLinks: s.cfg.Site.Footer.Links,
|
||||
Tabs: s.cfg.Site.Tabs,
|
||||
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,
|
||||
Meta: s.cfg.Site.Meta,
|
||||
Menu: s.cfg.Site.Header.Menu,
|
||||
FooterLinks: s.cfg.Site.Footer.Links,
|
||||
Tabs: s.cfg.Site.Tabs,
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user