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