clone

a desktop tv and program guide for over-the-air broadcasts: what's on air, what your tuner gets, live tv, recordings

Serve the Podcasts app: its shows, a search for more, and episodes to play

FelineStateMachine committed 758b5ec
/api/podcasts lists the shows and their episodes; shows are found with a
search for YouTube channels, added by link and taken out; an episode
plays like a recording, and how far it was watched is kept. The desktop
app and the web app have the same calls, /api/info says when the
server has the app, and airwavesd keeps the library in podcasts.json.
commit
758b5ecc158d5f4e94988fc02b87933acec2e10e
parent
6ca783a947264d232cb0a06a1f2be1cb94e76683
author
FelineStateMachine <226808055+FelineStateMachine@users.noreply.github.com>
kind
feat risk score 1.1 of 3, Jev's estimate
13 files changed, +591 -6
MREADME.md33+++++++
Mapp.go61++++++++++++
Mcmd/airwavesd/main.go6+
Mfrontend/dist/web.js12++
Mfrontend/wailsjs/go/main/App.d.ts13+++
Mfrontend/wailsjs/go/main/App.js24+++++
Mfrontend/wailsjs/go/models.ts149++++++++++++++++++++++++++++++
Minternal/api/api.go67+++++++++++++-
Minternal/api/api_test.go72++++++++++++++
Minternal/api/client.go43+++++++++
Ainternal/service/podcasts.go76+++++++++++++++
Ainternal/service/podcasts_test.go21++++
Minternal/service/service.go20+++-
M README.md +33 -0
@@ -260,6 +260,38 @@ images are served from `/wximg/`, so the app never handles coordinates.
260260 - **W page**: the weather report in the app.261261 - **Alert crawl**: NWS watches and warnings for the location crawl across live TV.262262 263+## Podcasts264+265+The Podcasts app is video podcasts on demand, from YouTube, where they are: their public266+RSS feeds carry only the sound. A show is a YouTube channel's videos, its past live streams267+(its `/streams` tab, where full shows that air live end up) or a playlist, added by its268+link, `@handle` or a search for channels. Videos shorter than the show's minimum (10269+minutes unless it's added with another) are clips, not episodes; Shorts, and streams to270+come, on now or not yet processed, are left out.271+272+`airwavesd` keeps each show's newest 30 episodes in `<data>/podcasts.json`, with how far273+each was watched (and any older ones part watched), backed up like the recordings:274+275+- every 6 hours it lists the show's newest 40 videos with yt-dlp, and every 30 minutes reads276+ its channel's feed for new uploads in between;277+- a video new to a show is looked up (its length, date, description and picture), a dozen278+ an update, a few seconds apart, so YouTube isn't asked too much;279+- the newest unwatched episode of each show has where it streams from found ahead, so it280+ starts at once.281+282+Nothing is downloaded: an episode streams from YouTube while it's watched, H.264 up to283+1080p with English sound, its picture copied rather than transcoded, and played like a284+recording, from where it was left. One show failing to list says so on that show alone.285+286+| | |287+| --- | --- |288+| `GET /api/podcasts` | `{"shows": [{"id", "title", "url", "thumbnail", "minMinutes", "error", "episodes": [{"id", "showId", "title", "description", "published", "duration", "thumbnail", "position", "watched"}]}], "updating"}`, episodes newest first |289+| `GET /api/podcasts/search?q=` | YouTube channels: `[{"id", "title", "url", "thumbnail", "description", "subscribers"}]` |290+| `POST /api/podcasts/shows` | `{"url", "minMinutes"}`: adds a show, listed before the reply, which is the show; 400 for what isn't one |291+| `DELETE /api/podcasts/shows/{id}` | Takes a show out |292+| `POST /api/podcasts/play` | `{"client", "id", "from"}`: plays an episode, as `/api/dvr/play` plays a recording |293+| `POST /api/podcasts/progress` | `{"id", "position", "duration", "watched"}`; within a minute of the end is watched |294+263295 ## HDHomeRun emulation264296 265297 `airwavesd` also appears on the home network as an HDHomeRun network tuner, so other
@@ -672,6 +704,7 @@ internal/hdhr the HDHomeRun's HTTP API and discovery (hdhrtest fakes
672704 internal/vchan custom channels: Airwaves Weather, folder, Jellyfin and YouTube channels673705 internal/jellyfin Jellyfin client: sign-in, Quick Connect, browsing, stream URLs674706 internal/youtube YouTube as a source: yt-dlp kept up to date, listings, feeds, search, where to stream from707+internal/podcast the Podcasts app's library: shows, their newest episodes, how far each was watched675708 deploy/, scripts/ Docker files for the server and the deploy script676709 deploy/standalone Docker Compose for custom channels only, built from source (docs/self-hosting.md)677710 frontend/dist the UI: plain HTML, CSS and JS, no build step; also served at /tv/
M app.go +61 -0
@@ -12,6 +12,7 @@ import (
1212 1313 "airwaves/internal/api"1414 "airwaves/internal/dvr"15+ "airwaves/internal/podcast"1516 "airwaves/internal/service"1617 "airwaves/internal/store"1718 "airwaves/internal/stream"
@@ -337,6 +338,66 @@ func (a *App) PlayRecording(id string, from float64) (*stream.Playback, error) {
337338 return c.PlayRecording(a.ctx, s.ClientID, id, from)338339 }339340 341+// Podcasts returns the Podcasts app's shows and their episodes.342+func (a *App) Podcasts() (*podcast.State, error) {343+ c, _, err := a.server()344+ if err != nil {345+ return nil, err346+ }347+ return c.Podcasts(a.ctx)348+}349+350+// SearchPodcasts finds YouTube channels to add as shows.351+func (a *App) SearchPodcasts(query string) ([]podcast.Found, error) {352+ c, _, err := a.server()353+ if err != nil {354+ return nil, err355+ }356+ return c.SearchPodcasts(a.ctx, query)357+}358+359+// AddPodcast adds a show: a YouTube channel, its live streams' tab or a360+// playlist; minMinutes is the shortest video that's an episode, 0 for the361+// default.362+func (a *App) AddPodcast(url string, minMinutes float64) (*podcast.Show, error) {363+ c, _, err := a.server()364+ if err != nil {365+ return nil, err366+ }367+ return c.AddPodcast(a.ctx, url, minMinutes)368+}369+370+// RemovePodcast takes a show out of the library.371+func (a *App) RemovePodcast(id string) error {372+ c, _, err := a.server()373+ if err != nil {374+ return err375+ }376+ return c.RemovePodcast(a.ctx, id)377+}378+379+// PlayEpisode streams an episode from from seconds in, in place of TV.380+func (a *App) PlayEpisode(id string, from float64) (*stream.Playback, error) {381+ c, s, err := a.server()382+ if err != nil {383+ return nil, err384+ }385+ if id == "" {386+ return nil, errors.New("no episode selected")387+ }388+ return c.PlayEpisode(a.ctx, s.ClientID, id, from)389+}390+391+// EpisodeProgress records how far an episode has been watched, and392+// whether it has.393+func (a *App) EpisodeProgress(id string, position, duration float64, watched bool) error {394+ c, _, err := a.server()395+ if err != nil {396+ return err397+ }398+ return c.EpisodeProgress(a.ctx, id, position, duration, watched)399+}400+340401 // SaveProgress records how far a recording has been watched.341402 func (a *App) SaveProgress(id string, position, duration float64) error {342403 c, _, err := a.server()
M cmd/airwavesd/main.go +6 -0
M frontend/dist/web.js +12 -0
M frontend/wailsjs/go/main/App.d.ts +13 -0
@@ -1,5 +1,6 @@
11 // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL22 // This file is automatically generated. DO NOT EDIT3+import {podcast} from '../models';34 import {main} from '../models';45 import {dvr} from '../models';56 import {time} from '../models';
@@ -8,6 +9,8 @@ import {stream} from '../models';
89 import {store} from '../models';910 import {weather} from '../models';1011 12+export function AddPodcast(arg1:string,arg2:number):Promise<podcast.Show>;13+1114 export function Boot():Promise<main.Boot>;1215 1316 export function CopyText(arg1:string):Promise<void>;
@@ -18,6 +21,8 @@ export function DeleteRecording(arg1:string):Promise<void>;
1821 1922 export function DeleteRule(arg1:string):Promise<void>;2023 24+export function EpisodeProgress(arg1:string,arg2:number,arg3:number,arg4:boolean):Promise<void>;25+2126 export function ExtendRecording(arg1:string,arg2:number):Promise<time.Time>;2227 2328 export function Heartbeat():Promise<boolean>;
@@ -30,10 +35,16 @@ export function Measure():Promise<service.SweepStatus>;
3035 3136 export function OpenURL(arg1:string):Promise<void>;3237 38+export function PlayEpisode(arg1:string,arg2:number):Promise<stream.Playback>;39+3340 export function PlayRecording(arg1:string,arg2:number):Promise<stream.Playback>;3441 42+export function Podcasts():Promise<podcast.State>;43+3544 export function Record(arg1:dvr.Request):Promise<dvr.Rule>;3645 46+export function RemovePodcast(arg1:string):Promise<void>;47+3748 export function SaveProgress(arg1:string,arg2:number,arg3:number):Promise<void>;3849 3950 export function SaveSettings(arg1:store.Settings):Promise<main.Boot>;
@@ -42,6 +53,8 @@ export function Scan(arg1:boolean):Promise<service.Snapshot>;
4253 4354 export function ScanChannels():Promise<service.TunerInfo>;4455 56+export function SearchPodcasts(arg1:string):Promise<Array<podcast.Found>>;57+4558 export function SetConfig(arg1:service.Config):Promise<service.Config>;4659 4760 export function SetDVRPrefs(arg1:dvr.Prefs):Promise<dvr.Prefs>;
M frontend/wailsjs/go/main/App.js +24 -0
@@ -2,6 +2,10 @@
22 // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL33 // This file is automatically generated. DO NOT EDIT44 5+export function AddPodcast(arg1, arg2) {6+ return window['go']['main']['App']['AddPodcast'](arg1, arg2);7+}8+59 export function Boot() {610 return window['go']['main']['App']['Boot']();711 }
@@ -22,6 +26,10 @@ export function DeleteRule(arg1) {
2226 return window['go']['main']['App']['DeleteRule'](arg1);2327 }2428 29+export function EpisodeProgress(arg1, arg2, arg3, arg4) {30+ return window['go']['main']['App']['EpisodeProgress'](arg1, arg2, arg3, arg4);31+}32+2533 export function ExtendRecording(arg1, arg2) {2634 return window['go']['main']['App']['ExtendRecording'](arg1, arg2);2735 }
@@ -46,14 +54,26 @@ export function OpenURL(arg1) {
4654 return window['go']['main']['App']['OpenURL'](arg1);4755 }4856 57+export function PlayEpisode(arg1, arg2) {58+ return window['go']['main']['App']['PlayEpisode'](arg1, arg2);59+}60+4961 export function PlayRecording(arg1, arg2) {5062 return window['go']['main']['App']['PlayRecording'](arg1, arg2);5163 }5264 65+export function Podcasts() {66+ return window['go']['main']['App']['Podcasts']();67+}68+5369 export function Record(arg1) {5470 return window['go']['main']['App']['Record'](arg1);5571 }5672 73+export function RemovePodcast(arg1) {74+ return window['go']['main']['App']['RemovePodcast'](arg1);75+}76+5777 export function SaveProgress(arg1, arg2, arg3) {5878 return window['go']['main']['App']['SaveProgress'](arg1, arg2, arg3);5979 }
@@ -70,6 +90,10 @@ export function ScanChannels() {
7090 return window['go']['main']['App']['ScanChannels']();7191 }7292 93+export function SearchPodcasts(arg1) {94+ return window['go']['main']['App']['SearchPodcasts'](arg1);95+}96+7397 export function SetConfig(arg1) {7498 return window['go']['main']['App']['SetConfig'](arg1);7599 }
M frontend/wailsjs/go/models.ts +149 -0
@@ -790,6 +790,153 @@ export namespace phase {
790790 791791 }792792 793+export namespace podcast {794+ 795+ export class Episode {796+ id: string;797+ showId: string;798+ title: string;799+ description: string;800+ published: time.Time;801+ duration: number;802+ thumbnail: string;803+ position: number;804+ watched: boolean;805+ 806+ static createFrom(source: any = {}) {807+ return new Episode(source);808+ }809+ 810+ constructor(source: any = {}) {811+ if ('string' === typeof source) source = JSON.parse(source);812+ this.id = source["id"];813+ this.showId = source["showId"];814+ this.title = source["title"];815+ this.description = source["description"];816+ this.published = this.convertValues(source["published"], time.Time);817+ this.duration = source["duration"];818+ this.thumbnail = source["thumbnail"];819+ this.position = source["position"];820+ this.watched = source["watched"];821+ }822+ 823+ convertValues(a: any, classs: any, asMap: boolean = false): any {824+ if (!a) {825+ return a;826+ }827+ if (a.slice && a.map) {828+ return (a as any[]).map(elem => this.convertValues(elem, classs));829+ } else if ("object" === typeof a) {830+ if (asMap) {831+ for (const key of Object.keys(a)) {832+ a[key] = new classs(a[key]);833+ }834+ return a;835+ }836+ return new classs(a);837+ }838+ return a;839+ }840+ }841+ export class Found {842+ id: string;843+ title: string;844+ url: string;845+ thumbnail: string;846+ description: string;847+ subscribers: number;848+ 849+ static createFrom(source: any = {}) {850+ return new Found(source);851+ }852+ 853+ constructor(source: any = {}) {854+ if ('string' === typeof source) source = JSON.parse(source);855+ this.id = source["id"];856+ this.title = source["title"];857+ this.url = source["url"];858+ this.thumbnail = source["thumbnail"];859+ this.description = source["description"];860+ this.subscribers = source["subscribers"];861+ }862+ }863+ export class Show {864+ id: string;865+ title: string;866+ url: string;867+ thumbnail: string;868+ minMinutes: number;869+ error?: string;870+ episodes: Episode[];871+ 872+ static createFrom(source: any = {}) {873+ return new Show(source);874+ }875+ 876+ constructor(source: any = {}) {877+ if ('string' === typeof source) source = JSON.parse(source);878+ this.id = source["id"];879+ this.title = source["title"];880+ this.url = source["url"];881+ this.thumbnail = source["thumbnail"];882+ this.minMinutes = source["minMinutes"];883+ this.error = source["error"];884+ this.episodes = this.convertValues(source["episodes"], Episode);885+ }886+ 887+ convertValues(a: any, classs: any, asMap: boolean = false): any {888+ if (!a) {889+ return a;890+ }891+ if (a.slice && a.map) {892+ return (a as any[]).map(elem => this.convertValues(elem, classs));893+ } else if ("object" === typeof a) {894+ if (asMap) {895+ for (const key of Object.keys(a)) {896+ a[key] = new classs(a[key]);897+ }898+ return a;899+ }900+ return new classs(a);901+ }902+ return a;903+ }904+ }905+ export class State {906+ shows: Show[];907+ updating: boolean;908+ 909+ static createFrom(source: any = {}) {910+ return new State(source);911+ }912+ 913+ constructor(source: any = {}) {914+ if ('string' === typeof source) source = JSON.parse(source);915+ this.shows = this.convertValues(source["shows"], Show);916+ this.updating = source["updating"];917+ }918+ 919+ convertValues(a: any, classs: any, asMap: boolean = false): any {920+ if (!a) {921+ return a;922+ }923+ if (a.slice && a.map) {924+ return (a as any[]).map(elem => this.convertValues(elem, classs));925+ } else if ("object" === typeof a) {926+ if (asMap) {927+ for (const key of Object.keys(a)) {928+ a[key] = new classs(a[key]);929+ }930+ return a;931+ }932+ return new classs(a);933+ }934+ return a;935+ }936+ }937+938+}939+793940 export namespace reception {794941 795942 export class Estimate {
@@ -1202,6 +1349,7 @@ export namespace service {
12021349 playback?: string;12031350 weatherStar?: boolean;12041351 antenna: boolean;1352+ podcasts: boolean;12051353 12061354 static createFrom(source: any = {}) {12071355 return new Info(source);
@@ -1217,6 +1365,7 @@ export namespace service {
12171365 this.playback = source["playback"];12181366 this.weatherStar = source["weatherStar"];12191367 this.antenna = source["antenna"];1368+ this.podcasts = source["podcasts"];12201369 }12211370 12221371 convertValues(a: any, classs: any, asMap: boolean = false): any {
M internal/api/api.go +64 -3
@@ -19,6 +19,7 @@ import (
1919 2020 "airwaves/internal/dvr"2121 "airwaves/internal/phase"22+ "airwaves/internal/podcast"2223 "airwaves/internal/service"2324 "airwaves/internal/stream"2425 "airwaves/internal/tuner"
@@ -56,6 +57,15 @@ type Backend interface {
5657 MarkWatched(ctx context.Context, id string, watched bool) error5758 UpdateRule(ctx context.Context, id string, u dvr.RuleUpdate) (*dvr.Rule, error)5859 SetDVRPrefs(ctx context.Context, p dvr.Prefs) (dvr.Prefs, error)60+ // The Podcasts app: shows and their episodes, YouTube channels found61+ // to add, and an episode played from a point, with how far it was62+ // watched.63+ Podcasts(ctx context.Context) (*podcast.State, error)64+ SearchPodcasts(ctx context.Context, query string) ([]podcast.Found, error)65+ AddPodcast(ctx context.Context, url string, minMinutes float64) (*podcast.Show, error)66+ RemovePodcast(ctx context.Context, id string) error67+ PlayEpisode(ctx context.Context, client, id string, from float64) (*stream.Playback, error)68+ EpisodeProgress(ctx context.Context, id string, position, duration float64, watched bool) error5969 }6070 6171 var (
@@ -115,6 +125,21 @@ type progressReq struct {
115125 Watched *bool `json:"watched,omitempty"`116126 }117127 128+// showReq adds a show: a YouTube channel, its live streams' tab or a129+// playlist, with the shortest video that's an episode (0 for the default).130+type showReq struct {131+ URL string `json:"url"`132+ MinMinutes float64 `json:"minMinutes,omitempty"`133+}134+135+// episodeProgressReq is how far an episode was watched.136+type episodeProgressReq struct {137+ ID string `json:"id"`138+ Position float64 `json:"position"`139+ Duration float64 `json:"duration"`140+ Watched bool `json:"watched"`141+}142+118143 // heartbeatReply says whether the client still has a stream.119144 type heartbeatReply struct {120145 Stream bool `json:"stream"`
@@ -262,6 +287,36 @@ func (s *Server) Handler() http.Handler {
262287 }263288 reply(w)(b.SetDVRPrefs(r.Context(), p))264289 })290+ mux.HandleFunc("GET /api/podcasts", func(w http.ResponseWriter, r *http.Request) {291+ reply(w)(b.Podcasts(r.Context()))292+ })293+ mux.HandleFunc("GET /api/podcasts/search", func(w http.ResponseWriter, r *http.Request) {294+ reply(w)(b.SearchPodcasts(r.Context(), r.URL.Query().Get("q")))295+ })296+ mux.HandleFunc("POST /api/podcasts/shows", func(w http.ResponseWriter, r *http.Request) {297+ var req showReq298+ if !decode(w, r, &req) {299+ return300+ }301+ reply(w)(b.AddPodcast(r.Context(), req.URL, req.MinMinutes))302+ })303+ mux.HandleFunc("DELETE /api/podcasts/shows/{id}", func(w http.ResponseWriter, r *http.Request) {304+ reply(w)(struct{}{}, b.RemovePodcast(r.Context(), r.PathValue("id")))305+ })306+ mux.HandleFunc("POST /api/podcasts/play", func(w http.ResponseWriter, r *http.Request) {307+ var req clientReq308+ if !decode(w, r, &req) {309+ return310+ }311+ reply(w)(b.PlayEpisode(r.Context(), req.Client, req.ID, req.From))312+ })313+ mux.HandleFunc("POST /api/podcasts/progress", func(w http.ResponseWriter, r *http.Request) {314+ var req episodeProgressReq315+ if !decode(w, r, &req) {316+ return317+ }318+ reply(w)(struct{}{}, b.EpisodeProgress(r.Context(), req.ID, req.Position, req.Duration, req.Watched))319+ })265320 if s.Streams != nil {266321 mux.Handle("/live/", s.Streams)267322 }
@@ -363,14 +418,20 @@ func reply(w http.ResponseWriter) func(any, error) {
363418 364419 // errStatus is the HTTP status for a backend error: 501 for what this365420 // server doesn't have (an antenna, a tuner, recording, a location for the366-// weather), 503 for a channel with no signal or no free tuner right now,367-// else 500.421+// weather, podcasts), 503 for a channel with no signal or no free tuner422+// right now, 400 for what isn't a show, 404 for a show or an episode423+// there isn't, else 500.368424 func errStatus(err error) int {369425 var noSignal *service.NoSignalError370426 var busy *tuner.BusyError371427 switch {372- case errors.Is(err, service.ErrNoAntenna), errors.Is(err, service.ErrNoTuner), errors.Is(err, service.ErrNoDVR), errors.Is(err, service.ErrNoLocation):428+ case errors.Is(err, service.ErrNoAntenna), errors.Is(err, service.ErrNoTuner), errors.Is(err, service.ErrNoDVR), errors.Is(err, service.ErrNoLocation),429+ errors.Is(err, service.ErrNoPodcasts):373430 return http.StatusNotImplemented431+ case errors.Is(err, podcast.ErrBadShow):432+ return http.StatusBadRequest433+ case errors.Is(err, podcast.ErrNotFound):434+ return http.StatusNotFound374435 case errors.As(err, &noSignal), errors.As(err, &busy):375436 return http.StatusServiceUnavailable376437 }
M internal/api/api_test.go +72 -0
@@ -14,6 +14,7 @@ import (
1414 "airwaves/internal/dvr"1515 "airwaves/internal/guide"1616 "airwaves/internal/lineup"17+ "airwaves/internal/podcast"1718 "airwaves/internal/service"1819 "airwaves/internal/signal"1920 "airwaves/internal/stream"
@@ -111,6 +112,77 @@ func (f *fake) UpdateRule(_ context.Context, id string, u dvr.RuleUpdate) (*dvr.
111112 112113 func (f *fake) SetDVRPrefs(_ context.Context, p dvr.Prefs) (dvr.Prefs, error) { return p, nil }113114 115+func (f *fake) Podcasts(context.Context) (*podcast.State, error) {116+ return &podcast.State{Shows: []podcast.Show{{ID: "s1", Title: "Show", Episodes: []podcast.Episode{{ID: "e1", ShowID: "s1", Duration: 3600}}}}}, nil117+}118+119+func (f *fake) SearchPodcasts(_ context.Context, q string) ([]podcast.Found, error) {120+ return []podcast.Found{{ID: "UCa", Title: q}}, nil121+}122+123+func (f *fake) AddPodcast(_ context.Context, u string, minMinutes float64) (*podcast.Show, error) {124+ if !strings.Contains(u, "youtube.com") {125+ return nil, fmt.Errorf("%w: give a YouTube channel or playlist", podcast.ErrBadShow)126+ }127+ return &podcast.Show{ID: "s2", URL: u, MinMinutes: minMinutes}, nil128+}129+130+func (f *fake) RemovePodcast(_ context.Context, id string) error {131+ if id != "s1" {132+ return podcast.ErrNotFound133+ }134+ f.deleted = id135+ return nil136+}137+138+func (f *fake) PlayEpisode(_ context.Context, client, id string, from float64) (*stream.Playback, error) {139+ f.tuned = client + ":" + id140+ return &stream.Playback{Path: "/live/ep/master.m3u8", Offset: from}, nil141+}142+143+func (f *fake) EpisodeProgress(_ context.Context, id string, pos, dur float64, watched bool) error {144+ f.progress = fmt.Sprintf("%s@%.0f/%.0f watched=%v", id, pos, dur, watched)145+ return nil146+}147+148+// TestPodcasts: the Podcasts app's calls, through the client.149+func TestPodcasts(t *testing.T) {150+ be := &fake{}151+ srv := httptest.NewServer((&Server{Backend: be}).Handler())152+ defer srv.Close()153+ ctx := context.Background()154+ c := NewClient(srv.URL, "")155+ if st, err := c.Podcasts(ctx); err != nil || len(st.Shows) != 1 || st.Shows[0].Episodes[0].Duration != 3600 {156+ t.Errorf("podcasts: %+v, %v", st, err)157+ }158+ if found, err := c.SearchPodcasts(ctx, "sad boyz"); err != nil || len(found) != 1 || found[0].Title != "sad boyz" {159+ t.Errorf("search: %+v, %v", found, err)160+ }161+ if s, err := c.AddPodcast(ctx, "https://www.youtube.com/@x/streams", 30); err != nil || s.ID != "s2" || s.MinMinutes != 30 {162+ t.Errorf("add: %+v, %v", s, err)163+ }164+ if _, err := c.AddPodcast(ctx, "https://example.com", 0); err == nil || !strings.Contains(err.Error(), "YouTube channel") {165+ t.Errorf("add something else: %v", err)166+ }167+ resp, err := http.Post(srv.URL+"/api/podcasts/shows", "application/json", strings.NewReader(`{"url":"nope"}`))168+ if err != nil || resp.StatusCode != http.StatusBadRequest {169+ t.Errorf("not a show: %v %v", resp.StatusCode, err)170+ }171+ if err := c.RemovePodcast(ctx, "s1"); err != nil || be.deleted != "s1" {172+ t.Errorf("remove: %v", err)173+ }174+ req, _ := http.NewRequest(http.MethodDelete, srv.URL+"/api/podcasts/shows/zz", nil)175+ if resp, err := http.DefaultClient.Do(req); err != nil || resp.StatusCode != http.StatusNotFound {176+ t.Errorf("remove a show there isn't: %v %v", resp.StatusCode, err)177+ }178+ if pb, err := c.PlayEpisode(ctx, "mac", "e1", 600); err != nil || pb.Offset != 600 || be.tuned != "mac:e1" || pb.URL != srv.URL+"/live/ep/master.m3u8" {179+ t.Errorf("play: %+v, %v", pb, err)180+ }181+ if err := c.EpisodeProgress(ctx, "e1", 600, 3600, true); err != nil || be.progress != "e1@600/3600 watched=true" {182+ t.Errorf("progress: %v, backend saw %q", err, be.progress)183+ }184+}185+114186 func TestClientServerRoundTrip(t *testing.T) {115187 be := &fake{}116188 srv := httptest.NewServer((&Server{Backend: be, Token: "s3cret"}).Handler())
M internal/api/client.go +43 -0
@@ -13,6 +13,7 @@ import (
1313 "time"1414 1515 "airwaves/internal/dvr"16+ "airwaves/internal/podcast"1617 "airwaves/internal/service"1718 "airwaves/internal/stream"1819 "airwaves/internal/weather"
@@ -220,3 +221,45 @@ func (c *Client) call(ctx context.Context, method, path string, in, out any) err
220221 }221222 return json.NewDecoder(resp.Body).Decode(out)222223 }224+225+// Podcasts implements Backend.226+func (c *Client) Podcasts(ctx context.Context) (*podcast.State, error) {227+ var s podcast.State228+ return &s, c.call(ctx, http.MethodGet, "/api/podcasts", nil, &s)229+}230+231+// SearchPodcasts implements Backend.232+func (c *Client) SearchPodcasts(ctx context.Context, query string) ([]podcast.Found, error) {233+ var out []podcast.Found234+ return out, c.call(ctx, http.MethodGet, "/api/podcasts/search?q="+url.QueryEscape(query), nil, &out)235+}236+237+// AddPodcast implements Backend.238+func (c *Client) AddPodcast(ctx context.Context, u string, minMinutes float64) (*podcast.Show, error) {239+ var s podcast.Show240+ if err := c.call(ctx, http.MethodPost, "/api/podcasts/shows", showReq{URL: u, MinMinutes: minMinutes}, &s); err != nil {241+ return nil, err242+ }243+ return &s, nil244+}245+246+// RemovePodcast implements Backend.247+func (c *Client) RemovePodcast(ctx context.Context, id string) error {248+ return c.call(ctx, http.MethodDelete, "/api/podcasts/shows/"+url.PathEscape(id), nil, nil)249+}250+251+// PlayEpisode implements Backend.252+func (c *Client) PlayEpisode(ctx context.Context, client, id string, from float64) (*stream.Playback, error) {253+ var pb stream.Playback254+ if err := c.call(ctx, http.MethodPost, "/api/podcasts/play", clientReq{Client: client, ID: id, From: from}, &pb); err != nil {255+ return nil, err256+ }257+ pb.URL = c.Base + pb.Path258+ return &pb, nil259+}260+261+// EpisodeProgress implements Backend.262+func (c *Client) EpisodeProgress(ctx context.Context, id string, position, duration float64, watched bool) error {263+ return c.call(ctx, http.MethodPost, "/api/podcasts/progress",264+ episodeProgressReq{ID: id, Position: position, Duration: duration, Watched: watched}, nil)265+}
A internal/service/podcasts.go +76 -0
@@ -0,0 +1,76 @@
1+package service2+3+import (4+ "context"5+ "errors"6+7+ "airwaves/internal/podcast"8+ "airwaves/internal/stream"9+)10+11+// ErrNoPodcasts is returned by the Podcasts app's calls on a server12+// without it.13+var ErrNoPodcasts = errors.New("this Airwaves server has no podcasts")14+15+// Podcasts is the Podcasts app's library: its shows and their episodes.16+func (s *Service) Podcasts(context.Context) (*podcast.State, error) {17+ if s.podcasts == nil {18+ return nil, ErrNoPodcasts19+ }20+ st := s.podcasts.State()21+ return &st, nil22+}23+24+// SearchPodcasts finds YouTube channels to add as shows.25+func (s *Service) SearchPodcasts(ctx context.Context, query string) ([]podcast.Found, error) {26+ if s.podcasts == nil {27+ return nil, ErrNoPodcasts28+ }29+ return s.podcasts.Search(ctx, query)30+}31+32+// AddPodcast adds a show: a YouTube channel, its live streams' tab, or a33+// playlist; minMinutes is the shortest video that's an episode, 0 for the34+// default.35+func (s *Service) AddPodcast(ctx context.Context, url string, minMinutes float64) (*podcast.Show, error) {36+ if s.podcasts == nil {37+ return nil, ErrNoPodcasts38+ }39+ show, err := s.podcasts.Add(ctx, url, minMinutes)40+ if err != nil {41+ return nil, err42+ }43+ return &show, nil44+}45+46+// RemovePodcast takes a show out of the library.47+func (s *Service) RemovePodcast(_ context.Context, id string) error {48+ if s.podcasts == nil {49+ return ErrNoPodcasts50+ }51+ return s.podcasts.Remove(id)52+}53+54+// PlayEpisode streams an episode to client from from seconds in.55+func (s *Service) PlayEpisode(ctx context.Context, client, id string, from float64) (*stream.Playback, error) {56+ if s.podcasts == nil {57+ return nil, ErrNoPodcasts58+ }59+ if s.streamErr != nil {60+ return nil, s.streamErr61+ }62+ in, err := s.podcasts.Input(ctx, id, from)63+ if err != nil {64+ return nil, err65+ }66+ return s.streams.Start(ctx, client, in)67+}68+69+// EpisodeProgress records how far an episode has been watched, and70+// whether it has.71+func (s *Service) EpisodeProgress(_ context.Context, id string, position, duration float64, watched bool) error {72+ if s.podcasts == nil {73+ return ErrNoPodcasts74+ }75+ return s.podcasts.Progress(id, position, duration, watched)76+}
A internal/service/podcasts_test.go +21 -0
M internal/service/service.go +17 -3
@@ -33,6 +33,7 @@ import (
3333 "airwaves/internal/hdhr"3434 "airwaves/internal/lineup"3535 "airwaves/internal/phase"36+ "airwaves/internal/podcast"3637 "airwaves/internal/sd"3738 "airwaves/internal/signal"3839 "airwaves/internal/store"
@@ -101,6 +102,8 @@ type Info struct {
101102 // Antenna is false for a server with custom channels only: no antenna102103 // channels, reception reports or recording.103104 Antenna bool `json:"antenna"`105+ // Podcasts is set when the server has the Podcasts app.106+ Podcasts bool `json:"podcasts"`104107 }105108 106109 // Snapshot is the lineup with its listings.
@@ -178,8 +181,12 @@ type Options struct {
178181 MusicDir string179182 // ChannelsDir holds folder channels, a subfolder of videos each.180183 ChannelsDir string181- // YtDlp runs yt-dlp for YouTube channels; yt-dlp from PATH when nil.184+ // YtDlp runs yt-dlp for YouTube channels and podcasts; yt-dlp from185+ // PATH when nil.182186 YtDlp *youtube.YtDlp187+ // PodcastsPath keeps the Podcasts app's shows and how far their188+ // episodes were watched; empty leaves the app off.189+ PodcastsPath string183190 // Loudness is the level custom channels' sound is evened out to as184191 // it streams, in LUFS (vchan.DefaultLoudness is antenna TV's); 0185192 // leaves it as it is.
@@ -213,6 +220,7 @@ type Service struct {
213220 wxStreams bool214221 wxLocal string215222 library *vchan.Library223+ podcasts *podcast.Library216224 217225 buildMu sync.Mutex // serializes report builds218226
@@ -328,6 +336,9 @@ func New(opt Options) (*Service, error) {
328336 Reserved: s.reserved, YtDlp: opt.YtDlp, Loudness: opt.Loudness,329337 }330338 }339+ if opt.PodcastsPath != "" && s.streamErr == nil {340+ s.podcasts = &podcast.Library{Path: opt.PodcastsPath, YtDlp: opt.YtDlp, FFmpeg: s.streams.FFmpeg()}341+ }331342 return s, nil332343 }333344
@@ -454,7 +465,7 @@ func (s *Service) StreamHandler() http.Handler {
454465 // Info implements the Backend contract.455466 func (s *Service) Info(context.Context) (Info, error) {456467 i := Info{Name: s.opt.Name, Mode: s.opt.Mode, Version: Version, Tuner: s.tunerDevice(), DVR: s.dvr != nil, WeatherStar: s.wxOn(),457- Antenna: !s.opt.NoAntenna}468+ Antenna: !s.opt.NoAntenna, Podcasts: s.podcasts != nil}458469 if s.streamErr != nil {459470 i.Playback = s.streamErr.Error()460471 }
@@ -999,11 +1010,14 @@ func (s *Service) recordingChannels(ctx context.Context) (*guide.Guide, []lineup
9991010 10001011 // Run keeps a server healthy until ctx ends: refreshes listings, finds the10011012 // tuner and keeps up with it, measures the signal while tuners are in use,1002-// and makes recordings.1013+// makes recordings, and keeps the podcasts up to date.10031014 func (s *Service) Run(ctx context.Context) {10041015 if s.listings != nil {10051016 go s.listings.Run(ctx)10061017 }1018+ if s.podcasts != nil {1019+ go s.podcasts.Run(ctx)1020+ }10071021 if s.tuners != nil {10081022 s.findTuner(ctx)10091023 go s.sample(ctx)