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 / jellyfin / items.go
items.go 18.1K
1package jellyfin23import (4 "cmp"5 "context"6 "fmt"7 "io"8 "net/http"9 "net/url"10 "regexp"11 "slices"12 "strconv"13 "strings"14 "time"15)1617// Item is a series, movie, collection, playlist or library on the server.18type Item struct {19 ID string20 Type string // "Series", "Movie", "BoxSet", "Playlist" or "Library"21 Name string22 Year int23 Genres []string24 Overview string25 Count int // episodes for a series, items for a collection or playlist26 Runtime time.Duration // movies27 HasImage bool28}2930// Key is how a channel config names the item: "Name (Year)", or "Name"31// without a year.32func (it Item) Key() string {33 if it.Year > 0 {34 return fmt.Sprintf("%s (%d)", it.Name, it.Year)35 }36 return it.Name37}3839// Matches reports whether name, from a channel config, is this item: its40// name, or its Key to pick between same-named items. Case doesn't matter.41func (it Item) Matches(name string) bool {42 name = strings.TrimSpace(name)43 return strings.EqualFold(name, it.Key()) || strings.EqualFold(name, it.Name)44}4546// Video is an episode or movie, with what playing it and listing it in a47// guide take.48type Video struct {49 ID string50 Source string // the media source to play51 Type string // "Episode" or "Movie"52 Name string53 Series string // the series' name, for an episode54 SeriesID string55 Season int // 0 when unknown, and for specials56 Episode int // 0 when unknown57 Premiere string // first aired, "2006-01-02", or "" when unknown58 Year int59 Overview string60 // Cast lists the actors, as "Name (Role)", leads first; Rating is the61 // content rating ("TV-14"); Genres as the library has them.62 Cast []string63 Rating string64 Genres []string65 Runtime time.Duration66 Audio bool67 // Image is artwork on the server, which loads without signing in: an68 // episode's still or its series' backdrop, a movie's backdrop or poster.69 Image string70 // AudioStreams and Subtitles are the video's sound and subtitle71 // streams, in the server's order. Files the server hasn't probed list72 // none.73 AudioStreams []Stream74 Subtitles []Stream75}7677// Stream is one of a video's sound or subtitle streams.78type Stream struct {79 // Index is the server's number for the stream, which subtitle URLs80 // and transcodes take.81 Index int82 // Position counts the streams of its kind in the file before it, as83 // ffmpeg's "0:a:1" does; -1 for a file beside the video.84 Position int85 Language string // as tagged, usually ISO 639-2 ("eng"); "" when untagged86 Codec string // "aac", "subrip", "ass", "pgssub"...87 Title string88 Default bool89 Forced bool90 // HearingImpaired marks subtitles for the deaf and hard of hearing91 // (SDH), which describe sounds as well as speech.92 HearingImpaired bool93 External bool // a file beside the video94 // Text marks a text subtitle, which the server converts to SRT, as it95 // can't picture ones (PGS, VobSub).96 Text bool97}9899// dto is the part of Jellyfin's BaseItemDto the client reads.100type dto struct {101 ID string `json:"Id"`102 Name string103 Type string104 MediaType string105 ProductionYear int106 PremiereDate string107 Overview string108 Genres []string109 ChildCount int110 RecursiveItemCount int111 RunTimeTicks int64112 SeriesName string113 SeriesID string `json:"SeriesId"`114 SeriesPrimaryImageTag string115 ParentIndexNumber int116 IndexNumber int117 ImageTags map[string]string118 BackdropImageTags []string119 ParentBackdropItemID string `json:"ParentBackdropItemId"`120 ParentBackdropImageTags []string121 OfficialRating string122 People []struct {123 Name string124 Role string125 Type string // "Actor", "GuestStar", "Director"...126 }127 MediaSources []struct {128 ID string `json:"Id"`129 RunTimeTicks int64130 MediaStreams []mediaStream131 }132}133134// mediaStream is the part of Jellyfin's MediaStream the client reads.135type mediaStream struct {136 Type string // "Video", "Audio", "Subtitle"...137 Index int138 Language string139 Codec string140 Title string141 IsDefault bool142 IsForced bool143 IsHearingImpaired bool144 IsExternal bool145 IsTextSubtitleStream bool146}147148// ticks converts Jellyfin's 100 ns ticks.149func ticks(t int64) time.Duration { return time.Duration(t) * 100 }150151// Series lists the account's series by name.152func (c *Client) Series(ctx context.Context) ([]Item, error) {153 return c.list(ctx, "Series", "Overview,Genres,RecursiveItemCount")154}155156// Movies lists the account's movies by name.157func (c *Client) Movies(ctx context.Context) ([]Item, error) {158 return c.list(ctx, "Movie", "Overview,Genres")159}160161// Collections lists the account's collections by name.162func (c *Client) Collections(ctx context.Context) ([]Item, error) {163 return c.list(ctx, "BoxSet", "Overview,Genres,ChildCount")164}165166// Playlists lists the account's playlists by name.167func (c *Client) Playlists(ctx context.Context) ([]Item, error) {168 return c.list(ctx, "Playlist", "Overview,Genres,ChildCount")169}170171// list returns every item of one type; without fields, only names and172// years. Music playlists are left out.173func (c *Client) list(ctx context.Context, kind, fields string) ([]Item, error) {174 q := url.Values{"recursive": {"true"}, "includeItemTypes": {kind}, "sortBy": {"SortName"}}175 if fields == "" {176 q.Set("enableImages", "false")177 q.Set("enableUserData", "false")178 } else {179 // User data stays on: series' episode counts come with it.180 q.Set("fields", fields)181 q.Set("enableImageTypes", "Primary")182 q.Set("imageTypeLimit", "1")183 }184 list, err := c.items(ctx, q)185 if err != nil {186 return nil, err187 }188 out := make([]Item, 0, len(list))189 for _, d := range list {190 if d.Type == "Playlist" && d.MediaType == "Audio" {191 continue192 }193 it := Item{194 ID: d.ID, Type: d.Type, Name: d.Name, Year: d.ProductionYear, Genres: d.Genres,195 Overview: d.Overview, HasImage: d.ImageTags["Primary"] != "",196 }197 switch d.Type {198 case "Series":199 it.Count = d.RecursiveItemCount200 case "BoxSet", "Playlist":201 it.Count = d.ChildCount202 case "Movie":203 it.Runtime = ticks(d.RunTimeTicks)204 }205 out = append(out, it)206 }207 return out, nil208}209210// Libraries lists the account's libraries (Jellyfin's user views).211func (c *Client) Libraries(ctx context.Context) ([]Item, error) {212 _, userID, err := c.signIn(ctx)213 if err != nil {214 return nil, err215 }216 path := "/UserViews"217 if userID == "" {218 path = "/Library/MediaFolders" // an API key acting as no user219 }220 var page struct{ Items []dto }221 if err := c.get(ctx, path, url.Values{}, &page); err != nil {222 return nil, err223 }224 var out []Item225 for _, d := range page.Items {226 out = append(out, Item{ID: d.ID, Type: "Library", Name: d.Name, HasImage: d.ImageTags["Primary"] != ""})227 }228 return out, nil229}230231// Find looks up names from a channel config among the account's items of232// one kind ("Series", "Movie", "BoxSet", "Playlist" or "Library"), as233// Matches does. It returns the items found, without overviews or images,234// and the names that matched nothing.235func (c *Client) Find(ctx context.Context, kind string, names []string) (found []Item, unmatched []string, err error) {236 if len(names) == 0 {237 return nil, nil, nil238 }239 var all []Item240 if kind == "Library" {241 all, err = c.Libraries(ctx)242 } else {243 all, err = c.list(ctx, kind, "")244 }245 if err != nil {246 return nil, nil, err247 }248 for _, n := range names {249 hit := false250 for _, it := range all {251 if it.Matches(n) {252 found = append(found, it)253 hit = true254 }255 }256 if !hit {257 unmatched = append(unmatched, n)258 }259 }260 return found, unmatched, nil261}262263// Videos returns the episodes and movies in items, each once: series,264// seasons, collections, playlists and libraries are expanded. Episodes the265// server knows of but has no file for are left out.266func (c *Client) Videos(ctx context.Context, items []Item) ([]Video, error) {267 var out []Video268 seen := map[string]bool{}269 add := func(list []dto) {270 for _, d := range list {271 if (d.Type == "Episode" || d.Type == "Movie") && !seen[d.ID] {272 seen[d.ID] = true273 out = append(out, c.video(d))274 }275 }276 }277 var ids []string278 for _, it := range items {279 if it.Type == "Episode" || it.Type == "Movie" {280 ids = append(ids, it.ID)281 continue282 }283 list, err := c.contents(ctx, it.ID, it.Type)284 if err != nil {285 return nil, err286 }287 add(list)288 }289 for len(ids) > 0 {290 n := min(len(ids), 50)291 list, err := c.items(ctx, videoQuery(url.Values{"ids": {strings.Join(ids[:n], ",")}}))292 if err != nil {293 return nil, err294 }295 add(list)296 ids = ids[n:]297 }298 return out, nil299}300301// contents lists what's in a series, season, collection, playlist or302// library, with the fields a Video needs.303func (c *Client) contents(ctx context.Context, id, kind string) ([]dto, error) {304 q := videoQuery(url.Values{"parentId": {id}})305 switch kind {306 case "BoxSet", "Playlist":307 // Entries can be whole series or seasons as well as videos.308 list, err := c.items(ctx, q)309 if err != nil {310 return nil, err311 }312 var out []dto313 for _, d := range list {314 if d.Type != "Series" && d.Type != "Season" {315 out = append(out, d)316 continue317 }318 eps, err := c.contents(ctx, d.ID, d.Type)319 if err != nil {320 return nil, err321 }322 out = append(out, eps...)323 }324 return out, nil325 case "Series", "Season":326 q.Set("includeItemTypes", "Episode")327 default:328 q.Set("includeItemTypes", "Episode,Movie")329 }330 q.Set("recursive", "true")331 return c.items(ctx, q)332}333334func videoQuery(q url.Values) url.Values {335 q.Set("fields", "Overview,MediaSources,People,OfficialRating,Genres")336 q.Set("excludeLocationTypes", "Virtual")337 q.Set("enableUserData", "false")338 return q339}340341const pageSize = 200342343// items pages through /Items.344func (c *Client) items(ctx context.Context, q url.Values) ([]dto, error) {345 var out []dto346 for {347 q.Set("startIndex", strconv.Itoa(len(out)))348 q.Set("limit", strconv.Itoa(pageSize))349 var page struct {350 Items []dto351 TotalRecordCount int352 }353 if err := c.get(ctx, "/Items", q, &page); err != nil {354 return nil, err355 }356 out = append(out, page.Items...)357 if len(page.Items) == 0 || len(out) >= page.TotalRecordCount {358 return out, nil359 }360 }361}362363func (c *Client) video(d dto) Video {364 v := Video{365 ID: d.ID, Source: d.ID, Type: d.Type, Name: d.Name, Series: d.SeriesName, SeriesID: d.SeriesID,366 Season: d.ParentIndexNumber, Episode: d.IndexNumber, Year: d.ProductionYear, Overview: d.Overview,367 Runtime: ticks(d.RunTimeTicks), Audio: true, Image: c.artwork(d), Rating: d.OfficialRating, Genres: d.Genres,368 }369 // Actors come in billing order, guest stars after them.370 for _, kind := range []string{"Actor", "GuestStar"} {371 for _, p := range d.People {372 if p.Type != kind || p.Name == "" || len(v.Cast) == 6 {373 continue374 }375 if p.Role != "" {376 v.Cast = append(v.Cast, p.Name+" ("+p.Role+")")377 } else {378 v.Cast = append(v.Cast, p.Name)379 }380 }381 }382 if len(d.PremiereDate) >= len("2006-01-02") {383 v.Premiere = d.PremiereDate[:len("2006-01-02")]384 }385 if len(d.MediaSources) > 0 {386 src := d.MediaSources[0]387 v.Source = src.ID388 if src.RunTimeTicks > 0 {389 v.Runtime = ticks(src.RunTimeTicks)390 }391 // Unprobed files list no streams; assume those have sound.392 if len(src.MediaStreams) > 0 {393 v.Audio = false394 for _, s := range src.MediaStreams {395 v.Audio = v.Audio || s.Type == "Audio"396 }397 }398 v.AudioStreams, v.Subtitles = streams(src.MediaStreams)399 }400 return v401}402403// streams sorts a media source's sound and subtitle streams out.404func streams(list []mediaStream) (audio, subs []Stream) {405 list = slices.Clone(list)406 slices.SortStableFunc(list, func(a, b mediaStream) int { return cmp.Compare(a.Index, b.Index) })407 pos := map[string]int{}408 for _, m := range list {409 s := Stream{410 Index: m.Index, Position: -1, Language: m.Language, Codec: m.Codec, Title: m.Title,411 Default: m.IsDefault, Forced: m.IsForced, HearingImpaired: m.IsHearingImpaired, External: m.IsExternal,412 }413 if !m.IsExternal {414 s.Position = pos[m.Type]415 pos[m.Type]++416 }417 switch m.Type {418 case "Audio":419 audio = append(audio, s)420 case "Subtitle":421 s.Text = m.IsTextSubtitleStream422 subs = append(subs, s)423 }424 }425 return audio, subs426}427428// english reports whether a stream's language tag is English.429func english(lang string) bool {430 l, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(lang)), "-")431 return l == "en" || l == "eng" || l == "english"432}433434// untagged reports whether a stream's language is unknown.435func untagged(lang string) bool {436 switch strings.ToLower(strings.TrimSpace(lang)) {437 case "", "und", "unknown":438 return true439 }440 return false441}442443var (444 extraAudio = regexp.MustCompile(`(?i)comment|descri`)445 sdhTitle = regexp.MustCompile(`(?i)\b(sdh|cc|hearing)`)446 forcedTitle = regexp.MustCompile(`(?i)\b(forced|signs)\b`)447)448449// PreferredAudio is the sound to play: English when there is some, else450// the default stream, else the first. Commentaries and described video451// come last, and the default goes first among equals. ok is false when452// the server lists no sound.453func (v Video) PreferredAudio() (s Stream, ok bool) {454 best := -1455 for _, a := range v.AudioStreams {456 score := 0457 if !extraAudio.MatchString(a.Title) {458 score += 4459 }460 if english(a.Language) {461 score += 2462 }463 if a.Default {464 score++465 }466 if score > best {467 s, best = a, score468 }469 }470 return s, best >= 0471}472473// EnglishSubtitle picks the subtitles to caption v with: full English474// subtitles, else English SDH, else untagged ones, which are usually475// English. Forced subtitles, which only translate the odd sign or line in476// another language, are taken last and only when forced is set: when the477// sound isn't English they're better than nothing. Picture subtitles478// can't become captions, so they're passed over.479func (v Video) EnglishSubtitle(forced bool) (s Stream, ok bool) {480 best := -1481 for _, sub := range v.Subtitles {482 if !sub.Text {483 continue484 }485 isForced := sub.Forced || forcedTitle.MatchString(sub.Title)486 sdh := sub.HearingImpaired || sdhTitle.MatchString(sub.Title)487 var score int488 switch {489 case isForced && (!forced || !english(sub.Language)):490 continue491 case isForced:492 score = 1493 case english(sub.Language) && !sdh:494 score = 4495 case english(sub.Language):496 score = 3497 case untagged(sub.Language):498 score = 2499 default:500 continue501 }502 score = score * 2503 if sub.Default {504 score++505 }506 if score > best {507 s, best = sub, score508 }509 }510 return s, best >= 0511}512513// Subtitle fetches one of v's text subtitle streams as SRT.514func (c *Client) Subtitle(ctx context.Context, v Video, s Stream) ([]byte, error) {515 if !s.Text {516 return nil, fmt.Errorf("jellyfin: subtitle %d of %s isn't text", s.Index, v.ID)517 }518 path := fmt.Sprintf("/Videos/%s/%s/Subtitles/%d/0/Stream.srt", url.PathEscape(v.ID), url.PathEscape(v.Source), s.Index)519 return c.fetch(ctx, path, 16<<20)520}521522// artwork picks a landscape image where there is one, as the guide shows523// them: an episode's still, else its series' backdrop or poster; a movie's524// backdrop, else its poster.525func (c *Client) artwork(d dto) string {526 img := func(id, kind, tag string) string {527 return c.s.server + "/Items/" + url.PathEscape(id) + "/Images/" + kind + "?" + url.Values{"tag": {tag}, "maxWidth": {"640"}}.Encode()528 }529 switch {530 case d.Type == "Episode" && d.ImageTags["Primary"] != "":531 return img(d.ID, "Primary", d.ImageTags["Primary"])532 case d.Type == "Episode" && d.ParentBackdropItemID != "" && len(d.ParentBackdropImageTags) > 0:533 return img(d.ParentBackdropItemID, "Backdrop", d.ParentBackdropImageTags[0])534 case d.Type == "Episode" && d.SeriesID != "" && d.SeriesPrimaryImageTag != "":535 return img(d.SeriesID, "Primary", d.SeriesPrimaryImageTag)536 case d.Type == "Movie" && len(d.BackdropImageTags) > 0:537 return img(d.ID, "Backdrop", d.BackdropImageTags[0])538 case d.Type == "Movie" && d.ImageTags["Primary"] != "":539 return img(d.ID, "Primary", d.ImageTags["Primary"])540 }541 return ""542}543544// FileURL streams v's original file, which ffmpeg can seek in with range545// requests. Send Authorization with it.546func (c *Client) FileURL(v Video) string {547 return c.s.server + "/Videos/" + url.PathEscape(v.ID) + "/stream?" + url.Values{"static": {"true"}, "mediaSourceId": {v.Source}}.Encode()548}549550// Transcode is what to ask the server to convert a stream to.551type Transcode struct {552 Bitrate int // bits per second, picture and sound together553 MaxWidth int554 MaxHeight int555}556557// transcodeAudio is the sound's share of a transcode's bitrate.558const transcodeAudio = 128_000559560// TranscodeURL streams v from start as MPEG-TS, converted by the server to561// H.264 and stereo AAC within t. The server seeks, so the stream begins at562// start. Of several audio streams it plays the one PreferredAudio picks.563// Send Authorization with it.564func (c *Client) TranscodeURL(v Video, start time.Duration, t Transcode) string {565 q := url.Values{566 "mediaSourceId": {v.Source},567 "videoCodec": {"h264"}, "videoBitRate": {strconv.Itoa(max(t.Bitrate-transcodeAudio, t.Bitrate/2))},568 "audioCodec": {"aac"}, "audioBitRate": {strconv.Itoa(transcodeAudio)}, "maxAudioChannels": {"2"},569 }570 if a, ok := v.PreferredAudio(); ok && len(v.AudioStreams) > 1 {571 q.Set("audioStreamIndex", strconv.Itoa(a.Index))572 }573 if t.MaxWidth > 0 {574 q.Set("maxWidth", strconv.Itoa(t.MaxWidth))575 }576 if t.MaxHeight > 0 {577 q.Set("maxHeight", strconv.Itoa(t.MaxHeight))578 }579 if start > 0 {580 q.Set("startTimeTicks", strconv.FormatInt(int64(start/100), 10))581 }582 return c.s.server + "/Videos/" + url.PathEscape(v.ID) + "/stream.ts?" + q.Encode()583}584585// Image fetches an item's primary image (poster), scaled to maxWidth, and586// its content type.587func (c *Client) Image(ctx context.Context, id string, maxWidth int) ([]byte, string, error) {588 token, _, err := c.signIn(ctx)589 if err != nil {590 return nil, "", err591 }592 q := url.Values{}593 if maxWidth > 0 {594 q.Set("maxWidth", strconv.Itoa(maxWidth))595 }596 resp, err := c.request(ctx, http.MethodGet, "/Items/"+url.PathEscape(id)+"/Images/Primary", q, nil, token)597 if err != nil {598 return nil, "", err599 }600 defer resp.Body.Close()601 if resp.StatusCode != http.StatusOK {602 return nil, "", fmt.Errorf("jellyfin: image for %s: %s", id, resp.Status)603 }604 b, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20))605 if err != nil {606 return nil, "", err607 }608 return b, resp.Header.Get("Content-Type"), nil609}