mirror of
https://github.com/imputnet/cobalt.git
synced 2025-07-10 07:18:30 +00:00
app: clean up and update request types
This commit is contained in:
parent
2a1aaef0b0
commit
1eb296cd8b
@ -32,7 +32,6 @@
|
||||
"express": "^4.21.2",
|
||||
"express-rate-limit": "^7.4.1",
|
||||
"ffmpeg-static": "^5.1.0",
|
||||
"ffprobe-static": "^3.1.0",
|
||||
"hls-parser": "^0.10.7",
|
||||
"ipaddr.js": "2.2.0",
|
||||
"mime": "^4.0.4",
|
||||
|
@ -330,11 +330,17 @@ export default async function({ host, patternMatch, params, authType }) {
|
||||
const lpEnv = env.forceLocalProcessing;
|
||||
const shouldForceLocal = lpEnv === "always" || (lpEnv === "session" && authType === "session");
|
||||
const localDisabled = (!localProcessing || localProcessing === "none");
|
||||
const isClip = typeof params.clipStart === 'number' && typeof params.clipEnd === 'number';
|
||||
|
||||
if (shouldForceLocal && localDisabled) {
|
||||
localProcessing = "preferred";
|
||||
}
|
||||
|
||||
if (isClip) {
|
||||
r.clipStart = params.clipStart;
|
||||
r.clipEnd = params.clipEnd;
|
||||
}
|
||||
|
||||
return matchAction({
|
||||
r,
|
||||
host,
|
||||
|
@ -291,13 +291,6 @@ export default async function (o) {
|
||||
return { error: "content.too_long" };
|
||||
}
|
||||
|
||||
if (typeof o.clipStart === 'number' && o.clipStart >= basicInfo.duration) {
|
||||
return { error: "clip.start_exceeds_duration" };
|
||||
}
|
||||
if (typeof o.clipEnd === 'number' && o.clipEnd > basicInfo.duration) {
|
||||
return { error: "clip.end_exceeds_duration" };
|
||||
}
|
||||
|
||||
// return a critical error if returned video is "Video Not Available"
|
||||
// or a similar stub by youtube
|
||||
if (basicInfo.id !== o.id) {
|
||||
@ -487,7 +480,7 @@ export default async function (o) {
|
||||
|
||||
const fileMetadata = {
|
||||
title: basicInfo.title.trim(),
|
||||
artist: basicInfo.author.replace("- Topic", "").trim(),
|
||||
artist: basicInfo.author.replace("- Topic", "").trim()
|
||||
}
|
||||
|
||||
if (basicInfo?.short_description?.startsWith("Provided to YouTube by")) {
|
||||
@ -581,8 +574,6 @@ export default async function (o) {
|
||||
|
||||
cover,
|
||||
cropCover: basicInfo.author.endsWith("- Topic"),
|
||||
clipStart: o.clipStart,
|
||||
clipEnd: o.clipEnd,
|
||||
}
|
||||
}
|
||||
|
||||
@ -628,9 +619,7 @@ export default async function (o) {
|
||||
fileMetadata,
|
||||
isHLS: useHLS,
|
||||
originalRequest,
|
||||
duration: basicInfo.duration,
|
||||
clipStart: o.clipStart,
|
||||
clipEnd: o.clipEnd,
|
||||
duration: basicInfo.duration
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -100,9 +100,7 @@ const remux = async (streamInfo, res) => {
|
||||
const format = streamInfo.filename.split('.').pop();
|
||||
const urls = Array.isArray(streamInfo.urls) ? streamInfo.urls : [streamInfo.urls];
|
||||
const isClipping = typeof streamInfo.clipStart === 'number' || typeof streamInfo.clipEnd === 'number';
|
||||
let args = [];
|
||||
|
||||
args.push(...urls.flatMap(url => ['-i', url]));
|
||||
const args = urls.flatMap(url => ['-i', url]);
|
||||
|
||||
if (typeof streamInfo.clipStart === 'number') {
|
||||
args.push('-ss', streamInfo.clipStart.toString());
|
||||
@ -110,7 +108,8 @@ const remux = async (streamInfo, res) => {
|
||||
if (typeof streamInfo.clipEnd === 'number') {
|
||||
args.push('-to', streamInfo.clipEnd.toString());
|
||||
}
|
||||
|
||||
|
||||
// if the stream type is merge, we expect two URLs
|
||||
if (streamInfo.type === 'merge' && urls.length !== 2) {
|
||||
return closeResponse(res);
|
||||
}
|
||||
@ -171,13 +170,11 @@ const remux = async (streamInfo, res) => {
|
||||
}
|
||||
|
||||
const convertAudio = async (streamInfo, res) => {
|
||||
let args = [];
|
||||
|
||||
args.push(
|
||||
const args = [
|
||||
'-i', streamInfo.urls,
|
||||
'-vn',
|
||||
...(streamInfo.audioCopy ? ['-c:a', 'copy'] : ['-b:a', `${streamInfo.audioBitrate}k`]),
|
||||
);
|
||||
];
|
||||
|
||||
if (typeof streamInfo.clipStart === 'number') {
|
||||
args.push('-ss', streamInfo.clipStart.toString());
|
||||
|
@ -37,9 +37,6 @@ importers:
|
||||
ffmpeg-static:
|
||||
specifier: ^5.1.0
|
||||
version: 5.2.0
|
||||
ffprobe-static:
|
||||
specifier: ^3.1.0
|
||||
version: 3.1.0
|
||||
hls-parser:
|
||||
specifier: ^0.10.7
|
||||
version: 0.10.9
|
||||
@ -1257,9 +1254,6 @@ packages:
|
||||
resolution: {integrity: sha512-WrM7kLW+do9HLr+H6tk7LzQ7kPqbAgLjdzNE32+u3Ff11gXt9Kkkd2nusGFrlWMIe+XaA97t+I8JS7sZIrvRgA==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
ffprobe-static@3.1.0:
|
||||
resolution: {integrity: sha512-Dvpa9uhVMOYivhHKWLGDoa512J751qN1WZAIO+Xw4L/mrUSPxS4DApzSUDbCFE/LUq2+xYnznEahTd63AqBSpA==}
|
||||
|
||||
file-entry-cache@8.0.0:
|
||||
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
@ -3195,8 +3189,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
ffprobe-static@3.1.0: {}
|
||||
|
||||
file-entry-cache@8.0.0:
|
||||
dependencies:
|
||||
flat-cache: 4.0.1
|
||||
|
247
web/src/components/save/ClipControls.svelte
Normal file
247
web/src/components/save/ClipControls.svelte
Normal file
@ -0,0 +1,247 @@
|
||||
<script lang="ts">
|
||||
import ClipRangeSlider from "./ClipRangeSlider.svelte";
|
||||
|
||||
let {
|
||||
metaLoading,
|
||||
metaError,
|
||||
metadata,
|
||||
clipStart = $bindable(),
|
||||
clipEnd = $bindable()
|
||||
} = $props<{
|
||||
metaLoading: boolean;
|
||||
metaError: string | null;
|
||||
metadata: { title?: string; author?: string; duration?: number } | null;
|
||||
clipStart: number;
|
||||
clipEnd: number;
|
||||
}>();
|
||||
|
||||
function formatTime(seconds: number) {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = (seconds % 60).toFixed(3).padStart(6, '0');
|
||||
return `${m}:${s}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="clip-controls">
|
||||
{#if metaLoading}
|
||||
<div class="loading-state">
|
||||
<div class="loading-spinner"></div>
|
||||
<span>Loading video metadata...</span>
|
||||
</div>
|
||||
{:else if metaError}
|
||||
<div class="error-state">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="15" y1="9" x2="9" y2="15"></line>
|
||||
<line x1="9" y1="9" x2="15" y2="15"></line>
|
||||
</svg>
|
||||
<span>{metaError}</span>
|
||||
</div>
|
||||
{:else if metadata}
|
||||
<div class="clip-metadata">
|
||||
<div class="metadata-header">
|
||||
<h4>Clip</h4>
|
||||
<div class="duration-badge">
|
||||
{typeof metadata.duration === 'number' ? formatTime(metadata.duration) : 'Unknown'}
|
||||
</div>
|
||||
</div>
|
||||
<div class="video-info">
|
||||
<div class="video-title" title={metadata.title}>
|
||||
{metadata.title}
|
||||
</div>
|
||||
{#if metadata.author}
|
||||
<div class="video-author">
|
||||
by {metadata.author}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ClipRangeSlider
|
||||
min={0}
|
||||
max={metadata.duration || 0}
|
||||
step={0.001}
|
||||
bind:start={clipStart}
|
||||
bind:end={clipEnd}
|
||||
/>
|
||||
|
||||
<div class="clip-time-display">
|
||||
<div class="time-indicators">
|
||||
<span class="start-time">{formatTime(clipStart)}</span>
|
||||
<span class="duration-selected">
|
||||
{formatTime(Math.max(0, clipEnd - clipStart))} selected
|
||||
</span>
|
||||
<span class="end-time">{formatTime(clipEnd)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.clip-controls {
|
||||
margin-top: 12px;
|
||||
padding: 16px;
|
||||
background: var(--button);
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px solid var(--button-stroke);
|
||||
animation: slideIn 0.3s cubic-bezier(0.2, 0, 0, 1);
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 0;
|
||||
color: var(--gray);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--button-hover);
|
||||
border-top: 2px solid var(--secondary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.error-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(237, 34, 54, 0.1);
|
||||
border: 1px solid rgba(237, 34, 54, 0.2);
|
||||
border-radius: 8px;
|
||||
color: var(--red);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.clip-metadata {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.metadata-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.metadata-header h4 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
.duration-badge {
|
||||
background: var(--button-elevated);
|
||||
color: var(--button-text);
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
}
|
||||
|
||||
.video-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.video-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.video-author {
|
||||
font-size: 12px;
|
||||
color: var(--gray);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.clip-time-display {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.time-indicators {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
}
|
||||
|
||||
.start-time, .end-time {
|
||||
color: var(--gray);
|
||||
background: var(--button-hover);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
min-width: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.duration-selected {
|
||||
grid-area: duration;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media screen and (max-width: 440px) {
|
||||
.clip-controls {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.metadata-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.video-title {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.time-indicators {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-areas:
|
||||
"start end"
|
||||
"duration duration";
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.start-time {
|
||||
grid-area: start;
|
||||
}
|
||||
|
||||
.end-time {
|
||||
grid-area: end;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -345,4 +345,4 @@
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
@ -37,6 +37,7 @@
|
||||
import API from "$lib/api/api";
|
||||
import ClipRangeSlider from "./ClipRangeSlider.svelte";
|
||||
import ClipCheckbox from "./buttons/ClipCheckbox.svelte";
|
||||
import ClipControls from "./ClipControls.svelte";
|
||||
|
||||
let linkInput: Optional<HTMLInputElement>;
|
||||
|
||||
@ -284,60 +285,13 @@
|
||||
</div>
|
||||
|
||||
{#if clipMode && validLink($link)}
|
||||
<div class="clip-controls">
|
||||
{#if metaLoading}
|
||||
<div class="loading-state">
|
||||
<div class="loading-spinner"></div>
|
||||
<span>Loading video metadata...</span>
|
||||
</div>
|
||||
{:else if metaError}
|
||||
<div class="error-state">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="15" y1="9" x2="9" y2="15"></line>
|
||||
<line x1="9" y1="9" x2="15" y2="15"></line>
|
||||
</svg>
|
||||
<span>{metaError}</span>
|
||||
</div>
|
||||
{:else if metadata}
|
||||
<div class="clip-metadata">
|
||||
<div class="metadata-header">
|
||||
<h4>Clip</h4>
|
||||
<div class="duration-badge">
|
||||
{typeof metadata.duration === 'number' ? formatTime(metadata.duration) : 'Unknown'}
|
||||
</div>
|
||||
</div>
|
||||
<div class="video-info">
|
||||
<div class="video-title" title={metadata.title}>
|
||||
{metadata.title}
|
||||
</div>
|
||||
{#if metadata.author}
|
||||
<div class="video-author">
|
||||
by {metadata.author}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ClipRangeSlider
|
||||
min={0}
|
||||
max={metadata.duration || 0}
|
||||
step={0.001}
|
||||
bind:start={clipStart}
|
||||
bind:end={clipEnd}
|
||||
/>
|
||||
|
||||
<div class="clip-time-display">
|
||||
<div class="time-indicators">
|
||||
<span class="start-time">{formatTime(clipStart)}</span>
|
||||
<span class="duration-selected">
|
||||
{formatTime(Math.max(0, clipEnd - clipStart))} selected
|
||||
</span>
|
||||
<span class="end-time">{formatTime(clipEnd)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<ClipControls
|
||||
{metaLoading}
|
||||
{metaError}
|
||||
{metadata}
|
||||
bind:clipStart
|
||||
bind:clipEnd
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@ -467,140 +421,6 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.clip-controls {
|
||||
margin-top: 12px;
|
||||
padding: 16px;
|
||||
background: var(--button);
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px solid var(--button-stroke);
|
||||
animation: slideIn 0.3s cubic-bezier(0.2, 0, 0, 1);
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 0;
|
||||
color: var(--gray);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--button-hover);
|
||||
border-top: 2px solid var(--secondary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.error-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(237, 34, 54, 0.1);
|
||||
border: 1px solid rgba(237, 34, 54, 0.2);
|
||||
border-radius: 8px;
|
||||
color: var(--red);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.clip-metadata {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.metadata-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.metadata-header h4 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
.duration-badge {
|
||||
background: var(--button-elevated);
|
||||
color: var(--button-text);
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
}
|
||||
|
||||
.video-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.video-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.video-author {
|
||||
font-size: 12px;
|
||||
color: var(--gray);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.clip-time-display {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.time-indicators {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
}
|
||||
|
||||
.start-time, .end-time {
|
||||
color: var(--gray);
|
||||
background: var(--button-hover);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
min-width: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.duration-selected {
|
||||
grid-area: duration;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media screen and (max-width: 440px) {
|
||||
#action-container {
|
||||
flex-direction: column;
|
||||
@ -618,36 +438,5 @@
|
||||
#paste-desktop-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.clip-controls {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.metadata-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.video-title {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.time-indicators {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-areas:
|
||||
"start end"
|
||||
"duration duration";
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.start-time {
|
||||
grid-area: start;
|
||||
}
|
||||
|
||||
.end-time {
|
||||
grid-area: end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -97,4 +97,4 @@
|
||||
font-size: 14.5px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
@ -61,12 +61,11 @@
|
||||
on:click={() => {
|
||||
hapticSwitch();
|
||||
if (clipMode) {
|
||||
const req: CobaltSaveRequestBody & { clipStart?: number; clipEnd?: number } = {
|
||||
savingHandler({ request: {
|
||||
url,
|
||||
clipStart,
|
||||
clipEnd,
|
||||
};
|
||||
savingHandler({ request: req });
|
||||
} });
|
||||
} else {
|
||||
savingHandler({ url });
|
||||
}
|
||||
|
@ -113,7 +113,10 @@ export type CobaltServerInfo = {
|
||||
// this allows for extra properties, which is not ideal,
|
||||
// but i couldn't figure out how to make a strict partial :(
|
||||
export type CobaltSaveRequestBody =
|
||||
{ url: string } & Partial<Omit<CobaltSettings['save'], 'savingMethod'>>;
|
||||
{ url: string } & Partial<Omit<CobaltSettings['save'], 'savingMethod'>> & {
|
||||
clipStart?: number,
|
||||
clipEnd?: number,
|
||||
};
|
||||
|
||||
export type CobaltSessionResponse = CobaltSession | CobaltErrorResponse;
|
||||
export type CobaltServerInfoResponse = CobaltServerInfo | CobaltErrorResponse;
|
||||
|
Loading…
Reference in New Issue
Block a user