clone

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

main
airwaves / internal / admin / logos.go
logos.go 10.6K
1package admin23// Channel logos: an image file in the channel's folder ("logo.png"), or4// the weather channel's beside its record (".weather-logo.png"), named in5// the channel's details. The page uploads one, takes the source's picture6// (the poster of a Jellyfin channel's first pick, the avatar of a YouTube7// channel's first channel), or removes it.89import (10 "bytes"11 "context"12 "errors"13 "fmt"14 "io"15 "log"16 "mime"17 "mime/multipart"18 "net/http"19 "os"20 "path/filepath"21 "regexp"22 "strings"23 "time"2425 "airwaves/internal/vchan"26 "airwaves/internal/youtube"27)2829const (30 // maxLogo is the largest logo file taken.31 maxLogo = 2 << 2032 // sourceTime is how long taking a source's picture may take.33 sourceTime = 60 * time.Second34 // sourcePicks is how many of a Jellyfin channel's picks are tried for35 // a picture.36 sourcePicks = 637)3839var (40 errLogoType = errors.New("the logo should be a PNG, JPEG, SVG or WebP image")41 errLogoSize = fmt.Errorf("the logo should be under %d MB", maxLogo>>20)42)4344// logoSpot is where a channel's logo goes.45type logoSpot struct {46 ch vchan.Channel47 dir string // the folder the logo file is in48 base string // its name, less the extension49 record string // the details file that names it50}5152func (s *Server) logoSpot(number string) (logoSpot, bool) {53 if s.isWeather(number) && s.Weather.Record != "" {54 return logoSpot{ch: s.Weather, dir: filepath.Dir(s.Weather.Record), base: vchan.WeatherLogoName, record: s.Weather.Record}, true55 }56 if e, ok := s.find(number); ok {57 dir := filepath.Join(s.Library.Root, e.Folder)58 return logoSpot{ch: e.Channel, dir: dir, base: vchan.LogoName, record: filepath.Join(dir, vchan.DetailsFile)}, true59 }60 return logoSpot{}, false61}6263// save makes data the channel's logo, in place of any other.64func (l logoSpot) save(data []byte) error {65 ext := logoType(data)66 if ext == "" {67 return errLogoType68 }69 old := l.ch.Details().Logo70 name := l.base + ext71 if err := writeFile(filepath.Join(l.dir, name), data, 0o644); err != nil {72 return err73 }74 if err := updateRecord(l.record, func(m map[string]any) { m["logo"] = name }); err != nil {75 return err76 }77 l.removeOthers(old, name)78 return nil79}8081// clear leaves the channel without a logo.82func (l logoSpot) clear() error {83 old := l.ch.Details().Logo84 if err := updateRecord(l.record, func(m map[string]any) { delete(m, "logo") }); err != nil {85 return err86 }87 l.removeOthers(old, "")88 return nil89}9091// removeOthers removes the logo that was there, and any other found by92// name, but keep.93func (l logoSpot) removeOthers(old, keep string) {94 if old != "" && filepath.Base(old) != keep {95 _ = os.Remove(old)96 }97 for ext := range vchan.LogoTypes {98 if name := l.base + ext; name != keep {99 _ = os.Remove(filepath.Join(l.dir, name))100 }101 }102}103104// logoType tells a logo's file extension from its first bytes: PNG, JPEG,105// WebP or SVG. "" for anything else.106func logoType(data []byte) string {107 switch {108 case bytes.HasPrefix(data, []byte("\x89PNG\r\n\x1a\n")):109 return ".png"110 case bytes.HasPrefix(data, []byte("\xff\xd8\xff")):111 return ".jpg"112 case len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP":113 return ".webp"114 }115 head := bytes.TrimSpace(bytes.TrimPrefix(data[:min(len(data), 4096)], []byte("\xef\xbb\xbf")))116 if bytes.HasPrefix(head, []byte("<")) && bytes.Contains(bytes.ToLower(head), []byte("<svg")) {117 return ".svg"118 }119 return ""120}121122// readLogo reads an uploaded logo: the request's body, or the first file123// in a multipart form.124func readLogo(w http.ResponseWriter, r *http.Request) ([]byte, error) {125 r.Body = http.MaxBytesReader(w, r.Body, maxLogo+64<<10)126 var src io.Reader = r.Body127 if kind, params, err := mime.ParseMediaType(r.Header.Get("Content-Type")); err == nil && strings.HasPrefix(kind, "multipart/") {128 form := multipart.NewReader(r.Body, params["boundary"])129 for {130 part, err := form.NextPart()131 if err != nil {132 if tooBig(err) {133 return nil, errLogoSize134 }135 return nil, errors.New("the form has no image file")136 }137 if part.FileName() != "" {138 src = part139 break140 }141 }142 }143 data, err := io.ReadAll(io.LimitReader(src, maxLogo+1))144 switch {145 case tooBig(err) || len(data) > maxLogo:146 return nil, errLogoSize147 case err != nil:148 return nil, err149 case len(data) == 0:150 return nil, errors.New("send the logo's image")151 }152 return data, nil153}154155func tooBig(err error) bool {156 var big *http.MaxBytesError157 return errors.As(err, &big)158}159160func (s *Server) getLogo(w http.ResponseWriter, r *http.Request) {161 l, ok := s.logoSpot(r.PathValue("number"))162 if !ok {163 http.NotFound(w, r)164 return165 }166 logo := l.ch.Details().Logo167 if logo == "" {168 http.NotFound(w, r)169 return170 }171 vchan.ServeLogo(w, r, logo)172}173174// putLogo takes an uploaded logo.175func (s *Server) putLogo(w http.ResponseWriter, r *http.Request) {176 number := r.PathValue("number")177 l, ok := s.logoSpot(number)178 if !ok {179 writeErr(w, http.StatusNotFound, fmt.Errorf("no channel %s", number))180 return181 }182 data, err := readLogo(w, r)183 if err != nil {184 code := http.StatusBadRequest185 if errors.Is(err, errLogoSize) {186 code = http.StatusRequestEntityTooLarge187 }188 writeErr(w, code, err)189 return190 }191 s.saveLogo(w, r, l, data)192}193194func (s *Server) saveLogo(w http.ResponseWriter, r *http.Request, l logoSpot, data []byte) {195 if err := l.save(data); err != nil {196 code := http.StatusInternalServerError197 if errors.Is(err, errLogoType) {198 code = http.StatusUnsupportedMediaType199 }200 writeErr(w, code, err)201 return202 }203 number := l.ch.Number()204 log.Printf("admin: new logo for channel %s", number)205 s.replyChannel(w, r, number)206}207208func (s *Server) deleteLogo(w http.ResponseWriter, r *http.Request) {209 number := r.PathValue("number")210 l, ok := s.logoSpot(number)211 if !ok {212 writeErr(w, http.StatusNotFound, fmt.Errorf("no channel %s", number))213 return214 }215 if err := l.clear(); err != nil {216 writeErr(w, http.StatusInternalServerError, err)217 return218 }219 log.Printf("admin: removed channel %s's logo", number)220 s.replyChannel(w, r, number)221}222223// replyChannel answers with the channel as the page shows it.224func (s *Server) replyChannel(w http.ResponseWriter, r *http.Request, number string) {225 c, ok := s.describeNumber(r.Context(), number)226 if !ok {227 writeErr(w, http.StatusNotFound, fmt.Errorf("no channel %s", number))228 return229 }230 writeJSON(w, c)231}232233// logoFromSource makes the source's picture the channel's logo: for a234// Jellyfin channel, the poster of its first pick that has one (its235// collections, then playlists, series and movies); for a YouTube channel,236// its first channel's avatar, or its playlist's channel's.237func (s *Server) logoFromSource(w http.ResponseWriter, r *http.Request) {238 number := r.PathValue("number")239 l, ok := s.logoSpot(number)240 if !ok {241 writeErr(w, http.StatusNotFound, fmt.Errorf("no channel %s", number))242 return243 }244 ctx, cancel := context.WithTimeout(r.Context(), sourceTime)245 defer cancel()246 var data []byte247 var err error248 switch ch := l.ch.(type) {249 case *vchan.Jellyfin:250 data, err = s.jellyfinPicture(ctx, l.dir)251 case *vchan.YouTube:252 var image string253 if image, ok = s.youtubeAvatar(w, r, l.dir, ch); !ok {254 return // answered255 }256 data, err = s.fetchImage(ctx, image)257 default:258 writeErr(w, http.StatusBadRequest, errors.New("only Jellyfin and YouTube channels have a picture to take"))259 return260 }261 if err != nil {262 writeErr(w, http.StatusBadGateway, err)263 return264 }265 s.saveLogo(w, r, l, data)266}267268// jellyfinPicture fetches the poster of a Jellyfin channel's first pick269// that has one.270func (s *Server) jellyfinPicture(ctx context.Context, dir string) ([]byte, error) {271 cfg, err := readConfig(filepath.Join(dir, channelFile))272 if err != nil {273 return nil, err274 }275 c, err := s.client()276 if err != nil {277 return nil, err278 }279 tried := 0280 for _, src := range []struct {281 kind string282 names []string283 }{{"BoxSet", cfg.Collections}, {"Playlist", cfg.Playlists}, {"Series", cfg.Series}, {"Movie", cfg.Movies}} {284 found, _, err := c.Find(ctx, src.kind, src.names)285 if err != nil {286 return nil, err287 }288 for _, it := range found {289 if tried++; tried > sourcePicks {290 break291 }292 if data, _, err := c.Image(ctx, it.ID, 400); err == nil && len(data) <= maxLogo {293 return data, nil294 }295 }296 }297 return nil, errors.New("none of the channel's picks has a picture on Jellyfin")298}299300// avatarSize is the size option of a YouTube avatar's address ("=s176").301var avatarSize = regexp.MustCompile(`=s\d+`)302303// youtubeAvatar finds the avatar of a YouTube channel's first channel, or304// of the channel that made its playlist, a lookup it may have to run305// yt-dlp for. When it can't, it answers the request itself and returns306// false.307func (s *Server) youtubeAvatar(w http.ResponseWriter, r *http.Request, dir string, y *vchan.YouTube) (string, bool) {308 cfg, err := vchan.ReadYouTubeConfig(dir)309 first := ""310 switch {311 case err == nil && cfg.Playlist != "":312 if st := y.Status(); len(st.Sources) > 0 && st.Sources[0].OwnerID != "" {313 first = st.Sources[0].OwnerID314 } else {315 writeErr(w, http.StatusBadRequest, errors.New("the playlist hasn't been listed yet; try again in a minute"))316 return "", false317 }318 case err == nil && len(cfg.Channels) > 0:319 first = cfg.Channels[0]320 default:321 writeErr(w, http.StatusBadRequest, errors.New("the channel plays no YouTube channels yet"))322 return "", false323 }324 page, err := youtubeChannel(first)325 if err != nil {326 writeErr(w, http.StatusBadRequest, err)327 return "", false328 }329 l, ok := s.lookedUp(page, "")330 if !ok {331 if !s.ytRun(w, r, lookupTime, func(ctx context.Context, tool *youtube.YtDlp) error {332 found, err := tool.LookupChannel(ctx, page)333 l = lookedUp{ch: channelOut(found), err: err, at: time.Now()}334 return err335 }) {336 return "", false337 }338 s.remember(page, l)339 }340 if l.err != nil {341 writeErr(w, http.StatusBadGateway, errors.New(ytMessage(l.err)))342 return "", false343 }344 if l.ch.Image == "" {345 writeErr(w, http.StatusBadGateway, errors.New("the YouTube channel has no picture"))346 return "", false347 }348 // Asked for at a size fit for a logo.349 image := l.ch.Image350 if i := strings.LastIndex(image, "="); i > 0 {351 image = image[:i] + avatarSize.ReplaceAllString(image[i:], "=s400")352 }353 return image, true354}355356// fetchImage downloads a picture.357func (s *Server) fetchImage(ctx context.Context, u string) ([]byte, error) {358 req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)359 if err != nil {360 return nil, err361 }362 client := s.HTTP363 if client == nil {364 client = http.DefaultClient365 }366 resp, err := client.Do(req)367 if err != nil {368 return nil, err369 }370 defer resp.Body.Close()371 if resp.StatusCode != http.StatusOK {372 return nil, fmt.Errorf("couldn't fetch the picture: %s", resp.Status)373 }374 data, err := io.ReadAll(io.LimitReader(resp.Body, maxLogo+1))375 if err != nil {376 return nil, err377 }378 if len(data) > maxLogo {379 return nil, errLogoSize380 }381 return data, nil382}