feat(stories): публикация видео-историй + фикс TTL

This commit is contained in:
klockky
2026-07-24 15:31:50 +03:00
parent f7265b4fe8
commit b76da00f56
13 changed files with 723 additions and 96 deletions
+80
View File
@@ -198,6 +198,12 @@ class FileUploader {
final respBody = utf8.decode(result.body, allowMalformed: true);
final hasError =
respBody.contains('error_msg') || respBody.contains('error_code');
if (result.status != 200 || hasError) {
logger.w(
'uploadMediaFile rejected: status=${result.status} '
'body=${respBody.length > 500 ? respBody.substring(0, 500) : respBody}',
);
}
return result.status == 200 && !hasError;
} catch (e) {
logger.w('uploadMediaFile: $e');
@@ -292,6 +298,80 @@ class FileUploader {
}
}
/// Загрузка видео для истории. В отличие от чата (чанковый `uploadVideoPath`
/// с GET-handshake), story-эндпоинт `su.oneme.ru/uploadVideo` ждёт **один POST
/// на весь файл** (как у оригинального клиента) и возвращает медиа-токен в теле
/// ответа — `[{"token":"..."}]`. Именно этот токен идёт в `media.token`
/// STORIES_SEND, а не токен из ответа VIDEO_UPLOAD.
Future<({bool ok, String? token})> uploadVideoWithToken(
Uri uri,
File file, {
void Function(int sent, int total)? onProgress,
}) async {
final session = api.session;
if (session == null) return (ok: false, token: null);
try {
final result = await _consume(
session.uploadFilePath(
url: uri.toString(),
path: file.path,
filename: _syntheticFilename(),
contentType: 'application/octet-stream',
connection: 'close',
),
onProgress: onProgress,
);
if (result.error != null || result.status != 200) {
logger.w(
'uploadVideoWithToken: status=${result.status} error=${result.error}',
);
return (ok: false, token: null);
}
final token = _parseVideoToken(
utf8.decode(result.body, allowMalformed: true),
);
return (ok: true, token: token);
} catch (e) {
logger.w('uploadVideoWithToken: $e');
return (ok: false, token: null);
}
}
String? _parseVideoToken(String body) {
try {
final json = jsonDecode(body);
// Сервер отвечает массивом: [{"token":"..."}]
if (json is List) {
for (final v in json) {
if (v is Map) {
final token = v['token'];
if (token is String && token.isNotEmpty) return token;
}
}
}
if (json is Map) {
for (final key in const ['videos', 'video', 'photos']) {
final node = json[key];
if (node is Map) {
for (final v in node.values) {
if (v is Map) {
final token = v['token'];
if (token is String && token.isNotEmpty) return token;
}
}
}
}
for (final key in const ['token', 'videoToken', 'photoToken']) {
final t = json[key];
if (t is String && t.isNotEmpty) return t;
}
}
} catch (e) {
logger.w('parseVideoToken: $e');
}
return null;
}
/// Прогоняет стрим ядра до конца, форвардит прогресс, отдаёт итог.
Future<({int status, Uint8List body, String? error})> _consume(
Stream<kb.UploadEvent> stream, {
+7 -4
View File
@@ -1329,8 +1329,11 @@ class MessagesModule {
);
}
Future<String?> requestPhotoUploadUrl() async {
final response = await _api.sendRequest(Opcode.photoUpload, {'count': 1});
Future<String?> requestPhotoUploadUrl({int? type}) async {
final response = await _api.sendRequest(Opcode.photoUpload, {
'type': ?type,
'count': 1,
});
if (!response.isOk) return null;
final data = response.payload;
if (data is! Map) return null;
@@ -1371,10 +1374,10 @@ class MessagesModule {
);
}
Future<VideoUploadInfo?> requestVideoUploadUrl() async {
Future<VideoUploadInfo?> requestVideoUploadUrl({int type = 0}) async {
final response = await _api.sendRequest(Opcode.videoUpload, {
'uploaderType': 0,
'type': 0,
'type': type,
'count': 1,
});
if (!response.isOk) return null;
+35 -3
View File
@@ -322,13 +322,45 @@ class StoriesModule {
}
/// Публикация фото-истории. [photoToken] — токен уже загруженного фото.
/// [settings]: 1 = видно всем, 2 = только контактам. [expiration] — TTL, сек.
/// [settings]: 1 = видно всем, 2 = только контактам. [expiration] — TTL, мс.
/// Бросает [PacketError]/[TimeoutException] при ошибке сервера — чтобы UI
/// показал реальную причину, а не общее «не удалось».
Future<void> publishPhoto({
required String photoToken,
int settings = 1,
int expiration = 86400,
int expiration = 86400000,
}) {
return _publishMedia(
media: {'_type': 'PHOTO', 'photoToken': photoToken},
settings: settings,
expiration: expiration,
);
}
/// Публикация видео-истории. [videoToken] — токен уже загруженного видео
/// (`VideoUploadInfo.token`), [durationMs] — длительность ролика в мс.
Future<void> publishVideo({
required String videoToken,
int? durationMs,
int settings = 1,
int expiration = 86400000,
}) {
return _publishMedia(
media: {
'_type': 'VIDEO',
'videoType': 2,
'token': videoToken,
'duration': ?durationMs,
},
settings: settings,
expiration: expiration,
);
}
Future<void> _publishMedia({
required Map<String, dynamic> media,
required int settings,
required int expiration,
}) async {
if (_api.state != SessionState.online) {
throw const PacketError('Нет соединения с сервером');
@@ -339,7 +371,7 @@ class StoriesModule {
{
'cid': cid,
'settings': settings,
'media': {'_type': 'PHOTO', 'photoToken': photoToken},
'media': media,
'expiration': expiration,
},
],