/* * Copyright (c) 2025, Антон Аксенов * This file is part of iptvc project * MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE */ package playlist import ( "axenov/iptv-checker/app/utils" "errors" "os" "regexp" "strings" "time" ) // Group - структура для хранения информации о группе каналов type Group struct { Id string `json:"id"` // Хэш MD5 от названия группы Name string `json:"name"` // Название группы (тег #EXTGRP или атрибут group-title тега #EXTINF) Attributes map[string]string `json:"attributes"` // Атрибуты тега #EXTGRP } // Channel - структура для хранения информации о канале и статусе его проверки type Channel struct { Id string `json:"id"` // Хэш MD5 от ссылки на канал Title string `json:"title"` // Название канала URL string `json:"url"` // Ссылка на канал GroupId string `json:"groupId"` // Хэш MD5 от названия группы канала (тег #EXTGRP или атрибут group-title тега #EXTINF) Attributes map[string]string `json:"attributes"` // Атрибуты тега #EXTINF Status int `json:"status"` // Код статуса HTTP IsOnline bool `json:"isOnline"` // Признак доступности канала (при Status < 400) Error string `json:"error"` // Текст ошибки (при Status >= 400) ContentType string `json:"contentType"` // MIME-тип тела ответа Tags []string `json:"tags"` // Список тегов канала CheckedAt int64 `json:"checkedAt"` // Время проверки в формате UNIX timestamp } // Playlist - структура для хранения информации о плейлисте type Playlist struct { Code string `json:"code"` // Код плейлиста (из ini-файла) Name string `json:"name"` // Название плейлиста (из ini-файла) Description string `json:"description"` // Описание плейлиста (из ini-файла) Url string `json:"url"` // URL плейлиста Source string `json:"source"` // Источник плейлиста (из ini-файла) Content string `json:"content"` // Содержимое плейлиста (m3u, m3u8) IsOnline bool `json:"isOnline"` // Признак доступности плейлиста Attributes map[string]string `json:"attributes"` // Атрибуты тега #EXTM3U Groups map[string]Group `json:"groups"` // Группы каналов (по тегам #EXTGRP и атрибутам tvg-group тегов #EXTINF) Channels map[string]Channel `json:"channels"` // Каналы OnlineCount int `json:"onlineCount"` // Количество рабочих каналов OfflineCount int `json:"offlineCount"` // Количество нерабочих каналов CheckedAt int64 `json:"checkedAt"` // Время проверки в формате UNIX timestamp } // tmpChannel хранит временные данные о канале, который обрабатывается в Parse var tmpChannel = Channel{} // MakeFromFile создаёт экземпляр плейлиста из файла func MakeFromFile(filepath string) (Playlist, error) { expandedPath, err := utils.ExpandPath(filepath) if err != nil { return Playlist{}, errors.New("File read error: " + err.Error()) } _, err = os.Stat(expandedPath) if err != nil { return Playlist{}, errors.New("File read error: " + err.Error()) } playlist := Playlist{ Code: "", Name: "", Description: "Playlist from filesystem", Url: expandedPath, Source: "-f", IsOnline: true, } return playlist, playlist.ReadFromFs() } // MakeFromUrl создаёт экземпляр плейлиста из URL-адреса func MakeFromUrl(url string) (Playlist, error) { playlist := Playlist{ Code: "", Name: "", Description: "Remote playlist", Url: url, Source: "-u", IsOnline: true, } return playlist, nil } // parseAttributes парсит атрибуты тегов #EXT* func parseAttributes(line string) map[string]string { result := make(map[string]string) regex := regexp.MustCompile(`(?U)([a-z-]+)="(.*)"`) regexMatches := regex.FindAllStringSubmatch(line, -1) for _, match := range regexMatches { result[match[1]] = match[2] } return result } // parseName парсит название канала из строки тега #EXTINF func parseName(line string) string { //TODO https://git.axenov.dev/IPTV/iptvc/issues/7 parts := strings.Split(line, ",") if len(parts) == 2 { return strings.Trim(parts[1], " ") } regex := regexp.MustCompile(`['"]?\s*,\s*(.+)`) regexMatches := regex.FindAllStringSubmatch(line, -1) return regexMatches[0][1] } // Download загружает плейлист по URL-адресу func (pls *Playlist) Download() error { content, err := utils.Fetch(pls.Url) if err != nil { pls.Content = err.Error() pls.CheckedAt = time.Now().Unix() return err } pls.Content = string(content) return nil } // ReadFromFs читает плейлист из файла func (pls *Playlist) ReadFromFs() error { content, err := os.ReadFile(pls.Url) if err != nil { pls.Content = err.Error() pls.CheckedAt = time.Now().Unix() return err } pls.Content = string(content) return nil } // Parse разбирает плейлист func (pls *Playlist) Parse() Playlist { isChannel := false pls.Attributes = make(map[string]string) pls.Channels = make(map[string]Channel) pls.Groups = make(map[string]Group) content := pls.Content content = strings.ReplaceAll(content, "\r\n", "\n") // replace windows line endings content = strings.ReplaceAll(content, "\xef\xbb\xbf", "") // remove UTF8 BOM for _, line := range strings.Split(content, "\n") { line = strings.Trim(line, "\t\r\n ") if line == "" || line == "#EXTM3U" { continue } if strings.HasPrefix(line, "#EXTM3U") { pls.Attributes = parseAttributes(line) continue } if strings.HasPrefix(line, "#EXTINF") { isChannel = true tmpChannel.Attributes = parseAttributes(line) tmpChannel.Title = parseName(line) if tmpChannel.Title == "" { if tvgid, ok := tmpChannel.Attributes["tvg-id"]; ok { tmpChannel.Title = "(канал без названия, tvg-id=" + tvgid + ")" } else { tmpChannel.Title = "(канал без названия, tvg-id неизвестен)" } } if groupName, ok := tmpChannel.Attributes["group-title"]; ok { id := utils.Md5str(groupName) tmpChannel.GroupId = id pls.Groups[id] = Group{ Id: id, Name: groupName, Attributes: nil, } } continue } if isChannel && strings.HasPrefix(line, "#EXTGRP") { parts := strings.Split(line, ":") groupName := strings.Trim(parts[1], " ") id := utils.Md5str(groupName) tmpChannel.GroupId = id pls.Groups[id] = Group{ Id: id, Name: groupName, Attributes: nil, } continue } if isChannel && strings.HasPrefix(line, "http") { tmpChannel.URL = strings.Trim(line, " ") tmpChannel.Id = utils.Md5str(tmpChannel.URL) if tmpChannel.Id != "" { pls.Channels[tmpChannel.Id] = tmpChannel isChannel = false tmpChannel = Channel{} tmpChannel.Attributes = make(map[string]string) } } } return *pls }