Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions lib/api/xmly_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,7 @@ class XmlyClient {
AppLogLevel.debug,
'api',
'XmlyClient.request parameters',
localOnly: true,
data: {
'method': method,
'origin': origin,
Expand All @@ -379,7 +380,7 @@ class XmlyClient {
},
);
}
return AppLog.instance.runWithErrorScope(() async {
return HttpLogOperation.run('api_request', () async {
AppLog.instance.trace(
"lib/api/xmly_client.dart::XmlyClient.request.closure#1",
);
Expand Down Expand Up @@ -709,7 +710,9 @@ class XmlyClient {
throw failure;
}
final code = '${payload['ret'] ?? 0}';
diagnostic.apiCode = int.tryParse(code);
if (code != (origin == searchOrigin ? '200' : '0')) {
diagnostic.apiRejected = true;
if (code == '429') _rateLimit(origin, const Duration(seconds: 30));
if (code == '401') {
if (authenticated) _expire();
Expand Down Expand Up @@ -2011,6 +2014,7 @@ class XmlyClient {
AppLogLevel.debug,
'api',
'XmlyClient.resolve parameters',
localOnly: true,
data: {
'trackId': trackId,
'quality': quality ?? this.quality,
Expand All @@ -2019,7 +2023,7 @@ class XmlyClient {
},
);
}
return AppLog.instance.runWithErrorScope(() {
return HttpLogOperation.run('audio_resolve', () {
AppLog.instance.trace(
"lib/api/xmly_client.dart::XmlyClient.resolve.closure#1",
);
Expand Down Expand Up @@ -2056,7 +2060,7 @@ class XmlyClient {
AppLog.instance.trace(
"lib/api/xmly_client.dart::XmlyClient.cachedPrepaidAudio",
);
return AppLog.instance.runWithErrorScope(() {
return HttpLogOperation.run('cached_audio', () {
AppLog.instance.trace(
"lib/api/xmly_client.dart::XmlyClient.cachedPrepaidAudio.closure#1",
);
Expand Down
149 changes: 142 additions & 7 deletions lib/api/xmly_experience.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import 'dart:convert';

import 'package:crypto/crypto.dart';
import 'package:uuid/uuid.dart';

import '../models/xmly_models.dart';
import 'xmly_client.dart';
import "../services/app_log.dart";
Expand Down Expand Up @@ -114,6 +119,98 @@ class AlbumIntroductionData {
final HomeSection recommendations;
}

/// Only the adjacent, bounded page summaries escape a membership scan. The
/// HMAC key never leaves this object, so album markers cannot link scans.
class _SubscriptionPagination {
_SubscriptionPagination()
: _markers = Hmac(sha256, utf8.encode(const Uuid().v4())) {
AppLog.instance.trace(
'lib/api/xmly_experience.dart::_SubscriptionPagination.new',
);
}

final Hmac _markers;
Map<String, Object?>? _previous, _current;

void observe({
required int page,
required int pageSize,
required Object? ret,
required Object? hasMore,
required List<dynamic> rows,
required Set<String> seen,
}) {
AppLog.instance.trace(
'lib/api/xmly_experience.dart::_SubscriptionPagination.observe',
);

final unique = <String>{};
final sequence = <String?>[];
var invalidIds = 0, added = 0;
for (final row in rows) {
final value = row['albumId'];
final text = value is String || value is int ? '$value' : '';
final match = RegExp(r'^(?:xmly:)?([1-9][0-9]{0,18})$').firstMatch(text);
if (match == null) {
invalidIds++;
sequence.add(null);
continue;
}
final id = match[1]!;
if (unique.add(id) && !seen.contains(id)) added++;
sequence.add(
_markers.convert(utf8.encode(id)).toString().substring(0, 16),
);
}
_previous = _current;
_current = Map.unmodifiable({
'page': page,
'pageSize': pageSize,
// XmlyClient.request only returns after checking HTTP status == 200.
'httpStatus': 200,
'ret': ret is int
? ret
: ret is String
? int.tryParse(ret)
: null,
'hasMore': hasMore is bool ? hasMore : null,
'resultCount': rows.length,
'uniqueCount': unique.length,
'duplicateCount': rows.length - invalidIds - unique.length,
'invalidIdCount': invalidIds,
'accumulatedBefore': seen.length,
'newCount': added,
'accumulatedAfter': seen.length + added,
// The endpoint allows at most 50 rows; preserve their order for comparison.
'albumMarkers': List<String?>.unmodifiable(sequence),
});
}

XmlyException failure(String reason, String code, String message) {
AppLog.instance.trace(
'lib/api/xmly_experience.dart::_SubscriptionPagination.failure',
);

return XmlyException(
code,
message,
diagnostics: Map.unmodifiable({
'pagination': Map<String, Object?>.unmodifiable({
'list': 'subscriptions',
'reason': reason,
if (_previous != null) 'previousPage': _previous,
if (_current != null) 'currentPage': _current,
}),
}),
fingerprint: List.unmodifiable([
'xmplus',
'subscription_pagination',
reason,
]),
);
}
}

/// Lite client surfaces and explicit official subscription/favorite mutations.
/// No audio admission, billing, history writes, or credential-bearing caches.
class XmlyExperience {
Expand Down Expand Up @@ -205,6 +302,7 @@ class XmlyExperience {
);

final seen = <String>{};
final subscriptionPages = favorite ? null : _SubscriptionPagination();
for (var page = 1; page <= 10000; page++) {
final Iterable<String> ids;
final bool more;
Expand All @@ -218,7 +316,11 @@ class XmlyExperience {
});
more = result.hasMore;
} else {
final result = await _subscriptions(page: page);
final result = await _subscriptions(
page: page,
diagnostics: subscriptionPages,
seen: seen,
);
ids = result.items.map((item) {
AppLog.instance.trace(
"lib/api/xmly_experience.dart::XmlyExperience._loadMembership.closure#2",
Expand All @@ -230,10 +332,24 @@ class XmlyExperience {
final before = seen.length;
seen.addAll(ids);
if ((ids.isNotEmpty || more) && seen.length == before) {
if (subscriptionPages != null) {
throw subscriptionPages.failure(
'no_progress',
'repeated_page',
'列表分页未前进,请刷新后重试',
);
}
throw const XmlyException('repeated_page', '列表分页未前进,请刷新后重试');
}
if (!more) return seen;
}
if (subscriptionPages != null) {
throw subscriptionPages.failure(
'page_limit',
'page_limit',
'列表超出读取范围,无法确认状态',
);
}
throw const XmlyException('page_limit', '列表超出读取范围,无法确认状态');
}

Expand Down Expand Up @@ -485,24 +601,41 @@ class XmlyExperience {
Future<SubscriptionPage> _subscriptions({
int page = 1,
int pageSize = 30,
_SubscriptionPagination? diagnostics,
Set<String> seen = const {},
}) async {
AppLog.instance.trace(
"lib/api/xmly_experience.dart::XmlyExperience._subscriptions",
);

_requireAccount();
_paging(page, pageSize);
final data = await _get(
final response = await client.request(
'GET',
XmlyClient.mobile,
'/fsubscribe-web/subscribe/comprehensive/rank/$_stamp',
{'pageId': page, 'sign': 2, 'size': pageSize},
query: {'pageId': page, 'sign': 2, 'size': pageSize},
);
if (response['data'] is! Map<String, dynamic>) throw _invalid;
final data = response['data'] as Map<String, dynamic>;
final rows = _list(data['albumResults'], maximum: pageSize);
final pages = diagnostics ?? _SubscriptionPagination();
pages.observe(
page: page,
pageSize: pageSize,
ret: response['ret'],
hasMore: data['hasMore'],
rows: rows,
seen: seen,
);
final items = <SubscriptionItem>[];
final ids = <String>{};
for (final raw in _list(data['albumResults'], maximum: pageSize)) {
for (final raw in rows) {
final row = xmlyMap(raw);
final album = _album(row);
if (!ids.add(album.id))
throw const XmlyException('repeated_page', '订阅列表重复,请刷新后重试');
if (!ids.add(album.id)) {
throw pages.failure('duplicate_ids', 'repeated_page', '订阅列表重复,请刷新后重试');
}
final unread = _integer(row['unreadNum']);
if (unread == null || unread < 0) throw _invalid;
items.add(
Expand All @@ -515,7 +648,9 @@ class XmlyExperience {
);
}
final more = _more(data);
if (more && items.isEmpty) throw _invalid;
if (more && items.isEmpty) {
throw pages.failure('empty_page', _invalid.code, _invalid.message);
}
return SubscriptionPage(items: items, hasMore: more);
}

Expand Down
9 changes: 8 additions & 1 deletion lib/api/xmly_login.dart
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ class XmlyLogin {
AppLogLevel.debug,
'api',
'XmlyLogin._request parameters',
localOnly: true,
data: {
'method': method,
'path': path,
Expand All @@ -217,7 +218,7 @@ class XmlyLogin {
},
);
}
return AppLog.instance.runWithErrorScope(() async {
return HttpLogOperation.run('login_request', () async {
AppLog.instance.trace(
"lib/api/xmly_login.dart::XmlyLogin._request.closure#1",
);
Expand Down Expand Up @@ -355,6 +356,12 @@ class XmlyLogin {
}
}
final transportFailure = retryableTransport(error) || timedOut;
if (error is XmlyLoginError &&
error.upstreamCode != null &&
diagnostic.status == 200) {
diagnostic.apiCode = error.upstreamCode;
diagnostic.apiRejected = true;
}
if (error is! XmlyException && error is! FormatException) {
diagnostic.transportError = '$error';
}
Expand Down
7 changes: 6 additions & 1 deletion lib/common/widgets/logged_network_image.dart
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,12 @@ class LoggedNetworkImage extends ImageProvider<LoggedNetworkImage> {
AppLog.instance.trace(
"lib/common/widgets/logged_network_image.dart::LoggedNetworkImage._loadAsync.begin",
);
return HttpLogAttempt(account: 'image', method: 'GET', url: url)
return HttpLogAttempt(
account: 'image',
method: 'GET',
url: url,
reportFailure: false,
)
..requestHeaders = visibleHeaders
..requestHeaderAvailability = '显式图片请求头;底层 HTTP 库补充的请求头不可见'
..responseIsBinary = true;
Expand Down
4 changes: 4 additions & 0 deletions lib/models/xmly_models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,15 @@ class XmlyException implements Exception {
final String code, message;
final Duration? retryAfter;
final bool definitive;
final Map<String, Object?>? diagnostics;
final List<String>? fingerprint;
const XmlyException(
this.code,
this.message, {
this.retryAfter,
this.definitive = false,
this.diagnostics,
this.fingerprint,
});
@override
String toString() {
Expand Down
1 change: 1 addition & 0 deletions lib/services/album_cache.dart
Original file line number Diff line number Diff line change
Expand Up @@ -766,6 +766,7 @@ class AlbumCache extends ChangeNotifier {
final bytes = BytesBuilder(copy: false);
diagnostic =
HttpLogAttempt(
reportFailure: false,
account: '公共专辑封面(无账号)',
method: 'GET',
url: '$uri',
Expand Down
11 changes: 9 additions & 2 deletions lib/services/app_log.dart
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,10 @@ class AppLog {
bool errorWasRecorded(Object error) =>
_currentErrorScope?.errors?.contains(error) ?? false;

/// A shared producer can deliver one recorded occurrence to another active
/// consumer. This does not suppress future invocations using the same error.
void inheritRecordedError(Object error) => _currentErrorScope?.record(error);

void linkError(Object replacement, Object original) {
final scope = _currentErrorScope;
if (scope != null && (scope.errors?.contains(original) ?? false)) {
Expand Down Expand Up @@ -314,6 +318,7 @@ class AppLog {
StackTrace? stackTrace,
bool networkError = false,
bool promptUser = false,
bool localOnly = false,
String? requestId,
}) {
if (_inside || _closed) return;
Expand All @@ -340,11 +345,13 @@ class AppLog {
_pendingBytes += bytes.length;
if (_persistenceError != null) _boundTail();
final scope = _currentErrorScope;
if (error != null && level.index >= AppLogLevel.error.index) {
if (!localOnly &&
error != null &&
level.index >= AppLogLevel.error.index) {
scope?.record(error);
}
final callback = sink;
if (callback != null && category != 'method') {
if (!localOnly && callback != null && category != 'method') {
try {
final result = callback(
AppLogRecord(
Expand Down
Loading