import 'dart:convert'; import 'dart:io'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:image/image.dart' as img; import 'package:path/path.dart' as p; import 'cbz_store.dart'; import 'ntfy_settings.dart'; const int _kMaxCbzAttachBytes = 15 * 1024 * 1024; const int _kPreviewMaxEdge = 720; const int _kPreviewMaxBytes = 250 * 1024; /// Encode for HTTP headers (Dart rejects non-ASCII / CR/LF). /// ntfy decodes RFC 2047 (`=?UTF-8?B?…?=`). String encodeNtfyHeader(String value) { final flat = value.replaceAll(RegExp(r'[\r\n]+'), '\n').trim(); if (flat.isEmpty) return flat; final needsEncode = flat.codeUnits.any((c) => c < 0x20 || c > 0x7e); if (!needsEncode) return flat; final b64 = base64.encode(utf8.encode(flat)); return '=?UTF-8?B?$b64?='; } class ChapterCompletedPayload { const ChapterCompletedPayload({ required this.seriesTitle, required this.chapterLabel, required this.imageCount, required this.rawBytes, required this.cbzBytes, this.cbzPath, this.cacheDir, this.coverUrl, }); final String seriesTitle; final String chapterLabel; final int imageCount; final int rawBytes; final int cbzBytes; final String? cbzPath; final String? cacheDir; final String? coverUrl; } /// Fire-and-forget ntfy publisher for download queue events. class NtfyNotifier { NtfyNotifier({Dio? dio, CbzStore? store}) : _dio = dio ?? Dio( BaseOptions( connectTimeout: const Duration(seconds: 20), receiveTimeout: const Duration(seconds: 60), sendTimeout: const Duration(seconds: 120), validateStatus: (s) => s != null && s >= 200 && s < 300, ), ), _store = store ?? CbzStore(); final Dio _dio; final CbzStore _store; static String formatBytes(int bytes) { if (bytes < 1024) return '$bytes B'; final kb = bytes / 1024; if (kb < 1024) return '${kb.toStringAsFixed(kb < 10 ? 1 : 0)} KB'; final mb = kb / 1024; if (mb < 1024) return '${mb.toStringAsFixed(mb < 10 ? 1 : 0)} MB'; final gb = mb / 1024; return '${gb.toStringAsFixed(2)} GB'; } static String completedTitle(String series, String chapter) => 'Downloaded · $series · $chapter'; static String failedTitle(String series, String chapter) => 'Download failed · $series · $chapter'; static String drainedTitle({required int ok, required int failed}) => 'Queue finished · $ok downloaded · $failed failed'; static String completedBody({ required int imageCount, required int rawBytes, required int cbzBytes, String? note, }) { final saved = rawBytes > 0 ? (((rawBytes - cbzBytes) / rawBytes) * 100).clamp(0, 100).round() : 0; final lines = [ 'Pages: $imageCount', 'Images: ${formatBytes(rawBytes)}', 'CBZ: ${formatBytes(cbzBytes)}${rawBytes > 0 ? ' (saved $saved%)' : ''}', ]; if (note != null && note.isNotEmpty) lines.add(note); return lines.join('\n'); } Future chapterCompleted(ChapterCompletedPayload payload) async { final settings = await NtfySettings.load(); if (!settings.enabled) return; final url = settings.urlForCompleted; if (url == null) return; final title = completedTitle(payload.seriesTitle, payload.chapterLabel); var note = ''; Uint8List? attachBytes; String? filename; String? attachUrl; switch (settings.attachmentMode) { case NtfyAttachmentMode.none: break; case NtfyAttachmentMode.cover: final cover = payload.coverUrl?.trim(); if (cover == null || cover.isEmpty) { note = 'Attachment skipped (no cover URL)'; } else { attachUrl = cover; } break; case NtfyAttachmentMode.firstImage: final preview = await _buildFirstPagePreview(payload.cacheDir); if (preview == null) { note = 'Preview resize failed'; } else { attachBytes = preview; filename = 'preview.jpg'; } break; case NtfyAttachmentMode.cbz: final path = payload.cbzPath; if (path == null || path.isEmpty) { note = 'Attachment skipped (no CBZ path)'; } else if (payload.cbzBytes > _kMaxCbzAttachBytes) { note = 'Attachment skipped (15 MB limit)'; } else { try { attachBytes = await _store.readCbzBytes(path); if (attachBytes.length > _kMaxCbzAttachBytes) { attachBytes = null; note = 'Attachment skipped (15 MB limit)'; } else { filename = _cbzFilename(payload.chapterLabel); } } catch (_) { note = 'Attachment skipped (read failed)'; } } } final body = completedBody( imageCount: payload.imageCount, rawBytes: payload.rawBytes, cbzBytes: payload.cbzBytes, note: note.isEmpty ? null : note, ); try { if (attachBytes != null && filename != null) { await _putAttachment( url: url, title: title, message: body, tags: 'white_check_mark', priority: '3', filename: filename, bytes: attachBytes, token: settings.authToken, ); } else if (attachUrl != null) { await _postText( url: url, title: title, message: body, tags: 'white_check_mark', priority: '3', token: settings.authToken, attachUrl: attachUrl, ); } else { await _postText( url: url, title: title, message: body, tags: 'white_check_mark', priority: '3', token: settings.authToken, ); } } catch (e, st) { debugPrint('ntfy chapterCompleted failed: $e\n$st'); } } Future jobFailed({ required String seriesTitle, required String chapterLabel, required String error, }) async { final settings = await NtfySettings.load(); if (!settings.enabled) return; final url = settings.urlForFailed; if (url == null) return; final trimmed = error.trim(); final snippet = trimmed.length > 500 ? '${trimmed.substring(0, 500)}…' : trimmed; try { await _postText( url: url, title: failedTitle(seriesTitle, chapterLabel), message: snippet.isEmpty ? 'Unknown error' : snippet, tags: 'x', priority: '4', token: settings.authToken, ); } catch (e, st) { debugPrint('ntfy jobFailed failed: $e\n$st'); } } Future queueDrained({ required int ok, required int failed, Duration? duration, }) async { final settings = await NtfySettings.load(); if (!settings.enabled) return; final url = settings.urlForDrained; if (url == null) return; final lines = []; if (duration != null) { lines.add('Finished in ${_formatDuration(duration)}'); } lines.add('$ok downloaded · $failed failed'); try { await _postText( url: url, title: drainedTitle(ok: ok, failed: failed), message: lines.join('\n'), tags: 'inbox_tray', priority: '3', token: settings.authToken, ); } catch (e, st) { debugPrint('ntfy queueDrained failed: $e\n$st'); } } /// Send a sample notification to [url] (for Settings Test buttons). Future testTopic(String url, {required String kind}) async { final settings = await NtfySettings.load(); final trimmed = url.trim(); if (trimmed.isEmpty) { throw StateError('URL is empty'); } switch (kind) { case 'completed': await _postText( url: trimmed, title: completedTitle('Sample Series', 'Chapter 1'), message: completedBody( imageCount: 12, rawBytes: 5 * 1024 * 1024, cbzBytes: 4 * 1024 * 1024, note: 'Test notification (${settings.attachmentMode.label})', ), tags: 'white_check_mark', priority: '3', token: settings.authToken, ); case 'failed': await _postText( url: trimmed, title: failedTitle('Sample Series', 'Chapter 2'), message: 'Sample error for testing', tags: 'x', priority: '4', token: settings.authToken, ); case 'drained': await _postText( url: trimmed, title: drainedTitle(ok: 3, failed: 1), message: 'Finished in 1m 5s\n3 downloaded · 1 failed', tags: 'inbox_tray', priority: '3', token: settings.authToken, ); default: throw StateError('Unknown test kind: $kind'); } } Future _postText({ required String url, required String title, required String message, required String tags, required String priority, required String token, String? attachUrl, }) async { await _dio.post( url, data: message, options: Options( headers: { 'Title': encodeNtfyHeader(title), 'Tags': tags, 'Priority': priority, if (attachUrl != null && attachUrl.isNotEmpty) 'Attach': encodeNtfyHeader(attachUrl), if (token.trim().isNotEmpty) 'Authorization': 'Bearer ${token.trim()}', }, contentType: 'text/plain; charset=utf-8', ), ); } Future _putAttachment({ required String url, required String title, required String message, required String tags, required String priority, required String filename, required Uint8List bytes, required String token, }) async { await _dio.put( url, data: bytes, options: Options( headers: { 'Title': encodeNtfyHeader(title), // Multiline body must be header-safe (RFC 2047), not raw newlines. 'Message': encodeNtfyHeader(message), 'Tags': tags, 'Priority': priority, 'Filename': encodeNtfyHeader(filename), if (token.trim().isNotEmpty) 'Authorization': 'Bearer ${token.trim()}', }, contentType: 'application/octet-stream', ), ); } Future _buildFirstPagePreview(String? cacheDir) async { if (cacheDir == null || cacheDir.isEmpty) return null; final dir = Directory(cacheDir); if (!await dir.exists()) return null; final files = await dir .list() .where((e) => e is File) .cast() .toList(); files.sort((a, b) => p.basename(a.path).compareTo(p.basename(b.path))); if (files.isEmpty) return null; final bytes = await files.first.readAsBytes(); return compute(_resizePreviewIsolate, bytes); } static String _cbzFilename(String chapterLabel) { final stem = chapterLabel .trim() .replaceAll(RegExp(r'[^a-zA-Z0-9._-]+'), '_') .replaceAll(RegExp(r'^_+|_+$'), ''); final name = stem.isEmpty ? 'chapter' : stem; return name.toLowerCase().endsWith('.cbz') ? name : '$name.cbz'; } static String _formatDuration(Duration d) { final s = d.inSeconds; if (s < 60) return '${s}s'; final m = s ~/ 60; final rem = s % 60; if (m < 60) return rem == 0 ? '${m}m' : '${m}m ${rem}s'; final h = m ~/ 60; final rm = m % 60; return rm == 0 ? '${h}h' : '${h}h ${rm}m'; } } /// Isolate entry: decode → max edge 720 → JPEG ≤ 250 KB. Uint8List? _resizePreviewIsolate(Uint8List input) { return resizePreviewForTest(input); } /// Visible for unit tests. Uint8List? resizePreviewForTest(Uint8List input) { final decoded = img.decodeImage(input); if (decoded == null) return null; var image = decoded; final longest = image.width > image.height ? image.width : image.height; if (longest > _kPreviewMaxEdge) { if (image.width >= image.height) { image = img.copyResize(image, width: _kPreviewMaxEdge); } else { image = img.copyResize(image, height: _kPreviewMaxEdge); } } var quality = 75; Uint8List? best; while (quality >= 40) { final encoded = Uint8List.fromList(img.encodeJpg(image, quality: quality)); best = encoded; if (encoded.length <= _kPreviewMaxBytes) return encoded; quality -= 15; } return best; }