Move visualization code to a dedicated file
This commit is contained in:
parent
044c89e977
commit
a9554e265f
@ -3,7 +3,6 @@
|
|||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>Template</title>
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@ -284,6 +283,9 @@
|
|||||||
</span>
|
</span>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="configurationpage?name=visualizer.js"></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// first and second episodes to fingerprint & compare
|
// first and second episodes to fingerprint & compare
|
||||||
var lhs = [];
|
var lhs = [];
|
||||||
@ -468,19 +470,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// re-render the troubleshooter with the latest offset
|
|
||||||
function renderTroubleshooter() {
|
|
||||||
paintFingerprintDiff(canvas, lhs, rhs, Number(offset.value));
|
|
||||||
findIntros();
|
|
||||||
}
|
|
||||||
|
|
||||||
// refresh the upper & lower bounds for the offset
|
|
||||||
function refreshBounds() {
|
|
||||||
const len = Math.min(lhs.length, rhs.length) - 1;
|
|
||||||
offset.min = -1 * len;
|
|
||||||
offset.max = len;
|
|
||||||
}
|
|
||||||
|
|
||||||
// make an authenticated GET to the server and parse the response as JSON
|
// make an authenticated GET to the server and parse the response as JSON
|
||||||
async function getJson(url, method = "GET") {
|
async function getJson(url, method = "GET") {
|
||||||
url = ApiClient.serverAddress() + "/" + url;
|
url = ApiClient.serverAddress() + "/" + url;
|
||||||
@ -551,109 +540,6 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
function findIntros() {
|
|
||||||
let times = [];
|
|
||||||
|
|
||||||
// get the times of all similar fingerprint points
|
|
||||||
for (let i in fprDiffs) {
|
|
||||||
if (fprDiffs[i] > fprDiffMinimum) {
|
|
||||||
times.push(i * 0.128);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// always close the last range
|
|
||||||
times.push(Number.MAX_VALUE);
|
|
||||||
|
|
||||||
let last = times[0];
|
|
||||||
let start = last;
|
|
||||||
let end = last;
|
|
||||||
let ranges = [];
|
|
||||||
|
|
||||||
for (let t of times) {
|
|
||||||
const diff = t - last;
|
|
||||||
|
|
||||||
if (diff <= 3.5) {
|
|
||||||
end = t;
|
|
||||||
last = t;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dur = Math.round(end - start);
|
|
||||||
if (dur >= 15) {
|
|
||||||
ranges.push({
|
|
||||||
"start": start,
|
|
||||||
"end": end,
|
|
||||||
"duration": dur
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
start = t;
|
|
||||||
end = t;
|
|
||||||
last = t;
|
|
||||||
}
|
|
||||||
|
|
||||||
const introsLog = document.querySelector("span#intros");
|
|
||||||
introsLog.style.position = "relative";
|
|
||||||
introsLog.style.left = "115px";
|
|
||||||
introsLog.innerHTML = "";
|
|
||||||
|
|
||||||
const offset = Number(txtOffset.value) * 0.128;
|
|
||||||
for (let r of ranges) {
|
|
||||||
let lStart, lEnd, rStart, rEnd;
|
|
||||||
|
|
||||||
if (offset < 0) {
|
|
||||||
// negative offset, the diff is aligned with the RHS
|
|
||||||
lStart = r.start - offset;
|
|
||||||
lEnd = r.end - offset;
|
|
||||||
rStart = r.start;
|
|
||||||
rEnd = r.end;
|
|
||||||
|
|
||||||
} else {
|
|
||||||
// positive offset, the diff is aligned with the LHS
|
|
||||||
lStart = r.start;
|
|
||||||
lEnd = r.end;
|
|
||||||
rStart = r.start + offset;
|
|
||||||
rEnd = r.end + offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lTitle = selectEpisode1.options[selectEpisode1.selectedIndex].text;
|
|
||||||
const rTitle = selectEpisode2.options[selectEpisode2.selectedIndex].text;
|
|
||||||
introsLog.innerHTML += "<span>" + lTitle + ": " +
|
|
||||||
secondsToString(lStart) + " - " + secondsToString(lEnd) + "</span> <br />";
|
|
||||||
introsLog.innerHTML += "<span>" + rTitle + ": " +
|
|
||||||
secondsToString(rStart) + " - " + secondsToString(rEnd) + "</span> <br />";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// find all shifts which align exact matches of audio.
|
|
||||||
function findExactMatches() {
|
|
||||||
let shifts = [];
|
|
||||||
|
|
||||||
for (let lhsIndex in lhs) {
|
|
||||||
let lhsPoint = lhs[lhsIndex];
|
|
||||||
let rhsIndex = rhs.findIndex((x) => x === lhsPoint);
|
|
||||||
|
|
||||||
if (rhsIndex === -1) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let shift = rhsIndex - lhsIndex;
|
|
||||||
if (shifts.includes(shift)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
shifts.push(shift);
|
|
||||||
}
|
|
||||||
|
|
||||||
txtSuggested.textContent = "Suggested shifts: ";
|
|
||||||
if (shifts.length === 0) {
|
|
||||||
txtSuggested.textContent += "none available";
|
|
||||||
} else {
|
|
||||||
shifts.sort((a, b) => { return a - b });
|
|
||||||
txtSuggested.textContent += shifts.join(", ");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// check that the user is still on the configuration page
|
// check that the user is still on the configuration page
|
||||||
function checkWindowHash() {
|
function checkWindowHash() {
|
||||||
const h = location.hash;
|
const h = location.hash;
|
||||||
@ -781,122 +667,6 @@
|
|||||||
timeContainer.style.top = (-1 * rect.height + y).toString() + "px";
|
timeContainer.style.top = (-1 * rect.height + y).toString() + "px";
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script>
|
|
||||||
// Modified from https://github.com/dnknth/acoustid-match/blob/ffbf21d8c53c40d3b3b4c92238c35846545d3cd7/fingerprints/static/fingerprints/fputils.js
|
|
||||||
// Originally licensed as MIT.
|
|
||||||
function renderFingerprintData(ctx, fp, xor = false) {
|
|
||||||
const pixels = ctx.createImageData(32, fp.length);
|
|
||||||
let idx = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < fp.length; i++) {
|
|
||||||
for (let j = 0; j < 32; j++) {
|
|
||||||
if (fp[i] & (1 << j)) {
|
|
||||||
pixels.data[idx + 0] = 255;
|
|
||||||
pixels.data[idx + 1] = 255;
|
|
||||||
pixels.data[idx + 2] = 255;
|
|
||||||
|
|
||||||
} else {
|
|
||||||
pixels.data[idx + 0] = 0;
|
|
||||||
pixels.data[idx + 1] = 0;
|
|
||||||
pixels.data[idx + 2] = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
pixels.data[idx + 3] = 255;
|
|
||||||
idx += 4;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!xor) {
|
|
||||||
return pixels;
|
|
||||||
}
|
|
||||||
|
|
||||||
// if rendering the XOR of the fingerprints, count how many bits are different at each timecode
|
|
||||||
fprDiffs = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < fp.length; i++) {
|
|
||||||
let count = 0;
|
|
||||||
|
|
||||||
for (let j = 0; j < 32; j++) {
|
|
||||||
if (fp[i] & (1 << j)) {
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// push the percentage similarity
|
|
||||||
fprDiffs[i] = 100 - (count * 100) / 32;
|
|
||||||
}
|
|
||||||
|
|
||||||
return pixels;
|
|
||||||
}
|
|
||||||
|
|
||||||
function paintFingerprintDiff(canvas, fp1, fp2, offset) {
|
|
||||||
if (fp1.length == 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let leftOffset = 0, rightOffset = 0;
|
|
||||||
if (offset < 0) {
|
|
||||||
leftOffset -= offset;
|
|
||||||
} else {
|
|
||||||
rightOffset += offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
let fpDiff = [];
|
|
||||||
fpDiff.length = Math.min(fp1.length, fp2.length) - Math.abs(offset);
|
|
||||||
for (let i = 0; i < fpDiff.length; i++) {
|
|
||||||
fpDiff[i] = fp1[i + leftOffset] ^ fp2[i + rightOffset];
|
|
||||||
}
|
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
const pixels1 = renderFingerprintData(ctx, fp1);
|
|
||||||
const pixels2 = renderFingerprintData(ctx, fp2);
|
|
||||||
const pixelsDiff = renderFingerprintData(ctx, fpDiff, true);
|
|
||||||
const border = 4;
|
|
||||||
|
|
||||||
canvas.width = pixels1.width + border + // left fingerprint
|
|
||||||
pixels2.width + border + // right fingerprint
|
|
||||||
pixelsDiff.width + border // fingerprint diff
|
|
||||||
+ 4; // if diff[x] >= fprDiffMinimum
|
|
||||||
|
|
||||||
canvas.height = Math.max(pixels1.height, pixels2.height) + Math.abs(offset);
|
|
||||||
|
|
||||||
ctx.rect(0, 0, canvas.width, canvas.height);
|
|
||||||
ctx.fillStyle = "#C5C5C5";
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
// draw left fingerprint
|
|
||||||
let dx = 0;
|
|
||||||
ctx.putImageData(pixels1, dx, rightOffset);
|
|
||||||
dx += pixels1.width + border;
|
|
||||||
|
|
||||||
// draw right fingerprint
|
|
||||||
ctx.putImageData(pixels2, dx, leftOffset);
|
|
||||||
dx += pixels2.width + border;
|
|
||||||
|
|
||||||
// draw fingerprint diff
|
|
||||||
ctx.putImageData(pixelsDiff, dx, Math.abs(offset));
|
|
||||||
dx += pixelsDiff.width + border;
|
|
||||||
|
|
||||||
// draw the fingerprint diff similarity indicator
|
|
||||||
// https://davidmathlogic.com/colorblind/#%23EA3535-%232C92EF
|
|
||||||
for (let i in fprDiffs) {
|
|
||||||
const j = Number(i);
|
|
||||||
const y = Math.abs(offset) + j;
|
|
||||||
const point = fprDiffs[j];
|
|
||||||
|
|
||||||
if (point >= 100) {
|
|
||||||
ctx.fillStyle = "#002FFF"
|
|
||||||
} else if (point >= fprDiffMinimum) {
|
|
||||||
ctx.fillStyle = "#2C92EF";
|
|
||||||
} else {
|
|
||||||
ctx.fillStyle = "#EA3535";
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.fillRect(dx, y, 4, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
@ -0,0 +1,229 @@
|
|||||||
|
// re-render the troubleshooter with the latest offset
|
||||||
|
function renderTroubleshooter() {
|
||||||
|
paintFingerprintDiff(canvas, lhs, rhs, Number(offset.value));
|
||||||
|
findIntros();
|
||||||
|
}
|
||||||
|
|
||||||
|
// refresh the upper & lower bounds for the offset
|
||||||
|
function refreshBounds() {
|
||||||
|
const len = Math.min(lhs.length, rhs.length) - 1;
|
||||||
|
offset.min = -1 * len;
|
||||||
|
offset.max = len;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findIntros() {
|
||||||
|
let times = [];
|
||||||
|
|
||||||
|
// get the times of all similar fingerprint points
|
||||||
|
for (let i in fprDiffs) {
|
||||||
|
if (fprDiffs[i] > fprDiffMinimum) {
|
||||||
|
times.push(i * 0.128);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// always close the last range
|
||||||
|
times.push(Number.MAX_VALUE);
|
||||||
|
|
||||||
|
let last = times[0];
|
||||||
|
let start = last;
|
||||||
|
let end = last;
|
||||||
|
let ranges = [];
|
||||||
|
|
||||||
|
for (let t of times) {
|
||||||
|
const diff = t - last;
|
||||||
|
|
||||||
|
if (diff <= 3.5) {
|
||||||
|
end = t;
|
||||||
|
last = t;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dur = Math.round(end - start);
|
||||||
|
if (dur >= 15) {
|
||||||
|
ranges.push({
|
||||||
|
"start": start,
|
||||||
|
"end": end,
|
||||||
|
"duration": dur
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
start = t;
|
||||||
|
end = t;
|
||||||
|
last = t;
|
||||||
|
}
|
||||||
|
|
||||||
|
const introsLog = document.querySelector("span#intros");
|
||||||
|
introsLog.style.position = "relative";
|
||||||
|
introsLog.style.left = "115px";
|
||||||
|
introsLog.innerHTML = "";
|
||||||
|
|
||||||
|
const offset = Number(txtOffset.value) * 0.128;
|
||||||
|
for (let r of ranges) {
|
||||||
|
let lStart, lEnd, rStart, rEnd;
|
||||||
|
|
||||||
|
if (offset < 0) {
|
||||||
|
// negative offset, the diff is aligned with the RHS
|
||||||
|
lStart = r.start - offset;
|
||||||
|
lEnd = r.end - offset;
|
||||||
|
rStart = r.start;
|
||||||
|
rEnd = r.end;
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// positive offset, the diff is aligned with the LHS
|
||||||
|
lStart = r.start;
|
||||||
|
lEnd = r.end;
|
||||||
|
rStart = r.start + offset;
|
||||||
|
rEnd = r.end + offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lTitle = selectEpisode1.options[selectEpisode1.selectedIndex].text;
|
||||||
|
const rTitle = selectEpisode2.options[selectEpisode2.selectedIndex].text;
|
||||||
|
introsLog.innerHTML += "<span>" + lTitle + ": " +
|
||||||
|
secondsToString(lStart) + " - " + secondsToString(lEnd) + "</span> <br />";
|
||||||
|
introsLog.innerHTML += "<span>" + rTitle + ": " +
|
||||||
|
secondsToString(rStart) + " - " + secondsToString(rEnd) + "</span> <br />";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// find all shifts which align exact matches of audio.
|
||||||
|
function findExactMatches() {
|
||||||
|
let shifts = [];
|
||||||
|
|
||||||
|
for (let lhsIndex in lhs) {
|
||||||
|
let lhsPoint = lhs[lhsIndex];
|
||||||
|
let rhsIndex = rhs.findIndex((x) => x === lhsPoint);
|
||||||
|
|
||||||
|
if (rhsIndex === -1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let shift = rhsIndex - lhsIndex;
|
||||||
|
if (shifts.includes(shift)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
shifts.push(shift);
|
||||||
|
}
|
||||||
|
|
||||||
|
txtSuggested.textContent = "Suggested shifts: ";
|
||||||
|
if (shifts.length === 0) {
|
||||||
|
txtSuggested.textContent += "none available";
|
||||||
|
} else {
|
||||||
|
shifts.sort((a, b) => { return a - b });
|
||||||
|
txtSuggested.textContent += shifts.join(", ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The below two functions were modified from https://github.com/dnknth/acoustid-match/blob/ffbf21d8c53c40d3b3b4c92238c35846545d3cd7/fingerprints/static/fingerprints/fputils.js
|
||||||
|
// Originally licensed as MIT.
|
||||||
|
function renderFingerprintData(ctx, fp, xor = false) {
|
||||||
|
const pixels = ctx.createImageData(32, fp.length);
|
||||||
|
let idx = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < fp.length; i++) {
|
||||||
|
for (let j = 0; j < 32; j++) {
|
||||||
|
if (fp[i] & (1 << j)) {
|
||||||
|
pixels.data[idx + 0] = 255;
|
||||||
|
pixels.data[idx + 1] = 255;
|
||||||
|
pixels.data[idx + 2] = 255;
|
||||||
|
|
||||||
|
} else {
|
||||||
|
pixels.data[idx + 0] = 0;
|
||||||
|
pixels.data[idx + 1] = 0;
|
||||||
|
pixels.data[idx + 2] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pixels.data[idx + 3] = 255;
|
||||||
|
idx += 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!xor) {
|
||||||
|
return pixels;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if rendering the XOR of the fingerprints, count how many bits are different at each timecode
|
||||||
|
fprDiffs = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < fp.length; i++) {
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
for (let j = 0; j < 32; j++) {
|
||||||
|
if (fp[i] & (1 << j)) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// push the percentage similarity
|
||||||
|
fprDiffs[i] = 100 - (count * 100) / 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
return pixels;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintFingerprintDiff(canvas, fp1, fp2, offset) {
|
||||||
|
if (fp1.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let leftOffset = 0, rightOffset = 0;
|
||||||
|
if (offset < 0) {
|
||||||
|
leftOffset -= offset;
|
||||||
|
} else {
|
||||||
|
rightOffset += offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
let fpDiff = [];
|
||||||
|
fpDiff.length = Math.min(fp1.length, fp2.length) - Math.abs(offset);
|
||||||
|
for (let i = 0; i < fpDiff.length; i++) {
|
||||||
|
fpDiff[i] = fp1[i + leftOffset] ^ fp2[i + rightOffset];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const pixels1 = renderFingerprintData(ctx, fp1);
|
||||||
|
const pixels2 = renderFingerprintData(ctx, fp2);
|
||||||
|
const pixelsDiff = renderFingerprintData(ctx, fpDiff, true);
|
||||||
|
const border = 4;
|
||||||
|
|
||||||
|
canvas.width = pixels1.width + border + // left fingerprint
|
||||||
|
pixels2.width + border + // right fingerprint
|
||||||
|
pixelsDiff.width + border // fingerprint diff
|
||||||
|
+ 4; // if diff[x] >= fprDiffMinimum
|
||||||
|
|
||||||
|
canvas.height = Math.max(pixels1.height, pixels2.height) + Math.abs(offset);
|
||||||
|
|
||||||
|
ctx.rect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.fillStyle = "#C5C5C5";
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// draw left fingerprint
|
||||||
|
let dx = 0;
|
||||||
|
ctx.putImageData(pixels1, dx, rightOffset);
|
||||||
|
dx += pixels1.width + border;
|
||||||
|
|
||||||
|
// draw right fingerprint
|
||||||
|
ctx.putImageData(pixels2, dx, leftOffset);
|
||||||
|
dx += pixels2.width + border;
|
||||||
|
|
||||||
|
// draw fingerprint diff
|
||||||
|
ctx.putImageData(pixelsDiff, dx, Math.abs(offset));
|
||||||
|
dx += pixelsDiff.width + border;
|
||||||
|
|
||||||
|
// draw the fingerprint diff similarity indicator
|
||||||
|
// https://davidmathlogic.com/colorblind/#%23EA3535-%232C92EF
|
||||||
|
for (let i in fprDiffs) {
|
||||||
|
const j = Number(i);
|
||||||
|
const y = Math.abs(offset) + j;
|
||||||
|
const point = fprDiffs[j];
|
||||||
|
|
||||||
|
if (point >= 100) {
|
||||||
|
ctx.fillStyle = "#002FFF"
|
||||||
|
} else if (point >= fprDiffMinimum) {
|
||||||
|
ctx.fillStyle = "#2C92EF";
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = "#EA3535";
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillRect(dx, y, 4, 1);
|
||||||
|
}
|
||||||
|
}
|
@ -23,6 +23,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Remove="Configuration\configPage.html" />
|
<None Remove="Configuration\configPage.html" />
|
||||||
<EmbeddedResource Include="Configuration\configPage.html" />
|
<EmbeddedResource Include="Configuration\configPage.html" />
|
||||||
|
<EmbeddedResource Include="Configuration\visualizer.js" />
|
||||||
<EmbeddedResource Include="Configuration\version.txt" />
|
<EmbeddedResource Include="Configuration\version.txt" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
@ -157,7 +157,12 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
|||||||
new PluginPageInfo
|
new PluginPageInfo
|
||||||
{
|
{
|
||||||
Name = this.Name,
|
Name = this.Name,
|
||||||
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.configPage.html", GetType().Namespace)
|
EmbeddedResourcePath = GetType().Namespace + ".Configuration.configPage.html"
|
||||||
|
},
|
||||||
|
new PluginPageInfo
|
||||||
|
{
|
||||||
|
Name = "visualizer.js",
|
||||||
|
EmbeddedResourcePath = GetType().Namespace + ".Configuration.visualizer.js"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user