diff --git a/tests/jellyfin-annotations.nix b/tests/jellyfin-annotations.nix index 1d3194b..3c5a4b9 100644 --- a/tests/jellyfin-annotations.nix +++ b/tests/jellyfin-annotations.nix @@ -4,40 +4,8 @@ ... }: let + jfLib = import ./jellyfin-test-lib.nix { inherit pkgs lib; }; mockGrafana = ./mock-grafana-server.py; - - mockJellyfin = pkgs.writeText "mock-jellyfin-server.py" '' - import http.server, json, sys - - PORT = int(sys.argv[1]) - DATA_FILE = sys.argv[2] - - class Handler(http.server.BaseHTTPRequestHandler): - def log_message(self, fmt, *args): - pass - - def _json(self, code, body): - data = json.dumps(body).encode() - self.send_response(code) - self.send_header("Content-Type", "application/json") - self.end_headers() - self.wfile.write(data) - - def do_GET(self): - if self.path.startswith("/Sessions"): - try: - with open(DATA_FILE) as f: - sessions = json.load(f) - except Exception: - sessions = [] - self._json(200, sessions) - else: - self.send_response(404) - self.end_headers() - - http.server.HTTPServer(("127.0.0.1", PORT), Handler).serve_forever() - ''; - script = ../services/jellyfin-annotations.py; python = pkgs.python3; in @@ -47,6 +15,7 @@ pkgs.testers.runNixOSTest { nodes.machine = { pkgs, ... }: { + imports = [ jfLib.jellyfinTestConfig ]; environment.systemPackages = [ pkgs.python3 ]; }; @@ -54,39 +23,42 @@ pkgs.testers.runNixOSTest { import json import time - JELLYFIN_PORT = 18096 + import importlib.util + _spec = importlib.util.spec_from_file_location("jf_helpers", "${jfLib.helpers}") + assert _spec and _spec.loader + _jf = importlib.util.module_from_spec(_spec) + _spec.loader.exec_module(_jf) + setup_jellyfin = _jf.setup_jellyfin + jellyfin_api = _jf.jellyfin_api + GRAFANA_PORT = 13000 - SESSIONS_FILE = "/tmp/sessions.json" ANNOTS_FILE = "/tmp/annotations.json" STATE_FILE = "/tmp/annotations-state.json" CREDS_DIR = "/tmp/test-creds" PYTHON = "${python}/bin/python3" MOCK_GRAFANA = "${mockGrafana}" - MOCK_JELLYFIN = "${mockJellyfin}" SCRIPT = "${script}" + auth_header = 'MediaBrowser Client="Infuse", DeviceId="test-dev-1", Device="iPhone", Version="1.0"' + auth_header2 = 'MediaBrowser Client="Jellyfin Web", DeviceId="test-dev-2", Device="Chrome", Version="1.0"' + def read_annotations(): out = machine.succeed(f"cat {ANNOTS_FILE} 2>/dev/null || echo '[]'") return json.loads(out.strip()) start_all() - machine.wait_for_unit("multi-user.target") + token, user_id, movie_id, media_source_id = setup_jellyfin( + machine, retry, auth_header, + "${jfLib.payloads.auth}", "${jfLib.payloads.empty}", + ) - with subtest("Setup mock credentials and data files"): - machine.succeed(f"mkdir -p {CREDS_DIR} && echo 'fake-api-key' > {CREDS_DIR}/jellyfin-api-key") - machine.succeed(f"echo '[]' > {SESSIONS_FILE}") + with subtest("Setup mock Grafana and credentials"): + machine.succeed(f"mkdir -p {CREDS_DIR}") + machine.succeed(f"echo '{token}' > {CREDS_DIR}/jellyfin-api-key") machine.succeed(f"echo '[]' > {ANNOTS_FILE}") - - with subtest("Start mock Jellyfin and Grafana servers"): - machine.succeed( - f"systemd-run --unit=mock-jellyfin {PYTHON} {MOCK_JELLYFIN} {JELLYFIN_PORT} {SESSIONS_FILE}" - ) machine.succeed( f"systemd-run --unit=mock-grafana {PYTHON} {MOCK_GRAFANA} {GRAFANA_PORT} {ANNOTS_FILE}" ) - machine.wait_until_succeeds( - f"curl -sf http://127.0.0.1:{JELLYFIN_PORT}/Sessions", timeout=10 - ) machine.wait_until_succeeds( f"curl -sf -X POST http://127.0.0.1:{GRAFANA_PORT}/api/annotations " f"-H 'Content-Type: application/json' -d '{{\"text\":\"ping\",\"tags\":[]}}' | grep -q id", @@ -94,10 +66,10 @@ pkgs.testers.runNixOSTest { ) machine.succeed(f"echo '[]' > {ANNOTS_FILE}") - with subtest("Start annotation service pointing at mock servers"): + with subtest("Start annotation service"): machine.succeed( f"systemd-run --unit=annotations-svc " - f"--setenv=JELLYFIN_URL=http://127.0.0.1:{JELLYFIN_PORT} " + f"--setenv=JELLYFIN_URL=http://127.0.0.1:8096 " f"--setenv=GRAFANA_URL=http://127.0.0.1:{GRAFANA_PORT} " f"--setenv=CREDENTIALS_DIRECTORY={CREDS_DIR} " f"--setenv=STATE_FILE={STATE_FILE} " @@ -106,38 +78,24 @@ pkgs.testers.runNixOSTest { ) time.sleep(2) - with subtest("No annotations pushed when no streams active"): + with subtest("No annotations when no streams active"): time.sleep(4) annots = read_annotations() assert annots == [], f"Expected no annotations, got: {annots}" - with subtest("Annotation created when stream starts"): - rich_session = json.dumps([{ - "Id": "sess-1", - "UserName": "alice", - "Client": "Infuse", - "DeviceName": "iPhone", - "PlayState": {"PlayMethod": "Transcode"}, - "NowPlayingItem": { - "Name": "Inception", - "Type": "Movie", - "Bitrate": 20000000, - "MediaStreams": [ - {"Type": "Video", "Codec": "h264", "Width": 1920, "Height": 1080}, - {"Type": "Audio", "Codec": "dts", "Channels": 6, "IsDefault": True}, - ], - }, - "TranscodingInfo": { - "IsVideoDirect": True, - "IsAudioDirect": False, - "VideoCodec": "h264", - "AudioCodec": "aac", - "AudioChannels": 2, - "Bitrate": 8000000, - "TranscodeReasons": ["AudioCodecNotSupported"], - }, - }]) - machine.succeed(f"echo {repr(rich_session)} > {SESSIONS_FILE}") + with subtest("Annotation created when playback starts"): + playback_start = json.dumps({ + "ItemId": movie_id, + "MediaSourceId": media_source_id, + "PlaySessionId": "test-play-1", + "CanSeek": True, + "IsPaused": False, + }) + machine.succeed( + f"curl -sf -X POST 'http://localhost:8096/Sessions/Playing' " + f"-d '{playback_start}' -H 'Content-Type:application/json' " + f"-H 'X-Emby-Authorization:{auth_header}, Token={token}'" + ) machine.wait_until_succeeds( f"cat {ANNOTS_FILE} | python3 -c \"import sys,json; a=json.load(sys.stdin); exit(0 if a else 1)\"", timeout=15, @@ -145,18 +103,24 @@ pkgs.testers.runNixOSTest { annots = read_annotations() assert len(annots) == 1, f"Expected 1 annotation, got: {annots}" text = annots[0]["text"] - assert "alice: Inception (movie)" in text, f"Missing title in: {text}" - assert "Transcode" in text, f"Missing method in: {text}" - assert "H.264" in text, f"Missing video codec in: {text}" - assert "DTS" in text and "AAC" in text, f"Missing audio codec in: {text}" - assert "8.0 Mbps" in text, f"Missing bitrate in: {text}" - assert "AudioCodecNotSupported" in text, f"Missing transcode reason in: {text}" - assert "Infuse" in text and "iPhone" in text, f"Missing client in: {text}" assert "jellyfin" in annots[0].get("tags", []), f"Missing jellyfin tag: {annots[0]}" + assert "Test Movie" in text, f"Missing title in: {text}" + assert "Infuse" in text, f"Missing client in: {text}" + assert "iPhone" in text, f"Missing device in: {text}" assert "timeEnd" not in annots[0], f"timeEnd should not be set yet: {annots[0]}" - with subtest("Annotation closed when stream ends"): - machine.succeed(f"echo '[]' > {SESSIONS_FILE}") + with subtest("Annotation closed when playback stops"): + playback_stop = json.dumps({ + "ItemId": movie_id, + "MediaSourceId": media_source_id, + "PlaySessionId": "test-play-1", + "PositionTicks": 50000000, + }) + machine.succeed( + f"curl -sf -X POST 'http://localhost:8096/Sessions/Playing/Stopped' " + f"-d '{playback_stop}' -H 'Content-Type:application/json' " + f"-H 'X-Emby-Authorization:{auth_header}, Token={token}'" + ) machine.wait_until_succeeds( f"cat {ANNOTS_FILE} | python3 -c \"import sys,json; a=json.load(sys.stdin); exit(0 if a and 'timeEnd' in a[0] else 1)\"", timeout=15, @@ -168,11 +132,37 @@ pkgs.testers.runNixOSTest { with subtest("Multiple concurrent streams each get their own annotation"): machine.succeed(f"echo '[]' > {ANNOTS_FILE}") + + auth_result2 = json.loads(machine.succeed( + f"curl -sf -X POST 'http://localhost:8096/Users/AuthenticateByName' " + f"-d '@${jfLib.payloads.auth}' -H 'Content-Type:application/json' " + f"-H 'X-Emby-Authorization:{auth_header2}'" + )) + token2 = auth_result2["AccessToken"] + + playback1 = json.dumps({ + "ItemId": movie_id, + "MediaSourceId": media_source_id, + "PlaySessionId": "test-play-multi-1", + "CanSeek": True, + "IsPaused": False, + }) machine.succeed( - f"""echo '[ - {{"Id":"sess-2","UserName":"bob","NowPlayingItem":{{"Name":"Breaking Bad","SeriesName":"Breaking Bad","ParentIndexNumber":1,"IndexNumber":1}}}}, - {{"Id":"sess-3","UserName":"carol","NowPlayingItem":{{"Name":"Inception","Type":"Movie"}}}} - ]' > {SESSIONS_FILE}""" + f"curl -sf -X POST 'http://localhost:8096/Sessions/Playing' " + f"-d '{playback1}' -H 'Content-Type:application/json' " + f"-H 'X-Emby-Authorization:{auth_header}, Token={token}'" + ) + playback2 = json.dumps({ + "ItemId": movie_id, + "MediaSourceId": media_source_id, + "PlaySessionId": "test-play-multi-2", + "CanSeek": True, + "IsPaused": False, + }) + machine.succeed( + f"curl -sf -X POST 'http://localhost:8096/Sessions/Playing' " + f"-d '{playback2}' -H 'Content-Type:application/json' " + f"-H 'X-Emby-Authorization:{auth_header2}, Token={token2}'" ) machine.wait_until_succeeds( f"cat {ANNOTS_FILE} | python3 -c \"import sys,json; a=json.load(sys.stdin); exit(0 if len(a)==2 else 1)\"", @@ -180,16 +170,13 @@ pkgs.testers.runNixOSTest { ) annots = read_annotations() assert len(annots) == 2, f"Expected 2 annotations, got: {annots}" - texts = sorted(a["text"] for a in annots) - assert any("Breaking Bad" in t and "S01E01" in t for t in texts), f"Missing Bob's annotation: {texts}" - assert any("carol" in t and "Inception" in t for t in texts), f"Missing Carol's annotation: {texts}" with subtest("State survives service restart (no duplicate annotations)"): machine.succeed("systemctl stop annotations-svc || true") time.sleep(1) machine.succeed( f"systemd-run --unit=annotations-svc-2 " - f"--setenv=JELLYFIN_URL=http://127.0.0.1:{JELLYFIN_PORT} " + f"--setenv=JELLYFIN_URL=http://127.0.0.1:8096 " f"--setenv=GRAFANA_URL=http://127.0.0.1:{GRAFANA_PORT} " f"--setenv=CREDENTIALS_DIRECTORY={CREDS_DIR} " f"--setenv=STATE_FILE={STATE_FILE} " diff --git a/tests/jellyfin-qbittorrent-monitor.nix b/tests/jellyfin-qbittorrent-monitor.nix index 3cb8b64..aec5a98 100644 --- a/tests/jellyfin-qbittorrent-monitor.nix +++ b/tests/jellyfin-qbittorrent-monitor.nix @@ -5,10 +5,7 @@ ... }: let - payloads = { - auth = pkgs.writeText "auth.json" (builtins.toJSON { Username = "jellyfin"; }); - empty = pkgs.writeText "empty.json" (builtins.toJSON { }); - }; + jfLib = import ./jellyfin-test-lib.nix { inherit pkgs lib; }; in pkgs.testers.runNixOSTest { name = "jellyfin-qbittorrent-monitor"; @@ -18,11 +15,10 @@ pkgs.testers.runNixOSTest { { ... }: { imports = [ + jfLib.jellyfinTestConfig inputs.vpn-confinement.nixosModules.default ]; - services.jellyfin.enable = true; - # Real qBittorrent service services.qbittorrent = { enable = true; @@ -56,11 +52,6 @@ pkgs.testers.runNixOSTest { }; }; - environment.systemPackages = with pkgs; [ - curl - ffmpeg - ]; - virtualisation.diskSize = 3 * 1024; networking.firewall.allowedTCPPorts = [ 8096 8080 @@ -106,20 +97,17 @@ pkgs.testers.runNixOSTest { testScript = '' import json import time - from urllib.parse import urlencode + + import importlib.util + _spec = importlib.util.spec_from_file_location("jf_helpers", "${jfLib.helpers}") + assert _spec and _spec.loader + _jf = importlib.util.module_from_spec(_spec) + _spec.loader.exec_module(_jf) + setup_jellyfin = _jf.setup_jellyfin + jellyfin_api = _jf.jellyfin_api auth_header = 'MediaBrowser Client="NixOS Test", DeviceId="test-1337", Device="TestDevice", Version="1.0"' - def api_get(path, token=None): - header = auth_header + (f", Token={token}" if token else "") - return f"curl -sf 'http://server:8096{path}' -H 'X-Emby-Authorization:{header}'" - - def api_post(path, json_file=None, token=None): - header = auth_header + (f", Token={token}" if token else "") - if json_file: - return f"curl -sf -X POST 'http://server:8096{path}' -d '@{json_file}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{header}'" - return f"curl -sf -X POST 'http://server:8096{path}' -H 'X-Emby-Authorization:{header}'" - def is_throttled(): return server.succeed("curl -s http://localhost:8080/api/v2/transfer/speedLimitsMode").strip() == "1" @@ -137,57 +125,15 @@ pkgs.testers.runNixOSTest { return False return all(t["state"].startswith("stopped") for t in torrents) - movie_id: str = "" - media_source_id: str = "" - start_all() - server.wait_for_unit("jellyfin.service") - server.wait_for_open_port(8096) - server.wait_until_succeeds("curl -sf http://localhost:8096/health | grep -q Healthy", timeout=60) server.wait_for_unit("qbittorrent.service") server.wait_for_open_port(8080) - - # Wait for qBittorrent WebUI to be responsive server.wait_until_succeeds("curl -sf http://localhost:8080/api/v2/app/version", timeout=30) - with subtest("Complete Jellyfin setup wizard"): - server.wait_until_succeeds(api_get("/Startup/Configuration")) - server.succeed(api_get("/Startup/FirstUser")) - server.succeed(api_post("/Startup/Complete")) - - with subtest("Authenticate and get token"): - auth_result = json.loads(server.succeed(api_post("/Users/AuthenticateByName", "${payloads.auth}"))) - token = auth_result["AccessToken"] - user_id = auth_result["User"]["Id"] - - with subtest("Create test video library"): - tempdir = server.succeed("mktemp -d -p /var/lib/jellyfin").strip() - server.succeed(f"chmod 755 '{tempdir}'") - server.succeed(f"ffmpeg -f lavfi -i testsrc2=duration=5 '{tempdir}/Test Movie (2024) [1080p].mkv'") - - add_folder_query = urlencode({ - "name": "Test Library", - "collectionType": "Movies", - "paths": tempdir, - "refreshLibrary": "true", - }) - server.succeed(api_post(f"/Library/VirtualFolders?{add_folder_query}", "${payloads.empty}", token)) - - def is_library_ready(_): - folders = json.loads(server.succeed(api_get("/Library/VirtualFolders", token))) - return all(f.get("RefreshStatus") == "Idle" for f in folders) - retry(is_library_ready, timeout=60) - - def get_movie(_): - global movie_id, media_source_id - items = json.loads(server.succeed(api_get(f"/Users/{user_id}/Items?IncludeItemTypes=Movie&Recursive=true", token))) - if items["TotalRecordCount"] > 0: - movie_id = items["Items"][0]["Id"] - item_info = json.loads(server.succeed(api_get(f"/Users/{user_id}/Items/{movie_id}", token))) - media_source_id = item_info["MediaSources"][0]["Id"] - return True - return False - retry(get_movie, timeout=60) + token, user_id, movie_id, media_source_id = setup_jellyfin( + server, retry, auth_header, + "${jfLib.payloads.auth}", "${jfLib.payloads.empty}", + ) with subtest("Start monitor service"): python = "${pkgs.python3.withPackages (ps: [ ps.requests ])}/bin/python" @@ -214,12 +160,12 @@ pkgs.testers.runNixOSTest { server_ip = "192.168.1.1" with subtest("Client authenticates from external network"): - auth_cmd = f"curl -sf -X POST 'http://{server_ip}:8096/Users/AuthenticateByName' -d '@${payloads.auth}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth}'" + auth_cmd = f"curl -sf -X POST 'http://{server_ip}:8096/Users/AuthenticateByName' -d '@${jfLib.payloads.auth}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth}'" client_auth_result = json.loads(client.succeed(auth_cmd)) client_token = client_auth_result["AccessToken"] with subtest("Second client authenticates from external network"): - auth_cmd2 = f"curl -sf -X POST 'http://{server_ip}:8096/Users/AuthenticateByName' -d '@${payloads.auth}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth2}'" + auth_cmd2 = f"curl -sf -X POST 'http://{server_ip}:8096/Users/AuthenticateByName' -d '@${jfLib.payloads.auth}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth2}'" client_auth_result2 = json.loads(client.succeed(auth_cmd2)) client_token2 = client_auth_result2["AccessToken"] @@ -430,7 +376,7 @@ pkgs.testers.runNixOSTest { with subtest("Local playback does NOT trigger throttling"): local_auth = 'MediaBrowser Client="Local Client", DeviceId="local-1111", Device="LocalDevice", Version="1.0"' local_auth_result = json.loads(server.succeed( - f"curl -sf -X POST 'http://localhost:8096/Users/AuthenticateByName' -d '@${payloads.auth}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{local_auth}'" + f"curl -sf -X POST 'http://localhost:8096/Users/AuthenticateByName' -d '@${jfLib.payloads.auth}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{local_auth}'" )) local_token = local_auth_result["AccessToken"] @@ -527,11 +473,11 @@ pkgs.testers.runNixOSTest { # Re-authenticate (old token invalid after restart) client_auth_result = json.loads(client.succeed( - f"curl -sf -X POST 'http://{server_ip}:8096/Users/AuthenticateByName' -d '@${payloads.auth}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth}'" + f"curl -sf -X POST 'http://{server_ip}:8096/Users/AuthenticateByName' -d '@${jfLib.payloads.auth}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth}'" )) client_token = client_auth_result["AccessToken"] client_auth_result2 = json.loads(client.succeed( - f"curl -sf -X POST 'http://{server_ip}:8096/Users/AuthenticateByName' -d '@${payloads.auth}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth2}'" + f"curl -sf -X POST 'http://{server_ip}:8096/Users/AuthenticateByName' -d '@${jfLib.payloads.auth}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth2}'" )) client_token2 = client_auth_result2["AccessToken"] @@ -542,11 +488,11 @@ pkgs.testers.runNixOSTest { with subtest("Monitor recovers after Jellyfin temporary unavailability"): # Re-authenticate with fresh token client_auth_result = json.loads(client.succeed( - f"curl -sf -X POST 'http://{server_ip}:8096/Users/AuthenticateByName' -d '@${payloads.auth}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth}'" + f"curl -sf -X POST 'http://{server_ip}:8096/Users/AuthenticateByName' -d '@${jfLib.payloads.auth}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth}'" )) client_token = client_auth_result["AccessToken"] client_auth_result2 = json.loads(client.succeed( - f"curl -sf -X POST 'http://{server_ip}:8096/Users/AuthenticateByName' -d '@${payloads.auth}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth2}'" + f"curl -sf -X POST 'http://{server_ip}:8096/Users/AuthenticateByName' -d '@${jfLib.payloads.auth}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth2}'" )) client_token2 = client_auth_result2["AccessToken"] diff --git a/tests/jellyfin-test-lib.nix b/tests/jellyfin-test-lib.nix new file mode 100644 index 0000000..24fa07f --- /dev/null +++ b/tests/jellyfin-test-lib.nix @@ -0,0 +1,20 @@ +{ pkgs, lib }: +{ + payloads = { + auth = pkgs.writeText "auth.json" (builtins.toJSON { Username = "jellyfin"; }); + empty = pkgs.writeText "empty.json" (builtins.toJSON { }); + }; + + helpers = ./jellyfin-test-lib.py; + + jellyfinTestConfig = + { pkgs, ... }: + { + services.jellyfin.enable = true; + environment.systemPackages = with pkgs; [ + curl + ffmpeg + ]; + virtualisation.diskSize = lib.mkDefault (3 * 1024); + }; +} diff --git a/tests/jellyfin-test-lib.py b/tests/jellyfin-test-lib.py new file mode 100644 index 0000000..81c164b --- /dev/null +++ b/tests/jellyfin-test-lib.py @@ -0,0 +1,90 @@ +import json +from urllib.parse import urlencode + + +def jellyfin_api(machine, method, path, auth_header, token=None, data_file=None, data=None): + hdr = auth_header + (f", Token={token}" if token else "") + cmd = f"curl -sf -X {method} 'http://localhost:8096{path}'" + if data_file: + cmd += f" -d '@{data_file}' -H 'Content-Type:application/json'" + elif data: + payload = json.dumps(data) if isinstance(data, dict) else data + cmd += f" -d '{payload}' -H 'Content-Type:application/json'" + cmd += f" -H 'X-Emby-Authorization:{hdr}'" + return machine.succeed(cmd) + + +def setup_jellyfin(machine, retry, auth_header, auth_payload, empty_payload): + machine.wait_for_unit("jellyfin.service") + machine.wait_for_open_port(8096) + machine.wait_until_succeeds( + "curl -sf http://localhost:8096/health | grep -q Healthy", timeout=60 + ) + + machine.wait_until_succeeds( + f"curl -sf 'http://localhost:8096/Startup/Configuration' " + f"-H 'X-Emby-Authorization:{auth_header}'" + ) + jellyfin_api(machine, "GET", "/Startup/FirstUser", auth_header) + jellyfin_api(machine, "POST", "/Startup/Complete", auth_header) + + result = json.loads( + jellyfin_api( + machine, "POST", "/Users/AuthenticateByName", + auth_header, data_file=auth_payload, + ) + ) + token = result["AccessToken"] + user_id = result["User"]["Id"] + + tempdir = machine.succeed("mktemp -d -p /var/lib/jellyfin").strip() + machine.succeed(f"chmod 755 '{tempdir}'") + machine.succeed( + f"ffmpeg -f lavfi -i testsrc2=duration=5 -f lavfi -i sine=frequency=440:duration=5 " + f"-c:v libx264 -c:a aac '{tempdir}/Test Movie (2024).mkv'" + ) + + query = urlencode({ + "name": "Test Library", + "collectionType": "Movies", + "paths": tempdir, + "refreshLibrary": "true", + }) + jellyfin_api( + machine, "POST", f"/Library/VirtualFolders?{query}", + auth_header, token=token, data_file=empty_payload, + ) + + def is_ready(_): + folders = json.loads( + jellyfin_api(machine, "GET", "/Library/VirtualFolders", auth_header, token=token) + ) + return all(f.get("RefreshStatus") == "Idle" for f in folders) + retry(is_ready, timeout=60) + + movie_id = None + media_source_id = None + + def get_movie(_): + nonlocal movie_id, media_source_id + items = json.loads( + jellyfin_api( + machine, "GET", + f"/Users/{user_id}/Items?IncludeItemTypes=Movie&Recursive=true", + auth_header, token=token, + ) + ) + if items["TotalRecordCount"] > 0: + movie_id = items["Items"][0]["Id"] + info = json.loads( + jellyfin_api( + machine, "GET", f"/Users/{user_id}/Items/{movie_id}", + auth_header, token=token, + ) + ) + media_source_id = info["MediaSources"][0]["Id"] + return True + return False + retry(get_movie, timeout=60) + + return token, user_id, movie_id, media_source_id