Initial commit

This commit is contained in:
admin1
2026-07-14 11:11:36 +08:00
commit 656499cf94
604 changed files with 119518 additions and 0 deletions
@@ -0,0 +1,59 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:smplayer/core/infra/emby_api/emby_headers.dart';
void main() {
group('EmbyRequestHeaders.media', () {
test('媒体流 UA 使用基础 UA', () {
final headers = EmbyRequestHeaders.media(
deviceId: '6f43c257-2d5e-407e-9827-0a59d37ceb97',
deviceName: 'TestDevice',
token: 't',
userId: 'u',
);
expect(headers['User-Agent'], EmbyRequestHeaders.userAgent);
expect(headers['Accept'], '*/*');
expect(headers['Accept-Language'], EmbyRequestHeaders.acceptLanguage);
});
});
group('EmbyRequestHeaders.directMedia', () {
test('播放器直连头不包含凭据或设备标识', () {
final headers = EmbyRequestHeaders.directMedia();
final normalizedNames = headers.keys
.map((name) => name.toLowerCase())
.toSet();
expect(headers['User-Agent'], EmbyRequestHeaders.userAgent);
expect(headers['Accept'], '*/*');
expect(normalizedNames, isNot(contains('authorization')));
expect(normalizedNames, isNot(contains('cookie')));
expect(normalizedNames, isNot(contains('x-emby-token')));
expect(normalizedNames, isNot(contains('x-mediabrowser-token')));
expect(normalizedNames, isNot(contains('x-emby-authorization')));
expect(normalizedNames, isNot(contains('x-emby-device-id')));
});
});
group('SenPlayer 身份对齐', () {
test('UA 为 SenPlayer 固定值', () {
expect(EmbyRequestHeaders.userAgent, 'SenPlayer/6.2.0');
expect(EmbyRequestHeaders.clientName, 'SenPlayer');
expect(EmbyRequestHeaders.version, '6.1.0');
});
test('auth 头字面格式与 SenPlayer 身份一致', () {
final auth = EmbyAuthHeader.build(
deviceId: 'd',
deviceName: 'Mac',
token: 't',
userId: 'u',
);
expect(
auth,
'MediaBrowser Token="t", Emby UserId="u", Client="SenPlayer", '
'Device="Mac", DeviceId="d", Version="6.1.0"',
);
});
});
}
@@ -0,0 +1,244 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:smplayer/core/contracts/library.dart';
import 'package:smplayer/core/contracts/playback.dart';
import 'package:smplayer/core/domain/ports/emby_gateway.dart';
import 'package:smplayer/core/infra/emby_api/playback_resolver.dart';
class _UnusedEmbyGateway implements EmbyGateway {
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
void main() {
const ctx = AuthedRequestContext(
baseUrl: 'https://emby.example.com',
token: 'tok123',
userId: 'user1',
);
final resolver = PlaybackResolver(
gateway: _UnusedEmbyGateway(),
deviceId: 'device-1',
deviceName: 'TestBox',
);
EmbyRawItem item({int? resumeTicks}) => EmbyRawItem(
Id: 'item1',
Name: 'Movie One',
Type: 'Movie',
RunTimeTicks: 600 * 10000000,
UserData: resumeTicks == null
? null
: EmbyRawUserData(PlaybackPositionTicks: resumeTicks),
);
Map<String, dynamic> playbackInfo(Map<String, dynamic> source) => {
'PlaySessionId': 'ps1',
'MediaSources': [source],
};
group('PlaybackResolver.prepare 流地址优先级', () {
test('DirectStreamUrl 优先,playMethod=DirectStream', () async {
final result = await resolver.prepare(
ctx: ctx,
payload: const PlaybackRequestPayload(itemId: 'item1'),
preloadedItem: item(),
preloadedPlaybackInfo: playbackInfo({
'Id': 's1',
'Container': 'mkv',
'DirectStreamUrl': '/Videos/item1/stream.mkv?x=1',
'TranscodingUrl': '/Videos/item1/main.m3u8',
'SupportsDirectStream': true,
}),
);
expect(
result.streamUrl,
'https://emby.example.com/Videos/item1/stream.mkv?x=1&api_key=tok123',
);
expect(result.playMethod, 'DirectStream');
expect(result.directStream, isTrue);
});
test('无 DirectStreamUrl 时用 TranscodingUrlplayMethod=Transcode', () async {
final result = await resolver.prepare(
ctx: ctx,
payload: const PlaybackRequestPayload(itemId: 'item1'),
preloadedItem: item(),
preloadedPlaybackInfo: playbackInfo({
'Id': 's1',
'Container': 'mkv',
'TranscodingUrl': '/Videos/item1/main.m3u8',
}),
);
expect(result.streamUrl, contains('/Videos/item1/main.m3u8'));
expect(result.streamUrl, contains('api_key=tok123'));
expect(result.playMethod, 'Transcode');
});
test('跨域绝对 CDN 地址不追加 Emby token', () async {
final result = await resolver.prepare(
ctx: ctx,
payload: const PlaybackRequestPayload(itemId: 'item1'),
preloadedItem: item(),
preloadedPlaybackInfo: playbackInfo({
'Id': 's1',
'Container': 'mkv',
'DirectStreamUrl':
'https://cdn.example.net/media/movie.mkv?signature=signed',
}),
);
expect(
result.streamUrl,
'https://cdn.example.net/media/movie.mkv?signature=signed',
);
expect(result.streamUrl, isNot(contains('tok123')));
expect(result.streamUrl, isNot(contains('api_key')));
});
test('部署在子路径下时保留 Emby base path', () async {
const subpathContext = AuthedRequestContext(
baseUrl: 'https://emby.example.com/emby',
token: 'tok123',
userId: 'user1',
);
final result = await resolver.prepare(
ctx: subpathContext,
payload: const PlaybackRequestPayload(itemId: 'item1'),
preloadedItem: item(),
preloadedPlaybackInfo: playbackInfo({
'Id': 's1',
'Container': 'mkv',
'DirectStreamUrl': '/Videos/item1/stream.mkv',
}),
);
expect(
result.streamUrl,
'https://emby.example.com/emby/Videos/item1/stream.mkv?api_key=tok123',
);
});
test('两者皆无时回退静态流(含容器扩展名与鉴权参数)', () async {
final result = await resolver.prepare(
ctx: ctx,
payload: const PlaybackRequestPayload(itemId: 'item1'),
preloadedItem: item(),
preloadedPlaybackInfo: playbackInfo({'Id': 's1', 'Container': 'mp4'}),
);
expect(
result.streamUrl,
startsWith('https://emby.example.com/Videos/item1/stream.mp4?'),
);
expect(result.streamUrl, contains('api_key=tok123'));
expect(result.streamUrl, contains('MediaSourceId=s1'));
expect(result.streamUrl, contains('PlaySessionId=ps1'));
expect(result.playMethod, 'DirectStream');
});
test('按 mediaSourceId 选源,未命中回退首源', () async {
final info = {
'MediaSources': [
{'Id': 's1', 'DirectStreamUrl': '/Videos/item1/a.mkv'},
{'Id': 's2', 'DirectStreamUrl': '/Videos/item1/b.mkv'},
],
};
final picked = await resolver.prepare(
ctx: ctx,
payload: const PlaybackRequestPayload(
itemId: 'item1',
mediaSourceId: 's2',
),
preloadedItem: item(),
preloadedPlaybackInfo: info,
);
expect(picked.streamUrl, contains('/Videos/item1/b.mkv'));
final fallback = await resolver.prepare(
ctx: ctx,
payload: const PlaybackRequestPayload(
itemId: 'item1',
mediaSourceId: 'missing',
),
preloadedItem: item(),
preloadedPlaybackInfo: info,
);
expect(fallback.streamUrl, contains('/Videos/item1/a.mkv'));
});
});
group('PlaybackResolver.prepare 续播位置', () {
Future<double> startSecondsFor(PlaybackRequestPayload payload) async {
final result = await resolver.prepare(
ctx: ctx,
payload: payload,
preloadedItem: item(resumeTicks: 120 * 10000000),
preloadedPlaybackInfo: playbackInfo({
'Id': 's1',
'DirectStreamUrl': '/Videos/item1/a.mkv',
}),
);
return result.startPositionSeconds;
}
test('playFromStart 强制从头', () async {
expect(
await startSecondsFor(
const PlaybackRequestPayload(itemId: 'item1', playFromStart: true),
),
0,
);
});
test('显式 startPositionTicks 优先于 UserData', () async {
expect(
await startSecondsFor(
const PlaybackRequestPayload(
itemId: 'item1',
startPositionTicks: 60 * 10000000,
),
),
60,
);
});
test('默认取 UserData.PlaybackPositionTicks', () async {
expect(
await startSecondsFor(const PlaybackRequestPayload(itemId: 'item1')),
120,
);
});
});
group('PlaybackResolver.resolveSubtitles', () {
test('未提供 baseUrl/token 时外挂轨全部过滤,仅留内封(详尽用例见 '
'playback_resolver_subtitles_test.dart', () {
final subtitles = PlaybackResolver.resolveSubtitles(const [
EmbyRawMediaStream(Index: 1, Type: 'Subtitle', Title: 'embedded'),
EmbyRawMediaStream(
Index: 2,
Type: 'Subtitle',
Codec: 'srt',
IsExternal: true,
),
EmbyRawMediaStream(
Index: 3,
Type: 'Subtitle',
Codec: 'srt',
DeliveryMethod: 'External',
),
EmbyRawMediaStream(
Index: 4,
Type: 'Subtitle',
Codec: 'srt',
DeliveryUrl: '/subs/4.srt',
),
EmbyRawMediaStream(Index: 5, Type: 'Audio'),
]);
expect(subtitles, hasLength(1));
expect(subtitles.single.index, 1);
expect(subtitles.single.title, 'embedded');
});
});
}
@@ -0,0 +1,295 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:smplayer/core/contracts/library.dart';
import 'package:smplayer/core/infra/emby_api/playback_resolver.dart';
void main() {
const baseUrl = 'https://emby.example.com';
const token = 'tok123';
const itemId = 'item1';
const mediaSourceId = 'src9';
EmbyRawMediaStream embeddedSubtitle({
int index = 2,
String codec = 'subrip',
String? title,
String? language,
bool isDefault = false,
bool isForced = false,
}) => EmbyRawMediaStream(
Index: index,
Type: 'Subtitle',
Codec: codec,
Title: title,
Language: language,
IsDefault: isDefault,
IsForced: isForced,
);
EmbyRawMediaStream externalSubtitle({
int index = 10,
String codec = 'srt',
String? deliveryUrl = '/Videos/item1/10/Subtitles/stream.srt',
String? deliveryMethod = 'External',
bool isExternal = true,
String? title,
String? language,
}) => EmbyRawMediaStream(
Index: index,
Type: 'Subtitle',
Codec: codec,
IsExternal: isExternal,
DeliveryMethod: deliveryMethod,
DeliveryUrl: deliveryUrl,
Title: title,
Language: language,
);
group('PlaybackResolver.resolveSubtitles 外挂字幕', () {
test('内封轨保留且 isExternal=false(未传 itemId 时无抽取 URL', () {
final subtitles = PlaybackResolver.resolveSubtitles(
[embeddedSubtitle(index: 2, title: '简中', language: 'chi')],
baseUrl: baseUrl,
token: token,
);
expect(subtitles, hasLength(1));
expect(subtitles.single.index, 2);
expect(subtitles.single.isExternal, isFalse);
expect(subtitles.single.deliveryUrl, isNull);
expect(subtitles.single.codec, 'subrip');
});
test('外挂文本轨保留,deliveryUrl 补全 base 与 api_key', () {
final subtitles = PlaybackResolver.resolveSubtitles(
[externalSubtitle(index: 10, codec: 'srt', language: 'chi')],
baseUrl: baseUrl,
token: token,
);
expect(subtitles, hasLength(1));
final track = subtitles.single;
expect(track.isExternal, isTrue);
expect(
track.deliveryUrl,
'$baseUrl/Videos/item1/10/Subtitles/stream.srt?api_key=$token',
);
});
test('deliveryUrl 已是绝对地址时不重复补 base,只补 api_key', () {
final subtitles = PlaybackResolver.resolveSubtitles(
[
externalSubtitle(
deliveryUrl: 'https://cdn.example.com/subs/a.ass',
codec: 'ass',
),
],
baseUrl: baseUrl,
token: token,
);
expect(
subtitles.single.deliveryUrl,
'https://cdn.example.com/subs/a.ass?api_key=$token',
);
});
test('外挂位图轨(pgs/vobsub/dvdsub)被过滤', () {
for (final bitmapCodec in ['pgssub', 'pgs', 'dvdsub', 'sup', 'vobsub']) {
final subtitles = PlaybackResolver.resolveSubtitles(
[externalSubtitle(codec: bitmapCodec)],
baseUrl: baseUrl,
token: token,
);
expect(subtitles, isEmpty, reason: 'codec=$bitmapCodec 应被过滤');
}
});
test('内封位图轨保留(由引擎渲染,行为不变)', () {
final subtitles = PlaybackResolver.resolveSubtitles(
[embeddedSubtitle(codec: 'pgssub')],
baseUrl: baseUrl,
token: token,
);
expect(subtitles, hasLength(1));
expect(subtitles.single.isExternal, isFalse);
});
test('外挂轨缺 DeliveryUrl 时被过滤', () {
final subtitles = PlaybackResolver.resolveSubtitles(
[externalSubtitle(deliveryUrl: null)],
baseUrl: baseUrl,
token: token,
);
expect(subtitles, isEmpty);
});
test('未提供 baseUrl/token 时外挂轨被过滤(内封不受影响)', () {
final subtitles = PlaybackResolver.resolveSubtitles([
embeddedSubtitle(index: 2),
externalSubtitle(index: 10),
]);
expect(subtitles, hasLength(1));
expect(subtitles.single.index, 2);
});
test('仅 DeliveryMethod==External(无 IsExternal 标记)也按外挂处理', () {
final subtitles = PlaybackResolver.resolveSubtitles(
[externalSubtitle(isExternal: false, deliveryMethod: 'External')],
baseUrl: baseUrl,
token: token,
);
expect(subtitles.single.isExternal, isTrue);
});
test('混合列表保持原顺序,各归其类', () {
final subtitles = PlaybackResolver.resolveSubtitles(
[
embeddedSubtitle(index: 2, codec: 'ass'),
externalSubtitle(index: 10, codec: 'srt'),
externalSubtitle(index: 11, codec: 'pgssub'),
embeddedSubtitle(index: 3, codec: 'subrip'),
],
baseUrl: baseUrl,
token: token,
);
expect(subtitles.map((t) => t.index).toList(), [2, 10, 3]);
expect(subtitles.map((t) => t.isExternal).toList(), [false, true, false]);
});
});
group('PlaybackResolver.resolveSubtitles 内封文本抽取地址', () {
test('内封文本轨生成服务端抽取 deliveryUrlisExternal 仍为 false', () {
final subtitles = PlaybackResolver.resolveSubtitles(
[embeddedSubtitle(index: 2, codec: 'subrip', language: 'chi')],
baseUrl: baseUrl,
token: token,
itemId: itemId,
mediaSourceId: mediaSourceId,
);
expect(subtitles, hasLength(1));
final track = subtitles.single;
expect(track.isExternal, isFalse);
expect(
track.deliveryUrl,
'$baseUrl/Videos/$itemId/$mediaSourceId/Subtitles/2/0/Stream.subrip'
'?api_key=$token',
);
});
test('扩展名用 codec 原名 passthroughwebvtt 归一化为 vtt', () {
final cases = {'subrip': 'subrip', 'ssa': 'ssa', 'webvtt': 'vtt'};
cases.forEach((codec, ext) {
final subtitles = PlaybackResolver.resolveSubtitles(
[embeddedSubtitle(index: 4, codec: codec)],
baseUrl: baseUrl,
token: token,
itemId: itemId,
mediaSourceId: mediaSourceId,
);
expect(
subtitles.single.deliveryUrl,
'$baseUrl/Videos/$itemId/$mediaSourceId/Subtitles/4/0/Stream.$ext'
'?api_key=$token',
reason: 'codec=$codec 应映射为 .$ext',
);
});
});
test('srt/ass/vtt codec 直接用作扩展名,含 /0/ 段', () {
final cases = {'srt': 'srt', 'ass': 'ass', 'vtt': 'vtt'};
cases.forEach((codec, ext) {
final subtitles = PlaybackResolver.resolveSubtitles(
[embeddedSubtitle(index: 5, codec: codec)],
baseUrl: baseUrl,
token: token,
itemId: itemId,
mediaSourceId: mediaSourceId,
);
expect(
subtitles.single.deliveryUrl,
endsWith('/Subtitles/5/0/Stream.$ext?api_key=$token'),
);
});
});
test('内封位图轨不生成抽取 URLdeliveryUrl 为 null', () {
for (final bitmapCodec in ['pgssub', 'pgs', 'dvdsub', 'vobsub', 'sup']) {
final subtitles = PlaybackResolver.resolveSubtitles(
[embeddedSubtitle(index: 6, codec: bitmapCodec)],
baseUrl: baseUrl,
token: token,
itemId: itemId,
mediaSourceId: mediaSourceId,
);
expect(subtitles, hasLength(1));
expect(
subtitles.single.deliveryUrl,
isNull,
reason: 'codec=$bitmapCodec 不应生成抽取 URL',
);
}
});
test('缺 mediaSourceId 时内封文本轨不生成抽取 URL', () {
final subtitles = PlaybackResolver.resolveSubtitles(
[embeddedSubtitle(index: 2, codec: 'subrip')],
baseUrl: baseUrl,
token: token,
itemId: itemId,
);
expect(subtitles.single.deliveryUrl, isNull);
});
test('外挂轨不受内封抽取逻辑影响,仍用自身 DeliveryUrl', () {
final subtitles = PlaybackResolver.resolveSubtitles(
[
embeddedSubtitle(index: 2, codec: 'subrip'),
externalSubtitle(index: 10, codec: 'srt'),
],
baseUrl: baseUrl,
token: token,
itemId: itemId,
mediaSourceId: mediaSourceId,
);
final embedded = subtitles.firstWhere((t) => !t.isExternal);
final external = subtitles.firstWhere((t) => t.isExternal);
expect(
embedded.deliveryUrl,
'$baseUrl/Videos/$itemId/$mediaSourceId/Subtitles/2/0/Stream.subrip'
'?api_key=$token',
);
expect(
external.deliveryUrl,
'$baseUrl/Videos/item1/10/Subtitles/stream.srt?api_key=$token',
);
});
});
group('PlaybackResolver.isTextSubtitleCodec', () {
test('文本 codec 判定大小写不敏感', () {
for (final textCodec in [
'srt',
'SUBRIP',
'vtt',
'WebVTT',
'ass',
'ssa',
]) {
expect(
PlaybackResolver.isTextSubtitleCodec(textCodec),
isTrue,
reason: 'codec=$textCodec',
);
}
});
test('位图/未知/null codec 返回 false', () {
for (final nonText in ['pgssub', 'dvdsub', 'sup', 'unknown', null]) {
expect(
PlaybackResolver.isTextSubtitleCodec(nonText),
isFalse,
reason: 'codec=$nonText',
);
}
});
});
}