didvan-app/lib/services/s3/s3_media_service.dart

155 lines
5.8 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// lib/services/s3/s3_media_service.dart
//
// تبدیلِ media-id (uuid) به URL signed-شده‌ی Arvan S3:
// GET {apiBaseUrl}/s3/{id} → JSON با فیلد `url` (presigned، حدود یک ساعت معتبر).
//
// نتایج در حافظه کش می‌شوند تا هر id فقط یک‌بار از سرور گرفته شود.
// در صورت expire شدنِ URL، اپ نیاز به refresh دارد (در نسخه‌های بعدی می‌شود
// زمان انقضا را parse کرد و auto-refresh اضافه نمود).
import 'dart:async';
import 'dart:convert';
import 'dart:developer' as dev;
// ignore: depend_on_referenced_packages
import 'package:http/http.dart' as http;
import 'package:didvan/config/auth_config.dart';
import 'package:didvan/services/auth/auth_interceptor.dart';
import 'package:didvan/services/auth/token_storage.dart';
class S3MediaService {
S3MediaService._();
static final S3MediaService instance = S3MediaService._();
/// کشِ در حافظه: id → (URL، duration، زمانِ ذخیره). URL پس از مدتی منقضی می‌شود.
final Map<String, _CachedUrl> _cache = <String, _CachedUrl>{};
/// درخواست‌های در حال اجرا (deduplication) — اگر همزمان چند جا یک id را
/// بخواهند، یک تماسِ شبکه کافی است.
final Map<String, Future<String?>> _inFlight = <String, Future<String?>>{};
/// مدت‌زمانِ cached شده‌ی فایل صوتی/ویدیویی (ثانیه) — null اگر ناشناخته.
int? durationFor(String? id) {
if (id == null || id.isEmpty) return null;
return _cache[id]?.duration;
}
/// mime-type کش‌شده‌ی فایل — null اگر ناشناخته.
String? mimeFor(String? id) {
if (id == null || id.isEmpty) return null;
return _cache[id]?.mimeType;
}
static const _timeout = Duration(seconds: 15);
// URL signed-شده‌ی Arvan معمولاً ۱ ساعت اعتبار دارد. کمی زودتر refresh می‌کنیم.
static const _cacheTtl = Duration(minutes: 50);
// وقتی duration هنوز null است (احتمالاً ffprobe در background بک‌اند),
// کش را کوتاه نگه می‌داریم تا زود دوباره چک شود.
static const _shortTtl = Duration(seconds: 30);
/// آدرسِ signed-شده‌ی یک media-id را برمی‌گرداند. اگر در کش نباشد، یک‌بار
/// از سرور می‌گیرد. در صورت خطا null.
Future<String?> resolveUrl(String? id) async {
if (id == null || id.isEmpty) return null;
final cached = _cache[id];
if (cached != null) {
// اگر duration پر است، تا پایان TTL طولانی معتبر است.
// اگر duration هنوز null است، فقط TTL کوتاه را قبول کنیم تا زود
// re-fetch شود (شاید بک‌اند تا الان duration را با ffprobe ذخیره کرده باشد).
final age = DateTime.now().difference(cached.savedAt);
final ttl = cached.duration != null ? _cacheTtl : _shortTtl;
if (age < ttl) {
return cached.url;
}
}
final pending = _inFlight[id];
if (pending != null) return pending;
final future = _fetch(id);
_inFlight[id] = future;
try {
final url = await future;
// در _fetch خود cache را با duration پر کرده‌ایم؛ اینجا فقط URL را
// برمی‌گردانیم.
return url;
} finally {
_inFlight.remove(id);
}
}
/// resolve چندِ id به‌صورت موازی. خروجی Map است.
Future<Map<String, String>> resolveBulk(Iterable<String> ids) async {
final unique = ids.where((e) => e.isNotEmpty).toSet();
final results = <String, String>{};
if (unique.isEmpty) return results;
final futures = unique.map((id) async {
final url = await resolveUrl(id);
if (url != null) results[id] = url;
});
await Future.wait(futures);
return results;
}
Future<String?> _fetch(String id) async {
try {
await AuthInterceptor.ensureFreshToken();
final token = TokenStorage.accessToken;
// cache-bust: گاتویِ بک‌اند روی این endpoint کش دارد، که برای URL
// signed-شده (که زود expire می‌شود) مخرب است. با ts متفاوت در
// هر فراخوانی، cache دور زده می‌شود.
final ts = DateTime.now().millisecondsSinceEpoch.toString();
final uri = Uri.parse('${AuthConfig.apiBaseUrl}/s3/$id?t=$ts');
final res = await http.get(uri, headers: {
'Accept': 'application/json',
if (token != null && token.isNotEmpty)
'Authorization': 'Bearer $token',
}).timeout(_timeout);
if (res.statusCode != 200) {
dev.log('s3/$id -> ${res.statusCode}', name: 'S3MediaService');
return null;
}
final body = json.decode(res.body);
if (body is Map && body['url'] is String) {
final url = body['url'] as String;
int? duration;
final d = body['duration'];
if (d is num) duration = d.toInt();
final mime = body['mimeType']?.toString();
_cache[id] = _CachedUrl(
url: url,
duration: duration,
mimeType: mime,
savedAt: DateTime.now(),
);
return url;
}
return null;
} catch (e) {
dev.log('resolveUrl($id) error: $e', name: 'S3MediaService');
return null;
}
}
/// پاک‌کردن کش (مثلا روی logout).
void clear() {
_cache.clear();
_inFlight.clear();
}
}
class _CachedUrl {
final String url;
final int? duration;
final String? mimeType;
final DateTime savedAt;
const _CachedUrl({
required this.url,
required this.savedAt,
this.duration,
this.mimeType,
});
}