This commit is contained in:
2026-07-13 12:28:59 +08:00
parent 6c3de4b2ef
commit f0fe5409fa
1409 changed files with 11369 additions and 413 deletions
+20 -21
View File
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025, Антон Аксенов
* Copyright (c) 2025-2026, Антон Аксенов
* This file is part of iptvc project
* MIT License: https://git.axenov.dev/IPTV/iptvc/src/branch/master/LICENSE
*/
@@ -54,9 +54,6 @@ type Playlist struct {
CheckedAt int64 `json:"checkedAt"` // Время проверки в формате UNIX timestamp
}
// tmpChannel хранит временные данные о канале, который обрабатывается в Parse
var tmpChannel = Channel{}
// MakeFromFile создаёт экземпляр плейлиста из файла
func MakeFromFile(filepath string) (Playlist, error) {
expandedPath, err := utils.ExpandPath(filepath)
@@ -131,8 +128,10 @@ func parseTitle(line string) string {
}
// Download загружает плейлист по URL-адресу
func (pls *Playlist) Download() error {
content, err := utils.Fetch(pls.Url)
// Download скачивает плейлист по URL.
// userAgent и timeout передаются в HTTP-клиент.
func (pls *Playlist) Download(userAgent string, timeout time.Duration) error {
content, err := utils.Fetch(pls.Url, userAgent, timeout)
if err != nil {
pls.Content = err.Error()
pls.CheckedAt = time.Now().Unix()
@@ -159,6 +158,7 @@ func (pls *Playlist) ReadFromFs() error {
// Parse разбирает плейлист
func (pls *Playlist) Parse() Playlist {
isChannel := false
var ch Channel
pls.Attributes = make(map[string]string)
pls.Channels = make(map[string]Channel)
pls.Groups = make(map[string]Group)
@@ -180,20 +180,20 @@ func (pls *Playlist) Parse() Playlist {
if strings.HasPrefix(line, "#EXTINF") {
isChannel = true
tmpChannel.Attributes = parseAttributes(line)
tmpChannel.Title = parseTitle(line)
ch = Channel{Attributes: parseAttributes(line)}
ch.Title = parseTitle(line)
if tmpChannel.Title == "" {
if tvgid, ok := tmpChannel.Attributes["tvg-id"]; ok {
tmpChannel.Title = "(канал без названия, tvg-id=" + tvgid + ")"
if ch.Title == "" {
if tvgid, ok := ch.Attributes["tvg-id"]; ok {
ch.Title = "(канал без названия, tvg-id=" + tvgid + ")"
} else {
tmpChannel.Title = "(канал без названия, tvg-id неизвестен)"
ch.Title = "(канал без названия, tvg-id неизвестен)"
}
}
if groupName, ok := tmpChannel.Attributes["group-title"]; ok {
if groupName, ok := ch.Attributes["group-title"]; ok {
id := utils.Md5str(groupName)
tmpChannel.GroupId = id
ch.GroupId = id
pls.Groups[id] = Group{
Id: id,
Name: groupName,
@@ -207,7 +207,7 @@ func (pls *Playlist) Parse() Playlist {
parts := strings.Split(line, ":")
groupName := strings.Trim(parts[1], " ")
id := utils.Md5str(groupName)
tmpChannel.GroupId = id
ch.GroupId = id
pls.Groups[id] = Group{
Id: id,
Name: groupName,
@@ -217,13 +217,12 @@ func (pls *Playlist) Parse() Playlist {
}
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
ch.URL = strings.Trim(line, " ")
ch.Id = utils.Md5str(ch.URL)
if ch.Id != "" {
pls.Channels[ch.Id] = ch
isChannel = false
tmpChannel = Channel{}
tmpChannel.Attributes = make(map[string]string)
ch = Channel{}
}
}
}
+266
View File
@@ -0,0 +1,266 @@
package playlist
import (
"fmt"
"os"
"path/filepath"
"sync"
"testing"
)
func TestParseAttributes(t *testing.T) {
line := `#EXTINF:-1 tvg-id="123" tvg-name="Channel" group-title="News",Channel Name`
attrs := parseAttributes(line)
if attrs["tvg-id"] != "123" {
t.Errorf("expected tvg-id=123, got %s", attrs["tvg-id"])
}
if attrs["tvg-name"] != "Channel" {
t.Errorf("expected tvg-name=Channel, got %s", attrs["tvg-name"])
}
if attrs["group-title"] != "News" {
t.Errorf("expected group-title=News, got %s", attrs["group-title"])
}
}
func TestParseAttributes_Empty(t *testing.T) {
attrs := parseAttributes("#EXTINF:-1,Plain Channel")
if len(attrs) != 0 {
t.Errorf("expected 0 attrs, got %d", len(attrs))
}
}
func TestParseTitle_WithComma(t *testing.T) {
cases := []struct {
in string
want string
}{
{`#EXTINF:-1 tvg-id="x",My Channel`, "My Channel"},
{`#EXTINF:-1,Hello, World`, "Hello, World"},
{`#EXTINF:-1 tvg-name="Test",Test Channel`, "Test Channel"},
}
for _, c := range cases {
got := parseTitle(c.in)
if got != c.want {
t.Errorf("parseTitle(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestParseTitle_NoComma(t *testing.T) {
got := parseTitle(`#EXTINF:-1 tvg-name="Foo"`)
if got == "" {
t.Error("expected non-empty title")
}
}
func TestParse_SimplePlaylist(t *testing.T) {
content := `#EXTM3U
#EXTINF:-1 tvg-id="ch1" tvg-name="Channel 1" group-title="News",Channel 1
http://example.com/ch1.m3u8
#EXTINF:-1 tvg-id="ch2" tvg-name="Channel 2" group-title="Movies",Channel 2
http://example.com/ch2.m3u8
`
pls := &Playlist{Content: content}
pls.Parse()
if len(pls.Channels) != 2 {
t.Fatalf("expected 2 channels, got %d", len(pls.Channels))
}
if len(pls.Groups) != 2 {
t.Fatalf("expected 2 groups, got %d", len(pls.Groups))
}
}
func TestParse_WithBOM(t *testing.T) {
content := "\xef\xbb\xbf#EXTM3U\n#EXTINF:-1,Test\nhttp://example.com/test.m3u8\n"
pls := &Playlist{Content: content}
pls.Parse()
if len(pls.Channels) != 1 {
t.Fatalf("expected 1 channel (BOM removed), got %d", len(pls.Channels))
}
}
func TestParse_WithWindowsLineEndings(t *testing.T) {
content := "#EXTM3U\r\n#EXTINF:-1,Test\r\nhttp://example.com/test.m3u8\r\n"
pls := &Playlist{Content: content}
pls.Parse()
if len(pls.Channels) != 1 {
t.Fatalf("expected 1 channel (CRLF), got %d", len(pls.Channels))
}
}
func TestParse_EmptyContent(t *testing.T) {
pls := &Playlist{Content: ""}
pls.Parse()
if len(pls.Channels) != 0 {
t.Errorf("expected 0 channels, got %d", len(pls.Channels))
}
}
func TestParse_Extgrp(t *testing.T) {
content := `#EXTM3U
#EXTINF:-1,Channel A
#EXTGRP:Sports
http://example.com/a.m3u8
`
pls := &Playlist{Content: content}
pls.Parse()
if len(pls.Groups) != 1 {
t.Fatalf("expected 1 group, got %d", len(pls.Groups))
}
for _, ch := range pls.Channels {
if ch.GroupId == "" {
t.Error("expected non-empty group ID")
}
}
}
func TestParse_ExtinfAttributes(t *testing.T) {
content := `#EXTM3U
#EXTINF:-1 tvg-id="abc" tvg-logo="logo.png" group-title="HD",HD Channel
http://example.com/hd.m3u8
`
pls := &Playlist{Content: content}
pls.Parse()
for _, ch := range pls.Channels {
if ch.Attributes["tvg-id"] != "abc" {
t.Errorf("expected tvg-id=abc, got %s", ch.Attributes["tvg-id"])
}
if ch.Attributes["tvg-logo"] != "logo.png" {
t.Errorf("expected tvg-logo=logo.png, got %s", ch.Attributes["tvg-logo"])
}
if ch.Title != "HD Channel" {
t.Errorf("expected title 'HD Channel', got '%s'", ch.Title)
}
}
}
func TestParse_ChannelWithoutTitle(t *testing.T) {
content := `#EXTM3U
#EXTINF:-1 tvg-id="notitle"
http://example.com/notitle.m3u8
`
pls := &Playlist{Content: content}
pls.Parse()
for _, ch := range pls.Channels {
if ch.Title == "" {
t.Error("expected non-empty title for channel without name")
}
}
}
func TestParse_Extm3uAttributes(t *testing.T) {
content := `#EXTM3U url-tvg="http://example.com/epg.xml"
#EXTINF:-1,Test
http://example.com/test.m3u8
`
pls := &Playlist{Content: content}
pls.Parse()
if pls.Attributes["url-tvg"] != "http://example.com/epg.xml" {
t.Errorf("expected url-tvg attr, got %s", pls.Attributes["url-tvg"])
}
}
func TestMakeFromUrl(t *testing.T) {
pls, err := MakeFromUrl("http://example.com/list.m3u8")
if err != nil {
t.Fatal(err)
}
if pls.Url != "http://example.com/list.m3u8" {
t.Errorf("expected URL, got %s", pls.Url)
}
if pls.Source != "-u" {
t.Errorf("expected source '-u', got '%s'", pls.Source)
}
if !pls.IsOnline {
t.Error("expected IsOnline=true")
}
}
func TestMakeFromFile_NotFound(t *testing.T) {
_, err := MakeFromFile("/nonexistent/file.m3u")
if err == nil {
t.Error("expected error for non-existent file")
}
}
func TestMakeFromFile_Success(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "test.m3u")
content := "#EXTM3U\n#EXTINF:-1,Test\nhttp://example.com/test.m3u8\n"
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
pls, err := MakeFromFile(path)
if err != nil {
t.Fatal(err)
}
if pls.Source != "-f" {
t.Errorf("expected source '-f', got '%s'", pls.Source)
}
if pls.Content != content {
t.Error("content mismatch")
}
}
func TestReadFromFs(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "test.m3u")
content := "#EXTM3U\n"
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
pls := &Playlist{Url: path}
if err := pls.ReadFromFs(); err != nil {
t.Fatal(err)
}
if pls.Content != content {
t.Errorf("expected '%s', got '%s'", content, pls.Content)
}
}
func TestReadFromFs_NotFound(t *testing.T) {
pls := &Playlist{Url: "/nonexistent/file.m3u"}
err := pls.ReadFromFs()
if err == nil {
t.Error("expected error for non-existent file")
}
}
// TestParseConcurrent проверяет, что парсинг нескольких плейлистов
// в параллельных горутинах не приводит к потере каналов из-за data race.
// До фикса tmpChannel была пакетной переменной, и каналы терялись.
func TestParseConcurrent(t *testing.T) {
const goroutines = 20
const channelsPerPlaylist = 50
// генерируем плейлист с channelsPerPlaylist каналами
content := "#EXTM3U\n"
for i := 0; i < channelsPerPlaylist; i++ {
content += fmt.Sprintf("#EXTINF:-1 tvg-id=\"ch%d\",Channel %d\n", i, i)
content += fmt.Sprintf("http://example.com/stream%d.m3u8\n", i)
}
var wg sync.WaitGroup
wg.Add(goroutines)
lost := make([]int, goroutines)
for i := 0; i < goroutines; i++ {
go func(idx int) {
defer wg.Done()
pls := &Playlist{Content: content}
pls.Parse()
lost[idx] = channelsPerPlaylist - len(pls.Channels)
}(i)
}
wg.Wait()
totalLost := 0
for _, l := range lost {
totalLost += l
}
if totalLost > 0 {
t.Errorf("data race detected: %d channels lost across %d goroutines (per-goroutine losses: %v)",
totalLost, goroutines, lost)
}
}