Compare commits
2 Commits
de89e70a05
...
a93c789278
| Author | SHA1 | Date | |
|---|---|---|---|
|
a93c789278
|
|||
|
df1d983b63
|
@@ -1,305 +1,261 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
# Mock qBittorrent - simple enough to keep mocked
|
||||
mockQBittorrent = pkgs.writers.writePython3Bin "mock-qbt" { flakeIgnore = [ "E501" ]; } ''
|
||||
import http.server
|
||||
import socketserver
|
||||
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def log_message(self, fmt, *args):
|
||||
print(f"qbt: {fmt % args}")
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/api/v2/transfer/speedLimitsMode":
|
||||
self.send_response(200)
|
||||
self.send_header("Content-type", "text/plain")
|
||||
self.end_headers()
|
||||
self.wfile.write(("1" if getattr(self.server, "alt_speed", False) else "0").encode())
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
def do_POST(self):
|
||||
if self.path == "/api/v2/transfer/toggleSpeedLimitsMode":
|
||||
self.server.alt_speed = not getattr(self.server, "alt_speed", False)
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
|
||||
with socketserver.TCPServer(("0.0.0.0", 8080), Handler) as s:
|
||||
print("Mock qBittorrent on port 8080")
|
||||
s.serve_forever()
|
||||
'';
|
||||
|
||||
payloads = {
|
||||
auth = pkgs.writeText "auth.json" (builtins.toJSON { Username = "jellyfin"; });
|
||||
empty = pkgs.writeText "empty.json" (builtins.toJSON { });
|
||||
};
|
||||
in
|
||||
pkgs.testers.runNixOSTest {
|
||||
name = "jellyfin-qbittorrent-monitor";
|
||||
|
||||
nodes = {
|
||||
server =
|
||||
{ pkgs, config, ... }:
|
||||
{
|
||||
# Mock qBittorrent service
|
||||
systemd.services.mock-qbittorrent = {
|
||||
description = "Mock qBittorrent API server";
|
||||
after = [ "network.target" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
ExecStart = lib.getExe (
|
||||
pkgs.writers.writePython3Bin "mock-qbt" { flakeIgnore = [ "E501" ]; } ''
|
||||
import http.server
|
||||
import socketserver
|
||||
|
||||
|
||||
class MockQBittorrentHandler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
if self.path == '/api/v2/transfer/speedLimitsMode':
|
||||
self.send_response(200)
|
||||
self.send_header('Content-type', 'text/plain')
|
||||
self.end_headers()
|
||||
response = '1' if getattr(self.server, 'speed_limits_mode', False) else '0'
|
||||
self.wfile.write(response.encode())
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
def do_POST(self):
|
||||
if self.path == '/api/v2/transfer/toggleSpeedLimitsMode':
|
||||
self.server.speed_limits_mode = not getattr(self.server, 'speed_limits_mode', False)
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
print(f'MONITOR_TEST: Speed limits toggled to {self.server.speed_limits_mode}')
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, format, *args):
|
||||
print(f'qBittorrent Mock: {format % args}')
|
||||
|
||||
|
||||
with socketserver.TCPServer(('127.0.0.1', 8080), MockQBittorrentHandler) as httpd:
|
||||
httpd.speed_limits_mode = False
|
||||
print('Mock qBittorrent server started on port 8080')
|
||||
httpd.serve_forever()
|
||||
''
|
||||
);
|
||||
Restart = "always";
|
||||
RestartSec = "5s";
|
||||
};
|
||||
};
|
||||
|
||||
# Mock Jellyfin service with controllable streaming state
|
||||
systemd.services.mock-jellyfin = {
|
||||
description = "Mock Jellyfin API server";
|
||||
after = [ "network.target" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
ExecStart = lib.getExe (
|
||||
pkgs.writers.writePython3Bin "mock-jellyfin" { flakeIgnore = [ "E501" ]; } ''
|
||||
import http.server
|
||||
import socketserver
|
||||
import json
|
||||
|
||||
|
||||
class MockJellyfinHandler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
if self.path == '/Sessions':
|
||||
self.send_response(200)
|
||||
self.send_header('Content-type', 'application/json')
|
||||
self.end_headers()
|
||||
|
||||
state = getattr(self.server, 'test_state', {
|
||||
'streaming': False,
|
||||
'paused': False,
|
||||
'local': False,
|
||||
'media_type': 'Movie'
|
||||
})
|
||||
|
||||
if state['streaming']:
|
||||
if state['local']:
|
||||
remote_ip = '192.168.1.100'
|
||||
else:
|
||||
remote_ip = '203.0.113.42'
|
||||
|
||||
# Map media types to names
|
||||
type_names = {
|
||||
'Movie': 'Test Movie',
|
||||
'Episode': 'Test Episode S01E01',
|
||||
'Video': 'Test Video',
|
||||
'Audio': 'Test Song'
|
||||
}
|
||||
|
||||
sessions = [{
|
||||
'Id': 'test-session-1',
|
||||
'UserName': 'ExternalUser',
|
||||
'RemoteEndPoint': remote_ip,
|
||||
'NowPlayingItem': {
|
||||
'Name': type_names.get(
|
||||
state['media_type'], 'Test Content'
|
||||
),
|
||||
'Type': state['media_type']
|
||||
},
|
||||
'PlayState': {
|
||||
'IsPaused': state['paused']
|
||||
}
|
||||
}]
|
||||
else:
|
||||
sessions = []
|
||||
|
||||
self.wfile.write(json.dumps(sessions).encode())
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
def do_POST(self):
|
||||
if self.path.startswith('/control/'):
|
||||
try:
|
||||
content_length = int(self.headers.get('Content-Length', 0))
|
||||
post_data = self.rfile.read(content_length)
|
||||
data = json.loads(post_data.decode()) if post_data else {}
|
||||
|
||||
if not hasattr(self.server, 'test_state'):
|
||||
self.server.test_state = {
|
||||
'streaming': False,
|
||||
'paused': False,
|
||||
'local': False,
|
||||
'media_type': 'Movie'
|
||||
}
|
||||
|
||||
if self.path == '/control/state':
|
||||
# Set complete state
|
||||
self.server.test_state.update(data)
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
self.wfile.write(b'OK')
|
||||
state_str = str(self.server.test_state)
|
||||
print(f'Jellyfin Mock: State updated to {state_str}')
|
||||
elif self.path == '/control/reset':
|
||||
# Reset to default state
|
||||
self.server.test_state = {
|
||||
'streaming': False,
|
||||
'paused': False,
|
||||
'local': False,
|
||||
'media_type': 'Movie'
|
||||
}
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
self.wfile.write(b'OK')
|
||||
print('Jellyfin Mock: State reset')
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
except Exception as e:
|
||||
print(f'Jellyfin Mock: Control error: {e}')
|
||||
self.send_response(500)
|
||||
self.end_headers()
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, format, *args):
|
||||
print(f'Jellyfin Mock: {format % args}')
|
||||
|
||||
|
||||
with socketserver.TCPServer(('127.0.0.1', 8096), MockJellyfinHandler) as httpd:
|
||||
print('Mock Jellyfin server started on port 8096')
|
||||
httpd.serve_forever()
|
||||
''
|
||||
);
|
||||
Restart = "always";
|
||||
RestartSec = "5s";
|
||||
};
|
||||
};
|
||||
|
||||
server = {
|
||||
services.jellyfin.enable = true;
|
||||
environment.systemPackages = with pkgs; [
|
||||
curl
|
||||
python3
|
||||
ffmpeg
|
||||
];
|
||||
|
||||
virtualisation.diskSize = 3 * 1024;
|
||||
networking.firewall.allowedTCPPorts = [
|
||||
8096
|
||||
8080
|
||||
];
|
||||
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [
|
||||
{
|
||||
address = "192.168.1.1";
|
||||
prefixLength = 24;
|
||||
}
|
||||
];
|
||||
networking.interfaces.eth1.ipv4.routes = [
|
||||
{
|
||||
address = "203.0.113.0";
|
||||
prefixLength = 24;
|
||||
}
|
||||
];
|
||||
systemd.services.mock-qbittorrent = {
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig.ExecStart = lib.getExe mockQBittorrent;
|
||||
};
|
||||
};
|
||||
|
||||
# Public test IP (RFC 5737 TEST-NET-3) so Jellyfin sees it as external
|
||||
client = {
|
||||
environment.systemPackages = [ pkgs.curl ];
|
||||
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [
|
||||
{
|
||||
address = "203.0.113.10";
|
||||
prefixLength = 24;
|
||||
}
|
||||
];
|
||||
networking.interfaces.eth1.ipv4.routes = [
|
||||
{
|
||||
address = "192.168.1.0";
|
||||
prefixLength = 24;
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
testScript = ''
|
||||
start_all()
|
||||
|
||||
# Wait for services to start
|
||||
server.wait_for_unit("multi-user.target")
|
||||
server.wait_for_unit("mock-jellyfin.service")
|
||||
server.wait_for_unit("mock-qbittorrent.service")
|
||||
|
||||
# Wait for services to be accessible
|
||||
server.wait_for_open_port(8096) # Mock Jellyfin
|
||||
server.wait_for_open_port(8080) # Mock qBittorrent
|
||||
|
||||
import time
|
||||
import json
|
||||
import time
|
||||
from urllib.parse import urlencode
|
||||
|
||||
time.sleep(5)
|
||||
auth_header = 'MediaBrowser Client="NixOS Test", DeviceId="test-1337", Device="TestDevice", Version="1.0"'
|
||||
|
||||
# Helper function to set mock server state
|
||||
def set_jellyfin_state(streaming=False, paused=False, local=False, media_type="Movie"):
|
||||
state = {
|
||||
"streaming": streaming,
|
||||
"paused": paused,
|
||||
"local": local,
|
||||
"media_type": media_type
|
||||
}
|
||||
server.succeed(f"curl -s -X POST -H 'Content-Type: application/json' -d '{json.dumps(state)}' http://localhost:8096/control/state")
|
||||
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}'"
|
||||
|
||||
# Helper function to get current qBittorrent throttling state
|
||||
def get_throttling_state():
|
||||
result = server.succeed("curl -s http://localhost:8080/api/v2/transfer/speedLimitsMode")
|
||||
return result.strip() == "1"
|
||||
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}'"
|
||||
|
||||
print("\\nTesting initial state...")
|
||||
assert not get_throttling_state(), "qBittorrent should start with normal speed limits"
|
||||
def is_throttled():
|
||||
return server.succeed("curl -s http://localhost:8080/api/v2/transfer/speedLimitsMode").strip() == "1"
|
||||
|
||||
sessions_result = server.succeed("curl -s http://localhost:8096/Sessions")
|
||||
print(f"Initial Jellyfin sessions: {sessions_result}")
|
||||
assert "[]" in sessions_result, "Should be no streaming sessions initially"
|
||||
movie_id: str = ""
|
||||
media_source_id: str = ""
|
||||
|
||||
# Start the monitor with fast delays for testing
|
||||
python_path = "${pkgs.python3.withPackages (ps: with ps; [ requests ])}/bin/python"
|
||||
monitor_path = "${../services/jellyfin-qbittorrent-monitor.py}"
|
||||
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("mock-qbittorrent.service")
|
||||
server.wait_for_open_port(8080)
|
||||
|
||||
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)
|
||||
|
||||
with subtest("Start monitor service"):
|
||||
python = "${pkgs.python3.withPackages (ps: [ ps.requests ])}/bin/python"
|
||||
monitor = "${../services/jellyfin-qbittorrent-monitor.py}"
|
||||
server.succeed(f"""
|
||||
systemd-run --unit=jellyfin-qbittorrent-monitor-test \\
|
||||
systemd-run --unit=monitor-test \\
|
||||
--setenv=JELLYFIN_URL=http://localhost:8096 \\
|
||||
--setenv=JELLYFIN_API_KEY={token} \\
|
||||
--setenv=QBITTORRENT_URL=http://localhost:8080 \\
|
||||
--setenv=CHECK_INTERVAL=1 \\
|
||||
--setenv=STREAMING_START_DELAY=1 \\
|
||||
--setenv=STREAMING_STOP_DELAY=1 \\
|
||||
{python_path} {monitor_path}
|
||||
{python} {monitor}
|
||||
""")
|
||||
time.sleep(2)
|
||||
assert not is_throttled(), "Should start unthrottled"
|
||||
|
||||
time.sleep(3)
|
||||
client_auth = 'MediaBrowser Client="External Client", DeviceId="external-9999", Device="ExternalDevice", Version="1.0"'
|
||||
server_ip = "192.168.1.1"
|
||||
|
||||
# Define test scenarios
|
||||
media_types = ["Movie", "Episode", "Video", "Audio"]
|
||||
playback_states = [False, True] # False=playing, True=paused
|
||||
network_locations = [False, True] # False=external, True=local
|
||||
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}'"
|
||||
client_auth_result = json.loads(client.succeed(auth_cmd))
|
||||
client_token = client_auth_result["AccessToken"]
|
||||
|
||||
test_count = 0
|
||||
for media_type in media_types:
|
||||
for is_paused in playback_states:
|
||||
for is_local in network_locations:
|
||||
test_count += 1
|
||||
print(f"\\nTest {test_count}: {media_type}, {'paused' if is_paused else 'playing'}, {'local' if is_local else 'external'}")
|
||||
with subtest("External video playback triggers throttling"):
|
||||
playback_start = {
|
||||
"ItemId": movie_id,
|
||||
"MediaSourceId": media_source_id,
|
||||
"PlaySessionId": "test-play-session-1",
|
||||
"CanSeek": True,
|
||||
"IsPaused": False,
|
||||
}
|
||||
start_cmd = f"curl -sf -X POST 'http://{server_ip}:8096/Sessions/Playing' -d '{json.dumps(playback_start)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth}, Token={client_token}'"
|
||||
client.succeed(start_cmd)
|
||||
time.sleep(2)
|
||||
assert is_throttled(), "Should throttle for external video playback"
|
||||
|
||||
# Set streaming state
|
||||
set_jellyfin_state(streaming=True, paused=is_paused, local=is_local, media_type=media_type)
|
||||
time.sleep(1.5) # Wait for monitor to detect and apply changes
|
||||
with subtest("Pausing disables throttling"):
|
||||
playback_progress = {
|
||||
"ItemId": movie_id,
|
||||
"MediaSourceId": media_source_id,
|
||||
"PlaySessionId": "test-play-session-1",
|
||||
"IsPaused": True,
|
||||
"PositionTicks": 10000000,
|
||||
}
|
||||
progress_cmd = f"curl -sf -X POST 'http://{server_ip}:8096/Sessions/Playing/Progress' -d '{json.dumps(playback_progress)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth}, Token={client_token}'"
|
||||
client.succeed(progress_cmd)
|
||||
time.sleep(2)
|
||||
|
||||
throttling_active = get_throttling_state()
|
||||
assert not is_throttled(), "Should unthrottle when paused"
|
||||
|
||||
# Determine expected behavior:
|
||||
# Throttling should be active only if:
|
||||
# - Not paused AND
|
||||
# - Not local AND
|
||||
# - Media type is video (Movie, Episode, Video) - NOT Audio
|
||||
should_throttle = (
|
||||
not is_paused and
|
||||
not is_local and
|
||||
media_type in ["Movie", "Episode", "Video"]
|
||||
)
|
||||
with subtest("Resuming re-enables throttling"):
|
||||
playback_progress["IsPaused"] = False
|
||||
playback_progress["PositionTicks"] = 20000000
|
||||
progress_cmd = f"curl -sf -X POST 'http://{server_ip}:8096/Sessions/Playing/Progress' -d '{json.dumps(playback_progress)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth}, Token={client_token}'"
|
||||
client.succeed(progress_cmd)
|
||||
time.sleep(2)
|
||||
|
||||
assert throttling_active == should_throttle, f"Expected {"no " if not should_throttle else ""} throttling for {media_type}, {'paused' if is_paused else 'playing'}, {'local' if is_local else 'external'}"
|
||||
assert is_throttled(), "Should re-throttle when resumed"
|
||||
|
||||
set_jellyfin_state(streaming=False)
|
||||
time.sleep(1.5) # Wait for stop delay
|
||||
with subtest("Stopping playback disables throttling"):
|
||||
playback_stop = {
|
||||
"ItemId": movie_id,
|
||||
"MediaSourceId": media_source_id,
|
||||
"PlaySessionId": "test-play-session-1",
|
||||
"PositionTicks": 50000000,
|
||||
}
|
||||
stop_cmd = f"curl -sf -X POST 'http://{server_ip}:8096/Sessions/Playing/Stopped' -d '{json.dumps(playback_stop)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth}, Token={client_token}'"
|
||||
client.succeed(stop_cmd)
|
||||
time.sleep(2)
|
||||
|
||||
assert not get_throttling_state(), "No streaming should disable throttling"
|
||||
assert not is_throttled(), "Should unthrottle when playback stops"
|
||||
|
||||
# Start with throttling-enabled state
|
||||
set_jellyfin_state(streaming=True, paused=False, local=False, media_type="Movie")
|
||||
time.sleep(1.5)
|
||||
assert get_throttling_state(), "Should enable throttling for external Movie"
|
||||
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}'"
|
||||
))
|
||||
local_token = local_auth_result["AccessToken"]
|
||||
|
||||
# Switch to paused (should disable throttling)
|
||||
set_jellyfin_state(streaming=True, paused=True, local=False, media_type="Movie")
|
||||
time.sleep(1.5)
|
||||
assert not get_throttling_state(), "Should disable throttling when paused"
|
||||
local_playback = {
|
||||
"ItemId": movie_id,
|
||||
"MediaSourceId": media_source_id,
|
||||
"PlaySessionId": "test-play-session-local",
|
||||
"CanSeek": True,
|
||||
"IsPaused": False,
|
||||
}
|
||||
server.succeed(f"curl -sf -X POST 'http://localhost:8096/Sessions/Playing' -d '{json.dumps(local_playback)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{local_auth}, Token={local_token}'")
|
||||
time.sleep(2)
|
||||
assert not is_throttled(), "Should NOT throttle for local playback"
|
||||
|
||||
# Switch back to playing (should re-enable throttling)
|
||||
set_jellyfin_state(streaming=True, paused=False, local=False, media_type="Movie")
|
||||
time.sleep(1.5)
|
||||
assert get_throttling_state(), "Should re-enable throttling when unpaused"
|
||||
local_playback["PositionTicks"] = 50000000
|
||||
server.succeed(f"curl -sf -X POST 'http://localhost:8096/Sessions/Playing/Stopped' -d '{json.dumps(local_playback)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{local_auth}, Token={local_token}'")
|
||||
'';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user