1import ./make-test-python.nix ({ lib, pkgs, ... }:
2
3 {
4 name = "jellyfin";
5 meta.maintainers = with lib.maintainers; [ minijackson ];
6
7 nodes.machine =
8 { ... }:
9 {
10 services.jellyfin.enable = true;
11 environment.systemPackages = with pkgs; [ ffmpeg ];
12 };
13
14 # Documentation of the Jellyfin API: https://api.jellyfin.org/
15 # Beware, this link can be resource intensive
16 testScript =
17 let
18 payloads = {
19 auth = pkgs.writeText "auth.json" (builtins.toJSON {
20 Username = "jellyfin";
21 });
22 empty = pkgs.writeText "empty.json" (builtins.toJSON { });
23 };
24 in
25 ''
26 import json
27 from urllib.parse import urlencode
28
29 machine.wait_for_unit("jellyfin.service")
30 machine.wait_for_open_port(8096)
31 machine.succeed("curl --fail http://localhost:8096/")
32
33 machine.wait_until_succeeds("curl --fail http://localhost:8096/health | grep Healthy")
34
35 auth_header = 'MediaBrowser Client="NixOS Integration Tests", DeviceId="1337", Device="Apple II", Version="20.09"'
36
37
38 def api_get(path):
39 return f"curl --fail 'http://localhost:8096{path}' -H 'X-Emby-Authorization:{auth_header}'"
40
41
42 def api_post(path, json_file=None):
43 if json_file:
44 return f"curl --fail -X post 'http://localhost:8096{path}' -d '@{json_file}' -H Content-Type:application/json -H 'X-Emby-Authorization:{auth_header}'"
45 else:
46 return f"curl --fail -X post 'http://localhost:8096{path}' -H 'X-Emby-Authorization:{auth_header}'"
47
48
49 with machine.nested("Wizard completes"):
50 machine.wait_until_succeeds(api_get("/Startup/Configuration"))
51 machine.succeed(api_get("/Startup/FirstUser"))
52 machine.succeed(api_post("/Startup/Complete"))
53
54 with machine.nested("Can login"):
55 auth_result_str = machine.succeed(
56 api_post(
57 "/Users/AuthenticateByName",
58 "${payloads.auth}",
59 )
60 )
61 auth_result = json.loads(auth_result_str)
62 auth_token = auth_result["AccessToken"]
63 auth_header += f", Token={auth_token}"
64
65 sessions_result_str = machine.succeed(api_get("/Sessions"))
66 sessions_result = json.loads(sessions_result_str)
67
68 this_session = [
69 session for session in sessions_result if session["DeviceId"] == "1337"
70 ]
71 if len(this_session) != 1:
72 raise Exception("Session not created")
73
74 me_str = machine.succeed(api_get("/Users/Me"))
75 me = json.loads(me_str)["Id"]
76
77 with machine.nested("Can add library"):
78 tempdir = machine.succeed("mktemp -d -p /var/lib/jellyfin").strip()
79 machine.succeed(f"chmod 755 '{tempdir}'")
80
81 # Generate a dummy video that we can test later
82 videofile = f"{tempdir}/Big Buck Bunny (2008) [1080p].mkv"
83 machine.succeed(f"ffmpeg -f lavfi -i testsrc2=duration=5 '{videofile}'")
84
85 add_folder_query = urlencode(
86 {
87 "name": "My Library",
88 "collectionType": "Movies",
89 "paths": tempdir,
90 "refreshLibrary": "true",
91 }
92 )
93
94 machine.succeed(
95 api_post(
96 f"/Library/VirtualFolders?{add_folder_query}",
97 "${payloads.empty}",
98 )
99 )
100
101
102 def is_refreshed(_):
103 folders_str = machine.succeed(api_get("/Library/VirtualFolders"))
104 folders = json.loads(folders_str)
105 print(folders)
106 return all(folder["RefreshStatus"] == "Idle" for folder in folders)
107
108
109 retry(is_refreshed)
110
111 with machine.nested("Can identify videos"):
112 items = []
113
114 # For some reason, having the folder refreshed doesn't mean the
115 # movie was scanned
116 def has_movie(_):
117 global items
118
119 items_str = machine.succeed(
120 api_get(f"/Users/{me}/Items?IncludeItemTypes=Movie&Recursive=true")
121 )
122 items = json.loads(items_str)["Items"]
123
124 return len(items) == 1
125
126 retry(has_movie)
127
128 video = items[0]["Id"]
129
130 item_info_str = machine.succeed(api_get(f"/Users/{me}/Items/{video}"))
131 item_info = json.loads(item_info_str)
132
133 if item_info["Name"] != "Big Buck Bunny":
134 raise Exception("Jellyfin failed to properly identify file")
135
136 with machine.nested("Can read videos"):
137 media_source_id = item_info["MediaSources"][0]["Id"]
138
139 machine.succeed(
140 "ffmpeg"
141 + f" -headers 'X-Emby-Authorization:{auth_header}'"
142 + f" -i http://localhost:8096/Videos/{video}/master.m3u8?mediaSourceId={media_source_id}"
143 + " /tmp/test.mkv"
144 )
145
146 duration = machine.succeed(
147 "ffprobe /tmp/test.mkv"
148 + " -show_entries format=duration"
149 + " -of compact=print_section=0:nokey=1"
150 )
151
152 if duration.strip() != "5.000000":
153 raise Exception("Downloaded video has wrong duration")
154 '';
155 })