package resolve import ( "io" "net/http" "net/http/httptest" "net/url" "testing" "time" ) func TestArgsTreatsXiaoyuzhouEpisodeAsPending(t *testing.T) { url := "https://www.xiaoyuzhoufm.com/episode/69a13b07a22480add648dd03?s=eyJ1IjogIjYxODEzNmZiZTBmNWU3MjNiYjk2MmE5MiJ9" got, err := Args([]string{url}) if err != nil { t.Fatalf("Args returned error: %v", err) } if len(got.Tracks) != 0 { t.Fatalf("Args returned %d immediate tracks, want 0", len(got.Tracks)) } if len(got.Pending) != 1 || got.Pending[0] != url { t.Fatalf("Args pending = %#v, want [%q]", got.Pending, url) } } func TestRemoteResolvesXiaoyuzhouEpisodeHTML(t *testing.T) { const episodeURL = "https://www.xiaoyuzhoufm.com/episode/69a13b07a22480add648dd03?s=eyJ1IjogIjYxODEzNmZiZTBmNWU3MjNiYjk2MmE5MiJ9" const audioURL = "https://media.xyzcdn.net/65d322815c5cc49b4db454a8/lqbqTgipk04QFSwIMACyGNK655rR.m4a" const title = "周轶君对话张艾嘉:我从不刻意标榜“女性”" const podcast = "山下声" srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/episode/69a13b07a22480add648dd03" { t.Errorf("unexpected path: %s", r.URL.Path) http.Error(w, "unexpected path", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write([]byte(`
`)) })) defer srv.Close() target, err := url.Parse(srv.URL) if err != nil { t.Fatalf("parsing test server URL: %v", err) } oldClient := httpClient httpClient = &http.Client{ Timeout: 30 * time.Second, Transport: rewriteHostTransport{target: target, rt: http.DefaultTransport}, } defer func() { httpClient = oldClient }() tracks, err := Remote([]string{episodeURL}) if err != nil { t.Fatalf("Remote returned error: %v", err) } if len(tracks) != 1 { t.Fatalf("Remote returned %d tracks, want 1", len(tracks)) } track := tracks[0] if track.Path != audioURL { t.Fatalf("track.Path = %q, want %q", track.Path, audioURL) } if track.Title != title { t.Fatalf("track.Title = %q, want %q", track.Title, title) } if track.Artist != podcast { t.Fatalf("track.Artist = %q, want %q", track.Artist, podcast) } if !track.Stream { t.Fatalf("track.Stream = false, want true") } if track.DurationSecs != 106*60 { t.Fatalf("track.DurationSecs = %d, want %d", track.DurationSecs, 106*60) } } func TestParseXiaoyuzhouOgAudioTakesPrecedence(t *testing.T) { const audioURL = "https://media.xyzcdn.net/audio.m4a" const title = "Test Episode" doc := ` ` track, err := parseXiaoyuzhouEpisodeHTML("https://www.xiaoyuzhoufm.com/episode/abc", doc) if err != nil { t.Fatalf("parseXiaoyuzhouEpisodeHTML returned error: %v", err) } if track.Path != audioURL { t.Fatalf("track.Path = %q, want %q", track.Path, audioURL) } if track.Title != title { t.Fatalf("track.Title = %q, want %q", track.Title, title) } } func TestParseItunesDuration(t *testing.T) { tests := []struct { input string want int }{ // Plain seconds {"3600", 3600}, {"90", 90}, {"0", 0}, // Fractional seconds {"3661.5", 3661}, {"90.9", 90}, // MM:SS {"1:30", 90}, {"87:05", 5225}, // HH:MM:SS {"1:27:05", 5225}, {"0:01:30", 90}, // Whitespace {" 3600 ", 3600}, // Empty {"", 0}, // Invalid — return 0 {"abc", 0}, {"12:xx", 0}, {"1:2:xx", 0}, // Negative — clamp to 0 {"-1", 0}, {"0:-10", 0}, } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { got := parseItunesDuration(tt.input) if got != tt.want { t.Errorf("parseItunesDuration(%q) = %d, want %d", tt.input, got, tt.want) } }) } } type rewriteHostTransport struct { target *url.URL rt http.RoundTripper } func (t rewriteHostTransport) RoundTrip(req *http.Request) (*http.Response, error) { clone := req.Clone(req.Context()) clone.URL.Scheme = t.target.Scheme clone.URL.Host = t.target.Host clone.Host = t.target.Host return t.rt.RoundTrip(clone) } func TestIsHLSPlaylist(t *testing.T) { master := "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1000000\nchunklist_abc.m3u8\n" media := "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:6\n#EXT-X-MEDIA-SEQUENCE:42\n#EXTINF:6.0,\nmedia_42.ts\n" simple := "#EXTM3U\n#EXTINF:-1,Radio\nhttp://radio.example.com/stream\n" if !isHLSPlaylist([]byte(master)) { t.Error("master playlist should be detected as HLS") } if !isHLSPlaylist([]byte(media)) { t.Error("media playlist should be detected as HLS") } if isHLSPlaylist([]byte(simple)) { t.Error("plain radio M3U must NOT be detected as HLS") } } func TestResolveM3U_HLS_ReturnsSingleStream(t *testing.T) { const master = "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1000000\nchunklist_abc.m3u8\n" srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/vnd.apple.mpegurl") _, _ = io.WriteString(w, master) })) defer srv.Close() u := srv.URL + "/primary/gaucha_rbs.sdp/playlist.m3u8" tracks, err := resolveM3U(u) if err != nil { t.Fatalf("resolveM3U: %v", err) } if len(tracks) != 1 { t.Fatalf("got %d tracks, want 1 (HLS = single stream)", len(tracks)) } if tracks[0].Path != u { t.Errorf("Path = %q, want original URL %q", tracks[0].Path, u) } if !tracks[0].Stream { t.Error("Stream should be true") } if !tracks[0].Realtime { t.Error("Realtime should be true (no #EXT-X-ENDLIST)") } } func TestResolveM3U_PlainPlaylist_StillParsesTracks(t *testing.T) { const pl = "#EXTM3U\n#EXTINF:-1,A\nhttp://x/a.mp3\n#EXTINF:-1,B\nhttp://x/b.mp3\n" srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = io.WriteString(w, pl) })) defer srv.Close() tracks, err := resolveM3U(srv.URL + "/list.m3u") if err != nil { t.Fatalf("resolveM3U: %v", err) } if len(tracks) != 2 { t.Fatalf("got %d tracks, want 2 (regression guard)", len(tracks)) } }