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) } }