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