267 lines
6.8 KiB
Go
267 lines
6.8 KiB
Go
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)
|
|
}
|
|
}
|