1// Package jellyfintest runs a fake Jellyfin server for tests: one account,2// a small library, and the endpoints package jellyfin uses.3package jellyfintest45import (6 "encoding/json"7 "fmt"8 "net/http"9 "net/http/httptest"10 "regexp"11 "slices"12 "strconv"13 "strings"14 "sync"15 "testing"16 "time"17)1819// Item is an entry in the fake library.20type Item struct {21 ID string22 // Type is "Series", "Season", "Episode", "Movie", "BoxSet", "Playlist",23 // or "CollectionFolder" for a library.24 Type string25 MediaType string // "Audio" for a music playlist26 Name string27 Parent string // the library, series or season it is in28 Children []string // a collection's or playlist's entries29 Year int30 Premiere string // "2006-01-02"31 Overview string32 Genres []string33 // Cast are actors as "Name/Role"; Rating the content rating.34 Cast []string35 Rating string36 Season int37 Episode int38 Runtime time.Duration39 Silent bool // no sound40 Image bool // has a primary image41 Missing bool // known to the server, with no file42 // Streams are a video's sound and subtitles, after its picture. Without43 // them it has one AAC track, unless Silent.44 Streams []Stream45}4647// Stream is a sound or subtitle stream in a video's file, or a subtitle48// file beside it.49type Stream struct {50 Type string // "Audio" or "Subtitle"51 Language string // "eng"52 Codec string // "aac", "subrip", "ass", "pgssub"...53 Title string54 Default bool55 Forced bool56 HearingImpaired bool57 External bool58 // SRT is a text subtitle's content, served by the subtitle endpoint.59 SRT string60}6162// textSubtitle reports whether the server converts a subtitle codec to63// other text formats.64func textSubtitle(codec string) bool {65 switch codec {66 case "subrip", "srt", "ass", "ssa", "webvtt", "vtt", "mov_text", "text":67 return true68 }69 return false70}7172// Request is a request the server received.73type Request struct {74 Method string75 URL string // path and query76 Authorization string77}7879// Server is a fake Jellyfin server. User and Password sign in; APIKey, if80// set, is accepted as a token too. As on a real server, a token works only81// from the device it was issued to, and signing in again from a device ends82// its last sign-in.83type Server struct {84 *httptest.Server85 User string86 Password string87 APIKey string88 QuickConnect bool // allow code sign-in8990 mu sync.Mutex91 items []Item92 attempts int93 issued int94 tokens map[string]string // token to device95 codes []*code96 requests []Request97}9899type code struct {100 code, secret, device string101 approved bool102}103104// New starts a server holding items, stopped when the test ends.105func New(t testing.TB, user, password string, items ...Item) *Server {106 s := &Server{User: user, Password: password, items: items}107 s.Server = httptest.NewServer(http.HandlerFunc(s.serve))108 t.Cleanup(s.Close)109 return s110}111112// SetItems replaces the library.113func (s *Server) SetItems(items ...Item) {114 s.mu.Lock()115 defer s.mu.Unlock()116 s.items = items117}118119// Logins counts sign-in attempts, refused ones included.120func (s *Server) Logins() int {121 s.mu.Lock()122 defer s.mu.Unlock()123 return s.attempts124}125126// Revoke ends every sign-in, as signing devices out does.127func (s *Server) Revoke() {128 s.mu.Lock()129 defer s.mu.Unlock()130 clear(s.tokens)131}132133// Approve approves a Quick Connect code, as the user entering it in a134// signed-in app does.135func (s *Server) Approve(c string) bool {136 s.mu.Lock()137 defer s.mu.Unlock()138 for _, x := range s.codes {139 if x.code == c {140 x.approved = true141 return true142 }143 }144 return false145}146147// issue signs a device in, ending its previous sign-in. s.mu is held.148func (s *Server) issue(device string) string {149 for t, d := range s.tokens {150 if d == device {151 delete(s.tokens, t)152 }153 }154 if s.tokens == nil {155 s.tokens = map[string]string{}156 }157 s.issued++158 t := fmt.Sprintf("session-%d", s.issued)159 s.tokens[t] = device160 return t161}162163// Requests lists the requests so far.164func (s *Server) Requests() []Request {165 s.mu.Lock()166 defer s.mu.Unlock()167 return slices.Clone(s.requests)168}169170var (171 tokenField = regexp.MustCompile(`Token="([^"]*)"`)172 deviceField = regexp.MustCompile(`DeviceId="([^"]+)"`)173)174175func (s *Server) serve(w http.ResponseWriter, r *http.Request) {176 s.mu.Lock()177 defer s.mu.Unlock()178 auth := r.Header.Get("Authorization")179 s.requests = append(s.requests, Request{Method: r.Method, URL: r.URL.String(), Authorization: auth})180 var token, device string181 if m := tokenField.FindStringSubmatch(auth); m != nil {182 token = m[1]183 }184 if m := deviceField.FindStringSubmatch(auth); m != nil {185 device = m[1]186 }187 path := r.URL.Path188 switch path {189 case "/QuickConnect/Enabled":190 writeJSON(w, s.QuickConnect)191 return192 case "/QuickConnect/Connect":193 for _, x := range s.codes {194 if x.secret == r.URL.Query().Get("secret") {195 writeJSON(w, map[string]any{"Authenticated": x.approved, "Secret": x.secret, "Code": x.code})196 return197 }198 }199 http.NotFound(w, r)200 return201 }202 if !strings.HasPrefix(auth, "MediaBrowser ") || device == "" {203 http.Error(w, "no MediaBrowser authorization with a DeviceId", http.StatusBadRequest)204 return205 }206 switch {207 case r.Method == http.MethodPost && path == "/Users/AuthenticateByName":208 var body struct{ Username, Pw string }209 _ = json.NewDecoder(r.Body).Decode(&body)210 s.attempts++211 if !strings.EqualFold(body.Username, s.User) || body.Pw != s.Password {212 http.Error(w, "Invalid username or password entered.", http.StatusUnauthorized)213 return214 }215 writeJSON(w, map[string]any{"AccessToken": s.issue(device), "User": map[string]any{"Id": "user-1", "Name": s.User}})216 return217 case r.Method == http.MethodPost && path == "/QuickConnect/Initiate":218 if !s.QuickConnect {219 http.Error(w, "Quick connect is disabled", http.StatusUnauthorized)220 return221 }222 x := &code{code: fmt.Sprintf("%06d", 100000+len(s.codes)), secret: fmt.Sprintf("secret-%d", len(s.codes)), device: device}223 s.codes = append(s.codes, x)224 writeJSON(w, map[string]any{"Authenticated": false, "Secret": x.secret, "Code": x.code, "DeviceId": device})225 return226 case r.Method == http.MethodPost && path == "/Users/AuthenticateWithQuickConnect":227 var body struct{ Secret string }228 _ = json.NewDecoder(r.Body).Decode(&body)229 for _, x := range s.codes {230 if x.secret == body.Secret && x.approved && x.device == device {231 writeJSON(w, map[string]any{"AccessToken": s.issue(device), "User": map[string]any{"Id": "user-1", "Name": s.User}})232 return233 }234 }235 http.Error(w, "Unknown or unapproved secret", http.StatusUnauthorized)236 return237 }238 if parts := strings.Split(strings.Trim(path, "/"), "/"); len(parts) == 4 && parts[0] == "Items" && parts[2] == "Images" {239 // Images load without signing in, as on a real server.240 if it, ok := s.find(parts[1]); ok && it.Image {241 // A JPEG's first bytes, then what was asked for.242 w.Header().Set("Content-Type", "image/jpeg")243 fmt.Fprintf(w, "\xff\xd8\xff\xe0jpeg %s %s %s", it.ID, parts[3], r.URL.Query().Get("maxWidth"))244 return245 }246 http.NotFound(w, r)247 return248 }249 apiKey := s.APIKey != "" && token == s.APIKey250 if d, ok := s.tokens[token]; !apiKey && (!ok || d != device) {251 http.Error(w, "Unauthorized", http.StatusUnauthorized)252 return253 }254 if strings.HasPrefix(path, "/Videos/") && strings.Contains(path, "/Subtitles/") {255 s.subtitle(w, r)256 return257 }258 switch path {259 case "/Users/Me":260 if apiKey {261 http.Error(w, "an API key is not a user", http.StatusBadRequest)262 return263 }264 writeJSON(w, map[string]any{"Id": "user-1", "Name": s.User})265 case "/Users":266 if !apiKey {267 http.Error(w, "Forbidden", http.StatusForbidden)268 return269 }270 writeJSON(w, []map[string]any{{"Id": "user-1", "Name": s.User}})271 case "/UserViews", "/Library/MediaFolders":272 if path == "/Library/MediaFolders" && !apiKey {273 http.Error(w, "Forbidden", http.StatusForbidden)274 return275 }276 var views []map[string]any277 for _, it := range s.items {278 if it.Type == "CollectionFolder" {279 views = append(views, s.dto(it, nil, true))280 }281 }282 writeJSON(w, map[string]any{"Items": views, "TotalRecordCount": len(views)})283 case "/Items":284 s.query(w, r)285 default:286 http.NotFound(w, r)287 }288}289290// query answers /Items with the filters package jellyfin uses.291func (s *Server) query(w http.ResponseWriter, r *http.Request) {292 q := r.URL.Query()293 var list []Item294 switch {295 case q.Get("ids") != "":296 for id := range strings.SplitSeq(q.Get("ids"), ",") {297 if it, ok := s.find(id); ok {298 list = append(list, it)299 }300 }301 case q.Get("parentId") != "":302 p, _ := s.find(q.Get("parentId"))303 if p.Type == "BoxSet" || p.Type == "Playlist" {304 for _, id := range p.Children {305 if it, ok := s.find(id); ok {306 list = append(list, it)307 }308 }309 break310 }311 for _, it := range s.items {312 if it.Parent == p.ID || (q.Get("recursive") == "true" && s.within(it, p.ID)) {313 list = append(list, it)314 }315 }316 default:317 for _, it := range s.items {318 if it.Type != "CollectionFolder" {319 list = append(list, it)320 }321 }322 }323 types := strings.Split(q.Get("includeItemTypes"), ",")324 list = slices.DeleteFunc(list, func(it Item) bool {325 return (q.Get("includeItemTypes") != "" && !slices.Contains(types, it.Type)) ||326 (it.Missing && strings.Contains(q.Get("excludeLocationTypes"), "Virtual"))327 })328 if q.Get("sortBy") == "SortName" {329 slices.SortStableFunc(list, func(a, b Item) int { return strings.Compare(a.Name, b.Name) })330 }331 total := len(list)332 start, _ := strconv.Atoi(q.Get("startIndex"))333 list = list[min(start, len(list)):]334 if n, err := strconv.Atoi(q.Get("limit")); err == nil && n < len(list) {335 list = list[:n]336 }337 fields := strings.Split(q.Get("fields"), ",")338 out := []map[string]any{}339 for _, it := range list {340 out = append(out, s.dto(it, fields, q.Get("enableUserData") != "false"))341 }342 writeJSON(w, map[string]any{"Items": out, "TotalRecordCount": total, "StartIndex": start})343}344345// subtitle serves /Videos/{id}/{source}/Subtitles/{index}[/{ticks}]/Stream.srt.346func (s *Server) subtitle(w http.ResponseWriter, r *http.Request) {347 parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")348 if len(parts) < 6 || parts[3] != "Subtitles" || parts[len(parts)-1] != "Stream.srt" {349 http.NotFound(w, r)350 return351 }352 it, ok := s.find(parts[1])353 index, err := strconv.Atoi(parts[4])354 if !ok || err != nil || parts[2] != "src-"+it.ID || index < 1 || index > len(it.Streams) {355 http.NotFound(w, r)356 return357 }358 st := it.Streams[index-1]359 if st.Type != "Subtitle" || !textSubtitle(st.Codec) {360 http.Error(w, "not a text subtitle", http.StatusBadRequest)361 return362 }363 w.Header().Set("Content-Type", "text/plain; charset=utf-8")364 fmt.Fprint(w, st.SRT)365}366367func (s *Server) find(id string) (Item, bool) {368 for _, it := range s.items {369 if it.ID == id {370 return it, true371 }372 }373 return Item{}, false374}375376// within reports whether id is one of its ancestors.377func (s *Server) within(it Item, id string) bool {378 for range 10 {379 p, ok := s.find(it.Parent)380 if !ok {381 return false382 }383 if p.ID == id {384 return true385 }386 it = p387 }388 return false389}390391// dto renders an item as Jellyfin's BaseItemDto, with the optional fields392// asked for. As on a real server, a series' episode count comes only with393// user data.394func (s *Server) dto(it Item, fields []string, userData bool) map[string]any {395 m := map[string]any{"Id": it.ID, "Name": it.Name, "Type": it.Type, "LocationType": "FileSystem", "ImageTags": map[string]string{}}396 if it.MediaType != "" {397 m["MediaType"] = it.MediaType398 }399 if it.Missing {400 m["LocationType"] = "Virtual"401 }402 if it.Year > 0 {403 m["ProductionYear"] = it.Year404 }405 if it.Premiere != "" {406 m["PremiereDate"] = it.Premiere + "T00:00:00.0000000Z"407 }408 if it.Runtime > 0 {409 m["RunTimeTicks"] = int64(it.Runtime / 100)410 }411 if it.Image {412 m["ImageTags"] = map[string]string{"Primary": "tag-" + it.ID}413 }414 if slices.Contains(fields, "Overview") {415 m["Overview"] = it.Overview416 }417 if slices.Contains(fields, "Genres") {418 m["Genres"] = it.Genres419 }420 if slices.Contains(fields, "OfficialRating") && it.Rating != "" {421 m["OfficialRating"] = it.Rating422 }423 if slices.Contains(fields, "People") && len(it.Cast) > 0 {424 var people []map[string]string425 people = append(people, map[string]string{"Name": "Someone Behind", "Type": "Director"})426 for _, c := range it.Cast {427 name, role, _ := strings.Cut(c, "/")428 people = append(people, map[string]string{"Name": name, "Role": role, "Type": "Actor"})429 }430 m["People"] = people431 }432 switch it.Type {433 case "Episode":434 for _, p := range s.items {435 if p.Type == "Series" && s.within(it, p.ID) {436 m["SeriesName"], m["SeriesId"] = p.Name, p.ID437 if p.Image {438 m["SeriesPrimaryImageTag"] = "tag-" + p.ID439 }440 }441 }442 if it.Season > 0 {443 m["ParentIndexNumber"] = it.Season444 }445 if it.Episode > 0 {446 m["IndexNumber"] = it.Episode447 }448 case "Series":449 if slices.Contains(fields, "RecursiveItemCount") && userData {450 n := 0451 for _, e := range s.items {452 if e.Type == "Episode" && s.within(e, it.ID) {453 n++454 }455 }456 m["RecursiveItemCount"] = n457 }458 case "BoxSet", "Playlist":459 if slices.Contains(fields, "ChildCount") {460 m["ChildCount"] = len(it.Children)461 }462 }463 if (it.Type == "Episode" || it.Type == "Movie") && !it.Missing && slices.Contains(fields, "MediaSources") {464 streams := []map[string]any{{"Type": "Video", "Codec": "h264", "Index": 0}}465 if !it.Silent && it.Streams == nil {466 streams = append(streams, map[string]any{"Type": "Audio", "Codec": "aac", "Index": 1, "IsDefault": true})467 }468 for i, st := range it.Streams {469 m := map[string]any{470 "Type": st.Type, "Index": i + 1, "Codec": st.Codec, "IsDefault": st.Default, "IsForced": st.Forced,471 "IsHearingImpaired": st.HearingImpaired, "IsExternal": st.External,472 "IsTextSubtitleStream": st.Type == "Subtitle" && textSubtitle(st.Codec),473 }474 if st.Language != "" {475 m["Language"] = st.Language476 }477 if st.Title != "" {478 m["Title"] = st.Title479 }480 streams = append(streams, m)481 }482 m["MediaSources"] = []map[string]any{{"Id": "src-" + it.ID, "RunTimeTicks": int64(it.Runtime / 100), "MediaStreams": streams}}483 }484 return m485}486487func writeJSON(w http.ResponseWriter, v any) {488 w.Header().Set("Content-Type", "application/json")489 _ = json.NewEncoder(w).Encode(v)490}