fix(jellyfin): handle external transcode subtitles
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import '../utils/codec_utils.dart';
|
||||
import '../utils/track_label_builder.dart' show buildTrackLabel;
|
||||
import '../utils/track_label_builder.dart' show TrackLabelBuilder, buildTrackLabel;
|
||||
|
||||
class MediaSourceInfo {
|
||||
final String videoUrl;
|
||||
@@ -149,9 +149,13 @@ class MediaSubtitleTrack with _TrackLabelMixin {
|
||||
});
|
||||
|
||||
String get label {
|
||||
final additionalParts = <String>[];
|
||||
if (forced) additionalParts.add('Forced');
|
||||
return buildLabel(additionalParts);
|
||||
return TrackLabelBuilder.buildSubtitleLabel(
|
||||
title: displayTitle ?? title,
|
||||
language: languageCode ?? language,
|
||||
codec: codec,
|
||||
forced: forced,
|
||||
index: (index ?? id) - 1,
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns true if this subtitle track is an external file (sidecar subtitle).
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart' show protected;
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../../utils/track_label_builder.dart';
|
||||
import '../font_loader.dart';
|
||||
import '../models.dart';
|
||||
import 'player.dart';
|
||||
@@ -416,8 +417,8 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
audioTracks.add(
|
||||
AudioTrack(
|
||||
id: id,
|
||||
title: track['title'] as String?,
|
||||
language: track['lang'] as String?,
|
||||
title: cleanTrackMetadataValue(track['title'] as String?),
|
||||
language: cleanTrackMetadataValue(track['lang'] as String?),
|
||||
codec: track['codec'] as String?,
|
||||
channels: (track['demux-channel-count'] as num?)?.toInt(),
|
||||
sampleRate: (track['demux-samplerate'] as num?)?.toInt(),
|
||||
@@ -426,12 +427,13 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
);
|
||||
} else if (type == 'sub') {
|
||||
if (selected) selectedSubtitleId = id;
|
||||
final codec = track['codec'] as String?;
|
||||
subtitleTracks.add(
|
||||
SubtitleTrack(
|
||||
id: id,
|
||||
title: track['title'] as String?,
|
||||
language: track['lang'] as String?,
|
||||
codec: track['codec'] as String?,
|
||||
title: cleanSubtitleTitle(track['title'] as String?, codec: codec),
|
||||
language: cleanTrackMetadataValue(track['lang'] as String?),
|
||||
codec: codec,
|
||||
isDefault: track['default'] as bool? ?? false,
|
||||
isForced: track['forced'] as bool? ?? false,
|
||||
isExternal: track['external'] as bool? ?? false,
|
||||
|
||||
@@ -30,6 +30,8 @@ typedef JellyfinStreamFields = ({
|
||||
});
|
||||
|
||||
JellyfinStreamFields parseJellyfinStreamFields(Map<String, dynamic> s, {int fallbackIndex = 0}) {
|
||||
final deliveryMethod = (s['DeliveryMethod'] as String?)?.toLowerCase();
|
||||
final isExternal = deliveryMethod != null ? deliveryMethod == 'external' : s['IsExternal'] == true;
|
||||
return (
|
||||
type: (s['Type'] as String?)?.toLowerCase(),
|
||||
index: flexibleInt(s['Index']) ?? fallbackIndex,
|
||||
@@ -40,7 +42,7 @@ JellyfinStreamFields parseJellyfinStreamFields(Map<String, dynamic> s, {int fall
|
||||
displayTitle: s['DisplayTitle'] as String?,
|
||||
isDefault: s['IsDefault'] as bool? ?? false,
|
||||
isForced: s['IsForced'] as bool? ?? false,
|
||||
isExternal: s['IsExternal'] as bool? ?? false,
|
||||
isExternal: isExternal,
|
||||
deliveryUrl: s['DeliveryUrl'] as String?,
|
||||
channels: flexibleInt(s['Channels']),
|
||||
frameRate: flexibleDouble(s['RealFrameRate']) ?? flexibleDouble(s['AverageFrameRate']),
|
||||
|
||||
@@ -33,6 +33,7 @@ import '../utils/log_redaction_manager.dart';
|
||||
import '../utils/external_ids.dart';
|
||||
import '../utils/media_server_http_client.dart';
|
||||
import '../utils/resolution_label.dart';
|
||||
import '../utils/track_label_builder.dart';
|
||||
import '../utils/watch_state_notifier.dart';
|
||||
import '../exceptions/media_server_exceptions.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
@@ -610,29 +611,12 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc
|
||||
if (bundle == null) {
|
||||
throw PlaybackException('Item ${metadata.id} returned no MediaSources');
|
||||
}
|
||||
final mediaInfo = jellyfinMediaSourceToMediaSourceInfo(
|
||||
var mediaInfo = jellyfinMediaSourceToMediaSourceInfo(
|
||||
bundle.selectedSource,
|
||||
chapters: bundle.chapters,
|
||||
trickplay: bundle.trickplay,
|
||||
);
|
||||
|
||||
final externalSubtitles = <SubtitleTrack>[];
|
||||
for (final track in mediaInfo.subtitleTracks) {
|
||||
if (track.isExternal) {
|
||||
final path = track.key ?? _jellyfinSubtitleFallbackPath(metadata.id, bundle.selectedSourceId, track);
|
||||
if (path == null) continue;
|
||||
// Jellyfin's subtitle URL is a path relative to baseUrl; build the
|
||||
// absolute URL with the api_key query param.
|
||||
final url = _withApiKey(path);
|
||||
externalSubtitles.add(
|
||||
SubtitleTrack.uri(
|
||||
url,
|
||||
title: track.displayTitle ?? track.title ?? track.language,
|
||||
language: track.languageCode,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
var externalSubtitles = _buildExternalSubtitles(metadata.id, bundle.selectedSourceId, mediaInfo);
|
||||
|
||||
// Only forward MediaSourceId when there's actually more than one source —
|
||||
// single-source items have `MediaSourceId == itemId` so the param is a
|
||||
@@ -670,6 +654,19 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc
|
||||
}
|
||||
chosenSource ??= sources.first is Map<String, dynamic> ? sources.first as Map<String, dynamic> : null;
|
||||
}
|
||||
final chosenStreams = chosenSource?['MediaStreams'];
|
||||
if (chosenSource != null && chosenStreams is List && chosenStreams.isNotEmpty) {
|
||||
mediaInfo = jellyfinMediaSourceToMediaSourceInfo(
|
||||
chosenSource,
|
||||
chapters: bundle.chapters,
|
||||
trickplay: bundle.trickplay,
|
||||
);
|
||||
externalSubtitles = _buildExternalSubtitles(
|
||||
metadata.id,
|
||||
chosenSource['Id'] as String? ?? bundle.selectedSourceId,
|
||||
mediaInfo,
|
||||
);
|
||||
}
|
||||
final transcodingUrl = chosenSource?['TranscodingUrl'];
|
||||
if (transcodingUrl is String && transcodingUrl.isNotEmpty) {
|
||||
// TranscodingUrl is server-relative and already encodes container,
|
||||
@@ -727,6 +724,28 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc
|
||||
return path.startsWith('/') ? path : '/$path';
|
||||
}
|
||||
|
||||
List<SubtitleTrack> _buildExternalSubtitles(String itemId, String? mediaSourceId, MediaSourceInfo mediaInfo) {
|
||||
final externalSubtitles = <SubtitleTrack>[];
|
||||
for (final track in mediaInfo.subtitleTracks) {
|
||||
if (!track.isExternal) continue;
|
||||
final path = track.key ?? _jellyfinSubtitleFallbackPath(itemId, mediaSourceId, track);
|
||||
if (path == null) continue;
|
||||
// Jellyfin's subtitle URL is a path relative to baseUrl; build the
|
||||
// absolute URL with the api_key query param.
|
||||
final url = _withApiKey(path);
|
||||
externalSubtitles.add(
|
||||
SubtitleTrack.uri(
|
||||
url,
|
||||
title:
|
||||
cleanSubtitleTitle(track.displayTitle ?? track.title, codec: track.codec) ??
|
||||
cleanTrackMetadataValue(track.language),
|
||||
language: cleanTrackMetadataValue(track.languageCode),
|
||||
),
|
||||
);
|
||||
}
|
||||
return externalSubtitles;
|
||||
}
|
||||
|
||||
/// Internal accessor for [PlaybackInitializationService]. Returns the
|
||||
/// chosen `MediaSource` JSON, every available source's [MediaVersion],
|
||||
/// and the item's `Chapters` array. One round-trip vs. fetchItem + raw
|
||||
@@ -1751,6 +1770,15 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc
|
||||
'AudioCodec': 'aac,mp3,ac3,eac3,flac,opus,vorbis,dts',
|
||||
},
|
||||
],
|
||||
'SubtitleProfiles': const <Map<String, Object?>>[
|
||||
{'Format': 'srt', 'Method': 'External'},
|
||||
{'Format': 'ass', 'Method': 'External'},
|
||||
{'Format': 'ssa', 'Method': 'External'},
|
||||
{'Format': 'vtt', 'Method': 'External'},
|
||||
{'Format': 'pgssub', 'Method': 'External'},
|
||||
{'Format': 'dvdsub', 'Method': 'External'},
|
||||
{'Format': 'dvbsub', 'Method': 'External'},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -2227,12 +2255,12 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc
|
||||
for (final raw in streams) {
|
||||
if (raw is! Map<String, dynamic>) continue;
|
||||
if (raw['Type'] != 'Subtitle') continue;
|
||||
final isExternal = raw['IsExternal'] == true;
|
||||
if (!isExternal) continue;
|
||||
final fields = parseJellyfinStreamFields(raw);
|
||||
if (!fields.isExternal) continue;
|
||||
final index = raw['Index'];
|
||||
if (index is! int) continue;
|
||||
final codec = (raw['Codec'] as String?)?.toLowerCase();
|
||||
final delivery = raw['DeliveryUrl'] as String?;
|
||||
final codec = fields.codec?.toLowerCase();
|
||||
final delivery = fields.deliveryUrl;
|
||||
final url = _withApiKey(
|
||||
delivery != null && delivery.isNotEmpty
|
||||
? delivery
|
||||
@@ -2243,10 +2271,10 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc
|
||||
id: index,
|
||||
url: url,
|
||||
codec: codec,
|
||||
language: raw['Language'] as String?,
|
||||
languageCode: raw['Language'] as String?,
|
||||
forced: raw['IsForced'] == true,
|
||||
displayTitle: raw['DisplayTitle'] as String?,
|
||||
language: fields.language,
|
||||
languageCode: fields.languageCode,
|
||||
forced: fields.isForced,
|
||||
displayTitle: fields.displayTitle,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,66 @@ String buildTrackLabel({
|
||||
return parts.isEmpty ? '$fallbackPrefix ${index + 1}' : parts.join(' · ');
|
||||
}
|
||||
|
||||
String? cleanTrackMetadataValue(String? value) {
|
||||
if (value == null) return null;
|
||||
var cleaned = value.trim();
|
||||
if (cleaned.isEmpty) return null;
|
||||
|
||||
final prefixed = RegExp(r'^(?:title|lang|language)\s*=\s*(.*)$', caseSensitive: false).firstMatch(cleaned);
|
||||
if (prefixed != null) {
|
||||
cleaned = prefixed.group(1)?.trim() ?? '';
|
||||
}
|
||||
|
||||
if ((cleaned.startsWith('"') && cleaned.endsWith('"')) || (cleaned.startsWith("'") && cleaned.endsWith("'"))) {
|
||||
cleaned = cleaned.substring(1, cleaned.length - 1).trim();
|
||||
}
|
||||
|
||||
return cleaned.isEmpty ? null : cleaned;
|
||||
}
|
||||
|
||||
String? cleanSubtitleTitle(String? title, {String? codec}) {
|
||||
var cleaned = cleanTrackMetadataValue(title);
|
||||
if (cleaned == null) return null;
|
||||
|
||||
final codecAliases = _subtitleCodecAliases(codec);
|
||||
if (codecAliases.isEmpty) return cleaned;
|
||||
|
||||
final parts = cleaned.split(RegExp(r'\s+-\s+'));
|
||||
while (parts.isNotEmpty && codecAliases.contains(_metadataToken(parts.last))) {
|
||||
parts.removeLast();
|
||||
}
|
||||
cleaned = parts.join(' - ').trim();
|
||||
|
||||
return cleaned.isEmpty ? null : cleaned;
|
||||
}
|
||||
|
||||
Set<String> _subtitleCodecAliases(String? codec) {
|
||||
final aliases = <String>{
|
||||
'SUBRIP',
|
||||
'SRT',
|
||||
'WEBVTT',
|
||||
'VTT',
|
||||
'ASS',
|
||||
'SSA',
|
||||
'PGS',
|
||||
'PGSSUB',
|
||||
'HDMV_PGS_SUBTITLE',
|
||||
'DVD',
|
||||
'DVDSUB',
|
||||
'DVD_SUBTITLE',
|
||||
'DVB_SUB',
|
||||
'DVB_SUBTITLE',
|
||||
};
|
||||
if (codec != null && codec.isNotEmpty) {
|
||||
aliases.add(_metadataToken(codec));
|
||||
aliases.add(_metadataToken(CodecUtils.formatSubtitleCodec(codec)));
|
||||
aliases.add(_metadataToken(CodecUtils.getSubtitleExtension(codec)));
|
||||
}
|
||||
return aliases;
|
||||
}
|
||||
|
||||
String _metadataToken(String value) => value.trim().toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]+'), '_');
|
||||
|
||||
/// Utility for building track labels for audio and subtitle tracks.
|
||||
class TrackLabelBuilder {
|
||||
TrackLabelBuilder._();
|
||||
@@ -52,11 +112,20 @@ class TrackLabelBuilder {
|
||||
/// Build a label for a subtitle track.
|
||||
///
|
||||
/// Combines title, language, and codec (with friendly codec names).
|
||||
static String buildSubtitleLabel({String? title, String? language, String? codec, required int index}) {
|
||||
static String buildSubtitleLabel({
|
||||
String? title,
|
||||
String? language,
|
||||
String? codec,
|
||||
bool forced = false,
|
||||
required int index,
|
||||
}) {
|
||||
final cleanedTitle = cleanSubtitleTitle(title, codec: codec);
|
||||
final cleanedLanguage = cleanTrackMetadataValue(language)?.toUpperCase();
|
||||
final extraParts = <String>[];
|
||||
if (forced && !_metadataToken(cleanedTitle ?? '').split('_').contains('FORCED')) extraParts.add('Forced');
|
||||
if (codec != null && codec.isNotEmpty) {
|
||||
extraParts.add(CodecUtils.formatSubtitleCodec(codec));
|
||||
}
|
||||
return buildTrackLabel(title: title, language: language?.toUpperCase(), extraParts: extraParts, index: index);
|
||||
return buildTrackLabel(title: cleanedTitle, language: cleanedLanguage, extraParts: extraParts, index: index);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ void main() {
|
||||
expect(body['IsPaused'], isTrue);
|
||||
});
|
||||
|
||||
test('resolveDownload pins direct stream URL to selected media source', () async {
|
||||
test('resolveDownload pins direct stream URL and subtitles to selected media source', () async {
|
||||
final requests = <Uri>[];
|
||||
final scoped = JellyfinClient.forTesting(
|
||||
connection: _conn(),
|
||||
@@ -139,7 +139,21 @@ void main() {
|
||||
jsonEncode({
|
||||
'MediaSources': [
|
||||
{'Id': 'src-1', 'MediaStreams': []},
|
||||
{'Id': 'src-2', 'MediaStreams': []},
|
||||
{
|
||||
'Id': 'src-2',
|
||||
'MediaStreams': [
|
||||
{
|
||||
'Index': 3,
|
||||
'Type': 'Subtitle',
|
||||
'Codec': 'srt',
|
||||
'Language': 'eng',
|
||||
'DisplayLanguage': 'English',
|
||||
'DisplayTitle': 'English - SRT',
|
||||
'DeliveryMethod': 'External',
|
||||
'DeliveryUrl': '/Videos/item-1/src-2/Subtitles/3/Stream.srt',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
@@ -160,6 +174,14 @@ void main() {
|
||||
expect(uri.queryParameters['MediaSourceId'], 'src-2');
|
||||
expect(uri.queryParameters['Container'], 'mkv');
|
||||
expect(requests.map((u) => u.path), contains('/Items/item-1/PlaybackInfo'));
|
||||
expect(resolution.externalSubtitles, hasLength(1));
|
||||
final subtitle = resolution.externalSubtitles.single;
|
||||
expect(subtitle.id, 3);
|
||||
expect(subtitle.language, 'English');
|
||||
expect(subtitle.languageCode, 'eng');
|
||||
final subtitleUri = Uri.parse(subtitle.url);
|
||||
expect(subtitleUri.path, '/Videos/item-1/src-2/Subtitles/3/Stream.srt');
|
||||
expect(subtitleUri.queryParameters['api_key'], 'tok-abc');
|
||||
});
|
||||
|
||||
test('getPlaybackInitialization preserves PlaySessionId from TranscodingUrl', () async {
|
||||
@@ -187,7 +209,18 @@ void main() {
|
||||
{
|
||||
'Id': 'src-1',
|
||||
'TranscodingUrl': '/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=play-session-1',
|
||||
'MediaStreams': [],
|
||||
'MediaStreams': [
|
||||
{'Index': 0, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng', 'DisplayTitle': 'English - AAC'},
|
||||
{
|
||||
'Index': 2,
|
||||
'Type': 'Subtitle',
|
||||
'Codec': 'srt',
|
||||
'Language': 'eng',
|
||||
'DisplayTitle': 'English - SRT',
|
||||
'DeliveryMethod': 'External',
|
||||
'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/2/Stream.srt',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -214,6 +247,13 @@ void main() {
|
||||
final uri = Uri.parse(result.videoUrl!);
|
||||
expect(uri.queryParameters['PlaySessionId'], 'play-session-1');
|
||||
expect(uri.queryParameters['api_key'], 'tok-abc');
|
||||
expect(result.mediaInfo!.subtitleTracks, hasLength(1));
|
||||
expect(result.externalSubtitles, hasLength(1));
|
||||
expect(result.externalSubtitles.single.title, 'English');
|
||||
expect(result.externalSubtitles.single.language, 'eng');
|
||||
final subtitleUri = Uri.parse(result.externalSubtitles.single.uri!);
|
||||
expect(subtitleUri.path, '/Videos/item-1/src-1/Subtitles/2/Stream.srt');
|
||||
expect(subtitleUri.queryParameters['api_key'], 'tok-abc');
|
||||
});
|
||||
|
||||
test('getPlaybackInitialization uses negotiated DirectStreamUrl when transcode URL is absent', () async {
|
||||
@@ -288,7 +328,7 @@ void main() {
|
||||
expect(capturedUri.toString(), contains('/Items/folder%2Fitem%20%231%3Fx/PlaybackInfo'));
|
||||
});
|
||||
|
||||
test('getPlaybackInfo keeps the known-good lean DeviceProfile', () async {
|
||||
test('getPlaybackInfo advertises external subtitle support', () async {
|
||||
Uri? capturedUri;
|
||||
String? capturedBody;
|
||||
final scoped = JellyfinClient.forTesting(
|
||||
@@ -324,7 +364,12 @@ void main() {
|
||||
expect(profile['DirectPlayProfiles'], isNotEmpty);
|
||||
expect(profile['TranscodingProfiles'], isNotEmpty);
|
||||
expect(profile['CodecProfiles'], isEmpty);
|
||||
expect(profile.containsKey('SubtitleProfiles'), isFalse);
|
||||
final subtitleProfiles = profile['SubtitleProfiles'] as List<dynamic>;
|
||||
expect(
|
||||
subtitleProfiles.map((profile) => (profile as Map<String, dynamic>)['Format']),
|
||||
containsAll(['srt', 'ass', 'ssa', 'vtt', 'pgssub', 'dvdsub', 'dvbsub']),
|
||||
);
|
||||
expect(subtitleProfiles.every((profile) => (profile as Map<String, dynamic>)['Method'] == 'External'), isTrue);
|
||||
});
|
||||
|
||||
test('path-encodes reserved ids for browse and watch-state endpoints', () async {
|
||||
|
||||
@@ -145,6 +145,24 @@ void main() {
|
||||
expect(info.subtitleTracks.single.isExternal, isFalse);
|
||||
});
|
||||
|
||||
test('DeliveryMethod External marks negotiated subtitles as external', () {
|
||||
final info = jellyfinMediaSourceToMediaSourceInfo({
|
||||
'MediaStreams': [
|
||||
{
|
||||
'Index': 2,
|
||||
'Type': 'Subtitle',
|
||||
'Codec': 'srt',
|
||||
'DeliveryMethod': 'External',
|
||||
'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/2/Stream.srt',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
final sub = info.subtitleTracks.single;
|
||||
expect(sub.key, '/Videos/item-1/src-1/Subtitles/2/Stream.srt');
|
||||
expect(sub.isExternal, isTrue);
|
||||
});
|
||||
|
||||
test('external subtitle without DeliveryUrl remains external for URL fallback', () {
|
||||
final info = jellyfinMediaSourceToMediaSourceInfo({
|
||||
'MediaStreams': [
|
||||
|
||||
@@ -92,9 +92,37 @@ void main() {
|
||||
expect(TrackLabelBuilder.buildSubtitleLabel(language: 'en', codec: '', index: 0), 'EN');
|
||||
});
|
||||
|
||||
test('does not duplicate forced when the title already says forced', () {
|
||||
expect(
|
||||
TrackLabelBuilder.buildSubtitleLabel(title: 'Forced', language: 'en', codec: 'subrip', forced: true, index: 0),
|
||||
'Forced · EN · SRT',
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back to "Track N" with default prefix', () {
|
||||
expect(TrackLabelBuilder.buildSubtitleLabel(index: 0), 'Track 1');
|
||||
expect(TrackLabelBuilder.buildSubtitleLabel(index: 7), 'Track 8');
|
||||
});
|
||||
|
||||
test('cleans raw Jellyfin/ExoPlayer subtitle metadata prefixes', () {
|
||||
expect(
|
||||
TrackLabelBuilder.buildSubtitleLabel(
|
||||
title: 'title=German - SUBRIP',
|
||||
language: 'LANG=DEU',
|
||||
codec: 'srt',
|
||||
index: 0,
|
||||
),
|
||||
'German · DEU · SRT',
|
||||
);
|
||||
expect(
|
||||
TrackLabelBuilder.buildSubtitleLabel(
|
||||
title: 'title=English - Default - SUBRIP',
|
||||
language: 'LANG=ENG',
|
||||
codec: 'subrip',
|
||||
index: 1,
|
||||
),
|
||||
'English - Default · ENG · SRT',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user