ExoPlayer 2.6.0からDownloaderが追加されたので実装を追った際のメモ書き.
Download
ダウンローダの構築に必要な情報を持つビルドパラメータクラスDownloaderConstructorHelper
ダウンローダのコンストラクタ引数に使われる.
public SegmentDownloader(Uri manifestUri, DownloaderConstructorHelper constructorHelper) {
ダウンロードメイン処理
// SegmentDownloader#download
@Override
public final synchronized void download(@Nullable ProgressListener listener)
throws IOException, InterruptedException {
priorityTaskManager.add(C.PRIORITY_DOWNLOAD);
try {
getManifestIfNeeded(false);
List<Segment> segments = initStatus(false);
notifyListener(listener); // Initial notification.
Collections.sort(segments);
byte[] buffer = new byte[BUFFER_SIZE_BYTES];
CachingCounters cachingCounters = new CachingCounters();
for (int i = 0; i < segments.size(); i++) {
CacheUtil.cache(segments.get(i).dataSpec, cache, dataSource, buffer,
priorityTaskManager, C.PRIORITY_DOWNLOAD, cachingCounters, true);
downloadedBytes += cachingCounters.newlyCachedBytes;
downloadedSegments++;
notifyListener(listener);
}
} finally {
priorityTaskManager.remove(C.PRIORITY_DOWNLOAD);
}
}
ダウンロード済みのコンテンツは再ダウンロード時にスキップされる
// CacheUtil#cache(DataSpec, Cache, CacheDataSource, byte[], PriorityTaskManager, int, CachingCounters, boolean)
long blockLength = cache.getCachedBytes(key, start,
left != C.LENGTH_UNSET ? left : Long.MAX_VALUE);
if (blockLength > 0) {
// Skip already cached data.
MasterPlaylist or MediaPlaylist?
ダウンロードするURLはMasterPlaylist or MediaPlaylist どちらかで, 内部ではMasterPlaylistではない(MediaPlaylistの)場合にSingleVariantMasterPlaylist
として扱うようにしている.
// HlsDownloader#getManifest
@Override
protected HlsMasterPlaylist getManifest(DataSource dataSource, Uri uri) throws IOException {
HlsPlaylist hlsPlaylist = loadManifest(dataSource, uri);
if (hlsPlaylist instanceof HlsMasterPlaylist) {
return (HlsMasterPlaylist) hlsPlaylist;
} else {
return HlsMasterPlaylist.createSingleVariantMasterPlaylist(hlsPlaylist.baseUri);
}
}
ダウンロードをとめる.
ダウンロードを停止するには Thread.currentThread().interrupt();
を使う.
割り込みをチェックする(停止できる)タイミングは
1 . 各セグメント毎の読み込み前
2. セグメントの指定バッファサイズ読み込みの都度
ダウンロードコンテンツの永続化
SegmentDownloader
はオンラインデータソース(dataSource
)とオフラインデータソース(offlineDataSource
)をそれぞれ持っている.
それぞれのデータソースはDownloadConstructorHelper
で生成される.
オンラインデータソースはコンストラクタで指定されたファクトリから生成されるデータソースを持つCacheDataSource
が作られる.
オフラインデータソースはデフォルトでFileDataSource
を持つCacheDataSource
が作られる.
また, オンラインデータソースにはキャッシュに情報を書き込むCacheDataSink
がデフォルトで設定される.
// DownloaderConstructorHelper#buildCacheDataSource
public CacheDataSource buildCacheDataSource(boolean offline) {
DataSource cacheReadDataSource = cacheReadDataSourceFactory != null
? cacheReadDataSourceFactory.createDataSource() : new FileDataSource();
if (offline) {
return new CacheDataSource(cache, DummyDataSource.INSTANCE,
cacheReadDataSource, null, CacheDataSource.FLAG_BLOCK_ON_CACHE, null);
} else {
DataSink cacheWriteDataSink = cacheWriteDataSinkFactory != null
? cacheWriteDataSinkFactory.createDataSink()
: new CacheDataSink(cache, CacheDataSource.DEFAULT_MAX_CACHE_FILE_SIZE);
DataSource upstream = upstreamDataSourceFactory.createDataSource();
upstream = priorityTaskManager == null ? upstream
: new PriorityDataSource(upstream, priorityTaskManager, C.PRIORITY_DOWNLOAD);
return new CacheDataSource(cache, upstream, cacheReadDataSource,
cacheWriteDataSink, CacheDataSource.FLAG_BLOCK_ON_CACHE, null);
}
オンラインデータソースではcacheWriteDataSource
の設定が行われる.
DownloaderConstructorHelper#buildCacheDataSource
で特に指定がない限り,
cacheWriteDataSource
には, オンラインデータソース(upstream
)とCacheDataSink
が設定されたTeeDataSource
が指定される.
これで, オンラインデータソースの情報がCacheに保存される.
// CacheDataSource#CacheDataSource(...)
public CacheDataSource(Cache cache, DataSource upstream, DataSource cacheReadDataSource,
DataSink cacheWriteDataSink, @Flags int flags, @Nullable EventListener eventListener) {
...
if (cacheWriteDataSink != null) {
this.cacheWriteDataSource = new TeeDataSource(upstream, cacheWriteDataSink);
CacheDataSource
はwriteDataSink
が指定されている場合は, TeeDataSource
をcurrentDataSource
として設定する.
// CacheDataSource#openNextSource
if (cacheWriteDataSource != null) {
currentDataSource = cacheWriteDataSource;
lockedSpan = span;
} else {
currentDataSource = upstreamDataSource;
cache.releaseHoleSpan(span);
}
TeeDataSource
はオンラインデータソースの情報をdataSink
に書き込みながら読み込み処理を行う.
// TeeDataSource#read
@Override
public int read(byte[] buffer, int offset, int max) throws IOException {
int num = upstream.read(buffer, offset, max);
if (num > 0) {
// TODO: Consider continuing even if disk writes fail.
dataSink.write(buffer, offset, num);
}
return num;
}
ここで書き込まれているdataSink
はDownloaderConstructorHelper
で生成された(デフォルトだと)CacheDataSink
になる.
// DownloaderConstructorHelper#buildCacheDataSource
DataSink cacheWriteDataSink = cacheWriteDataSinkFactory != null
? cacheWriteDataSinkFactory.createDataSink()
: new CacheDataSink(cache, CacheDataSource.DEFAULT_MAX_CACHE_FILE_SIZE);
CacheDataSink.write
によってファイルへの書き込みが行われる.
// CacheDataSink#openNextOutputStream
cache.startFile(dataSpec.key, dataSpec.absoluteStreamPosition + dataSpecBytesWritten,
maxLength);
underlyingFileOutputStream = new FileOutputStream(file);
if (bufferSize > 0) {
if (bufferedOutputStream == null) {
bufferedOutputStream = new ReusableBufferedOutputStream(underlyingFileOutputStream,
bufferSize);
} else {
bufferedOutputStream.reset(underlyingFileOutputStream);
}
outputStream = bufferedOutputStream;
// CacheDataSink#write
outputStream.write(buffer, offset + bytesWritten, bytesToWrite);
書き込まれる対象のファイルはChache.startFile
から取得できる.
// SimpleCache#startFile
return SimpleCacheSpan.getCacheFile(cacheDir, index.assignIdForKey(key), position,
System.currentTimeMillis());
SimpleCacheSpan.getCacheFile
でキャッシュするべきファイルパスを取得できる.
// SimpleCacheSpan#getCacheFile
return new File(cacheDir, id + "." + position + "." + lastAccessTimestamp + SUFFIX);
SegmentDownloader.download
で各セグメントをキャッシュする.
// SegmentDownloader#download
for (int i = 0; i < segments.size(); i++) {
CacheUtil.cache(segments.get(i).dataSpec, cache, dataSource, buffer,
priorityTaskManager, C.PRIORITY_DOWNLOAD, cachingCounters, true);
CacheUtil.cache
により, オンラインストリームの情報が永続化される.
// CacheUtil#cache(...)
while (left != 0) {
long blockLength = cache.getCachedBytes(key, start,
left != C.LENGTH_UNSET ? left : Long.MAX_VALUE);
if (blockLength > 0) {
// Skip already cached data.
キャッシュヒットした場合はオンラインソースからの情報取得をスキップする.
// CacheUtil#cache(...)
if (blockLength > 0) {
// Skip already cached data.
} else {
// There is a hole in the cache which is at least "-blockLength" long.
blockLength = -blockLength;
long read = readAndDiscard(dataSpec, start, blockLength, dataSource, buffer,
priorityTaskManager, priority, counters);
キャッシュヒットしなかった場合はデータソースから読み込む.
このデータソースはTeeDataSource
であるため, オンラインデータソースから読み込みながらキャッシュへ書き込むことになる.
// CacheUtil#readAndDiscard
int read = dataSource.read(buffer, 0,
length != C.LENGTH_UNSET ? (int) Math.min(buffer.length, length - totalRead)
: buffer.length);
// CacheUtil#cache(DataSpec, Cache, DataSource, CachingCounters)
cache(dataSpec, cache, new CacheDataSource(cache, upstream),
new byte[DEFAULT_BUFFER_SIZE_BYTES], null, 0, counters, false);
キャッシュインデックスファイル: cached_content_index.exi
キャッシュコンテンツファイル:下記パターンにマッチするファイル名
Matcher matcher = CACHE_FILE_PATTERN_V3.matcher(name);
キャッシュコンテンツのバージョンが古い場合(現時点だと.v1.exo
, or .v2.exo
なファイル)はアップグレード処理の機能が動く.
// SimpleCacheSpan#upgradeFile
private static File upgradeFile(File file, CachedContentIndex index) {
String key;
String filename = file.getName();
Matcher matcher = CACHE_FILE_PATTERN_V2.matcher(filename);
if (matcher.matches()) {
key = Util.unescapeFileName(matcher.group(1));
if (key == null) {
return null;
}
} else {
matcher = CACHE_FILE_PATTERN_V1.matcher(filename);
if (!matcher.matches()) {
return null;
}
key = matcher.group(1); // Keys were not escaped in version 1.
}
File newCacheFile = getCacheFile(file.getParentFile(), index.assignIdForKey(key),
Long.parseLong(matcher.group(2)), Long.parseLong(matcher.group(3)));
if (!file.renameTo(newCacheFile)) {
return null;
}
return newCacheFile;
}
これらに該当しないファイル(ExoDownloader管理外ファイル)は不要ファイルとしてSimpleCache.initialize()
で削除される.
// SimpleCache#initialize
File[] files = cacheDir.listFiles();
if (files == null) {
return;
}
for (File file : files) {
if (file.getName().equals(CachedContentIndex.FILE_NAME)) {
continue;
}
SimpleCacheSpan span = file.length() > 0
? SimpleCacheSpan.createCacheEntry(file, index) : null;
if (span != null) {
addSpan(span);
} else {
file.delete();
}
}
インデックス
CachedContentIndex
のコンストラクタパラメータencrypt
をtrue
にすればインデックスファイルが暗号化される.
// CachedContentIndex#CachedContentIndex(File, byte[], boolean)
public CachedContentIndex(File cacheDir, byte[] secretKey, boolean encrypt) {
this.encrypt = encrypt;
if (secretKey != null) {
Assertions.checkArgument(secretKey.length == 16);
try {
cipher = getCipher();
secretKeySpec = new SecretKeySpec(secretKey, "AES");
} catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
throw new IllegalStateException(e); // Should never happen.
}
...
// CachedContentIndex#getChipher
// Workaround for https://issuetracker.google.com/issues/36976726
if (Util.SDK_INT == 18) {
try {
return Cipher.getInstance("AES/CBC/PKCS5PADDING", "BC");
} catch (Throwable ignored) {
// ignored
}
}
return Cipher.getInstance("AES/CBC/PKCS5PADDING");
...
// CachedContentIndex#writeFile
if (encrypt) {
byte[] initializationVector = new byte[16];
new Random().nextBytes(initializationVector);
output.write(initializationVector);
IvParameterSpec ivParameterSpec = new IvParameterSpec(initializationVector);
try {
cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec);
} catch (InvalidKeyException | InvalidAlgorithmParameterException e) {
throw new IllegalStateException(e); // Should never happen.
}
output.flush();
output = new DataOutputStream(new CipherOutputStream(bufferedOutputStream, cipher));
}
...
インデックスファイルを読み込む
// CachedContentIndex#readFile
int count = input.readInt();
int hashCode = 0;
for (int i = 0; i < count; i++) {
CachedContent cachedContent = new CachedContent(input);
add(cachedContent);
hashCode += cachedContent.headerHashCode();
}
キャッシュファイルはインデックス情報と紐づけて管理されている.
インデックス情報のクラスはSimpleCache
クラスで生成される.
// SimpleCache#SimpleCache(File, CacheEvictor, byte[], boolean)
this(cacheDir, evictor, new CachedContentIndex(cacheDir, secretKey, encrypt));
インデックス情報はSimpleCache
の初期化時に読み込まれる.
// SimpleCache#initialize
index.load();
インデックス情報が格納されたファイルは下記のような構造になっており, フォーマットにしたがって順番にロードされる.
(暗号化されている場合はIV情報が格納されて, number_of_CachedContent
以降が暗号化される)
private final byte[] testIndexV1File = {
0, 0, 0, 1, // version
0, 0, 0, 0, // flags
(byte) 0xFA, 0x12, ..., // IV
0, 0, 0, 2, // number_of_CachedContent
// number_of_CachedContentの分格納される
0, 0, 0, 5, // cache_id
0, 5, 65, 66, 67, 68, 69, // cache_key
0, 0, 0, 0, 0, 0, 0, 10, // original_content_length
(byte) 0xF6, (byte) 0xFB, 0x50, 0x41 // hashcode_of_CachedContent_array
};
// CachedContentIndex#readFile
DataInputStream inputStream = new DataInputStream(new BufferedInputStream(atomicFile.openRead()));
int version = input.readInt();
int flags = input.readInt();
if ((flags & FLAG_ENCRYPTED_INDEX) != 0) input.readFully(initializationVector);
int count = input.readInt();
for (int i = 0; i < count; i++) {
CachedContent cachedContent = new CachedContent(input);
add(cachedContent)
hashCode += cachedContent.headerHashCode();
}
if (input.readInt() != hashCode) return false;
CachedContent
の情報を読み込む際にインデックス情報をメモリにロードする.
上記のインデックス情報にはコンテンツのメタ情報が格納されている.
- id: 元のストリームを識別するためのファイルID
- key: 元のストリームを識別するためのキー
- length: 元のストリームの長さ
// CachedContentIndex#add(CachedContent)
private void add(CachedContent cachedContent) {
keyToContent.put(cachedContent.key, cachedContent);
idToKey.put(cachedContent.id, cachedContent.key);
}
鍵の保存
HlsDownloader.loadManifest
でマニフェストがパース・ロードされる.
// HlsDownloader#loadManifest
ParsingLoadable<HlsPlaylist> loadable = new ParsingLoadable<>(dataSource, dataSpec,
C.DATA_TYPE_MANIFEST, new HlsPlaylistParser());
loadable.load();
ロード時にはTeeDataSource
が設定されたDataSourceInputStreamから読み込まれるため,
マニフェストはこのタイミングで永続化される.
DataSourceInputStream inputStream = new DataSourceInputStream(dataSource, dataSpec);
try {
inputStream.open();
result = parser.parse(dataSource.getUri(), inputStream);
さらに, パース処理ではマニフェストの先頭から各行ごとに解析される.
鍵の情報はSegment
インスタンスにも記録されていく.
// HlsPlaylistParser#parse
if (line.isEmpty()) {
// Do nothing.
} else if (line.startsWith(TAG_STREAM_INF)) {
extraLines.add(line);
return parseMasterPlaylist(new LineIterator(extraLines, reader), uri.toString());
} else if (line.startsWith(TAG_TARGET_DURATION)
|| line.startsWith(TAG_MEDIA_SEQUENCE)
|| line.startsWith(TAG_MEDIA_DURATION)
|| line.startsWith(TAG_KEY)
|| line.startsWith(TAG_BYTERANGE)
|| line.equals(TAG_DISCONTINUITY)
|| line.equals(TAG_DISCONTINUITY_SEQUENCE)
|| line.equals(TAG_ENDLIST)) {
extraLines.add(line);
return parseMediaPlaylist(new LineIterator(extraLines, reader), uri.toString());
// HlsPlaylistParser#parseMediaPlaylist
} else if (line.startsWith(TAG_KEY)) {
String method = parseStringAttr(line, REGEX_METHOD);
String keyFormat = parseOptionalStringAttr(line, REGEX_KEYFORMAT);
encryptionKeyUri = null;
encryptionIV = null;
if (!METHOD_NONE.equals(method)) {
encryptionIV = parseOptionalStringAttr(line, REGEX_IV);
if (KEYFORMAT_IDENTITY.equals(keyFormat) || keyFormat == null) {
if (METHOD_AES_128.equals(method)) {
// The segment is fully encrypted using an identity key.
encryptionKeyUri = parseStringAttr(line, REGEX_URI);
} else {
// Do nothing. Samples are encrypted using an identity key, but this is not supported.
// Hopefully, a traditional DRM alternative is also provided.
}
...
} else if (!line.startsWith("#")) {
String segmentEncryptionIV;
if (encryptionKeyUri == null) {
segmentEncryptionIV = null;
} else if (encryptionIV != null) {
segmentEncryptionIV = encryptionIV;
} else {
segmentEncryptionIV = Integer.toHexString(segmentMediaSequence);
}
segments.add(new Segment(line, segmentDurationUs, relativeDiscontinuitySequence,
segmentStartTimeUs, encryptionKeyUri, segmentEncryptionIV,
segmentByteRangeOffset, segmentByteRangeLength));
ここで追加されたSegmentはダウンローダが保存する形式のSegmentに変換される.
プレイリストのセグメント:
HlsMediaPlaylist.Segment#Segment
ダウンローダのセグメント:
SegmentDownloader.Segment
変換はHlsDownloader#addSegment
で行われる.
Segmentに鍵情報が格納されているので, fullSegmentEncryptionKeyUri != null
となる.
encryptionKeyUris
はHashSet
なので, 新しい鍵Uriの場合にencryptionKeyUris.add(keyUri)
がtrue
を返し,
その鍵のURI情報はDataSpec
のuri
として格納され, 一つのセグメントとしてコレクションに追加される.
// HlsDownloader#addSegment
if (hlsSegment.fullSegmentEncryptionKeyUri != null) {
Uri keyUri = UriUtil.resolveToUri(mediaPlaylist.baseUri,
hlsSegment.fullSegmentEncryptionKeyUri);
if (encryptionKeyUris.add(keyUri)) {
segments.add(new Segment(startTimeUs, new DataSpec(keyUri)));
}
}
Uri resolvedUri = UriUtil.resolveToUri(mediaPlaylist.baseUri, hlsSegment.url);
segments.add(new Segment(startTimeUs,
new DataSpec(resolvedUri, hlsSegment.byterangeOffset, hlsSegment.byterangeLength, null)));
セグメントのコレクションはSegmentDownloader
によってキャッシュされるので, 結果的に鍵情報も同じように永続化される.
for (int i = 0; i < segments.size(); i++) {
CacheUtil.cache(segments.get(i).dataSpec, cache, dataSource, buffer,
priorityTaskManager, C.PRIORITY_DOWNLOAD, cachingCounters, true);
.v3.exo
ファイルサイズの上限はCacheDataSource#DEFAULT_MAX_CACHE_FILE_SIZE
で定義されており,
デフォルトで2MiB(2 * 1024 * 1024)が指定されている.
これを変更するにはDownloaderConstructorHelper
のコンストラクタ引数cacheWriteDataSinkFactory
に自前のDataSink.Factory
を設定する.
Cache cache = new SimpleCache(dir, new NoOpCacheEvictor());
DownloaderConstructorHelper constructor =
new DownloaderConstructorHelper(cache,
new DefaultHttpDataSourceFactory("ExoPlayer", null),
null,
new CacheDataSinkFactory(cache, 20480),
null);
.v3.exo
の1ファイルあたりの上限サイズは2MiBがデフォルトで, これを超えると次の.v3.exo
ファイルに分割保存される.
.v3.exo
はSegmentDownloader.Segment
の単位で保存され, のファイル名は
<id>.<ストリームの書き込みバイト位置>.<ファイル書き込み時のタイムスタンプ>.<バージョン>.exo
の形式で決まる.
idはSegmentDownloader.Segment
の単位で管理されており, idが同じであれば同じセグメントを指す.
(SegmentDownloader.Segment
はPlaylistや.ts, 暗号キーといった単位を表現する)
セグメントが変わればidも変わるため, .exoの書き込み容量が上限サイズを迎えていなくても次のファイル名に変わる.
分割保存された.exo
ファイルを, 後に結合するためには前述のcached_content_index.exi
にある
インデックス情報(id, key, content length)を使って復元される.
暗号化
.exi
は平文で保存されるのがデフォルトの挙動.
これを暗号化して保存したい場合はSimpleCache
に秘密鍵を渡すことで実現できる.
byte[] secretKey = "Bar12345Bar12345".getBytes("UTF-8")
new SimpleCache(cacheDir, new NoOpCacheEvictor(), secretKey);