155 lines
5.8 KiB
Dart
155 lines
5.8 KiB
Dart
// 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,
|
||
});
|
||
}
|