mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 06:28:27 +02:00
Move WebDAV/HTML parsing, content-addressed artwork caching, cover promotion, and streamed upload verification to native workers. Preserve proxy authentication, cancellation, cache limits, and deep XML fallback behavior.
742 lines
23 KiB
Dart
742 lines
23 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:math';
|
|
import 'dart:typed_data';
|
|
import 'package:crypto/crypto.dart';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
import 'package:html/parser.dart' as html;
|
|
import 'package:spotiflac_android/services/network_certificate.dart';
|
|
import 'package:spotiflac_android/services/network_smb_client.dart';
|
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
|
import 'package:xml/xml.dart';
|
|
|
|
part 'network_storage_upload.dart';
|
|
|
|
List<NetworkEntry> _parseNetworkListingInBackground(
|
|
Map<String, dynamic> request,
|
|
) {
|
|
final connection = NetworkConnection(
|
|
id: 'listing',
|
|
name: 'listing',
|
|
protocol: NetworkProtocol.values.byName(request['protocol'] as String),
|
|
address: request['address'] as String,
|
|
);
|
|
return NetworkStorageService.parseListing(
|
|
connection,
|
|
request['path'] as String,
|
|
utf8.decode(request['bytes'] as Uint8List, allowMalformed: true),
|
|
);
|
|
}
|
|
|
|
List<NetworkEntry> _hydrateNetworkEntriesInBackground(List<dynamic> entries) =>
|
|
[
|
|
for (final raw in entries)
|
|
NetworkEntry(
|
|
(raw as Map)['path'] as String,
|
|
raw['name'] as String,
|
|
directory: raw['directory'] as bool,
|
|
size: (raw['size'] as num?)?.toInt(),
|
|
),
|
|
];
|
|
|
|
enum NetworkProtocol { smb, webdav, http }
|
|
|
|
class NetworkConnection {
|
|
const NetworkConnection({
|
|
required this.id,
|
|
required this.name,
|
|
required this.protocol,
|
|
required this.address,
|
|
this.username = '',
|
|
this.password = '',
|
|
this.domain = '',
|
|
this.trustedCertificateSha256,
|
|
});
|
|
final String id, name, address, username, password, domain;
|
|
final NetworkProtocol protocol;
|
|
final String? trustedCertificateSha256;
|
|
Uri get root => Uri.parse(address);
|
|
Map<String, dynamic> toJson() => {
|
|
'id': id,
|
|
'name': name,
|
|
'protocol': protocol.name,
|
|
'address': address,
|
|
'username': username,
|
|
'password': password,
|
|
'domain': domain,
|
|
if (trustedCertificateSha256 != null)
|
|
'trustedCertificateSha256': trustedCertificateSha256,
|
|
};
|
|
factory NetworkConnection.fromJson(Map<String, dynamic> json) =>
|
|
NetworkConnection(
|
|
id: json['id'] as String,
|
|
name: json['name'] as String,
|
|
protocol: NetworkProtocol.values.byName(json['protocol'] as String),
|
|
address: json['address'] as String,
|
|
username: json['username'] as String? ?? '',
|
|
password: json['password'] as String? ?? '',
|
|
domain: json['domain'] as String? ?? '',
|
|
trustedCertificateSha256: json['trustedCertificateSha256'] as String?,
|
|
);
|
|
|
|
void validate() {
|
|
if (!RegExp(r'^[a-z0-9_-]+$').hasMatch(id) ||
|
|
name.trim().isEmpty ||
|
|
root.host.isEmpty ||
|
|
root.userInfo.isNotEmpty ||
|
|
root.hasFragment ||
|
|
root.pathSegments.any((s) => s == '..') ||
|
|
(root.hasQuery && protocol != NetworkProtocol.http) ||
|
|
(protocol == NetworkProtocol.smb
|
|
? root.scheme != 'smb' ||
|
|
(root.hasPort && (root.port < 1 || root.port > 65535))
|
|
: !['http', 'https'].contains(root.scheme))) {
|
|
throw const FormatException('Invalid network address');
|
|
}
|
|
}
|
|
}
|
|
|
|
class NetworkEntry {
|
|
const NetworkEntry(this.path, this.name, {this.directory = false, this.size});
|
|
final String path, name;
|
|
final bool directory;
|
|
final int? size;
|
|
String get extension => name.split('.').last.toLowerCase();
|
|
bool get audio =>
|
|
!directory &&
|
|
const {
|
|
'flac',
|
|
'mp3',
|
|
'm4a',
|
|
'mp4',
|
|
'aac',
|
|
'ogg',
|
|
'opus',
|
|
'wav',
|
|
'aiff',
|
|
'aif',
|
|
'alac',
|
|
}.contains(extension);
|
|
}
|
|
|
|
/// Paths remain relative to the selected root, never credential-bearing URLs.
|
|
String networkPath(String path) {
|
|
if (path.startsWith('/') ||
|
|
path.contains('\\') ||
|
|
path.split('/').any((part) => part == '..' || part == '.') ||
|
|
path.contains('\u0000')) {
|
|
throw const FormatException('Invalid network path');
|
|
}
|
|
return path;
|
|
}
|
|
|
|
Uri networkTarget(NetworkConnection connection, String path) {
|
|
networkPath(path);
|
|
final root = connection.root;
|
|
if (path.isEmpty) return root;
|
|
final segments = [...root.pathSegments];
|
|
if (segments.isNotEmpty && segments.last.isEmpty) segments.removeLast();
|
|
return Uri(
|
|
scheme: root.scheme,
|
|
host: root.host,
|
|
port: root.hasPort ? root.port : null,
|
|
pathSegments: [...segments, ...path.split('/')],
|
|
);
|
|
}
|
|
|
|
/// Inclusive byte range; a null header means the entire representation.
|
|
({int start, int end}) networkByteRange(String? header, int size) {
|
|
if (header == null) return (start: 0, end: size - 1);
|
|
final match = RegExp(r'^bytes=(\d*)-(\d*)$').firstMatch(header);
|
|
if (match == null || size <= 0 || (match[1]!.isEmpty && match[2]!.isEmpty)) {
|
|
throw const FormatException('Invalid range');
|
|
}
|
|
final suffix = match[1]!.isEmpty;
|
|
final start = suffix
|
|
? max(0, size - int.parse(match[2]!))
|
|
: int.parse(match[1]!);
|
|
final end = suffix || match[2]!.isEmpty
|
|
? size - 1
|
|
: min(size - 1, int.parse(match[2]!));
|
|
if (start >= size || start > end) {
|
|
throw const FormatException('Invalid range');
|
|
}
|
|
return (start: start, end: end);
|
|
}
|
|
|
|
class NetworkStorageService {
|
|
NetworkStorageService({
|
|
Future<String?> Function()? read,
|
|
Future<void> Function(String)? write,
|
|
}) : _read = read ?? (() => _storage.read(key: _key)),
|
|
_write = write ?? ((value) => _storage.write(key: _key, value: value));
|
|
static final instance = NetworkStorageService();
|
|
static const _storage = FlutterSecureStorage();
|
|
static const _key = 'network_storage_connections_v1';
|
|
final Future<String?> Function() _read;
|
|
final Future<void> Function(String) _write;
|
|
static const timeout = Duration(seconds: 20);
|
|
Future<List<NetworkConnection>>? _loading;
|
|
List<NetworkConnection>? _connections;
|
|
Future<HttpServer>? _server;
|
|
Future<void> _mutation = Future.value();
|
|
final _routes = <String, ({String id, String path})>{};
|
|
|
|
static String newId() {
|
|
final random = Random.secure();
|
|
return List.generate(
|
|
24,
|
|
(_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'),
|
|
).join();
|
|
}
|
|
|
|
Future<void> _mutate(Future<void> Function() action) {
|
|
final result = _mutation.then((_) => action());
|
|
_mutation = result.catchError((Object _) {});
|
|
return result;
|
|
}
|
|
|
|
Future<List<NetworkConnection>> connections() async {
|
|
if (_connections != null) return _connections!;
|
|
return _loading ??= () async {
|
|
try {
|
|
final raw = await _read();
|
|
final result = raw == null
|
|
? <NetworkConnection>[]
|
|
: [
|
|
for (final row in jsonDecode(raw) as List)
|
|
NetworkConnection.fromJson(
|
|
Map<String, dynamic>.from(row as Map),
|
|
),
|
|
];
|
|
_connections = List<NetworkConnection>.unmodifiable(result);
|
|
return _connections!;
|
|
} finally {
|
|
_loading = null;
|
|
}
|
|
}();
|
|
}
|
|
|
|
Future<void> save(NetworkConnection connection) => _mutate(() async {
|
|
connection.validate();
|
|
final next = [...await connections()]
|
|
..removeWhere((c) => c.id == connection.id);
|
|
next.add(connection);
|
|
await _write(jsonEncode(next.map((c) => c.toJson()).toList()));
|
|
_connections = List.unmodifiable(next);
|
|
});
|
|
|
|
Future<void> remove(String id) => _mutate(() async {
|
|
final next = [...await connections()]..removeWhere((c) => c.id == id);
|
|
await _write(jsonEncode(next.map((c) => c.toJson()).toList()));
|
|
_connections = List.unmodifiable(next);
|
|
_routes.removeWhere((_, route) => route.id == id);
|
|
});
|
|
|
|
Future<NetworkConnection> connection(String id) async =>
|
|
(await connections()).firstWhere(
|
|
(c) => c.id == id,
|
|
orElse: () => throw StateError('Network connection unavailable'),
|
|
);
|
|
|
|
Future<void> checkUploadAccess(String folder) => _checkUploadAccess(folder);
|
|
|
|
static String source(String id, String path) => Uri(
|
|
scheme: 'network',
|
|
host: id,
|
|
pathSegments: networkPath(path).split('/'),
|
|
).toString();
|
|
|
|
Future<String> resolve(String source) async {
|
|
final uri = Uri.parse(source);
|
|
if (uri.scheme != 'network' ||
|
|
uri.userInfo.isNotEmpty ||
|
|
uri.hasQuery ||
|
|
uri.hasFragment) {
|
|
throw const FormatException('Invalid network source');
|
|
}
|
|
final path = networkPath(uri.pathSegments.join('/'));
|
|
await connection(uri.host);
|
|
_server ??= HttpServer.bind(InternetAddress.loopbackIPv4, 0).then((server) {
|
|
server.listen((request) => unawaited(_serve(request)));
|
|
return server;
|
|
});
|
|
final HttpServer server;
|
|
try {
|
|
server = await _server!;
|
|
} catch (_) {
|
|
_server = null;
|
|
rethrow;
|
|
}
|
|
final token = newId();
|
|
while (_routes.length >= 256) {
|
|
_routes.remove(_routes.keys.first);
|
|
}
|
|
_routes[token] = (id: uri.host, path: path);
|
|
return 'http://127.0.0.1:${server.port}/$token';
|
|
}
|
|
|
|
Future<NetworkSmbClient> _smb(NetworkConnection c, Uri target) async {
|
|
var expired = false;
|
|
final pending = NetworkSmbClient.connect(
|
|
target,
|
|
username: c.username,
|
|
password: c.password,
|
|
domain: c.domain,
|
|
timeout: timeout,
|
|
);
|
|
unawaited(
|
|
pending
|
|
.then((smb) async {
|
|
if (expired) await smb.close();
|
|
})
|
|
.catchError((Object _) {}),
|
|
);
|
|
return pending.timeout(
|
|
timeout,
|
|
onTimeout: () {
|
|
expired = true;
|
|
throw TimeoutException('Network connection timed out');
|
|
},
|
|
);
|
|
}
|
|
|
|
HttpClient _client() => HttpClient()
|
|
..connectionTimeout = timeout
|
|
..idleTimeout = timeout
|
|
..autoUncompress = false;
|
|
|
|
Future<HttpClientResponse> _request(
|
|
HttpClient client,
|
|
NetworkConnection c,
|
|
String method,
|
|
Uri uri, {
|
|
String? range,
|
|
bool dav = false,
|
|
bool directoryListing = false,
|
|
int redirects = 0,
|
|
Map<String, String> headers = const {},
|
|
File? upload,
|
|
void Function()? checkpoint,
|
|
void Function(int)? progress,
|
|
}) async {
|
|
NetworkCertificateException? rejectedCertificate;
|
|
client.badCertificateCallback = (certificate, host, port) {
|
|
if (uri.scheme != 'https' ||
|
|
uri.origin != c.root.origin ||
|
|
host != c.root.host ||
|
|
port != c.root.port) {
|
|
return false;
|
|
}
|
|
final presented = NetworkCertificateException.fromCertificate(
|
|
uri,
|
|
certificate,
|
|
);
|
|
if (c.trustedCertificateSha256 == presented.fingerprint) return true;
|
|
rejectedCertificate = presented;
|
|
return false;
|
|
};
|
|
final HttpClientRequest request;
|
|
try {
|
|
request = await client.openUrl(method, uri).timeout(timeout);
|
|
} on HandshakeException {
|
|
if (rejectedCertificate != null) throw rejectedCertificate!;
|
|
rethrow;
|
|
}
|
|
request.followRedirects = false;
|
|
request.headers.set(HttpHeaders.acceptEncodingHeader, 'identity');
|
|
if (c.username.isNotEmpty || c.password.isNotEmpty) {
|
|
request.headers.set(
|
|
HttpHeaders.authorizationHeader,
|
|
'Basic ${base64Encode(utf8.encode('${c.username}:${c.password}'))}',
|
|
);
|
|
}
|
|
if (range != null) request.headers.set(HttpHeaders.rangeHeader, range);
|
|
headers.forEach(request.headers.set);
|
|
if (upload != null) {
|
|
request.contentLength = await upload.length();
|
|
var sent = 0;
|
|
try {
|
|
await for (final chunk in upload.openRead()) {
|
|
checkpoint?.call();
|
|
request.add(chunk);
|
|
await request.flush().timeout(timeout);
|
|
sent += chunk.length;
|
|
progress?.call(sent);
|
|
}
|
|
} catch (error) {
|
|
request.abort();
|
|
rethrow;
|
|
}
|
|
}
|
|
if (dav) {
|
|
request.headers.set('Depth', '1');
|
|
request.headers.contentType = ContentType(
|
|
'application',
|
|
'xml',
|
|
charset: 'utf-8',
|
|
);
|
|
request.write(
|
|
'<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop>'
|
|
'<d:resourcetype/><d:getcontentlength/></d:prop></d:propfind>',
|
|
);
|
|
}
|
|
final HttpClientResponse response;
|
|
try {
|
|
response = await request.close().timeout(timeout);
|
|
} on HandshakeException {
|
|
if (rejectedCertificate != null) throw rejectedCertificate!;
|
|
rethrow;
|
|
}
|
|
if ([301, 302, 307, 308].contains(response.statusCode)) {
|
|
// Never replay a mutating request to an unselected path.
|
|
if (!['GET', 'HEAD', 'PROPFIND'].contains(method)) {
|
|
throw const HttpException('Write destination redirected');
|
|
}
|
|
final location = response.headers.value(HttpHeaders.locationHeader);
|
|
final next = location == null ? null : uri.resolve(location);
|
|
// Keep credentials on the configured server; never forward them to a CDN
|
|
// or follow a TLS downgrade, even when requested by a redirect.
|
|
if (next == null ||
|
|
next.origin != uri.origin ||
|
|
next.userInfo.isNotEmpty ||
|
|
(directoryListing &&
|
|
next.path != uri.path &&
|
|
next.path != '${uri.path}/') ||
|
|
redirects >= 3) {
|
|
throw const HttpException('Unsupported server redirect');
|
|
}
|
|
await response.drain<void>().timeout(timeout);
|
|
return _request(
|
|
client,
|
|
c,
|
|
method,
|
|
next,
|
|
range: range,
|
|
dav: dav,
|
|
directoryListing: directoryListing,
|
|
redirects: redirects + 1,
|
|
);
|
|
}
|
|
return response;
|
|
}
|
|
|
|
Future<List<NetworkEntry>> list(
|
|
NetworkConnection c, [
|
|
String path = '',
|
|
]) async {
|
|
c.validate();
|
|
final target = networkTarget(c, path);
|
|
if (c.protocol == NetworkProtocol.smb) {
|
|
if (target.pathSegments.every((s) => s.isEmpty)) {
|
|
final shares = await NetworkSmbClient.shares(
|
|
target,
|
|
username: c.username,
|
|
password: c.password,
|
|
domain: c.domain,
|
|
timeout: timeout,
|
|
).timeout(timeout);
|
|
return _sorted([
|
|
for (final share in shares)
|
|
if (share.isDisk)
|
|
NetworkEntry('$path${share.name}/', share.name, directory: true),
|
|
]);
|
|
}
|
|
final smb = await _smb(c, target);
|
|
try {
|
|
final files = await smb.list().timeout(timeout);
|
|
return _sorted([
|
|
for (final file in files)
|
|
if (file.name != '.' && file.name != '..')
|
|
NetworkEntry(
|
|
'$path${file.name}${file.stat.isDirectory ? '/' : ''}',
|
|
file.name,
|
|
directory: file.stat.isDirectory,
|
|
size: file.stat.size,
|
|
),
|
|
]);
|
|
} finally {
|
|
// A failed disconnect must not hide the original share/login error.
|
|
try {
|
|
await smb.close();
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
final client = _client();
|
|
try {
|
|
if (c.protocol == NetworkProtocol.http &&
|
|
NetworkEntry('', target.pathSegments.lastOrNull ?? '').audio) {
|
|
final response = await _request(
|
|
client,
|
|
c,
|
|
'GET',
|
|
target,
|
|
range: 'bytes=0-0',
|
|
);
|
|
if (response.statusCode != 200 && response.statusCode != 206) {
|
|
throw HttpException('Server returned ${response.statusCode}');
|
|
}
|
|
return [NetworkEntry(path, target.pathSegments.last)];
|
|
}
|
|
final response = await _request(
|
|
client,
|
|
c,
|
|
c.protocol == NetworkProtocol.webdav ? 'PROPFIND' : 'GET',
|
|
target,
|
|
dav: c.protocol == NetworkProtocol.webdav,
|
|
directoryListing: true,
|
|
);
|
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
throw HttpException('Server returned ${response.statusCode}');
|
|
}
|
|
final bytes = BytesBuilder();
|
|
await for (final chunk in response.timeout(timeout)) {
|
|
if (bytes.length + chunk.length > 4 * 1024 * 1024) {
|
|
throw const HttpException('Directory listing is too large');
|
|
}
|
|
bytes.add(chunk);
|
|
}
|
|
return await parseListingInBackground(c, path, bytes.takeBytes());
|
|
} finally {
|
|
client.close(force: true);
|
|
}
|
|
}
|
|
|
|
static List<NetworkEntry> _sorted(List<NetworkEntry> entries) =>
|
|
entries..sort(
|
|
(a, b) => a.directory != b.directory
|
|
? (a.directory ? -1 : 1)
|
|
: a.name.toLowerCase().compareTo(b.name.toLowerCase()),
|
|
);
|
|
|
|
static List<NetworkEntry> parseListing(
|
|
NetworkConnection c,
|
|
String path,
|
|
String body,
|
|
) {
|
|
final target = networkTarget(c, path);
|
|
final base = target.path.endsWith('/')
|
|
? target
|
|
: target.replace(path: '${target.path}/');
|
|
final found = <String, NetworkEntry>{};
|
|
void add(String href, bool directory, int? size) {
|
|
final uri = base.resolve(href);
|
|
if (uri.origin != base.origin ||
|
|
uri.hasQuery ||
|
|
uri.hasFragment ||
|
|
uri.userInfo.isNotEmpty ||
|
|
!uri.path.startsWith(base.path)) {
|
|
return;
|
|
}
|
|
final tail = Uri.decodeComponent(uri.path.substring(base.path.length));
|
|
final name = tail.endsWith('/')
|
|
? tail.substring(0, tail.length - 1)
|
|
: tail;
|
|
if (name.isEmpty ||
|
|
name.contains('/') ||
|
|
name == '..' ||
|
|
name == '.' ||
|
|
name.contains('\\')) {
|
|
return;
|
|
}
|
|
final relative = '$path$name${directory ? '/' : ''}';
|
|
found[relative] = NetworkEntry(
|
|
relative,
|
|
name,
|
|
directory: directory,
|
|
size: size,
|
|
);
|
|
}
|
|
|
|
if (c.protocol == NetworkProtocol.webdav) {
|
|
final doc = XmlDocument.parse(body);
|
|
for (final response in doc.descendants.whereType<XmlElement>().where(
|
|
(e) => e.name.local == 'response',
|
|
)) {
|
|
final elements = response.descendants.whereType<XmlElement>();
|
|
final href = elements
|
|
.where((e) => e.name.local == 'href')
|
|
.firstOrNull
|
|
?.innerText;
|
|
final props = elements
|
|
.where((e) => e.name.local == 'propstat')
|
|
.where(
|
|
(e) => e.childElements.any(
|
|
(s) =>
|
|
s.name.local == 'status' && s.innerText.contains(' 200 '),
|
|
),
|
|
);
|
|
final values = props.expand(
|
|
(p) => p.descendants.whereType<XmlElement>(),
|
|
);
|
|
if (href != null && props.isNotEmpty) {
|
|
add(
|
|
href,
|
|
values.any((e) => e.name.local == 'collection'),
|
|
int.tryParse(
|
|
values
|
|
.where((e) => e.name.local == 'getcontentlength')
|
|
.firstOrNull
|
|
?.innerText ??
|
|
'',
|
|
),
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
for (final a in html.parse(body).querySelectorAll('a[href]')) {
|
|
final href = a.attributes['href']!;
|
|
add(href, Uri.tryParse(href)?.path.endsWith('/') == true, null);
|
|
}
|
|
}
|
|
return _sorted(found.values.toList());
|
|
}
|
|
|
|
/// Keep DOM parsing and sorting away from the Flutter frame while retaining
|
|
/// the synchronous parser for unsupported native hosts and contract tests.
|
|
static Future<List<NetworkEntry>> parseListingInBackground(
|
|
NetworkConnection connection,
|
|
String path,
|
|
Uint8List bytes,
|
|
) async {
|
|
List<dynamic> entries;
|
|
try {
|
|
final result = await PlatformBridge.runNativeDataJob({
|
|
'operation': 'parse_network_listing',
|
|
'protocol': connection.protocol.name,
|
|
'base_url': networkTarget(connection, path).toString(),
|
|
'path': path,
|
|
}, bytes: bytes);
|
|
entries = result['entries'] as List;
|
|
} on MissingPluginException {
|
|
return compute(_parseNetworkListingInBackground, {
|
|
'protocol': connection.protocol.name,
|
|
'address': connection.address,
|
|
'path': path,
|
|
'bytes': bytes,
|
|
});
|
|
} on PlatformException catch (error) {
|
|
if (error.message?.contains('XML nesting exceeds') != true) rethrow;
|
|
return compute(_parseNetworkListingInBackground, {
|
|
'protocol': connection.protocol.name,
|
|
'address': connection.address,
|
|
'path': path,
|
|
'bytes': bytes,
|
|
});
|
|
}
|
|
return compute(_hydrateNetworkEntriesInBackground, entries);
|
|
}
|
|
|
|
Future<void> _serve(HttpRequest request) async {
|
|
HttpClient? client;
|
|
NetworkSmbClient? smb;
|
|
try {
|
|
final route = _routes[request.uri.path.replaceFirst('/', '')];
|
|
if (route == null || !['GET', 'HEAD'].contains(request.method)) {
|
|
request.response.statusCode = HttpStatus.notFound;
|
|
return;
|
|
}
|
|
final c = await connection(route.id);
|
|
final target = networkTarget(c, route.path);
|
|
final range = request.headers.value(HttpHeaders.rangeHeader);
|
|
if (c.protocol == NetworkProtocol.smb) {
|
|
smb = await _smb(c, target);
|
|
final file = await smb.stat().timeout(timeout);
|
|
if (!file.isFile) {
|
|
throw const HttpException('File unavailable');
|
|
}
|
|
final ({int start, int end}) part;
|
|
try {
|
|
part = networkByteRange(range, file.size);
|
|
} on FormatException {
|
|
request.response.statusCode = HttpStatus.requestedRangeNotSatisfiable;
|
|
request.response.headers.set(
|
|
HttpHeaders.contentRangeHeader,
|
|
'bytes */${file.size}',
|
|
);
|
|
return;
|
|
}
|
|
request.response.statusCode = range == null ? 200 : 206;
|
|
request.response.headers.set(HttpHeaders.acceptRangesHeader, 'bytes');
|
|
request.response.headers.contentType = ContentType.parse(
|
|
networkContentType(target.pathSegments.last),
|
|
);
|
|
request.response.contentLength = part.end - part.start + 1;
|
|
if (range != null) {
|
|
request.response.headers.set(
|
|
HttpHeaders.contentRangeHeader,
|
|
'bytes ${part.start}-${part.end}/${file.size}',
|
|
);
|
|
}
|
|
if (request.method == 'GET' && file.size > 0) {
|
|
await request.response.addStream(
|
|
smb.read(part.start, part.end + 1).timeout(timeout),
|
|
);
|
|
}
|
|
} else {
|
|
client = _client();
|
|
final response = await _request(
|
|
client,
|
|
c,
|
|
request.method,
|
|
target,
|
|
range: range,
|
|
);
|
|
request.response.statusCode =
|
|
response.statusCode >= 300 && response.statusCode < 400
|
|
? HttpStatus.badGateway
|
|
: response.statusCode;
|
|
for (final header in [
|
|
HttpHeaders.contentTypeHeader,
|
|
HttpHeaders.contentLengthHeader,
|
|
HttpHeaders.contentRangeHeader,
|
|
HttpHeaders.acceptRangesHeader,
|
|
]) {
|
|
final value = response.headers.value(header);
|
|
if (value != null) request.response.headers.set(header, value);
|
|
}
|
|
if (request.method == 'GET') {
|
|
await request.response.addStream(response.timeout(timeout));
|
|
}
|
|
}
|
|
} catch (_) {
|
|
try {
|
|
request.response.statusCode = HttpStatus.badGateway;
|
|
} catch (_) {}
|
|
} finally {
|
|
client?.close(force: true);
|
|
try {
|
|
await smb?.close();
|
|
} catch (_) {}
|
|
try {
|
|
await request.response.close();
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
|
|
Future<void> dispose() async {
|
|
final server = _server;
|
|
_server = null;
|
|
_routes.clear();
|
|
if (server != null) await (await server).close(force: true);
|
|
}
|
|
}
|
|
|
|
String networkContentType(String name) =>
|
|
switch (name.split('.').last.toLowerCase()) {
|
|
'mp3' => 'audio/mpeg',
|
|
'flac' => 'audio/flac',
|
|
'm4a' || 'mp4' || 'alac' => 'audio/mp4',
|
|
'aac' => 'audio/aac',
|
|
'ogg' || 'opus' => 'audio/ogg',
|
|
'wav' => 'audio/wav',
|
|
'aif' || 'aiff' => 'audio/aiff',
|
|
'jpg' || 'jpeg' => 'image/jpeg',
|
|
'png' => 'image/png',
|
|
_ => 'application/octet-stream',
|
|
};
|