Refactor profiles
This commit is contained in:
85
lib/features/profile/data/profile_data_mapper.dart
Normal file
85
lib/features/profile/data/profile_data_mapper.dart
Normal file
@@ -0,0 +1,85 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:hiddify/data/local/database.dart';
|
||||
import 'package:hiddify/features/profile/model/profile_entity.dart';
|
||||
|
||||
extension ProfileEntityMapper on ProfileEntity {
|
||||
ProfileEntriesCompanion toEntry() {
|
||||
return switch (this) {
|
||||
RemoteProfileEntity(:final url, :final options, :final subInfo) =>
|
||||
ProfileEntriesCompanion.insert(
|
||||
id: id,
|
||||
type: ProfileType.remote,
|
||||
active: active,
|
||||
name: name,
|
||||
url: Value(url),
|
||||
lastUpdate: lastUpdate,
|
||||
updateInterval: Value(options?.updateInterval),
|
||||
upload: Value(subInfo?.upload),
|
||||
download: Value(subInfo?.download),
|
||||
total: Value(subInfo?.total),
|
||||
expire: Value(subInfo?.expire),
|
||||
webPageUrl: Value(subInfo?.webPageUrl),
|
||||
supportUrl: Value(subInfo?.supportUrl),
|
||||
),
|
||||
LocalProfileEntity() => ProfileEntriesCompanion.insert(
|
||||
id: id,
|
||||
type: ProfileType.local,
|
||||
active: active,
|
||||
name: name,
|
||||
lastUpdate: lastUpdate,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
extension RemoteProfileEntityMapper on RemoteProfileEntity {
|
||||
ProfileEntriesCompanion subInfoPatch() {
|
||||
return ProfileEntriesCompanion(
|
||||
upload: Value(subInfo?.upload),
|
||||
download: Value(subInfo?.download),
|
||||
total: Value(subInfo?.total),
|
||||
expire: Value(subInfo?.expire),
|
||||
webPageUrl: Value(subInfo?.webPageUrl),
|
||||
supportUrl: Value(subInfo?.supportUrl),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileEntryMapper on ProfileEntry {
|
||||
ProfileEntity toEntity() {
|
||||
ProfileOptions? options;
|
||||
if (updateInterval != null) {
|
||||
options = ProfileOptions(updateInterval: updateInterval!);
|
||||
}
|
||||
|
||||
SubscriptionInfo? subInfo;
|
||||
if (upload != null && download != null && total != null && expire != null) {
|
||||
subInfo = SubscriptionInfo(
|
||||
upload: upload!,
|
||||
download: download!,
|
||||
total: total!,
|
||||
expire: expire!,
|
||||
webPageUrl: webPageUrl,
|
||||
supportUrl: supportUrl,
|
||||
);
|
||||
}
|
||||
|
||||
return switch (type) {
|
||||
ProfileType.remote => RemoteProfileEntity(
|
||||
id: id,
|
||||
active: active,
|
||||
name: name,
|
||||
url: url!,
|
||||
lastUpdate: lastUpdate,
|
||||
options: options,
|
||||
subInfo: subInfo,
|
||||
),
|
||||
ProfileType.local => LocalProfileEntity(
|
||||
id: id,
|
||||
active: active,
|
||||
name: name,
|
||||
lastUpdate: lastUpdate,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
32
lib/features/profile/data/profile_data_providers.dart
Normal file
32
lib/features/profile/data/profile_data_providers.dart
Normal file
@@ -0,0 +1,32 @@
|
||||
import 'package:hiddify/data/data_providers.dart';
|
||||
import 'package:hiddify/features/profile/data/profile_data_source.dart';
|
||||
import 'package:hiddify/features/profile/data/profile_path_resolver.dart';
|
||||
import 'package:hiddify/features/profile/data/profile_repository.dart';
|
||||
import 'package:hiddify/services/service_providers.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'profile_data_providers.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
Future<ProfileRepository> profileRepository(ProfileRepositoryRef ref) async {
|
||||
final repo = ProfileRepositoryImpl(
|
||||
profileDataSource: ref.watch(profileDataSourceProvider),
|
||||
profilePathResolver: ref.watch(profilePathResolverProvider),
|
||||
configValidator: ref.watch(coreFacadeProvider).parseConfig,
|
||||
dio: ref.watch(dioProvider),
|
||||
);
|
||||
await repo.init().getOrElse((l) => throw l).run();
|
||||
return repo;
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
ProfileDataSource profileDataSource(ProfileDataSourceRef ref) {
|
||||
return ProfileDao(ref.watch(appDatabaseProvider));
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
ProfilePathResolver profilePathResolver(ProfilePathResolverRef ref) {
|
||||
return ProfilePathResolver(
|
||||
ref.watch(filesEditorServiceProvider).dirs.workingDir,
|
||||
);
|
||||
}
|
||||
133
lib/features/profile/data/profile_data_source.dart
Normal file
133
lib/features/profile/data/profile_data_source.dart
Normal file
@@ -0,0 +1,133 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:hiddify/data/local/database.dart';
|
||||
import 'package:hiddify/data/local/tables.dart';
|
||||
import 'package:hiddify/features/profile/model/profile_sort_enum.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
|
||||
part 'profile_data_source.g.dart';
|
||||
|
||||
abstract interface class ProfileDataSource {
|
||||
Future<ProfileEntry?> getById(String id);
|
||||
Future<ProfileEntry?> getByUrl(String url);
|
||||
Stream<ProfileEntry?> watchActiveProfile();
|
||||
Stream<int> watchProfilesCount();
|
||||
Stream<List<ProfileEntry>> watchAll({
|
||||
required ProfilesSort sort,
|
||||
required SortMode sortMode,
|
||||
});
|
||||
Future<void> insert(ProfileEntriesCompanion entry);
|
||||
Future<void> edit(String id, ProfileEntriesCompanion entry);
|
||||
Future<void> deleteById(String id);
|
||||
}
|
||||
|
||||
Map<SortMode, OrderingMode> orderMap = {
|
||||
SortMode.ascending: OrderingMode.asc,
|
||||
SortMode.descending: OrderingMode.desc,
|
||||
};
|
||||
|
||||
@DriftAccessor(tables: [ProfileEntries])
|
||||
class ProfileDao extends DatabaseAccessor<AppDatabase>
|
||||
with _$ProfileDaoMixin, InfraLogger
|
||||
implements ProfileDataSource {
|
||||
ProfileDao(super.db);
|
||||
|
||||
@override
|
||||
Future<ProfileEntry?> getById(String id) async {
|
||||
return (profileEntries.select()..where((tbl) => tbl.id.equals(id)))
|
||||
.getSingleOrNull();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProfileEntry?> getByUrl(String url) async {
|
||||
return (select(profileEntries)
|
||||
..where((tbl) => tbl.url.like('%$url%'))
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<ProfileEntry?> watchActiveProfile() {
|
||||
return (profileEntries.select()
|
||||
..where((tbl) => tbl.active.equals(true))
|
||||
..limit(1))
|
||||
.watchSingleOrNull();
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<int> watchProfilesCount() {
|
||||
final count = profileEntries.id.count();
|
||||
return (profileEntries.selectOnly()..addColumns([count]))
|
||||
.map((exp) => exp.read(count)!)
|
||||
.watchSingle();
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<List<ProfileEntry>> watchAll({
|
||||
required ProfilesSort sort,
|
||||
required SortMode sortMode,
|
||||
}) {
|
||||
return (profileEntries.select()
|
||||
..orderBy(
|
||||
[
|
||||
(tbl) {
|
||||
final trafficRatio = (tbl.download + tbl.upload) / tbl.total;
|
||||
final isExpired =
|
||||
tbl.expire.isSmallerOrEqualValue(DateTime.now());
|
||||
return OrderingTerm(
|
||||
expression: (trafficRatio.isNull() |
|
||||
trafficRatio.isSmallerThanValue(1)) &
|
||||
(isExpired.isNull() | isExpired.equals(false)),
|
||||
mode: OrderingMode.desc,
|
||||
);
|
||||
},
|
||||
switch (sort) {
|
||||
ProfilesSort.name => (tbl) => OrderingTerm(
|
||||
expression: tbl.name,
|
||||
mode: orderMap[sortMode]!,
|
||||
),
|
||||
ProfilesSort.lastUpdate => (tbl) => OrderingTerm(
|
||||
expression: tbl.lastUpdate,
|
||||
mode: orderMap[sortMode]!,
|
||||
),
|
||||
},
|
||||
],
|
||||
))
|
||||
.watch();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> insert(ProfileEntriesCompanion entry) async {
|
||||
await transaction(
|
||||
() async {
|
||||
if (entry.active.present && entry.active.value) {
|
||||
await update(profileEntries)
|
||||
.write(const ProfileEntriesCompanion(active: Value(false)));
|
||||
}
|
||||
await into(profileEntries).insert(entry);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> edit(String id, ProfileEntriesCompanion entry) async {
|
||||
await transaction(
|
||||
() async {
|
||||
if (entry.active.present && entry.active.value) {
|
||||
await update(profileEntries)
|
||||
.write(const ProfileEntriesCompanion(active: Value(false)));
|
||||
}
|
||||
await (update(profileEntries)..where((tbl) => tbl.id.equals(id)))
|
||||
.write(entry);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteById(String id) async {
|
||||
await transaction(
|
||||
() async {
|
||||
await (delete(profileEntries)..where((tbl) => tbl.id.equals(id))).go();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
105
lib/features/profile/data/profile_parser.dart
Normal file
105
lib/features/profile/data/profile_parser.dart
Normal file
@@ -0,0 +1,105 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:hiddify/features/profile/model/profile_entity.dart';
|
||||
import 'package:hiddify/utils/utils.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// parse profile subscription url and headers for data
|
||||
///
|
||||
/// ***name parser hierarchy:***
|
||||
/// - `profile-title` header
|
||||
/// - `content-disposition` header
|
||||
/// - url fragment (example: `https://example.com/config#user`) -> name=`user`
|
||||
/// - url filename extension (example: `https://example.com/config.json`) -> name=`config`
|
||||
/// - if none of these methods return a non-blank string, fallback to `Remote Profile`
|
||||
abstract class ProfileParser {
|
||||
static RemoteProfileEntity parse(
|
||||
String url,
|
||||
Map<String, List<String>> headers,
|
||||
) {
|
||||
var name = '';
|
||||
if (headers['profile-title'] case [final titleHeader]) {
|
||||
if (titleHeader.startsWith("base64:")) {
|
||||
name =
|
||||
utf8.decode(base64.decode(titleHeader.replaceFirst("base64:", "")));
|
||||
} else {
|
||||
name = titleHeader.trim();
|
||||
}
|
||||
}
|
||||
if (headers['content-disposition'] case [final contentDispositionHeader]
|
||||
when name.isEmpty) {
|
||||
final regExp = RegExp('filename="([^"]*)"');
|
||||
final match = regExp.firstMatch(contentDispositionHeader);
|
||||
if (match != null && match.groupCount >= 1) {
|
||||
name = match.group(1) ?? '';
|
||||
}
|
||||
}
|
||||
if (Uri.parse(url).fragment case final fragment when name.isEmpty) {
|
||||
name = fragment;
|
||||
}
|
||||
if (url.split("/").lastOrNull case final part? when name.isEmpty) {
|
||||
final pattern = RegExp(r"\.(json|yaml|yml|txt)[\s\S]*");
|
||||
name = part.replaceFirst(pattern, "");
|
||||
}
|
||||
if (name.isBlank) name = "Remote Profile";
|
||||
|
||||
ProfileOptions? options;
|
||||
if (headers['profile-update-interval'] case [final updateIntervalStr]) {
|
||||
final updateInterval = Duration(hours: int.parse(updateIntervalStr));
|
||||
options = ProfileOptions(updateInterval: updateInterval);
|
||||
}
|
||||
|
||||
SubscriptionInfo? subInfo;
|
||||
if (headers['subscription-userinfo'] case [final subInfoStr]) {
|
||||
subInfo = parseSubscriptionInfo(subInfoStr);
|
||||
}
|
||||
|
||||
if (subInfo != null) {
|
||||
if (headers['profile-web-page-url'] case [final profileWebPageUrl]
|
||||
when isUrl(profileWebPageUrl)) {
|
||||
subInfo = subInfo.copyWith(webPageUrl: profileWebPageUrl);
|
||||
}
|
||||
if (headers['support-url'] case [final profileSupportUrl]
|
||||
when isUrl(profileSupportUrl)) {
|
||||
subInfo = subInfo.copyWith(supportUrl: profileSupportUrl);
|
||||
}
|
||||
}
|
||||
|
||||
return RemoteProfileEntity(
|
||||
id: const Uuid().v4(),
|
||||
active: false,
|
||||
name: name,
|
||||
url: url,
|
||||
lastUpdate: DateTime.now(),
|
||||
options: options,
|
||||
subInfo: subInfo,
|
||||
);
|
||||
}
|
||||
|
||||
static SubscriptionInfo? parseSubscriptionInfo(String subInfoStr) {
|
||||
final values = subInfoStr.split(';');
|
||||
final map = {
|
||||
for (final v in values)
|
||||
v.split('=').first.trim():
|
||||
num.tryParse(v.split('=').second.trim())?.toInt(),
|
||||
};
|
||||
if (map
|
||||
case {
|
||||
"upload": final upload?,
|
||||
"download": final download?,
|
||||
"total": final total,
|
||||
"expire": final expire
|
||||
}) {
|
||||
return SubscriptionInfo(
|
||||
upload: upload,
|
||||
download: download,
|
||||
total: total ?? 9223372036854775807,
|
||||
expire: DateTime.fromMillisecondsSinceEpoch(
|
||||
(expire ?? 92233720368) * 1000,
|
||||
),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
17
lib/features/profile/data/profile_path_resolver.dart
Normal file
17
lib/features/profile/data/profile_path_resolver.dart
Normal file
@@ -0,0 +1,17 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
class ProfilePathResolver {
|
||||
const ProfilePathResolver(this._workingDir);
|
||||
|
||||
final Directory _workingDir;
|
||||
|
||||
Directory get directory => Directory(p.join(_workingDir.path, "configs"));
|
||||
|
||||
File file(String fileName) {
|
||||
return File(p.join(directory.path, "$fileName.json"));
|
||||
}
|
||||
|
||||
File tempFile(String fileName) => file("$fileName.tmp");
|
||||
}
|
||||
392
lib/features/profile/data/profile_repository.dart
Normal file
392
lib/features/profile/data/profile_repository.dart
Normal file
@@ -0,0 +1,392 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import 'package:hiddify/data/local/database.dart';
|
||||
import 'package:hiddify/data/repository/exception_handlers.dart';
|
||||
import 'package:hiddify/domain/core_service_failure.dart';
|
||||
import 'package:hiddify/features/profile/data/profile_data_mapper.dart';
|
||||
import 'package:hiddify/features/profile/data/profile_data_source.dart';
|
||||
import 'package:hiddify/features/profile/data/profile_parser.dart';
|
||||
import 'package:hiddify/features/profile/data/profile_path_resolver.dart';
|
||||
import 'package:hiddify/features/profile/model/profile_entity.dart';
|
||||
import 'package:hiddify/features/profile/model/profile_failure.dart';
|
||||
import 'package:hiddify/features/profile/model/profile_sort_enum.dart';
|
||||
import 'package:hiddify/utils/custom_loggers.dart';
|
||||
import 'package:hiddify/utils/link_parsers.dart';
|
||||
import 'package:meta/meta.dart';
|
||||
import 'package:retry/retry.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
abstract interface class ProfileRepository {
|
||||
TaskEither<ProfileFailure, Unit> init();
|
||||
TaskEither<ProfileFailure, ProfileEntity?> getById(String id);
|
||||
Stream<Either<ProfileFailure, ProfileEntity?>> watchActiveProfile();
|
||||
Stream<Either<ProfileFailure, bool>> watchHasAnyProfile();
|
||||
|
||||
Stream<Either<ProfileFailure, List<ProfileEntity>>> watchAll({
|
||||
ProfilesSort sort = ProfilesSort.lastUpdate,
|
||||
SortMode sortMode = SortMode.ascending,
|
||||
});
|
||||
|
||||
TaskEither<ProfileFailure, Unit> addByUrl(
|
||||
String url, {
|
||||
bool markAsActive = false,
|
||||
});
|
||||
|
||||
TaskEither<ProfileFailure, Unit> addByContent(
|
||||
String content, {
|
||||
required String name,
|
||||
bool markAsActive = false,
|
||||
});
|
||||
|
||||
TaskEither<ProfileFailure, Unit> add(RemoteProfileEntity baseProfile);
|
||||
|
||||
TaskEither<ProfileFailure, Unit> updateSubscription(
|
||||
RemoteProfileEntity baseProfile,
|
||||
);
|
||||
|
||||
TaskEither<ProfileFailure, Unit> patch(ProfileEntity profile);
|
||||
TaskEither<ProfileFailure, Unit> setAsActive(String id);
|
||||
TaskEither<ProfileFailure, Unit> deleteById(String id);
|
||||
}
|
||||
|
||||
class ProfileRepositoryImpl
|
||||
with ExceptionHandler, InfraLogger
|
||||
implements ProfileRepository {
|
||||
ProfileRepositoryImpl({
|
||||
required this.profileDataSource,
|
||||
required this.profilePathResolver,
|
||||
required this.configValidator,
|
||||
required this.dio,
|
||||
});
|
||||
|
||||
final ProfileDataSource profileDataSource;
|
||||
final ProfilePathResolver profilePathResolver;
|
||||
final TaskEither<CoreServiceFailure, Unit> Function(
|
||||
String path,
|
||||
String tempPath,
|
||||
bool debug,
|
||||
) configValidator;
|
||||
final Dio dio;
|
||||
|
||||
@override
|
||||
TaskEither<ProfileFailure, Unit> init() {
|
||||
return exceptionHandler(
|
||||
() async {
|
||||
if (!await profilePathResolver.directory.exists()) {
|
||||
await profilePathResolver.directory.create(recursive: true);
|
||||
}
|
||||
return right(unit);
|
||||
},
|
||||
ProfileUnexpectedFailure.new,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
TaskEither<ProfileFailure, ProfileEntity?> getById(String id) {
|
||||
return TaskEither.tryCatch(
|
||||
() => profileDataSource.getById(id).then((value) => value?.toEntity()),
|
||||
ProfileUnexpectedFailure.new,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<Either<ProfileFailure, ProfileEntity?>> watchActiveProfile() {
|
||||
return profileDataSource
|
||||
.watchActiveProfile()
|
||||
.map((event) => event?.toEntity())
|
||||
.handleExceptions(
|
||||
(error, stackTrace) {
|
||||
loggy.error("error watching active profile", error, stackTrace);
|
||||
return ProfileUnexpectedFailure(error, stackTrace);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<Either<ProfileFailure, bool>> watchHasAnyProfile() {
|
||||
return profileDataSource
|
||||
.watchProfilesCount()
|
||||
.map((event) => event != 0)
|
||||
.handleExceptions(ProfileUnexpectedFailure.new);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<Either<ProfileFailure, List<ProfileEntity>>> watchAll({
|
||||
ProfilesSort sort = ProfilesSort.lastUpdate,
|
||||
SortMode sortMode = SortMode.ascending,
|
||||
}) {
|
||||
return profileDataSource
|
||||
.watchAll(sort: sort, sortMode: sortMode)
|
||||
.map((event) => event.map((e) => e.toEntity()).toList())
|
||||
.handleExceptions(ProfileUnexpectedFailure.new);
|
||||
}
|
||||
|
||||
@override
|
||||
TaskEither<ProfileFailure, Unit> addByUrl(
|
||||
String url, {
|
||||
bool markAsActive = false,
|
||||
}) {
|
||||
return exceptionHandler(
|
||||
() async {
|
||||
final existingProfile = await profileDataSource
|
||||
.getByUrl(url)
|
||||
.then((value) => value?.toEntity());
|
||||
if (existingProfile case RemoteProfileEntity()) {
|
||||
loggy.info("profile with same url already exists, updating");
|
||||
final baseProfile = markAsActive
|
||||
? existingProfile.copyWith(active: true)
|
||||
: existingProfile;
|
||||
return updateSubscription(baseProfile).run();
|
||||
}
|
||||
|
||||
final profileId = const Uuid().v4();
|
||||
return fetch(url, profileId)
|
||||
.flatMap(
|
||||
(profile) => TaskEither(
|
||||
() async {
|
||||
await profileDataSource.insert(
|
||||
profile
|
||||
.copyWith(id: profileId, active: markAsActive)
|
||||
.toEntry(),
|
||||
);
|
||||
return right(unit);
|
||||
},
|
||||
),
|
||||
)
|
||||
.run();
|
||||
},
|
||||
(error, stackTrace) {
|
||||
loggy.warning("error adding profile by url", error, stackTrace);
|
||||
return ProfileUnexpectedFailure(error, stackTrace);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
TaskEither<ProfileFailure, Unit> addByContent(
|
||||
String content, {
|
||||
required String name,
|
||||
bool markAsActive = false,
|
||||
}) {
|
||||
return exceptionHandler(
|
||||
() async {
|
||||
final profileId = const Uuid().v4();
|
||||
final file = profilePathResolver.file(profileId);
|
||||
final tempFile = profilePathResolver.tempFile(profileId);
|
||||
|
||||
try {
|
||||
await tempFile.writeAsString(content);
|
||||
final parseResult =
|
||||
await configValidator(file.path, tempFile.path, false).run();
|
||||
return parseResult.fold(
|
||||
(err) async {
|
||||
loggy.warning("error parsing config", err);
|
||||
return left(ProfileFailure.invalidConfig(err.msg));
|
||||
},
|
||||
(_) async {
|
||||
final profile = LocalProfileEntity(
|
||||
id: profileId,
|
||||
active: markAsActive,
|
||||
name: name,
|
||||
lastUpdate: DateTime.now(),
|
||||
);
|
||||
await profileDataSource.insert(profile.toEntry());
|
||||
return right(unit);
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
if (tempFile.existsSync()) tempFile.deleteSync();
|
||||
}
|
||||
},
|
||||
(error, stackTrace) {
|
||||
loggy.warning("error adding profile by content", error, stackTrace);
|
||||
return ProfileUnexpectedFailure(error, stackTrace);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
TaskEither<ProfileFailure, Unit> add(RemoteProfileEntity baseProfile) {
|
||||
return exceptionHandler(
|
||||
() async {
|
||||
return fetch(baseProfile.url, baseProfile.id)
|
||||
.flatMap(
|
||||
(remoteProfile) => TaskEither(() async {
|
||||
await profileDataSource.insert(
|
||||
baseProfile
|
||||
.copyWith(
|
||||
subInfo: remoteProfile.subInfo,
|
||||
lastUpdate: DateTime.now(),
|
||||
)
|
||||
.toEntry(),
|
||||
);
|
||||
return right(unit);
|
||||
}),
|
||||
)
|
||||
.run();
|
||||
},
|
||||
(error, stackTrace) {
|
||||
loggy.warning("error adding profile", error, stackTrace);
|
||||
return ProfileUnexpectedFailure(error, stackTrace);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
TaskEither<ProfileFailure, Unit> updateSubscription(
|
||||
RemoteProfileEntity baseProfile,
|
||||
) {
|
||||
return exceptionHandler(
|
||||
() async {
|
||||
loggy.debug(
|
||||
"updating profile [${baseProfile.name} (${baseProfile.id})]",
|
||||
);
|
||||
return fetch(baseProfile.url, baseProfile.id)
|
||||
.flatMap(
|
||||
(remoteProfile) => TaskEither(() async {
|
||||
await profileDataSource.edit(
|
||||
baseProfile.id,
|
||||
remoteProfile
|
||||
.subInfoPatch()
|
||||
.copyWith(lastUpdate: Value(DateTime.now())),
|
||||
);
|
||||
return right(unit);
|
||||
}),
|
||||
)
|
||||
.run();
|
||||
},
|
||||
(error, stackTrace) {
|
||||
loggy.warning("error updating profile", error, stackTrace);
|
||||
return ProfileUnexpectedFailure(error, stackTrace);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
TaskEither<ProfileFailure, Unit> patch(ProfileEntity profile) {
|
||||
return exceptionHandler(
|
||||
() async {
|
||||
loggy.debug(
|
||||
"editing profile [${profile.name} (${profile.id})]",
|
||||
);
|
||||
await profileDataSource.edit(profile.id, profile.toEntry());
|
||||
return right(unit);
|
||||
},
|
||||
(error, stackTrace) {
|
||||
loggy.warning("error editing profile", error, stackTrace);
|
||||
return ProfileUnexpectedFailure(error, stackTrace);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
TaskEither<ProfileFailure, Unit> setAsActive(String id) {
|
||||
return TaskEither.tryCatch(
|
||||
() async {
|
||||
await profileDataSource.edit(
|
||||
id,
|
||||
const ProfileEntriesCompanion(active: Value(true)),
|
||||
);
|
||||
return unit;
|
||||
},
|
||||
ProfileUnexpectedFailure.new,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
TaskEither<ProfileFailure, Unit> deleteById(String id) {
|
||||
return TaskEither.tryCatch(
|
||||
() async {
|
||||
await profileDataSource.deleteById(id);
|
||||
await profilePathResolver.file(id).delete();
|
||||
return unit;
|
||||
},
|
||||
ProfileUnexpectedFailure.new,
|
||||
);
|
||||
}
|
||||
|
||||
final _subInfoHeaders = [
|
||||
'profile-title',
|
||||
'content-disposition',
|
||||
'subscription-userinfo',
|
||||
'profile-update-interval',
|
||||
'support-url',
|
||||
'profile-web-page-url',
|
||||
];
|
||||
|
||||
@visibleForTesting
|
||||
TaskEither<ProfileFailure, RemoteProfileEntity> fetch(
|
||||
String url,
|
||||
String fileName,
|
||||
) {
|
||||
return TaskEither(
|
||||
() async {
|
||||
final file = profilePathResolver.file(fileName);
|
||||
final tempFile = profilePathResolver.tempFile(fileName);
|
||||
try {
|
||||
final response = await retry(
|
||||
() async => dio.download(url.trim(), tempFile.path),
|
||||
maxAttempts: 3,
|
||||
);
|
||||
final headers =
|
||||
await _populateHeaders(response.headers.map, tempFile.path);
|
||||
final parseResult =
|
||||
await configValidator(file.path, tempFile.path, false).run();
|
||||
return parseResult.fold(
|
||||
(err) async {
|
||||
loggy.warning("error parsing config", err);
|
||||
return left(ProfileFailure.invalidConfig(err.msg));
|
||||
},
|
||||
(_) async {
|
||||
final profile = ProfileParser.parse(url, headers);
|
||||
return right(profile);
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
if (tempFile.existsSync()) tempFile.deleteSync();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, List<String>>> _populateHeaders(
|
||||
Map<String, List<String>> headers,
|
||||
String path,
|
||||
) async {
|
||||
var headersFound = 0;
|
||||
for (final key in _subInfoHeaders) {
|
||||
if (headers.containsKey(key)) headersFound++;
|
||||
}
|
||||
if (headersFound >= 4) return headers;
|
||||
|
||||
loggy.debug(
|
||||
"only [$headersFound] headers found, checking file content for possible information",
|
||||
);
|
||||
var content = await File(path).readAsString();
|
||||
content = safeDecodeBase64(content);
|
||||
final lines = content.split("\n");
|
||||
final linesToProcess = lines.length < 10 ? lines.length : 10;
|
||||
for (int i = 0; i < linesToProcess; i++) {
|
||||
final line = lines[i];
|
||||
if (line.startsWith("#") || line.startsWith("//")) {
|
||||
final index = line.indexOf(':');
|
||||
if (index == -1) continue;
|
||||
final key = line
|
||||
.substring(0, index)
|
||||
.replaceFirst(RegExp("^#|//"), "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
final value = line.substring(index + 1).trim();
|
||||
if (!headers.keys.contains(key) &&
|
||||
_subInfoHeaders.contains(key) &&
|
||||
value.isNotEmpty) {
|
||||
headers[key] = [value];
|
||||
}
|
||||
}
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user