234 lines
8.5 KiB
Go
234 lines
8.5 KiB
Go
/*
|
|
* 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
|
|
}
|
|
|
|
var (
|
|
attrRegex = regexp.MustCompile(`(?U)([a-z-]+)="(.*)"`)
|
|
titleRegex = regexp.MustCompile(`['"]?\s*,\s*(.+)`)
|
|
)
|
|
|
|
// 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)
|
|
regexMatches := attrRegex.FindAllStringSubmatch(line, -1)
|
|
for _, match := range regexMatches {
|
|
result[match[1]] = match[2]
|
|
}
|
|
return result
|
|
}
|
|
|
|
// parseTitle парсит название канала из строки тега #EXTINF
|
|
func parseTitle(line string) string {
|
|
// сначала пытаемся по-доброму: в строке есть тег, могут быть атрибуты,
|
|
// есть запятая-разделитель, после неё -- название канала (с запятыми или без)
|
|
regexMatches := titleRegex.FindAllStringSubmatch(line, -1)
|
|
if len(regexMatches) > 0 && len(regexMatches[0]) >= 2 {
|
|
return strings.TrimSpace(regexMatches[0][1])
|
|
}
|
|
|
|
// теперь пытаемся хоть как-то: в строке есть тег, могут быть атрибуты,
|
|
// НЕТ запятой-разделителя и название канала (с запятыми или без)
|
|
lastQuotePos := strings.LastIndexAny(line, `,"'`)
|
|
if lastQuotePos != -1 && lastQuotePos < len(line)-1 {
|
|
afterLastQuote := line[lastQuotePos+1:]
|
|
name := strings.TrimSpace(afterLastQuote)
|
|
if name != "" {
|
|
return name
|
|
}
|
|
}
|
|
|
|
return line // ну штош
|
|
}
|
|
|
|
// 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
|
|
tmpChannel := Channel{}
|
|
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 = parseTitle(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
|
|
}
|