Initial commit
All checks were successful
release / release (push) Successful in 5m47s

This commit is contained in:
2025-05-01 00:46:24 +08:00
commit d15d4f47b6
22 changed files with 1556 additions and 0 deletions

216
app/playlist/playlist.go Normal file
View File

@@ -0,0 +1,216 @@
/*
* 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"
)
// 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)
Content string `json:"content"` // Тело ответа (формат m3u, либо маскированные бинарные данные, либо пусто)
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 {
return err
}
pls.Content = string(content)
return nil
}
// ReadFromFs читает плейлист из файла
func (pls *Playlist) ReadFromFs() error {
content, err := os.ReadFile(pls.Url)
if err != nil {
return err
}
pls.Content = string(content)
return nil
}
// Parse разбирает плейлист
func (pls *Playlist) Parse() Playlist {
isChannel := false
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(content)
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
}