Parse ID3 sample timestamp for HLS audio chunks
Pending improvement: * Peek just the required priv frame. Avoid decoding all id3 information. * Sniff the used container format instead of using the extension. ------------- Created by MOE: https://github.com/google/moe MOE_MIGRATED_REVID=141181781
This commit is contained in:
parent
e5bc00ea94
commit
a66b4a9bad
@ -278,10 +278,9 @@ import java.util.Locale;
|
|||||||
DataSpec dataSpec = new DataSpec(chunkUri, segment.byterangeOffset, segment.byterangeLength,
|
DataSpec dataSpec = new DataSpec(chunkUri, segment.byterangeOffset, segment.byterangeLength,
|
||||||
null);
|
null);
|
||||||
out.chunk = new HlsMediaChunk(dataSource, dataSpec, initDataSpec, variants[newVariantIndex],
|
out.chunk = new HlsMediaChunk(dataSource, dataSpec, initDataSpec, variants[newVariantIndex],
|
||||||
trackSelection.getSelectionReason(), trackSelection.getSelectionData(),
|
trackSelection.getSelectionReason(), trackSelection.getSelectionData(), startTimeUs,
|
||||||
startTimeUs, startTimeUs + segment.durationUs, chunkMediaSequence,
|
startTimeUs + segment.durationUs, chunkMediaSequence, segment.discontinuitySequenceNumber,
|
||||||
segment.discontinuitySequenceNumber, isTimestampMaster, timestampAdjuster, previous,
|
isTimestampMaster, timestampAdjuster, previous, encryptionKey, encryptionIv);
|
||||||
encryptionKey, encryptionIv);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -16,6 +16,8 @@
|
|||||||
package com.google.android.exoplayer2.source.hls;
|
package com.google.android.exoplayer2.source.hls;
|
||||||
|
|
||||||
import android.text.TextUtils;
|
import android.text.TextUtils;
|
||||||
|
import com.google.android.exoplayer2.C;
|
||||||
|
import com.google.android.exoplayer2.ParserException;
|
||||||
import com.google.android.exoplayer2.extractor.DefaultExtractorInput;
|
import com.google.android.exoplayer2.extractor.DefaultExtractorInput;
|
||||||
import com.google.android.exoplayer2.extractor.Extractor;
|
import com.google.android.exoplayer2.extractor.Extractor;
|
||||||
import com.google.android.exoplayer2.extractor.ExtractorInput;
|
import com.google.android.exoplayer2.extractor.ExtractorInput;
|
||||||
@ -26,11 +28,15 @@ import com.google.android.exoplayer2.extractor.ts.Ac3Extractor;
|
|||||||
import com.google.android.exoplayer2.extractor.ts.AdtsExtractor;
|
import com.google.android.exoplayer2.extractor.ts.AdtsExtractor;
|
||||||
import com.google.android.exoplayer2.extractor.ts.DefaultTsPayloadReaderFactory;
|
import com.google.android.exoplayer2.extractor.ts.DefaultTsPayloadReaderFactory;
|
||||||
import com.google.android.exoplayer2.extractor.ts.TsExtractor;
|
import com.google.android.exoplayer2.extractor.ts.TsExtractor;
|
||||||
|
import com.google.android.exoplayer2.metadata.Metadata;
|
||||||
|
import com.google.android.exoplayer2.metadata.id3.Id3Decoder;
|
||||||
|
import com.google.android.exoplayer2.metadata.id3.PrivFrame;
|
||||||
import com.google.android.exoplayer2.source.chunk.MediaChunk;
|
import com.google.android.exoplayer2.source.chunk.MediaChunk;
|
||||||
import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist.HlsUrl;
|
import com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist.HlsUrl;
|
||||||
import com.google.android.exoplayer2.upstream.DataSource;
|
import com.google.android.exoplayer2.upstream.DataSource;
|
||||||
import com.google.android.exoplayer2.upstream.DataSpec;
|
import com.google.android.exoplayer2.upstream.DataSpec;
|
||||||
import com.google.android.exoplayer2.util.MimeTypes;
|
import com.google.android.exoplayer2.util.MimeTypes;
|
||||||
|
import com.google.android.exoplayer2.util.ParsableByteArray;
|
||||||
import com.google.android.exoplayer2.util.Util;
|
import com.google.android.exoplayer2.util.Util;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
@ -42,6 +48,9 @@ import java.util.concurrent.atomic.AtomicInteger;
|
|||||||
|
|
||||||
private static final AtomicInteger UID_SOURCE = new AtomicInteger();
|
private static final AtomicInteger UID_SOURCE = new AtomicInteger();
|
||||||
|
|
||||||
|
private static final String PRIV_TIMESTAMP_FRAME_OWNER =
|
||||||
|
"com.apple.streaming.transportStreamTimestamp";
|
||||||
|
|
||||||
private static final String AAC_FILE_EXTENSION = ".aac";
|
private static final String AAC_FILE_EXTENSION = ".aac";
|
||||||
private static final String AC3_FILE_EXTENSION = ".ac3";
|
private static final String AC3_FILE_EXTENSION = ".ac3";
|
||||||
private static final String EC3_FILE_EXTENSION = ".ec3";
|
private static final String EC3_FILE_EXTENSION = ".ec3";
|
||||||
@ -71,6 +80,11 @@ import java.util.concurrent.atomic.AtomicInteger;
|
|||||||
private final boolean isMasterTimestampSource;
|
private final boolean isMasterTimestampSource;
|
||||||
private final TimestampAdjuster timestampAdjuster;
|
private final TimestampAdjuster timestampAdjuster;
|
||||||
private final HlsMediaChunk previousChunk;
|
private final HlsMediaChunk previousChunk;
|
||||||
|
private final String lastPathSegment;
|
||||||
|
|
||||||
|
private final boolean isPackedAudio;
|
||||||
|
private final Id3Decoder id3Decoder;
|
||||||
|
private final ParsableByteArray id3Data;
|
||||||
|
|
||||||
private Extractor extractor;
|
private Extractor extractor;
|
||||||
private int initSegmentBytesLoaded;
|
private int initSegmentBytesLoaded;
|
||||||
@ -113,6 +127,19 @@ import java.util.concurrent.atomic.AtomicInteger;
|
|||||||
this.previousChunk = previousChunk;
|
this.previousChunk = previousChunk;
|
||||||
// Note: this.dataSource and dataSource may be different.
|
// Note: this.dataSource and dataSource may be different.
|
||||||
this.isEncrypted = this.dataSource instanceof Aes128DataSource;
|
this.isEncrypted = this.dataSource instanceof Aes128DataSource;
|
||||||
|
lastPathSegment = dataSpec.uri.getLastPathSegment();
|
||||||
|
isPackedAudio = lastPathSegment.endsWith(AAC_FILE_EXTENSION)
|
||||||
|
|| lastPathSegment.endsWith(AC3_FILE_EXTENSION)
|
||||||
|
|| lastPathSegment.endsWith(EC3_FILE_EXTENSION)
|
||||||
|
|| lastPathSegment.endsWith(MP3_FILE_EXTENSION);
|
||||||
|
if (isPackedAudio) {
|
||||||
|
id3Decoder = previousChunk != null ? previousChunk.id3Decoder : new Id3Decoder();
|
||||||
|
id3Data = previousChunk != null ? previousChunk.id3Data
|
||||||
|
: new ParsableByteArray(Id3Decoder.ID3_HEADER_LENGTH);
|
||||||
|
} else {
|
||||||
|
id3Decoder = null;
|
||||||
|
id3Data = null;
|
||||||
|
}
|
||||||
initDataSource = dataSource;
|
initDataSource = dataSource;
|
||||||
adjustedEndTimeUs = endTimeUs;
|
adjustedEndTimeUs = endTimeUs;
|
||||||
uid = UID_SOURCE.getAndIncrement();
|
uid = UID_SOURCE.getAndIncrement();
|
||||||
@ -167,8 +194,9 @@ import java.util.concurrent.atomic.AtomicInteger;
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void load() throws IOException, InterruptedException {
|
public void load() throws IOException, InterruptedException {
|
||||||
if (extractor == null) {
|
if (extractor == null && !isPackedAudio) {
|
||||||
extractor = buildExtractor();
|
// See HLS spec, version 20, Section 3.4 for more information on packed audio extraction.
|
||||||
|
extractor = buildExtractorByExtension();
|
||||||
}
|
}
|
||||||
maybeLoadInitData();
|
maybeLoadInitData();
|
||||||
if (!loadCanceled) {
|
if (!loadCanceled) {
|
||||||
@ -176,27 +204,152 @@ import java.util.concurrent.atomic.AtomicInteger;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Private methods.
|
// Internal loading methods.
|
||||||
|
|
||||||
private Extractor buildExtractor() {
|
private void maybeLoadInitData() throws IOException, InterruptedException {
|
||||||
|
if ((previousChunk != null && previousChunk.extractor == extractor) || initLoadCompleted
|
||||||
|
|| initDataSpec == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
DataSpec initSegmentDataSpec = Util.getRemainderDataSpec(initDataSpec, initSegmentBytesLoaded);
|
||||||
|
try {
|
||||||
|
ExtractorInput input = new DefaultExtractorInput(initDataSource,
|
||||||
|
initSegmentDataSpec.absoluteStreamPosition, initDataSource.open(initSegmentDataSpec));
|
||||||
|
try {
|
||||||
|
int result = Extractor.RESULT_CONTINUE;
|
||||||
|
while (result == Extractor.RESULT_CONTINUE && !loadCanceled) {
|
||||||
|
result = extractor.read(input, null);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
initSegmentBytesLoaded = (int) (input.getPosition() - initDataSpec.absoluteStreamPosition);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
Util.closeQuietly(dataSource);
|
||||||
|
}
|
||||||
|
initLoadCompleted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadMedia() throws IOException, InterruptedException {
|
||||||
|
// If we previously fed part of this chunk to the extractor, we need to skip it this time. For
|
||||||
|
// encrypted content we need to skip the data by reading it through the source, so as to ensure
|
||||||
|
// correct decryption of the remainder of the chunk. For clear content, we can request the
|
||||||
|
// remainder of the chunk directly.
|
||||||
|
DataSpec loadDataSpec;
|
||||||
|
boolean skipLoadedBytes;
|
||||||
|
if (isEncrypted) {
|
||||||
|
loadDataSpec = dataSpec;
|
||||||
|
skipLoadedBytes = bytesLoaded != 0;
|
||||||
|
} else {
|
||||||
|
loadDataSpec = Util.getRemainderDataSpec(dataSpec, bytesLoaded);
|
||||||
|
skipLoadedBytes = false;
|
||||||
|
}
|
||||||
|
if (!isMasterTimestampSource) {
|
||||||
|
timestampAdjuster.waitUntilInitialized();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ExtractorInput input = new DefaultExtractorInput(dataSource,
|
||||||
|
loadDataSpec.absoluteStreamPosition, dataSource.open(loadDataSpec));
|
||||||
|
if (extractor == null) {
|
||||||
|
// Media segment format is packed audio.
|
||||||
|
long id3Timestamp = peekId3PrivTimestamp(input);
|
||||||
|
if (id3Timestamp == C.TIME_UNSET) {
|
||||||
|
throw new ParserException("ID3 PRIV timestamp missing.");
|
||||||
|
}
|
||||||
|
extractor = buildPackedAudioExtractor(timestampAdjuster.adjustTsTimestamp(id3Timestamp));
|
||||||
|
}
|
||||||
|
if (skipLoadedBytes) {
|
||||||
|
input.skipFully(bytesLoaded);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
int result = Extractor.RESULT_CONTINUE;
|
||||||
|
while (result == Extractor.RESULT_CONTINUE && !loadCanceled) {
|
||||||
|
result = extractor.read(input, null);
|
||||||
|
}
|
||||||
|
long adjustedEndTimeUs = extractorOutput.getLargestQueuedTimestampUs();
|
||||||
|
if (adjustedEndTimeUs != Long.MIN_VALUE) {
|
||||||
|
this.adjustedEndTimeUs = adjustedEndTimeUs;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
bytesLoaded = (int) (input.getPosition() - dataSpec.absoluteStreamPosition);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
Util.closeQuietly(dataSource);
|
||||||
|
}
|
||||||
|
loadCompleted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Peek the presentation timestamp of the first sample in the chunk from an ID3 PRIV as defined
|
||||||
|
* in the HLS spec, version 20, Section 3.4. Returns {@link C#TIME_UNSET} if the frame is not
|
||||||
|
* found. This method only modifies the peek position.
|
||||||
|
*
|
||||||
|
* @param input The {@link ExtractorInput} to obtain the PRIV frame from.
|
||||||
|
* @return The parsed, adjusted timestamp in microseconds
|
||||||
|
* @throws IOException If an error occurred peeking from the input.
|
||||||
|
* @throws InterruptedException If the thread was interrupted.
|
||||||
|
*/
|
||||||
|
private long peekId3PrivTimestamp(ExtractorInput input) throws IOException, InterruptedException {
|
||||||
|
input.resetPeekPosition();
|
||||||
|
if (!input.peekFully(id3Data.data, 0, Id3Decoder.ID3_HEADER_LENGTH, true)) {
|
||||||
|
return C.TIME_UNSET;
|
||||||
|
}
|
||||||
|
id3Data.reset(Id3Decoder.ID3_HEADER_LENGTH);
|
||||||
|
int id = id3Data.readUnsignedInt24();
|
||||||
|
if (id != Id3Decoder.ID3_TAG) {
|
||||||
|
return C.TIME_UNSET;
|
||||||
|
}
|
||||||
|
id3Data.skipBytes(3); // version(2), flags(1).
|
||||||
|
int id3Size = id3Data.readSynchSafeInt();
|
||||||
|
int requiredCapacity = id3Size + Id3Decoder.ID3_HEADER_LENGTH;
|
||||||
|
if (requiredCapacity > id3Data.capacity()) {
|
||||||
|
byte[] data = id3Data.data;
|
||||||
|
id3Data.reset(requiredCapacity);
|
||||||
|
System.arraycopy(data, 0, id3Data.data, 0, Id3Decoder.ID3_HEADER_LENGTH);
|
||||||
|
}
|
||||||
|
if (!input.peekFully(id3Data.data, Id3Decoder.ID3_HEADER_LENGTH, id3Size, true)) {
|
||||||
|
return C.TIME_UNSET;
|
||||||
|
}
|
||||||
|
Metadata metadata = id3Decoder.decode(id3Data.data, id3Size);
|
||||||
|
if (metadata == null) {
|
||||||
|
return C.TIME_UNSET;
|
||||||
|
}
|
||||||
|
int metadataLength = metadata.length();
|
||||||
|
for (int i = 0; i < metadataLength; i++) {
|
||||||
|
Metadata.Entry frame = metadata.get(i);
|
||||||
|
if (frame instanceof PrivFrame) {
|
||||||
|
PrivFrame privFrame = (PrivFrame) frame;
|
||||||
|
if (PRIV_TIMESTAMP_FRAME_OWNER.equals(privFrame.owner)) {
|
||||||
|
System.arraycopy(privFrame.privateData, 0, id3Data.data, 0, 8 /* timestamp size */);
|
||||||
|
id3Data.reset(8);
|
||||||
|
return id3Data.readLong();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return C.TIME_UNSET;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal factory methods.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the content is encrypted, returns an {@link Aes128DataSource} that wraps the original in
|
||||||
|
* order to decrypt the loaded data. Else returns the original.
|
||||||
|
*/
|
||||||
|
private static DataSource buildDataSource(DataSource dataSource, byte[] encryptionKey,
|
||||||
|
byte[] encryptionIv) {
|
||||||
|
if (encryptionKey == null || encryptionIv == null) {
|
||||||
|
return dataSource;
|
||||||
|
}
|
||||||
|
return new Aes128DataSource(dataSource, encryptionKey, encryptionIv);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Extractor buildExtractorByExtension() {
|
||||||
// Set the extractor that will read the chunk.
|
// Set the extractor that will read the chunk.
|
||||||
Extractor extractor;
|
Extractor extractor;
|
||||||
boolean needNewExtractor = previousChunk == null
|
boolean needNewExtractor = previousChunk == null
|
||||||
|| previousChunk.discontinuitySequenceNumber != discontinuitySequenceNumber
|
|| previousChunk.discontinuitySequenceNumber != discontinuitySequenceNumber
|
||||||
|| trackFormat != previousChunk.trackFormat;
|
|| trackFormat != previousChunk.trackFormat;
|
||||||
boolean usingNewExtractor = true;
|
boolean usingNewExtractor = true;
|
||||||
String lastPathSegment = dataSpec.uri.getLastPathSegment();
|
if (lastPathSegment.endsWith(WEBVTT_FILE_EXTENSION)
|
||||||
if (lastPathSegment.endsWith(AAC_FILE_EXTENSION)) {
|
|
||||||
// TODO: Inject a timestamp adjuster and use it along with ID3 PRIV tag values with owner
|
|
||||||
// identifier com.apple.streaming.transportStreamTimestamp. This may also apply to the MP3
|
|
||||||
// case below.
|
|
||||||
extractor = new AdtsExtractor(startTimeUs);
|
|
||||||
} else if (lastPathSegment.endsWith(AC3_FILE_EXTENSION)
|
|
||||||
|| lastPathSegment.endsWith(EC3_FILE_EXTENSION)) {
|
|
||||||
extractor = new Ac3Extractor(startTimeUs);
|
|
||||||
} else if (lastPathSegment.endsWith(MP3_FILE_EXTENSION)) {
|
|
||||||
extractor = new Mp3Extractor(startTimeUs);
|
|
||||||
} else if (lastPathSegment.endsWith(WEBVTT_FILE_EXTENSION)
|
|
||||||
|| lastPathSegment.endsWith(VTT_FILE_EXTENSION)) {
|
|| lastPathSegment.endsWith(VTT_FILE_EXTENSION)) {
|
||||||
extractor = new WebvttExtractor(trackFormat.language, timestampAdjuster);
|
extractor = new WebvttExtractor(trackFormat.language, timestampAdjuster);
|
||||||
} else if (!needNewExtractor) {
|
} else if (!needNewExtractor) {
|
||||||
@ -231,80 +384,20 @@ import java.util.concurrent.atomic.AtomicInteger;
|
|||||||
return extractor;
|
return extractor;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void maybeLoadInitData() throws IOException, InterruptedException {
|
private Extractor buildPackedAudioExtractor(long startTimeUs) {
|
||||||
if ((previousChunk != null && previousChunk.extractor == extractor)
|
Extractor extractor;
|
||||||
|| initLoadCompleted || initDataSpec == null) {
|
if (lastPathSegment.endsWith(AAC_FILE_EXTENSION)) {
|
||||||
return;
|
extractor = new AdtsExtractor(startTimeUs);
|
||||||
}
|
} else if (lastPathSegment.endsWith(AC3_FILE_EXTENSION)
|
||||||
DataSpec initSegmentDataSpec = Util.getRemainderDataSpec(initDataSpec, initSegmentBytesLoaded);
|
|| lastPathSegment.endsWith(EC3_FILE_EXTENSION)) {
|
||||||
try {
|
extractor = new Ac3Extractor(startTimeUs);
|
||||||
ExtractorInput input = new DefaultExtractorInput(initDataSource,
|
} else if (lastPathSegment.endsWith(MP3_FILE_EXTENSION)) {
|
||||||
initSegmentDataSpec.absoluteStreamPosition, initDataSource.open(initSegmentDataSpec));
|
extractor = new Mp3Extractor(startTimeUs);
|
||||||
try {
|
|
||||||
int result = Extractor.RESULT_CONTINUE;
|
|
||||||
while (result == Extractor.RESULT_CONTINUE && !loadCanceled) {
|
|
||||||
result = extractor.read(input, null);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
initSegmentBytesLoaded += (int) (input.getPosition() - dataSpec.absoluteStreamPosition);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
Util.closeQuietly(dataSource);
|
|
||||||
}
|
|
||||||
initLoadCompleted = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void loadMedia() throws IOException, InterruptedException {
|
|
||||||
// If we previously fed part of this chunk to the extractor, we need to skip it this time. For
|
|
||||||
// encrypted content we need to skip the data by reading it through the source, so as to ensure
|
|
||||||
// correct decryption of the remainder of the chunk. For clear content, we can request the
|
|
||||||
// remainder of the chunk directly.
|
|
||||||
DataSpec loadDataSpec;
|
|
||||||
boolean skipLoadedBytes;
|
|
||||||
if (isEncrypted) {
|
|
||||||
loadDataSpec = dataSpec;
|
|
||||||
skipLoadedBytes = bytesLoaded != 0;
|
|
||||||
} else {
|
} else {
|
||||||
loadDataSpec = Util.getRemainderDataSpec(dataSpec, bytesLoaded);
|
throw new IllegalArgumentException("Unkown extension for audio file: " + lastPathSegment);
|
||||||
skipLoadedBytes = false;
|
|
||||||
}
|
}
|
||||||
try {
|
extractor.init(extractorOutput);
|
||||||
ExtractorInput input = new DefaultExtractorInput(dataSource,
|
return extractor;
|
||||||
loadDataSpec.absoluteStreamPosition, dataSource.open(loadDataSpec));
|
|
||||||
if (skipLoadedBytes) {
|
|
||||||
input.skipFully(bytesLoaded);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
int result = Extractor.RESULT_CONTINUE;
|
|
||||||
if (!isMasterTimestampSource && timestampAdjuster != null) {
|
|
||||||
timestampAdjuster.waitUntilInitialized();
|
|
||||||
}
|
|
||||||
while (result == Extractor.RESULT_CONTINUE && !loadCanceled) {
|
|
||||||
result = extractor.read(input, null);
|
|
||||||
}
|
|
||||||
long adjustedEndTimeUs = extractorOutput.getLargestQueuedTimestampUs();
|
|
||||||
if (adjustedEndTimeUs != Long.MIN_VALUE) {
|
|
||||||
this.adjustedEndTimeUs = adjustedEndTimeUs;
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
bytesLoaded = (int) (input.getPosition() - dataSpec.absoluteStreamPosition);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
Util.closeQuietly(dataSource);
|
|
||||||
}
|
|
||||||
loadCompleted = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* If the content is encrypted, returns an {@link Aes128DataSource} that wraps the original in
|
|
||||||
* order to decrypt the loaded data. Else returns the original.
|
|
||||||
*/
|
|
||||||
private static DataSource buildDataSource(DataSource dataSource, byte[] encryptionKey,
|
|
||||||
byte[] encryptionIv) {
|
|
||||||
if (encryptionKey == null || encryptionIv == null) {
|
|
||||||
return dataSource;
|
|
||||||
}
|
|
||||||
return new Aes128DataSource(dataSource, encryptionKey, encryptionIv);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user