// 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 _cache = {}; /// درخواست‌های در حال اجرا (deduplication) — اگر همزمان چند جا یک id را /// بخواهند، یک تماسِ شبکه کافی است. final Map> _inFlight = >{}; /// مدت‌زمانِ cached شده‌ی فایل صوتی/ویدیویی (ثانیه) — null اگر ناشناخته. int? durationFor(String? id) { if (id == null || id.isEmpty) return null; return _cache[id]?.duration; } 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 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> resolveBulk(Iterable ids) async { final unique = ids.where((e) => e.isNotEmpty).toSet(); final results = {}; 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 _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(); _cache[id] = _CachedUrl( url: url, duration: duration, 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 DateTime savedAt; const _CachedUrl({ required this.url, required this.savedAt, this.duration, }); }