mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-26 00:26:05 -07:00
refactor: move youtube primary subtitle config to youtube
This commit is contained in:
5
changes/youtube-primary-subtitle-config.md
Normal file
5
changes/youtube-primary-subtitle-config.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
type: changed
|
||||||
|
area: launcher
|
||||||
|
|
||||||
|
Moved YouTube primary subtitle language defaults to `youtube.primarySubLanguages`.
|
||||||
|
Removed the old `youtubeSubgen.primarySubLanguages` config path from the generated config and docs.
|
||||||
@@ -417,20 +417,11 @@
|
|||||||
// YouTube Playback Settings
|
// YouTube Playback Settings
|
||||||
// Defaults for SubMiner YouTube subtitle loading and languages.
|
// Defaults for SubMiner YouTube subtitle loading and languages.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"youtubeSubgen": {
|
"youtube": {
|
||||||
"whisperBin": "", // Legacy compatibility path kept for external subtitle fallback tools; not used by default.
|
|
||||||
"whisperModel": "", // Legacy compatibility model path kept for external subtitle fallback tooling; not used by default.
|
|
||||||
"whisperVadModel": "", // Legacy compatibility VAD path kept for external subtitle fallback tooling; not used by default.
|
|
||||||
"whisperThreads": 4, // Legacy thread tuning for subtitle fallback tooling; not used by default.
|
|
||||||
"fixWithAi": false, // Legacy subtitle fallback post-processing switch kept for compatibility; use is currently disabled by default. Values: true | false
|
|
||||||
"ai": {
|
|
||||||
"model": "", // Optional model override for legacy subtitle fallback post-processing; not used by default.
|
|
||||||
"systemPrompt": "" // Optional system prompt override for legacy subtitle fallback post-processing; not used by default.
|
|
||||||
}, // Ai setting.
|
|
||||||
"primarySubLanguages": [
|
"primarySubLanguages": [
|
||||||
"ja",
|
"ja",
|
||||||
"jpn"
|
"jpn"
|
||||||
] // Comma-separated primary subtitle language priority used by the launcher.
|
] // Comma-separated primary subtitle language priority for YouTube auto-loading.
|
||||||
}, // Defaults for SubMiner YouTube subtitle loading and languages.
|
}, // Defaults for SubMiner YouTube subtitle loading and languages.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ test('parseLauncherYoutubeSubgenConfig keeps only valid typed values', () => {
|
|||||||
model: 'openrouter/subgen-model',
|
model: 'openrouter/subgen-model',
|
||||||
systemPrompt: 'Fix subtitles only.',
|
systemPrompt: 'Fix subtitles only.',
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
youtube: {
|
||||||
primarySubLanguages: ['ja', 42, 'en'],
|
primarySubLanguages: ['ja', 42, 'en'],
|
||||||
},
|
},
|
||||||
secondarySub: {
|
secondarySub: {
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ export function parseLauncherYoutubeSubgenConfig(
|
|||||||
youtubeSubgenRaw && typeof youtubeSubgenRaw === 'object'
|
youtubeSubgenRaw && typeof youtubeSubgenRaw === 'object'
|
||||||
? (youtubeSubgenRaw as Record<string, unknown>)
|
? (youtubeSubgenRaw as Record<string, unknown>)
|
||||||
: null;
|
: null;
|
||||||
|
const youtubeRaw = root.youtube;
|
||||||
|
const youtube =
|
||||||
|
youtubeRaw && typeof youtubeRaw === 'object' ? (youtubeRaw as Record<string, unknown>) : null;
|
||||||
const secondarySubRaw = root.secondarySub;
|
const secondarySubRaw = root.secondarySub;
|
||||||
const secondarySub =
|
const secondarySub =
|
||||||
secondarySubRaw && typeof secondarySubRaw === 'object'
|
secondarySubRaw && typeof secondarySubRaw === 'object'
|
||||||
@@ -74,7 +77,7 @@ export function parseLauncherYoutubeSubgenConfig(
|
|||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
),
|
),
|
||||||
primarySubLanguages: asStringArray(youtubeSubgen?.primarySubLanguages),
|
primarySubLanguages: asStringArray(youtube?.primarySubLanguages),
|
||||||
secondarySubLanguages: asStringArray(secondarySub?.secondarySubLanguages),
|
secondarySubLanguages: asStringArray(secondarySub?.secondarySubLanguages),
|
||||||
jimakuApiKey: typeof jimaku?.apiKey === 'string' ? jimaku.apiKey : undefined,
|
jimakuApiKey: typeof jimaku?.apiKey === 'string' ? jimaku.apiKey : undefined,
|
||||||
jimakuApiKeyCommand:
|
jimakuApiKeyCommand:
|
||||||
|
|||||||
@@ -919,7 +919,7 @@ test('accepts trailing commas in jsonc', () => {
|
|||||||
"enabled": "auto",
|
"enabled": "auto",
|
||||||
"port": 7788,
|
"port": 7788,
|
||||||
},
|
},
|
||||||
"youtubeSubgen": {
|
"youtube": {
|
||||||
"primarySubLanguages": ["ja", "en",],
|
"primarySubLanguages": ["ja", "en",],
|
||||||
},
|
},
|
||||||
}`,
|
}`,
|
||||||
@@ -929,7 +929,7 @@ test('accepts trailing commas in jsonc', () => {
|
|||||||
const service = new ConfigService(dir);
|
const service = new ConfigService(dir);
|
||||||
const config = service.getConfig();
|
const config = service.getConfig();
|
||||||
assert.equal(config.websocket.port, 7788);
|
assert.equal(config.websocket.port, 7788);
|
||||||
assert.deepEqual(config.youtubeSubgen.primarySubLanguages, ['ja', 'en']);
|
assert.deepEqual(config.youtube.primarySubLanguages, ['ja', 'en']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reloadConfigStrict rejects invalid jsonc and preserves previous config', () => {
|
test('reloadConfigStrict rejects invalid jsonc and preserves previous config', () => {
|
||||||
@@ -1149,8 +1149,10 @@ test('parses global shortcuts and startup settings', () => {
|
|||||||
"toggleVisibleOverlayGlobal": "Alt+Shift+U",
|
"toggleVisibleOverlayGlobal": "Alt+Shift+U",
|
||||||
"openJimaku": "Ctrl+Alt+J"
|
"openJimaku": "Ctrl+Alt+J"
|
||||||
},
|
},
|
||||||
|
"youtube": {
|
||||||
|
"primarySubLanguages": ["ja", "jpn", "jp"]
|
||||||
|
},
|
||||||
"youtubeSubgen": {
|
"youtubeSubgen": {
|
||||||
"primarySubLanguages": ["ja", "jpn", "jp"],
|
|
||||||
"whisperVadModel": "/models/vad.bin",
|
"whisperVadModel": "/models/vad.bin",
|
||||||
"whisperThreads": 12,
|
"whisperThreads": 12,
|
||||||
"fixWithAi": true
|
"fixWithAi": true
|
||||||
@@ -1165,7 +1167,7 @@ test('parses global shortcuts and startup settings', () => {
|
|||||||
assert.equal(config.ai.apiKeyCommand, 'pass show subminer/ai');
|
assert.equal(config.ai.apiKeyCommand, 'pass show subminer/ai');
|
||||||
assert.equal(config.shortcuts.toggleVisibleOverlayGlobal, 'Alt+Shift+U');
|
assert.equal(config.shortcuts.toggleVisibleOverlayGlobal, 'Alt+Shift+U');
|
||||||
assert.equal(config.shortcuts.openJimaku, 'Ctrl+Alt+J');
|
assert.equal(config.shortcuts.openJimaku, 'Ctrl+Alt+J');
|
||||||
assert.deepEqual(config.youtubeSubgen.primarySubLanguages, ['ja', 'jpn', 'jp']);
|
assert.deepEqual(config.youtube.primarySubLanguages, ['ja', 'jpn', 'jp']);
|
||||||
assert.equal(config.youtubeSubgen.whisperVadModel, '/models/vad.bin');
|
assert.equal(config.youtubeSubgen.whisperVadModel, '/models/vad.bin');
|
||||||
assert.equal(config.youtubeSubgen.whisperThreads, 12);
|
assert.equal(config.youtubeSubgen.whisperThreads, 12);
|
||||||
assert.equal(config.youtubeSubgen.fixWithAi, true);
|
assert.equal(config.youtubeSubgen.fixWithAi, true);
|
||||||
@@ -2008,7 +2010,8 @@ test('template generator includes known keys', () => {
|
|||||||
assert.match(output, /"websocket":/);
|
assert.match(output, /"websocket":/);
|
||||||
assert.match(output, /"discordPresence":/);
|
assert.match(output, /"discordPresence":/);
|
||||||
assert.match(output, /"startupWarmups":/);
|
assert.match(output, /"startupWarmups":/);
|
||||||
assert.match(output, /"youtubeSubgen":/);
|
assert.match(output, /"youtube":/);
|
||||||
|
assert.doesNotMatch(output, /"youtubeSubgen":/);
|
||||||
assert.match(output, /"characterDictionary":\s*\{/);
|
assert.match(output, /"characterDictionary":\s*\{/);
|
||||||
assert.match(output, /"preserveLineBreaks": false/);
|
assert.match(output, /"preserveLineBreaks": false/);
|
||||||
assert.match(output, /"knownWords"\s*:\s*\{/);
|
assert.match(output, /"knownWords"\s*:\s*\{/);
|
||||||
@@ -2074,17 +2077,11 @@ test('template generator includes known keys', () => {
|
|||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
output,
|
output,
|
||||||
/"fixWithAi": false,? \/\/ Legacy subtitle fallback post-processing switch kept for compatibility; use is currently disabled by default\. Values: true \| false/,
|
/"primarySubLanguages": \[\s*"ja",\s*"jpn"\s*\],? \/\/ Comma-separated primary subtitle language priority for YouTube auto-loading\./,
|
||||||
);
|
|
||||||
assert.match(
|
|
||||||
output,
|
|
||||||
/"systemPrompt": "",? \/\/ Optional system prompt override for legacy subtitle fallback post-processing; not used by default\./,
|
|
||||||
);
|
);
|
||||||
assert.doesNotMatch(output, /"mode": "automatic"/);
|
assert.doesNotMatch(output, /"mode": "automatic"/);
|
||||||
assert.match(
|
assert.doesNotMatch(output, /"fixWithAi": false/);
|
||||||
output,
|
assert.doesNotMatch(output, /"whisperThreads": 4/);
|
||||||
/"whisperThreads": 4,? \/\/ Legacy thread tuning for subtitle fallback tooling; not used by default\./,
|
|
||||||
);
|
|
||||||
assert.match(
|
assert.match(
|
||||||
output,
|
output,
|
||||||
/"launchAtStartup": true,? \/\/ Launch texthooker server automatically when SubMiner starts\. Values: true \| false/,
|
/"launchAtStartup": true,? \/\/ Launch texthooker server automatically when SubMiner starts\. Values: true \| false/,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const {
|
|||||||
controller,
|
controller,
|
||||||
shortcuts,
|
shortcuts,
|
||||||
secondarySub,
|
secondarySub,
|
||||||
|
youtube,
|
||||||
subsync,
|
subsync,
|
||||||
startupWarmups,
|
startupWarmups,
|
||||||
auto_start_overlay,
|
auto_start_overlay,
|
||||||
@@ -51,6 +52,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
|
|||||||
ankiConnect,
|
ankiConnect,
|
||||||
shortcuts,
|
shortcuts,
|
||||||
secondarySub,
|
secondarySub,
|
||||||
|
youtube,
|
||||||
subsync,
|
subsync,
|
||||||
startupWarmups,
|
startupWarmups,
|
||||||
subtitleStyle,
|
subtitleStyle,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
|||||||
| 'controller'
|
| 'controller'
|
||||||
| 'shortcuts'
|
| 'shortcuts'
|
||||||
| 'secondarySub'
|
| 'secondarySub'
|
||||||
|
| 'youtube'
|
||||||
| 'subsync'
|
| 'subsync'
|
||||||
| 'startupWarmups'
|
| 'startupWarmups'
|
||||||
| 'auto_start_overlay'
|
| 'auto_start_overlay'
|
||||||
@@ -93,6 +94,9 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
|||||||
autoLoadSecondarySub: false,
|
autoLoadSecondarySub: false,
|
||||||
defaultMode: 'hover',
|
defaultMode: 'hover',
|
||||||
},
|
},
|
||||||
|
youtube: {
|
||||||
|
primarySubLanguages: ['ja', 'jpn'],
|
||||||
|
},
|
||||||
subsync: {
|
subsync: {
|
||||||
defaultMode: 'auto',
|
defaultMode: 'auto',
|
||||||
alass_path: '',
|
alass_path: '',
|
||||||
|
|||||||
@@ -152,6 +152,5 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
|||||||
model: '',
|
model: '',
|
||||||
systemPrompt: '',
|
systemPrompt: '',
|
||||||
},
|
},
|
||||||
primarySubLanguages: ['ja', 'jpn'],
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ test('config option registry includes critical paths and has unique entries', ()
|
|||||||
'controller.enabled',
|
'controller.enabled',
|
||||||
'controller.scrollPixelsPerSecond',
|
'controller.scrollPixelsPerSecond',
|
||||||
'startupWarmups.lowPowerMode',
|
'startupWarmups.lowPowerMode',
|
||||||
|
'youtube.primarySubLanguages',
|
||||||
'subtitleStyle.enableJlpt',
|
'subtitleStyle.enableJlpt',
|
||||||
'subtitleStyle.autoPauseVideoOnYomitanPopup',
|
'subtitleStyle.autoPauseVideoOnYomitanPopup',
|
||||||
'ankiConnect.enabled',
|
'ankiConnect.enabled',
|
||||||
@@ -43,6 +44,7 @@ test('config template sections include expected domains and unique keys', () =>
|
|||||||
'annotationWebsocket',
|
'annotationWebsocket',
|
||||||
'controller',
|
'controller',
|
||||||
'startupWarmups',
|
'startupWarmups',
|
||||||
|
'youtube',
|
||||||
'subtitleStyle',
|
'subtitleStyle',
|
||||||
'ankiConnect',
|
'ankiConnect',
|
||||||
'yomitan',
|
'yomitan',
|
||||||
|
|||||||
@@ -83,6 +83,12 @@ export function buildCoreConfigOptionRegistry(
|
|||||||
defaultValue: defaultConfig.logging.level,
|
defaultValue: defaultConfig.logging.level,
|
||||||
description: 'Minimum log level for runtime logging.',
|
description: 'Minimum log level for runtime logging.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'youtube.primarySubLanguages',
|
||||||
|
kind: 'string',
|
||||||
|
defaultValue: defaultConfig.youtube.primarySubLanguages.join(','),
|
||||||
|
description: 'Comma-separated primary subtitle language priority for YouTube auto-loading.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'controller.enabled',
|
path: 'controller.enabled',
|
||||||
kind: 'boolean',
|
kind: 'boolean',
|
||||||
|
|||||||
@@ -411,11 +411,5 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
description:
|
description:
|
||||||
'Optional system prompt override for legacy subtitle fallback post-processing; not used by default.',
|
'Optional system prompt override for legacy subtitle fallback post-processing; not used by default.',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'youtubeSubgen.primarySubLanguages',
|
|
||||||
kind: 'string',
|
|
||||||
defaultValue: defaultConfig.youtubeSubgen.primarySubLanguages.join(','),
|
|
||||||
description: 'Comma-separated primary subtitle language priority used by the launcher.',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
|||||||
{
|
{
|
||||||
title: 'YouTube Playback Settings',
|
title: 'YouTube Playback Settings',
|
||||||
description: ['Defaults for SubMiner YouTube subtitle loading and languages.'],
|
description: ['Defaults for SubMiner YouTube subtitle loading and languages.'],
|
||||||
key: 'youtubeSubgen',
|
key: 'youtube',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Anilist',
|
title: 'Anilist',
|
||||||
|
|||||||
@@ -524,6 +524,21 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isObject(src.youtube)) {
|
||||||
|
if (Array.isArray(src.youtube.primarySubLanguages)) {
|
||||||
|
resolved.youtube.primarySubLanguages = src.youtube.primarySubLanguages.filter(
|
||||||
|
(item): item is string => typeof item === 'string',
|
||||||
|
);
|
||||||
|
} else if (src.youtube.primarySubLanguages !== undefined) {
|
||||||
|
warn(
|
||||||
|
'youtube.primarySubLanguages',
|
||||||
|
src.youtube.primarySubLanguages,
|
||||||
|
resolved.youtube.primarySubLanguages,
|
||||||
|
'Expected string array.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isObject(src.subsync)) {
|
if (isObject(src.subsync)) {
|
||||||
const mode = src.subsync.defaultMode;
|
const mode = src.subsync.defaultMode;
|
||||||
if (mode === 'auto' || mode === 'manual') {
|
if (mode === 'auto' || mode === 'manual') {
|
||||||
|
|||||||
@@ -135,16 +135,12 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
|||||||
warn('youtubeSubgen.ai', src.youtubeSubgen.ai, resolved.youtubeSubgen.ai, 'Expected object.');
|
warn('youtubeSubgen.ai', src.youtubeSubgen.ai, resolved.youtubeSubgen.ai, 'Expected object.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(src.youtubeSubgen.primarySubLanguages)) {
|
if (src.youtubeSubgen.primarySubLanguages !== undefined) {
|
||||||
resolved.youtubeSubgen.primarySubLanguages = src.youtubeSubgen.primarySubLanguages.filter(
|
|
||||||
(item): item is string => typeof item === 'string',
|
|
||||||
);
|
|
||||||
} else if (src.youtubeSubgen.primarySubLanguages !== undefined) {
|
|
||||||
warn(
|
warn(
|
||||||
'youtubeSubgen.primarySubLanguages',
|
'youtubeSubgen.primarySubLanguages',
|
||||||
src.youtubeSubgen.primarySubLanguages,
|
src.youtubeSubgen.primarySubLanguages,
|
||||||
resolved.youtubeSubgen.primarySubLanguages,
|
undefined,
|
||||||
'Expected string array.',
|
'Removed. Use youtube.primarySubLanguages instead.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1337,7 +1337,7 @@ const startupOsdSequencer = createStartupOsdSequencer({
|
|||||||
showOsd: (message) => showMpvOsd(message),
|
showOsd: (message) => showMpvOsd(message),
|
||||||
});
|
});
|
||||||
const youtubePrimarySubtitleNotificationRuntime = createYoutubePrimarySubtitleNotificationRuntime({
|
const youtubePrimarySubtitleNotificationRuntime = createYoutubePrimarySubtitleNotificationRuntime({
|
||||||
getPrimarySubtitleLanguages: () => getResolvedConfig().youtubeSubgen.primarySubLanguages,
|
getPrimarySubtitleLanguages: () => getResolvedConfig().youtube.primarySubLanguages,
|
||||||
notifyFailure: (message) => reportYoutubeSubtitleFailure(message),
|
notifyFailure: (message) => reportYoutubeSubtitleFailure(message),
|
||||||
schedule: (fn, delayMs) => setTimeout(fn, delayMs),
|
schedule: (fn, delayMs) => setTimeout(fn, delayMs),
|
||||||
clearSchedule: clearYoutubePrimarySubtitleNotificationTimer,
|
clearSchedule: clearYoutubePrimarySubtitleNotificationTimer,
|
||||||
|
|||||||
10
src/types.ts
10
src/types.ts
@@ -683,6 +683,10 @@ export interface AiConfig {
|
|||||||
requestTimeoutMs?: number;
|
requestTimeoutMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface YoutubeConfig {
|
||||||
|
primarySubLanguages?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface YoutubeSubgenConfig {
|
export interface YoutubeSubgenConfig {
|
||||||
whisperBin?: string;
|
whisperBin?: string;
|
||||||
whisperModel?: string;
|
whisperModel?: string;
|
||||||
@@ -690,7 +694,6 @@ export interface YoutubeSubgenConfig {
|
|||||||
whisperThreads?: number;
|
whisperThreads?: number;
|
||||||
fixWithAi?: boolean;
|
fixWithAi?: boolean;
|
||||||
ai?: AiFeatureConfig;
|
ai?: AiFeatureConfig;
|
||||||
primarySubLanguages?: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StatsConfig {
|
export interface StatsConfig {
|
||||||
@@ -750,6 +753,7 @@ export interface Config {
|
|||||||
jellyfin?: JellyfinConfig;
|
jellyfin?: JellyfinConfig;
|
||||||
discordPresence?: DiscordPresenceConfig;
|
discordPresence?: DiscordPresenceConfig;
|
||||||
ai?: AiConfig;
|
ai?: AiConfig;
|
||||||
|
youtube?: YoutubeConfig;
|
||||||
youtubeSubgen?: YoutubeSubgenConfig;
|
youtubeSubgen?: YoutubeSubgenConfig;
|
||||||
immersionTracking?: ImmersionTrackingConfig;
|
immersionTracking?: ImmersionTrackingConfig;
|
||||||
stats?: StatsConfig;
|
stats?: StatsConfig;
|
||||||
@@ -929,6 +933,9 @@ export interface ResolvedConfig {
|
|||||||
systemPrompt: string;
|
systemPrompt: string;
|
||||||
requestTimeoutMs: number;
|
requestTimeoutMs: number;
|
||||||
};
|
};
|
||||||
|
youtube: YoutubeConfig & {
|
||||||
|
primarySubLanguages: string[];
|
||||||
|
};
|
||||||
youtubeSubgen: YoutubeSubgenConfig & {
|
youtubeSubgen: YoutubeSubgenConfig & {
|
||||||
whisperBin: string;
|
whisperBin: string;
|
||||||
whisperModel: string;
|
whisperModel: string;
|
||||||
@@ -936,7 +943,6 @@ export interface ResolvedConfig {
|
|||||||
whisperThreads: number;
|
whisperThreads: number;
|
||||||
fixWithAi: boolean;
|
fixWithAi: boolean;
|
||||||
ai: AiFeatureConfig;
|
ai: AiFeatureConfig;
|
||||||
primarySubLanguages: string[];
|
|
||||||
};
|
};
|
||||||
immersionTracking: {
|
immersionTracking: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user