A show is a channel's videos or past live streams, or a playlist. Its newest 30 episodes are kept, with how far each was watched, in podcasts.json: listed every 6 hours, its channel's feed read every 30 minutes for new uploads, new videos looked up a few at a time, and clips, Shorts and streams to come left out. The newest unwatched episode of each show has where it streams from found ahead. An episode plays through the stream server from where it's asked to, its H.264 picture copied rather than transcoded.
- commit
- 6ca783a947264d232cb0a06a1f2be1cb94e76683
- parent
- 6af73667de0900ad072a29d7039e5074b2dd8bc2
- author
- FelineStateMachine <226808055+FelineStateMachine@users.noreply.github.com>
- kind
- feat risk score 1.6 of 3, Jev's estimate
| A | internal/ | 423 | +++++++++++++++++++++++++ |
| A | internal/ | 308 | +++++++++++++++++++ |
| A | internal/ | 498 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 29 | +- |
| M | internal/ | 15 | + |
| M | internal/ | 3 | + |
| M | internal/ | 24 | +- |
@@ -0,0 +1,423 @@
1+// Package podcast is the Podcasts app's library: video podcasts from2+// YouTube, watched on demand. A show is a YouTube channel's tab (its3+// videos, or its past live streams) or a playlist; the library keeps its4+// newest episodes, what's known of each, and how far each has been5+// watched, in one file. Nothing is downloaded: an episode streams from6+// YouTube while it's watched, as the YouTube channels' videos do.7+package podcast8+9+import (10+ "context"11+ "crypto/sha1"12+ "encoding/hex"13+ "encoding/json"14+ "errors"15+ "fmt"16+ "io/fs"17+ "log"18+ "net/url"19+ "slices"20+ "strings"21+ "sync"22+ "time"23+24+ "airwaves/internal/store"25+ "airwaves/internal/tuner"26+ "airwaves/internal/youtube"27+)28+29+const (30+ // keepEpisodes is how many of a show's newest episodes are kept, and31+ // listLimit how far down its listing they're looked for.32+ keepEpisodes = 3033+ listLimit = 4034+ // DefaultMinMinutes leaves out clips: shorter videos aren't episodes.35+ DefaultMinMinutes = 1036+ // maxHeight is the picture streamed, at most.37+ maxHeight = 108038+)39+40+var (41+ // ErrBadShow is wrapped by errors about what was given as a show.42+ ErrBadShow = errors.New("not a show")43+ // ErrNotFound is the error for a show or an episode the library44+ // doesn't have.45+ ErrNotFound = errors.New("no such show or episode")46+)47+48+// Library is the shows and their episodes, kept in Path.49+type Library struct {50+ Path string51+ // YtDlp runs yt-dlp; yt-dlp from PATH when nil.52+ YtDlp *youtube.YtDlp53+ // FFmpeg is the ffmpeg that plays episodes, asked whether it fetches54+ // in ranges.55+ FFmpeg string56+57+ mu sync.Mutex58+ loaded bool59+ st file60+ resolved map[string]resolved // where episodes stream from, by ID61+ updating bool // listing a show62+ kick chan struct{}63+ now func() time.Time64+}65+66+// file is the library as Path keeps it.67+type file struct {68+ Shows []*show `json:"shows"`69+}70+71+// show is a show as the file keeps it.72+type show struct {73+ ID string `json:"id"`74+ URL string `json:"url"` // its tab or playlist, as listed75+ Title string `json:"title"`76+ Thumbnail string `json:"thumbnail,omitempty"`77+ MinMinutes float64 `json:"minMinutes"`78+ Error string `json:"error,omitempty"` // the last listing's79+ ChannelID string `json:"channelId,omitempty"`80+ Listed time.Time `json:"listed,omitzero"`81+ Checked time.Time `json:"checked,omitzero"` // its channel's feed82+ Episodes []*episode `json:"episodes"` // newest first83+ // Skipped are videos found not to be episodes (clips, Shorts), so84+ // the feed doesn't bring them back.85+ Skipped []string `json:"skipped,omitempty"`86+}87+88+// episode is an episode as the file keeps it.89+type episode struct {90+ ID string `json:"id"`91+ Title string `json:"title"`92+ Description string `json:"description,omitempty"`93+ Published time.Time `json:"published,omitzero"`94+ Approx bool `json:"approx,omitempty"` // Published is a listing's rough date95+ Seconds float64 `json:"seconds,omitempty"`96+ Thumbnail string `json:"thumbnail,omitempty"`97+ Looked time.Time `json:"looked,omitzero"` // its details, looked up98+ Position float64 `json:"position,omitempty"`99+ Watched bool `json:"watched,omitempty"`100+}101+102+// State is the library for the app: its shows, as they were added, and103+// whether a show is being listed.104+type State struct {105+ Shows []Show `json:"shows"`106+ Updating bool `json:"updating"`107+}108+109+// Show is a show for the app.110+type Show struct {111+ ID string `json:"id"`112+ Title string `json:"title"`113+ URL string `json:"url"`114+ Thumbnail string `json:"thumbnail"`115+ MinMinutes float64 `json:"minMinutes"`116+ Error string `json:"error,omitempty"`117+ Episodes []Episode `json:"episodes"` // newest first118+}119+120+// Episode is an episode for the app: Duration and Position in seconds.121+type Episode struct {122+ ID string `json:"id"`123+ ShowID string `json:"showId"`124+ Title string `json:"title"`125+ Description string `json:"description"`126+ Published time.Time `json:"published,omitzero"`127+ Duration float64 `json:"duration"`128+ Thumbnail string `json:"thumbnail"`129+ Position float64 `json:"position"`130+ Watched bool `json:"watched"`131+}132+133+// Found is a YouTube channel a search found, to add as a show.134+type Found struct {135+ ID string `json:"id"`136+ Title string `json:"title"`137+ URL string `json:"url"`138+ Thumbnail string `json:"thumbnail"`139+ Description string `json:"description"`140+ Subscribers int64 `json:"subscribers"`141+}142+143+func (l *Library) tool() *youtube.YtDlp {144+ if l.YtDlp != nil {145+ return l.YtDlp146+ }147+ return pathYtDlp148+}149+150+// pathYtDlp runs yt-dlp from PATH for a library given none.151+var pathYtDlp = &youtube.YtDlp{}152+153+func (l *Library) clock() time.Time {154+ if l.now != nil {155+ return l.now()156+ }157+ return time.Now()158+}159+160+// load reads the file once. l.mu is held.161+func (l *Library) load() {162+ if l.loaded {163+ return164+ }165+ l.loaded = true166+ l.resolved = map[string]resolved{}167+ l.kick = make(chan struct{}, 1)168+ err := store.ReadFile(l.Path, func(raw []byte) error { return json.Unmarshal(raw, &l.st) })169+ if err != nil && !errors.Is(err, fs.ErrNotExist) {170+ log.Printf("podcasts: %v; starting over", err)171+ l.st = file{}172+ }173+}174+175+// save writes the file. l.mu is held.176+func (l *Library) save() {177+ raw, err := json.MarshalIndent(l.st, "", " ")178+ if err == nil {179+ err = store.WriteFile(l.Path, raw, 0o644)180+ }181+ if err != nil {182+ log.Printf("podcasts: save: %v", err)183+ }184+}185+186+// State lists the shows and their episodes.187+func (l *Library) State() State {188+ l.mu.Lock()189+ defer l.mu.Unlock()190+ l.load()191+ out := State{Shows: make([]Show, 0, len(l.st.Shows)), Updating: l.updating}192+ for _, s := range l.st.Shows {193+ out.Shows = append(out.Shows, s.view())194+ }195+ return out196+}197+198+func (s *show) view() Show {199+ v := Show{ID: s.ID, Title: s.Title, URL: s.URL, Thumbnail: s.Thumbnail, MinMinutes: s.MinMinutes, Error: s.Error,200+ Episodes: make([]Episode, 0, len(s.Episodes))}201+ for _, e := range s.Episodes {202+ v.Episodes = append(v.Episodes, Episode{ID: e.ID, ShowID: s.ID, Title: e.Title, Description: e.Description,203+ Published: e.Published, Duration: e.Seconds, Thumbnail: e.thumbnail(), Position: e.Position, Watched: e.Watched})204+ }205+ return v206+}207+208+func (e *episode) thumbnail() string {209+ if e.Thumbnail != "" {210+ return e.Thumbnail211+ }212+ return youtube.Thumbnail(e.ID)213+}214+215+// Search finds YouTube channels to add as shows.216+func (l *Library) Search(ctx context.Context, query string) ([]Found, error) {217+ query = strings.TrimSpace(query)218+ if query == "" {219+ return nil, fmt.Errorf("%w: search for something", ErrBadShow)220+ }221+ cs, err := l.tool().SearchChannels(ctx, query, 12)222+ if err != nil {223+ return nil, err224+ }225+ out := make([]Found, 0, len(cs))226+ for _, c := range cs {227+ out = append(out, Found{ID: c.ID, Title: c.Name, URL: c.URL, Thumbnail: c.Image, Description: c.Description, Subscribers: c.Subscribers})228+ }229+ return out, nil230+}231+232+// Add adds the show at raw (a channel, one of its tabs, or a playlist; see233+// source), listing it first, with minMinutes the shortest video that's an234+// episode (DefaultMinMinutes when 0). A show already there is returned235+// as it is.236+func (l *Library) Add(ctx context.Context, raw string, minMinutes float64) (Show, error) {237+ src, err := source(raw)238+ if err != nil {239+ return Show{}, err240+ }241+ if minMinutes <= 0 {242+ minMinutes = DefaultMinMinutes243+ }244+ id := showID(src.url)245+ l.mu.Lock()246+ l.load()247+ if s := l.find(id); s != nil {248+ v := s.view()249+ l.mu.Unlock()250+ return v, nil251+ }252+ l.mu.Unlock()253+ s := &show{ID: id, URL: src.url, MinMinutes: minMinutes}254+ lst, err := l.list(ctx, src)255+ if err != nil {256+ return Show{}, err257+ }258+ l.mu.Lock()259+ defer l.mu.Unlock()260+ if old := l.find(id); old != nil { // added meanwhile261+ return old.view(), nil262+ }263+ s.merge(lst, src, l.clock())264+ l.st.Shows = append(l.st.Shows, s)265+ l.save()266+ l.poke()267+ log.Printf("podcasts: added %s (%s), %d episodes", s.Title, s.URL, len(s.Episodes))268+ return s.view(), nil269+}270+271+// Remove takes a show out of the library.272+func (l *Library) Remove(id string) error {273+ l.mu.Lock()274+ defer l.mu.Unlock()275+ l.load()276+ i := slices.IndexFunc(l.st.Shows, func(s *show) bool { return s.ID == id })277+ if i < 0 {278+ return ErrNotFound279+ }280+ for _, e := range l.st.Shows[i].Episodes {281+ delete(l.resolved, e.ID)282+ }283+ l.st.Shows = slices.Delete(l.st.Shows, i, i+1)284+ l.save()285+ return nil286+}287+288+// Progress records how far an episode has been watched: Position289+// seconds in, of duration (which fills in a length the library lacks).290+// It's watched when the app says so, or within a minute of its end.291+func (l *Library) Progress(id string, position, duration float64, watched bool) error {292+ l.mu.Lock()293+ defer l.mu.Unlock()294+ l.load()295+ _, e := l.episode(id)296+ if e == nil {297+ return ErrNotFound298+ }299+ if e.Seconds <= 0 && duration > 0 {300+ e.Seconds = duration301+ }302+ e.Position = max(position, 0)303+ e.Watched = watched || e.Seconds > 0 && e.Position >= e.Seconds-60304+ l.save()305+ return nil306+}307+308+// find is the show with id. l.mu is held.309+func (l *Library) find(id string) *show {310+ for _, s := range l.st.Shows {311+ if s.ID == id {312+ return s313+ }314+ }315+ return nil316+}317+318+// episode is the episode with id, and its show. l.mu is held.319+func (l *Library) episode(id string) (*show, *episode) {320+ for _, s := range l.st.Shows {321+ for _, e := range s.Episodes {322+ if e.ID == id {323+ return s, e324+ }325+ }326+ }327+ return nil, nil328+}329+330+// src is what a show lists: a channel's tab ("videos" or "streams"), or331+// a playlist.332+type src struct {333+ url string334+ playlist bool335+ tab string336+}337+338+// source reads a show as given: a playlist's link or ID; or a channel, as339+// its link, "@handle" or "UC…" ID, its videos unless the link is to its340+// live streams' tab (/streams).341+func source(raw string) (src, error) {342+ raw = strings.TrimSpace(raw)343+ if raw == "" {344+ return src{}, fmt.Errorf("%w: give a YouTube channel or playlist", ErrBadShow)345+ }346+ if page, err := youtube.Playlist(raw); err == nil {347+ return src{url: page, playlist: true}, nil348+ }349+ u := raw350+ if !strings.HasPrefix(u, "@") && !(strings.HasPrefix(u, "UC") && !strings.Contains(u, "/")) {351+ if !strings.Contains(u, "://") {352+ u = "https://" + u353+ }354+ p, err := url.Parse(u)355+ host := ""356+ if err == nil {357+ host = strings.TrimPrefix(strings.TrimPrefix(strings.ToLower(p.Hostname()), "www."), "m.")358+ }359+ switch {360+ case host == "youtu.be", host == "youtube.com" && (p.Path == "/watch" || strings.HasPrefix(p.Path, "/shorts/") || strings.HasPrefix(p.Path, "/live/")):361+ return src{}, fmt.Errorf("%w: that's a link to a video; give its channel or a playlist", ErrBadShow)362+ case host != "youtube.com":363+ return src{}, fmt.Errorf("%w: give a YouTube channel or playlist", ErrBadShow)364+ case strings.HasSuffix(strings.TrimRight(p.Path, "/"), "/podcasts"):365+ return src{}, fmt.Errorf("%w: that's the channel's list of podcasts; give one of them, a playlist", ErrBadShow)366+ }367+ u = "https://www.youtube.com" + p.Path // one address for a show368+ }369+ tab := "videos"370+ if strings.HasSuffix(strings.TrimRight(u, "/"), "/streams") {371+ tab = "streams"372+ }373+ base := strings.TrimSuffix(youtube.VideosURL(u), "/videos")374+ return src{url: base + "/" + tab, tab: tab}, nil375+}376+377+func (s *show) source() src {378+ if strings.Contains(s.URL, "playlist?list=") {379+ return src{url: s.URL, playlist: true}380+ }381+ tab := "videos"382+ if strings.HasSuffix(s.URL, "/streams") {383+ tab = "streams"384+ }385+ return src{url: s.URL, tab: tab}386+}387+388+// showID names a show by its address: short, and the same each time.389+func showID(u string) string {390+ sum := sha1.Sum([]byte(u))391+ return hex.EncodeToString(sum[:6])392+}393+394+// Input is how to play an episode from from seconds in: where it streams395+// from (found ahead, or now), for the stream server to copy its H.264396+// picture and make its sound AAC, keeping every segment to seek in.397+func (l *Library) Input(ctx context.Context, id string, from float64) (tuner.Input, error) {398+ l.mu.Lock()399+ l.load()400+ _, e := l.episode(id)401+ var length float64402+ if e != nil {403+ length = e.Seconds404+ }405+ l.mu.Unlock()406+ if e == nil {407+ return tuner.Input{}, ErrNotFound408+ }409+ info, err := l.resolve(ctx, id)410+ if err != nil {411+ return tuner.Input{}, err412+ }413+ in := info.Inputs()414+ if from <= 5 || length > 0 && from >= length-5 {415+ from = 0416+ }417+ args, audio := youtube.InputArgs(in, time.Duration(from*float64(time.Second)), youtube.HTTPChunks(l.FFmpeg))418+ out := tuner.Input{Args: args, Video: "0:v:0", VOD: true, Offset: from, Copy: strings.HasPrefix(in[0].VCodec, "avc1")}419+ if audio != "" {420+ out.Audio = []tuner.AudioTrack{{Map: audio}}421+ }422+ return out, nil423+}@@ -0,0 +1,308 @@
1+package podcast2+3+import (4+ "context"5+ "encoding/json"6+ "errors"7+ "fmt"8+ "io"9+ "net/http"10+ "os"11+ "path/filepath"12+ "slices"13+ "strings"14+ "testing"15+ "time"16+17+ "airwaves/internal/youtube"18+)19+20+// TestMain lets the test binary stand in for yt-dlp: run with21+// AIRWAVES_FAKE_YTDLP set, it answers like yt-dlp would.22+func TestMain(m *testing.M) {23+ if os.Getenv("AIRWAVES_FAKE_YTDLP") != "" {24+ os.Exit(fakeYtDlp(os.Args[1:]))25+ }26+ lookupGap = 027+ os.Exit(m.Run())28+}29+30+// fakeYtDlp answers yt-dlp's command line, noting each run's arguments in31+// the file AIRWAVES_FAKE_YTDLP names. Show A (@showa) has three episodes,32+// a clip and a premiere to come in its videos, and a past and a current33+// stream in its streams; Show B is a playlist with a private video. Any34+// video's details give it an hour, but "feedclip" lasts 200 seconds; with35+// a format asked for, an H.264 picture and AAC sound six hours long.36+func fakeYtDlp(args []string) int {37+ if len(args) == 1 && args[0] == "--version" {38+ fmt.Println("2026.08.19")39+ return 040+ }41+ if f, err := os.OpenFile(os.Getenv("AIRWAVES_FAKE_YTDLP"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644); err == nil {42+ fmt.Fprintln(f, strings.Join(args, " "))43+ f.Close()44+ }45+ u := args[len(args)-1]46+ now := time.Now().Unix()47+ day := int64(86400)48+ var out any49+ switch {50+ case u == "https://www.youtube.com/@showa/videos":51+ out = map[string]any{"channel": "Show A", "channel_id": "UCa", "uploader_id": "@showa",52+ "thumbnails": []map[string]any{{"url": "https://yt3.example/a=s900-c-k", "width": 900, "height": 900}},53+ "entries": []map[string]any{54+ {"id": "ep3", "title": "Episode 3", "duration": 3600, "timestamp": now - day},55+ {"id": "clip1", "title": "A clip", "duration": 300, "timestamp": now - day},56+ {"id": "ep2", "title": "Episode 2", "duration": 3000, "timestamp": now - 2*day},57+ {"id": "up1", "title": "Premiere", "live_status": "is_upcoming"},58+ {"id": "ep1", "title": "Episode 1", "duration": 4000, "timestamp": now - 3*day},59+ }}60+ case u == "https://www.youtube.com/@showa/streams":61+ out = map[string]any{"channel": "Show A", "channel_id": "UCa", "entries": []map[string]any{62+ {"id": "now1", "title": "Live now", "live_status": "is_live"},63+ {"id": "live1", "title": "Last night's show", "duration": 7200, "timestamp": now - day, "live_status": "was_live"},64+ }}65+ case strings.Contains(u, "playlist?list=PLtest00000001"):66+ out = map[string]any{"_type": "playlist", "id": "PLtest00000001", "title": "Show B", "channel": "B", "channel_id": "UCb",67+ "entries": []map[string]any{68+ {"id": "pb1", "title": "B 1", "duration": 3600, "timestamp": now - 5*day},69+ {"id": "pbx", "title": "[Private video]"},70+ {"id": "pb2", "title": "B 2", "duration": 3600, "timestamp": now - day},71+ }}72+ case strings.Contains(u, "results?search_query="):73+ out = map[string]any{"entries": []map[string]any{{"channel_id": "UCa", "channel": "Show A", "uploader_id": "@showa",74+ "channel_follower_count": 1000, "description": "A show."}}}75+ case strings.Contains(u, "watch?v="):76+ id := u[strings.Index(u, "v=")+2:]77+ info := map[string]any{"id": id, "title": "Looked up " + id, "duration": 3600, "timestamp": now - 3600,78+ "description": "About " + id + "\n\nhttps://example.com/sponsor", "live_status": "not_live", "media_type": "video"}79+ if id == "feedclip" {80+ info["duration"] = 20081+ }82+ if slices.Contains(args, "-f") {83+ exp := now + 6*360084+ info["requested_formats"] = []map[string]any{85+ {"url": fmt.Sprintf("https://example.com/%s.mp4?expire=%d", id, exp), "vcodec": "avc1.640028", "acodec": "none", "height": 1080},86+ {"url": fmt.Sprintf("https://example.com/%s.m4a?expire=%d", id, exp), "vcodec": "none", "acodec": "mp4a.40.2"},87+ }88+ }89+ out = info90+ default:91+ fmt.Fprintln(os.Stderr, "ERROR: fake yt-dlp doesn't know "+u)92+ return 193+ }94+ json.NewEncoder(os.Stdout).Encode(out)95+ return 096+}97+98+// youtubeStub stands in for YouTube's feeds and pictures: Show A's feed has a new99+// upload, a Short and a clip, and an episode it has; only ep3 has a100+// maxresdefault picture.101+type youtubeStub struct{}102+103+func (youtubeStub) RoundTrip(r *http.Request) (*http.Response, error) {104+ body, code := "", http.StatusNotFound105+ switch u := r.URL.String(); {106+ case u == youtube.FeedURL("UCa"):107+ at := time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)108+ body, code = `<feed xmlns:yt="http://www.youtube.com/xml/schemas/2015" xmlns:media="http://search.yahoo.com/mrss/" xmlns="http://www.w3.org/2005/Atom">109+<entry><yt:videoId>feednew</yt:videoId><title>New one</title><link href="https://www.youtube.com/watch?v=feednew"/><published>`+at+`</published><media:group><media:description>Fresh</media:description></media:group></entry>110+<entry><yt:videoId>feedshort</yt:videoId><title>A Short</title><link href="https://www.youtube.com/shorts/feedshort"/><published>`+at+`</published></entry>111+<entry><yt:videoId>feedclip</yt:videoId><title>A clip</title><link href="https://www.youtube.com/watch?v=feedclip"/><published>`+at+`</published></entry>112+<entry><yt:videoId>ep3</yt:videoId><title>Episode 3</title><link href="https://www.youtube.com/watch?v=ep3"/><published>`+at+`</published><media:group><media:description>From the feed</media:description></media:group></entry>113+</feed>`, http.StatusOK114+ case u == "https://i.ytimg.com/vi/ep3/maxresdefault.jpg":115+ code = http.StatusOK116+ }117+ return &http.Response{StatusCode: code, Body: io.NopCloser(strings.NewReader(body)), Request: r}, nil118+}119+120+// library is a library in a test's folder, running the fake yt-dlp, whose121+// runs are logged to the file it returns.122+func library(t *testing.T) (*Library, string) {123+ t.Helper()124+ dir := t.TempDir()125+ calls := filepath.Join(dir, "yt-dlp.log")126+ t.Setenv("AIRWAVES_FAKE_YTDLP", calls)127+ return &Library{Path: filepath.Join(dir, "podcasts.json"),128+ YtDlp: &youtube.YtDlp{Path: os.Args[0], HTTP: &http.Client{Transport: youtubeStub{}}}}, calls129+}130+131+func ids(eps []Episode) []string {132+ out := []string{}133+ for _, e := range eps {134+ out = append(out, e.ID)135+ }136+ return out137+}138+139+func TestAddChannel(t *testing.T) {140+ l, calls := library(t)141+ s, err := l.Add(context.Background(), "https://www.youtube.com/@showa", 0)142+ if err != nil {143+ t.Fatal(err)144+ }145+ if s.Title != "Show A" || s.URL != "https://www.youtube.com/@showa/videos" || s.MinMinutes != DefaultMinMinutes ||146+ s.Thumbnail != "https://yt3.example/a=s176-c-k" {147+ t.Errorf("show: %+v", s)148+ }149+ // The clip is too short, the premiere not yet on.150+ if got := ids(s.Episodes); !slices.Equal(got, []string{"ep3", "ep2", "ep1"}) {151+ t.Errorf("episodes: %v", got)152+ }153+ if e := s.Episodes[1]; e.ShowID != s.ID || e.Duration != 3000 || e.Thumbnail != youtube.Thumbnail("ep2") || e.Published.IsZero() {154+ t.Errorf("episode: %+v", e)155+ }156+ raw, _ := os.ReadFile(calls)157+ if !strings.Contains(string(raw), "youtubetab:approximate_date -I 1:40 https://www.youtube.com/@showa/videos") {158+ t.Errorf("listing: %s", raw)159+ }160+ // Again, by handle: the same show.161+ again, err := l.Add(context.Background(), "@showa", 20)162+ if err != nil || again.ID != s.ID || len(l.State().Shows) != 1 {163+ t.Errorf("again: %+v %v", again, err)164+ }165+}166+167+func TestAddStreamsAndPlaylist(t *testing.T) {168+ l, _ := library(t)169+ s, err := l.Add(context.Background(), "youtube.com/@showa/streams/", 30)170+ if err != nil {171+ t.Fatal(err)172+ }173+ if got := ids(s.Episodes); s.URL != "https://www.youtube.com/@showa/streams" || !slices.Equal(got, []string{"live1"}) {174+ t.Errorf("streams: %s %v", s.URL, got)175+ }176+ p, err := l.Add(context.Background(), "https://www.youtube.com/playlist?list=PLtest00000001", 0)177+ if err != nil {178+ t.Fatal(err)179+ }180+ // Newest first, without the private video.181+ if got := ids(p.Episodes); p.Title != "Show B" || !slices.Equal(got, []string{"pb2", "pb1"}) || p.Thumbnail != youtube.Thumbnail("pb1") {182+ t.Errorf("playlist: %+v %v", p, got)183+ }184+ if st := l.State(); len(st.Shows) != 2 || st.Shows[0].ID != s.ID {185+ t.Errorf("state: %+v", st)186+ }187+}188+189+func TestBadShows(t *testing.T) {190+ l, _ := library(t)191+ for _, raw := range []string{"", "https://www.youtube.com/watch?v=abc", "https://youtu.be/abc", "https://www.youtube.com/@showa/podcasts", "https://example.com/feed"} {192+ if _, err := l.Add(context.Background(), raw, 0); !errors.Is(err, ErrBadShow) {193+ t.Errorf("%q: %v", raw, err)194+ }195+ }196+}197+198+// An update reads the channel's feed: its new upload joins, its Short and199+// clip don't, and the episode it has gets the feed's date. New videos are200+// looked up, and the newest unwatched episode found ahead.201+func TestUpdate(t *testing.T) {202+ l, calls := library(t)203+ s, err := l.Add(context.Background(), "@showa", 0)204+ if err != nil {205+ t.Fatal(err)206+ }207+ l.update(context.Background())208+ got := l.State().Shows[0]209+ if ids := ids(got.Episodes); !slices.Equal(ids, []string{"feednew", "ep3", "ep2", "ep1"}) {210+ t.Fatalf("episodes: %v", ids)211+ }212+ fresh, ep3 := got.Episodes[0], got.Episodes[1]213+ if fresh.Title != "Looked up feednew" || fresh.Duration != 3600 || fresh.Description != "About feednew" || fresh.Thumbnail != youtube.Thumbnail("feednew") {214+ t.Errorf("new upload: %+v", fresh)215+ }216+ if ep3.Thumbnail != "https://i.ytimg.com/vi/ep3/maxresdefault.jpg" {217+ t.Errorf("ep3: %+v", ep3)218+ }219+ raw, _ := os.ReadFile(calls)220+ if strings.Contains(string(raw), "watch?v=feedshort") {221+ t.Errorf("the Short was looked up: %s", raw)222+ }223+ if !strings.Contains(string(raw), "-f "+youtube.FormatSpec(maxHeight)+" https://www.youtube.com/watch?v=feednew") {224+ t.Errorf("not found ahead: %s", raw)225+ }226+ // The clip was looked up and left out for good.227+ l.mu.Lock()228+ skipped := slices.Clone(l.find(s.ID).Skipped)229+ l.mu.Unlock()230+ if !slices.Contains(skipped, "feedclip") || !slices.Contains(skipped, "feedshort") || !slices.Contains(skipped, "clip1") {231+ t.Errorf("skipped: %v", skipped)232+ }233+}234+235+func TestProgressPersists(t *testing.T) {236+ l, _ := library(t)237+ if _, err := l.Add(context.Background(), "@showa", 0); err != nil {238+ t.Fatal(err)239+ }240+ if err := l.Progress("ep2", 120, 0, false); err != nil {241+ t.Fatal(err)242+ }243+ if err := l.Progress("nope", 1, 1, false); !errors.Is(err, ErrNotFound) {244+ t.Errorf("unknown episode: %v", err)245+ }246+ again := &Library{Path: l.Path, YtDlp: l.YtDlp}247+ e := again.State().Shows[0].Episodes[1]248+ if e.ID != "ep2" || e.Position != 120 || e.Watched {249+ t.Errorf("after reopening: %+v", e)250+ }251+ // Within a minute of the end is watched.252+ if err := again.Progress("ep2", 2950, 3000, false); err != nil {253+ t.Fatal(err)254+ }255+ if e := again.State().Shows[0].Episodes[1]; !e.Watched {256+ t.Errorf("near the end: %+v", e)257+ }258+ if err := again.Progress("ep2", 0, 0, false); err != nil || again.State().Shows[0].Episodes[1].Watched {259+ t.Errorf("marked unwatched: %v", err)260+ }261+}262+263+// Playing an episode streams its H.264 picture and AAC sound from YouTube264+// from where it's asked to, copied.265+func TestInput(t *testing.T) {266+ l, _ := library(t)267+ if _, err := l.Add(context.Background(), "@showa", 0); err != nil {268+ t.Fatal(err)269+ }270+ in, err := l.Input(context.Background(), "ep2", 600)271+ if err != nil {272+ t.Fatal(err)273+ }274+ args := strings.Join(in.Args, " ")275+ if !strings.Contains(args, "-ss 600.000 -i https://example.com/ep2.mp4") || !strings.Contains(args, "-ss 600.000 -i https://example.com/ep2.m4a") {276+ t.Errorf("args: %s", args)277+ }278+ if !in.VOD || !in.Copy || in.Offset != 600 || in.Video != "0:v:0" || len(in.Audio) != 1 || in.Audio[0].Map != "1:a:0" {279+ t.Errorf("input: %+v", in)280+ }281+ if in, err := l.Input(context.Background(), "ep2", 3); err != nil || in.Offset != 0 || strings.Contains(strings.Join(in.Args, " "), "-ss") {282+ t.Errorf("from the start: %+v %v", in, err)283+ }284+ if _, err := l.Input(context.Background(), "nope", 0); !errors.Is(err, ErrNotFound) {285+ t.Errorf("unknown episode: %v", err)286+ }287+}288+289+func TestSearchAndRemove(t *testing.T) {290+ l, _ := library(t)291+ found, err := l.Search(context.Background(), "show a")292+ if err != nil || len(found) != 1 || found[0].ID != "UCa" || found[0].URL != "https://www.youtube.com/@showa" || found[0].Subscribers != 1000 {293+ t.Errorf("search: %+v %v", found, err)294+ }295+ if _, err := l.Search(context.Background(), " "); !errors.Is(err, ErrBadShow) {296+ t.Errorf("empty search: %v", err)297+ }298+ s, err := l.Add(context.Background(), found[0].URL, 0)299+ if err != nil {300+ t.Fatal(err)301+ }302+ if err := l.Remove(s.ID); err != nil || len(l.State().Shows) != 0 {303+ t.Errorf("remove: %v", err)304+ }305+ if err := l.Remove(s.ID); !errors.Is(err, ErrNotFound) {306+ t.Errorf("remove again: %v", err)307+ }308+}@@ -0,0 +1,498 @@
1+package podcast2+3+import (4+ "context"5+ "errors"6+ "log"7+ "math"8+ "net/http"9+ "slices"10+ "strconv"11+ "time"12+13+ "airwaves/internal/web"14+ "airwaves/internal/youtube"15+)16+17+// Keeping up: each show is listed every listEvery (its newest listLimit18+// videos), and a channel's feed, YouTube's cheap word of new uploads but19+// down at times, is read every feedEvery in between. A video new to a20+// show is looked up (its length, exact date, description and whether it's21+// an episode at all), a few an update with a gap between, so yt-dlp isn't22+// run hard. The newest unwatched episode of each show has where it23+// streams from found ahead, so Play starts at once.24+const (25+ listEvery = 6 * time.Hour26+ feedEvery = 30 * time.Minute27+ retryList = time.Hour // after a listing failed28+ maxLookups = 12 // videos looked up per update29+ tickEvery = time.Minute30+ // minLeft is how long addresses must still work to be used; they're31+ // found again sooner.32+ minLeft = 20 * time.Minute33+ // resolveTime bounds finding where an episode streams from.34+ resolveTime = 90 * time.Second35+ keepSkipped = 30036+)37+38+// lookupGap spaces out yt-dlp runs; tests shorten it.39+var lookupGap = 3 * time.Second40+41+// resolved is where an episode streams from, until when.42+type resolved struct {43+ info youtube.Info44+ until time.Time45+}46+47+// Run keeps the shows up to date until ctx ends.48+func (l *Library) Run(ctx context.Context) {49+ l.mu.Lock()50+ l.load()51+ kick := l.kick52+ l.mu.Unlock()53+ t := time.NewTicker(tickEvery)54+ defer t.Stop()55+ for {56+ l.update(ctx)57+ select {58+ case <-ctx.Done():59+ return60+ case <-t.C:61+ case <-kick:62+ }63+ }64+}65+66+// poke has Run update now. l.mu is held.67+func (l *Library) poke() {68+ select {69+ case l.kick <- struct{}{}:70+ default:71+ }72+}73+74+// update lists the shows due, reads the feeds due, looks up new videos75+// and finds the next episodes' streams ahead. One show failing doesn't76+// stop the others.77+func (l *Library) update(ctx context.Context) {78+ now := l.clock()79+ l.mu.Lock()80+ l.load()81+ var lists, feeds []*show82+ for _, s := range l.st.Shows {83+ switch {84+ case l.listDue(s, now):85+ lists = append(lists, s)86+ case !s.source().playlist && s.ChannelID != "" && now.Sub(s.Checked) >= feedEvery:87+ feeds = append(feeds, s)88+ }89+ }90+ l.updating = len(lists) > 091+ l.mu.Unlock()92+ for _, s := range lists {93+ if ctx.Err() != nil {94+ return95+ }96+ l.relist(ctx, s)97+ }98+ l.mu.Lock()99+ l.updating = false100+ l.mu.Unlock()101+ for _, s := range feeds {102+ if ctx.Err() != nil {103+ return104+ }105+ l.readFeed(ctx, s)106+ }107+ l.lookups(ctx)108+ l.ahead(ctx)109+}110+111+// listDue reports whether a show is due a listing. l.mu is held.112+func (l *Library) listDue(s *show, now time.Time) bool {113+ if s.Error != "" {114+ return now.Sub(s.Listed) >= retryList115+ }116+ return now.Sub(s.Listed) >= listEvery117+}118+119+// list lists a show's newest videos, one listing job at a time across120+// yt-dlp's users.121+func (l *Library) list(ctx context.Context, s src) (youtube.Listing, error) {122+ ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)123+ defer cancel()124+ tool := l.tool()125+ args := append(slices.Clone(youtube.ListingArgs), "-I", "1:"+strconv.Itoa(listLimit), s.url)126+ tool.Jobs.Lock()127+ raw, err := tool.Run(ctx, args...)128+ tool.Jobs.Unlock()129+ if err != nil {130+ return youtube.Listing{}, err131+ }132+ if s.playlist {133+ return youtube.ParsePlaylist(raw)134+ }135+ return youtube.ParseListing(raw)136+}137+138+// relist lists a show again and takes in what's new.139+func (l *Library) relist(ctx context.Context, s *show) {140+ lst, err := l.list(ctx, s.source())141+ now := l.clock()142+ l.mu.Lock()143+ defer l.mu.Unlock()144+ if l.find(s.ID) != s { // removed meanwhile145+ return146+ }147+ s.Listed = now148+ if err != nil {149+ if ctx.Err() != nil {150+ return151+ }152+ s.Error = err.Error()153+ log.Printf("podcasts: %s: %v", s.Title, err)154+ l.save()155+ return156+ }157+ s.Error = ""158+ s.merge(lst, s.source(), now)159+ l.save()160+}161+162+// merge takes in a listing: its newest episodes, keeping what's known of163+// each, plus older ones part watched. Videos too short to be episodes,164+// and streams to come or under way, are left out.165+func (s *show) merge(lst youtube.Listing, sr src, now time.Time) {166+ if s.Title == "" || lst.Name != "" {167+ s.Title = lst.Name168+ }169+ if lst.Image != "" {170+ s.Thumbnail = lst.Image171+ }172+ if !sr.playlist {173+ s.ChannelID = lst.ID174+ } else {175+ s.ChannelID = lst.OwnerID176+ }177+ s.Listed = now178+ known := map[string]*episode{}179+ for _, e := range s.Episodes {180+ known[e.ID] = e181+ }182+ var out []*episode183+ seen := map[string]bool{}184+ for _, v := range lst.Entries {185+ if seen[v.ID] || !s.fits(v.Seconds, v.LiveStatus, sr) {186+ if v.Seconds > 0 && float64(v.Seconds) < s.MinMinutes*60 {187+ s.skip(v.ID)188+ }189+ continue190+ }191+ seen[v.ID] = true192+ e := known[v.ID]193+ if e == nil {194+ e = &episode{ID: v.ID}195+ }196+ e.Title = v.Title197+ if v.Seconds > 0 {198+ e.Seconds = float64(v.Seconds)199+ }200+ if !v.Published.IsZero() && (e.Published.IsZero() || e.Approx) {201+ e.Published, e.Approx = v.Published, true202+ }203+ out = append(out, e)204+ }205+ if sr.playlist && !slices.ContainsFunc(out, func(e *episode) bool { return e.Published.IsZero() }) {206+ slices.SortStableFunc(out, func(a, b *episode) int { return b.Published.Compare(a.Published) })207+ }208+ // New uploads the feed found that the listing doesn't show yet stay.209+ var fresh []*episode210+ for _, e := range s.Episodes {211+ if !seen[e.ID] && !e.Published.IsZero() && now.Sub(e.Published) < 3*24*time.Hour && !e.Approx {212+ fresh = append(fresh, e)213+ seen[e.ID] = true214+ }215+ }216+ out = append(fresh, out...)217+ if len(out) > keepEpisodes {218+ out = out[:keepEpisodes]219+ }220+ for _, e := range s.Episodes { // part watched, kept till finished221+ if !seen[e.ID] && e.Position > 0 && !e.Watched && !slices.Contains(out, e) {222+ out = append(out, e)223+ }224+ }225+ s.Episodes = out226+}227+228+// fits reports whether a video can be one of the show's episodes, by229+// what's known: long enough (when its length is), and not a stream to230+// come, on now or not yet processed. A show of a channel's videos takes231+// no past streams, which have their own tab.232+func (s *show) fits(seconds int, live string, sr src) bool {233+ switch live {234+ case "is_upcoming", "is_live", "post_live":235+ return false236+ case "was_live":237+ if sr.tab == "videos" {238+ return false239+ }240+ }241+ return seconds <= 0 || float64(seconds) >= s.MinMinutes*60242+}243+244+// skip remembers a video that isn't an episode.245+func (s *show) skip(id string) {246+ if slices.Contains(s.Skipped, id) {247+ return248+ }249+ s.Skipped = append(s.Skipped, id)250+ if len(s.Skipped) > keepSkipped {251+ s.Skipped = s.Skipped[len(s.Skipped)-keepSkipped:]252+ }253+}254+255+// readFeed reads a channel's feed: uploads the show hasn't seen join it,256+// to be looked up, and ones it has get their exact dates and257+// descriptions. Shorts are left out by their links.258+func (l *Library) readFeed(ctx context.Context, s *show) {259+ ctx, cancel := context.WithTimeout(ctx, 30*time.Second)260+ defer cancel()261+ l.mu.Lock()262+ id := s.ChannelID263+ l.mu.Unlock()264+ raw, err := web.Get(ctx, l.tool().Client(), youtube.FeedURL(id))265+ var entries []youtube.FeedEntry266+ if err == nil {267+ entries, err = youtube.ParseFeed(raw)268+ }269+ now := l.clock()270+ l.mu.Lock()271+ defer l.mu.Unlock()272+ if l.find(s.ID) != s {273+ return274+ }275+ s.Checked = now276+ if err != nil {277+ return // the listings will do278+ }279+ known := map[string]*episode{}280+ for _, e := range s.Episodes {281+ known[e.ID] = e282+ }283+ var fresh []*episode284+ for _, f := range entries {285+ if f.ID == "" || slices.Contains(s.Skipped, f.ID) {286+ continue287+ }288+ if e := known[f.ID]; e != nil {289+ if !f.Published.IsZero() && (e.Published.IsZero() || e.Approx) {290+ e.Published, e.Approx = f.Published, false291+ }292+ if d := youtube.Description(f.Description); e.Description == "" && d != "" {293+ e.Description = d294+ }295+ continue296+ }297+ if f.Short() {298+ s.skip(f.ID)299+ continue300+ }301+ // Older than the episodes kept: not new, just not kept.302+ if n := len(s.Episodes); n >= keepEpisodes && !f.Published.After(s.Episodes[n-1].Published) {303+ continue304+ }305+ fresh = append(fresh, &episode{ID: f.ID, Title: f.Title, Published: f.Published, Description: youtube.Description(f.Description)})306+ }307+ s.Episodes = append(fresh, s.Episodes...) // the next listing trims them308+ l.save()309+}310+311+// lookups looks up the details of videos new to their shows, newest312+// first, a few an update: their lengths, dates and descriptions, and313+// whether they're episodes at all.314+func (l *Library) lookups(ctx context.Context) {315+ type due struct {316+ show *show317+ id string318+ published time.Time319+ }320+ l.mu.Lock()321+ var todo []due322+ for _, s := range l.st.Shows {323+ for _, e := range s.Episodes {324+ if e.Looked.IsZero() {325+ todo = append(todo, due{s, e.ID, e.Published})326+ }327+ }328+ }329+ l.mu.Unlock()330+ slices.SortStableFunc(todo, func(a, b due) int { return b.published.Compare(a.published) })331+ for i, d := range todo {332+ if i == maxLookups || ctx.Err() != nil {333+ return334+ }335+ if i > 0 {336+ select {337+ case <-ctx.Done():338+ return339+ case <-time.After(lookupGap):340+ }341+ }342+ l.lookup(ctx, d.show, d.id)343+ }344+}345+346+// lookup looks up a video's details, and drops it from its show when it347+// isn't an episode after all.348+func (l *Library) lookup(ctx context.Context, s *show, id string) {349+ ctx, cancel := context.WithTimeout(ctx, resolveTime)350+ defer cancel()351+ tool := l.tool()352+ tool.Jobs.Lock()353+ raw, err := tool.Run(ctx, "-J", "--skip-download", "--no-playlist", "--ignore-no-formats-error", youtube.WatchURL(id))354+ tool.Jobs.Unlock()355+ var info youtube.Info356+ if err == nil {357+ info, err = youtube.ParseInfo(raw)358+ }359+ thumb := ""360+ if err == nil {361+ thumb = l.thumbnail(ctx, id)362+ }363+ now := l.clock()364+ l.mu.Lock()365+ defer l.mu.Unlock()366+ if l.find(s.ID) != s {367+ return368+ }369+ i := slices.IndexFunc(s.Episodes, func(e *episode) bool { return e.ID == id })370+ if i < 0 {371+ return372+ }373+ e := s.Episodes[i]374+ if err != nil {375+ if youtube.Gone(err) {376+ s.Episodes = slices.Delete(s.Episodes, i, i+1)377+ s.skip(id)378+ l.save()379+ } else if ctx.Err() == nil {380+ e.Looked = now // tried; the next listing tries again381+ log.Printf("podcasts: %s: looking up %s: %v", s.Title, id, err)382+ }383+ return384+ }385+ e.Looked = now386+ if info.Title != "" {387+ e.Title = info.Title388+ }389+ if info.Duration > 0 {390+ e.Seconds = math.Round(info.Duration)391+ }392+ if info.Timestamp > 0 {393+ e.Published, e.Approx = time.Unix(int64(info.Timestamp), 0).UTC(), false394+ }395+ if d := youtube.Description(info.Description); d != "" {396+ e.Description = d397+ }398+ if thumb != "" {399+ e.Thumbnail = thumb400+ }401+ live := info.LiveStatus402+ if info.MediaType == "short" || !s.fits(int(e.Seconds), live, s.source()) {403+ s.Episodes = slices.Delete(s.Episodes, i, i+1)404+ if live != "is_upcoming" && live != "is_live" && live != "post_live" {405+ s.skip(id) // for good; a stream to come may be one later406+ }407+ }408+ l.save()409+}410+411+// thumbnail is a video's largest picture: maxresdefault when YouTube has412+// one, which not every video does, else "" for the one every video has.413+func (l *Library) thumbnail(ctx context.Context, id string) string {414+ u := "https://i.ytimg.com/vi/" + id + "/maxresdefault.jpg"415+ req, err := http.NewRequestWithContext(ctx, http.MethodHead, u, nil)416+ if err != nil {417+ return ""418+ }419+ resp, err := l.tool().Client().Do(req)420+ if err != nil {421+ return ""422+ }423+ resp.Body.Close()424+ if resp.StatusCode != http.StatusOK {425+ return ""426+ }427+ return u428+}429+430+// ahead finds where the newest unwatched episode of each show streams431+// from, so Play starts at once, and forgets addresses that expired.432+func (l *Library) ahead(ctx context.Context) {433+ now := l.clock()434+ l.mu.Lock()435+ for id, r := range l.resolved {436+ if now.After(r.until) {437+ delete(l.resolved, id)438+ }439+ }440+ var ids []string441+ for _, s := range l.st.Shows {442+ for _, e := range s.Episodes {443+ if e.Watched || e.Looked.IsZero() {444+ continue445+ }446+ if r, ok := l.resolved[e.ID]; !ok || r.until.Sub(now) < minLeft {447+ ids = append(ids, e.ID)448+ }449+ break450+ }451+ }452+ l.mu.Unlock()453+ tool := l.tool()454+ for _, id := range ids {455+ if ctx.Err() != nil {456+ return457+ }458+ tool.Ahead.Lock()459+ _, err := l.fetch(ctx, id)460+ tool.Ahead.Unlock()461+ if err != nil && ctx.Err() == nil {462+ log.Printf("podcasts: finding %s ahead: %v", id, err)463+ }464+ }465+}466+467+// resolve finds where an episode streams from: as found ahead, else now.468+func (l *Library) resolve(ctx context.Context, id string) (youtube.Info, error) {469+ l.mu.Lock()470+ r, ok := l.resolved[id]471+ l.mu.Unlock()472+ if ok && time.Until(r.until) >= minLeft {473+ return r.info, nil474+ }475+ return l.fetch(ctx, id)476+}477+478+// fetch asks yt-dlp where an episode streams from, and keeps it until it479+// expires.480+func (l *Library) fetch(ctx context.Context, id string) (youtube.Info, error) {481+ ctx, cancel := context.WithTimeout(ctx, resolveTime)482+ defer cancel()483+ raw, err := l.tool().Run(ctx, "-J", "--no-playlist", "-f", youtube.FormatSpec(maxHeight), youtube.WatchURL(id))484+ if err != nil {485+ return youtube.Info{}, err486+ }487+ info, err := youtube.ParseInfo(raw)488+ if err == nil && len(info.Inputs()) == 0 {489+ err = errors.New("yt-dlp found no stream")490+ }491+ if err != nil {492+ return youtube.Info{}, err493+ }494+ l.mu.Lock()495+ l.resolved[id] = resolved{info: info, until: youtube.AddressesUntil(info, time.Now())}496+ l.mu.Unlock()497+ return info, nil498+}@@ -337,10 +337,11 @@ func playlists(in tuner.Input) []string {
337337 // live streams with their captions as text.338338 func subtitled(in tuner.Input) bool { return in.Subtitles != nil && !in.VOD }339339 340-// ffmpegArgs transcodes to H.264 and AAC in HLS. Several audio tracks become341-// separate renditions ("v:0 a:0 a:1"); closed captions (CEA-608 in the342-// picture of a broadcast or a custom channel) are carried into the H.264343-// stream by libx264.340+// ffmpegArgs transcodes to H.264 and AAC in HLS, or copies a picture341+// that's H.264 already (Copy). Several audio tracks become separate342+// renditions ("v:0 a:0 a:1"); closed captions (CEA-608 in the picture of a343+// broadcast or a custom channel) are carried into the H.264 stream by344+// libx264.344345 func ffmpegArgs(in tuner.Input, dir string) []string {345346 args := []string{"-hide_banner", "-loglevel", "error", "-nostdin"}346347 args = append(args, probeArgs(in)...)@@ -349,16 +350,22 @@ func ffmpegArgs(in tuner.Input, dir string) []string {
349350 for _, a := range in.Audio {350351 args = append(args, "-map", a.Map)351352 }352- if in.Broadcast {353+ switch {354+ case in.Copy:355+ // Segments then start at the picture's own key frames.356+ args = append(args, "-c:v", "copy")357+ case in.Broadcast:353358 // Deinterlace only frames flagged interlaced (1080i, 480i).354359 args = append(args, "-vf", "bwdif=mode=send_frame:deint=interlaced")355360 }356- args = append(args,357- "-c:v", "libx264", "-preset", "veryfast", "-tune", "zerolatency", "-pix_fmt", "yuv420p",358- "-profile:v", "high", "-crf", "21", "-maxrate", "8M", "-bufsize", "12M",359- "-g", "60", "-keyint_min", "60", "-sc_threshold", "0",360- "-force_key_frames", "expr:gte(t,n_forced*2)", "-a53cc", "1",361- )361+ if !in.Copy {362+ args = append(args,363+ "-c:v", "libx264", "-preset", "veryfast", "-tune", "zerolatency", "-pix_fmt", "yuv420p",364+ "-profile:v", "high", "-crf", "21", "-maxrate", "8M", "-bufsize", "12M",365+ "-g", "60", "-keyint_min", "60", "-sc_threshold", "0",366+ "-force_key_frames", "expr:gte(t,n_forced*2)", "-a53cc", "1",367+ )368+ }362369 streamMap := "v:0"363370 switch len(in.Audio) {364371 case 0:@@ -113,3 +113,18 @@ func TestMasterPlaylistForCaptions(t *testing.T) {
113113 t.Errorf("ffmpeg args: %s", args)114114 }115115 }116+117+// A picture that's H.264 already is copied, its segments kept to seek in;118+// the sound is still made AAC.119+func TestCopyArgs(t *testing.T) {120+ in := tuner.Input{Args: []string{"-i", "v.mp4", "-i", "a.m4a"}, Video: "0:v:0", Audio: []tuner.AudioTrack{{Map: "1:a:0"}}, VOD: true, Copy: true}121+ args := strings.Join(ffmpegArgs(in, "/tmp/x"), " ")122+ for _, want := range []string{"-map 0:v:0 -map 1:a:0", "-c:v copy", "-c:a aac", "-hls_list_size 0"} {123+ if !strings.Contains(args, want) {124+ t.Errorf("no %q in %s", want, args)125+ }126+ }127+ if strings.Contains(args, "libx264") || strings.Contains(args, "-force_key_frames") {128+ t.Errorf("transcodes: %s", args)129+ }130+}@@ -58,6 +58,9 @@ type Input struct {
5858 // VOD marks a recording: transcode as fast as possible and keep every5959 // segment so the player can seek.6060 VOD bool61+ // Copy marks a picture that's H.264 already (a podcast's), copied as62+ // it is rather than transcoded; its sound is still made AAC.63+ Copy bool6164 // Offset is where in the recording this input starts, in seconds.6265 Offset float646366 // Source, when set, writes the channel's MPEG-TS for ffmpeg to read on@@ -51,6 +51,9 @@ type Listing struct {
5151 Entries []Entry // a channel's newest first; a playlist's in its order5252 // Owner and OwnerID are a playlist's channel (see ParsePlaylist).5353 Owner, OwnerID string54+ // Image is a channel's avatar, or a playlist's first video's55+ // thumbnail; "" when the listing has none.56+ Image string5457 }5558 5659 // Entry is a video in a listing.@@ -62,8 +65,10 @@ type Entry struct {
6265 Published time.Time6366 // Skip is "upcoming" for a premiere or a stream to come, "live" for6467 // one on now or not yet processed (and in a channel's tab, one that65- // was live), else "".66- Skip string68+ // was live), else "". LiveStatus is yt-dlp's own word for it69+ // ("is_upcoming", "is_live", "post_live", "was_live", "not_live"), ""70+ // when the listing doesn't say.71+ Skip, LiveStatus string6772 // Channel is a playlist's video's channel, which may not be the6873 // playlist's.6974 Channel string@@ -79,9 +84,8 @@ var PlaylistArgs = []string{"--flat-playlist", "-J"}
7984 // ParseListing reads a channel tab's listing.8085 func ParseListing(raw []byte) (Listing, error) {8186 var p struct {82- Channel string `json:"channel"`83- ChannelID string `json:"channel_id"`84- Entries []struct {87+ channelInfo88+ Entries []struct {8589 ID string `json:"id"`8690 Title string `json:"title"`8791 Duration float64 `json:"duration"`@@ -95,12 +99,12 @@ func ParseListing(raw []byte) (Listing, error) {
9599 if p.ChannelID == "" {96100 return Listing{}, errors.New("not a YouTube channel")97101 }98- l := Listing{ID: p.ChannelID, Name: p.Channel}102+ l := Listing{ID: p.ChannelID, Name: p.Channel, Image: p.channel().Image}99103 for _, e := range p.Entries {100104 if e.ID == "" {101105 continue102106 }103- v := Entry{ID: e.ID, Title: e.Title, Seconds: int(math.Round(e.Duration))}107+ v := Entry{ID: e.ID, Title: e.Title, Seconds: int(math.Round(e.Duration)), LiveStatus: e.LiveStatus}104108 if e.Timestamp > 0 {105109 v.Published = time.Unix(int64(e.Timestamp), 0).UTC()106110 }@@ -158,7 +162,8 @@ func ParsePlaylist(raw []byte) (Listing, error) {
158162 continue159163 }160164 seen[e.ID] = true161- v := Entry{ID: e.ID, Title: e.Title, Seconds: int(math.Round(e.Duration)), Channel: cmp.Or(e.Channel, e.Uploader)}165+ v := Entry{ID: e.ID, Title: e.Title, Seconds: int(math.Round(e.Duration)), Channel: cmp.Or(e.Channel, e.Uploader),166+ LiveStatus: e.LiveStatus}162167 if e.Timestamp > 0 {163168 v.Published = time.Unix(int64(e.Timestamp), 0).UTC()164169 }@@ -170,6 +175,9 @@ func ParsePlaylist(raw []byte) (Listing, error) {
170175 }171176 l.Entries = append(l.Entries, v)172177 }178+ if len(l.Entries) > 0 {179+ l.Image = Thumbnail(l.Entries[0].ID)180+ }173181 return l, nil174182 }175183