diff --git a/bin/running_on_dart.dart b/bin/running_on_dart.dart index 1eb8e4a..2b6206f 100644 --- a/bin/running_on_dart.dart +++ b/bin/running_on_dart.dart @@ -22,13 +22,16 @@ void main() async { ..addCommand(admin) ..addCommand(jellyfin) ..addCommand(reminderMessageCommand) + ..addCommand(kavita) ..addConverter(settingsConverter) ..addConverter(manageableTagConverter) ..addConverter(durationConverter) ..addConverter(reminderConverter) ..addConverter(packageDocsConverter) ..addConverter(jellyfinConfigConverter) - ..addConverter(jellyfinConfigUserConverter); + ..addConverter(jellyfinConfigUserConverter) + ..addConverter(kavitaConfigConverter) + ..addConverter(kavitaUserConfigsConverter); commands.onCommandError.listen(handleException); diff --git a/docker-compose.yml b/docker-compose.yml index f8df0a2..e831fe4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,8 @@ services: image: postgres:17 container_name: running_on_dart_db restart: always + ports: + - "13323:5432" env_file: - .env volumes: diff --git a/lib/running_on_dart.dart b/lib/running_on_dart.dart index e4efe72..1612901 100644 --- a/lib/running_on_dart.dart +++ b/lib/running_on_dart.dart @@ -9,6 +9,7 @@ export 'src/commands/admin.dart' show admin; export 'src/commands/jellyfin.dart' show jellyfin; export 'src/commands/tag.dart' show tag; export 'src/commands/reminder.dart' show reminderMessageCommand; +export 'src/commands/kavita.dart' show kavita; export 'src/modules/join_logs.dart'; export 'src/modules/poop_name.dart'; diff --git a/lib/src/checks.dart b/lib/src/checks.dart index d2b35e1..179be8d 100644 --- a/lib/src/checks.dart +++ b/lib/src/checks.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:injector/injector.dart'; import 'package:nyxx/nyxx.dart'; import 'package:nyxx_commands/nyxx_commands.dart'; @@ -11,24 +13,31 @@ const jellyfinFeatureEnabledCheckName = 'jellyfinFeatureEnabledCheck'; final administratorCheck = UserCheck.anyId(adminIds, name: 'Administrator check'); final administratorGuildCheck = GuildCheck.id(adminGuildId, name: 'Administrator Guild check'); -final jellyfinFeatureEnabledCheck = Check( - (CommandContext context) { - if (context.guild == null) { - return true; - } +FutureOr _checkForSetting(Setting setting, CommandContext context) { + if (context.guild == null) { + return true; + } - return Injector.appInstance.get().isEnabled(Setting.jellyfin, context.guild!.id); - }, + return Injector.appInstance.get().isEnabled(Setting.jellyfin, context.guild!.id); +} + +final kavitaJellyfinCheck = Check( + (CommandContext context) => _checkForSetting(Setting.kavita, context), name: jellyfinFeatureEnabledCheckName, ); -Future<(bool?, FeatureSetting?)> fetchAndCheckSetting(CommandContext context) async { +final jellyfinFeatureEnabledCheck = Check( + (CommandContext context) => _checkForSetting(Setting.jellyfin, context), + name: jellyfinFeatureEnabledCheckName, +); + +Future<(bool?, FeatureSetting?)> fetchAndCheckSetting(CommandContext context, Setting settingToCheck) async { if (context.guild == null) { return (true, null); } final setting = - await Injector.appInstance.get().fetchSetting(Setting.jellyfin, context.guild!.id); + await Injector.appInstance.get().fetchSetting(settingToCheck, context.guild!.id); if (setting == null) { return (false, null); } @@ -42,7 +51,23 @@ Future<(bool?, FeatureSetting?)> fetchAndCheckSetting(CommandContext context) as final jellyfinFeatureCreateInstanceCommandCheck = Check( (CommandContext context) async { - final (checkResult, setting) = await fetchAndCheckSetting(context); + final (checkResult, setting) = await fetchAndCheckSetting(context, Setting.jellyfin); + if (checkResult != null) { + return checkResult; + } + + if (context.member?.permissions?.isAdministrator ?? false) { + return true; + } + + final roleId = Snowflake.parse(setting!.dataAsJson!['create_instance_role']); + return context.member!.roleIds.contains(roleId); + }, +); + +final kavitaFeatureCreateInstanceCommandCheck = Check( + (CommandContext context) async { + final (checkResult, setting) = await fetchAndCheckSetting(context, Setting.kavita); if (checkResult != null) { return checkResult; } diff --git a/lib/src/commands/kavita.dart b/lib/src/commands/kavita.dart new file mode 100644 index 0000000..e547771 --- /dev/null +++ b/lib/src/commands/kavita.dart @@ -0,0 +1,115 @@ +import 'package:injector/injector.dart'; +import 'package:nyxx/nyxx.dart'; +import 'package:nyxx_commands/nyxx_commands.dart'; +import 'package:nyxx_extensions/nyxx_extensions.dart'; +import 'package:running_on_dart/src/checks.dart'; +import 'package:running_on_dart/src/models/kavita.dart'; +import 'package:running_on_dart/src/modules/kavita.dart'; +import 'package:running_on_dart/src/repository/kavita.dart'; +import 'package:running_on_dart/src/util/kavita.dart'; +import 'package:running_on_dart/src/util/util.dart'; + +Future getKavitaClient(KavitaUserConfig? config, ChatContext context) async { + config ??= await Injector.appInstance + .get() + .fetchGetUserConfigWithFallback(userId: context.user.id, parentId: getParentIdFromContext(context)); + + if (config == null) { + throw Exception("Invalid jellyfin config or user not logged in."); + } + return Injector.appInstance.get().createAuthenticatedClient(config); +} + +final kavita = ChatGroup('kavita', 'Kavita related commands', checks: [ + kavitaJellyfinCheck, +], children: [ + ChatGroup( + 'settings', + 'Settings for Kavita', + children: [ + ChatCommand( + 'add-instance', + 'Add new kavita instance', + id('kavita-settings-add-instance', (InteractionChatContext context) async { + final modalResponse = await context.getModal(title: "New Instance Configuration", components: [ + TextInputBuilder(customId: "name", style: TextInputStyle.short, label: "Instance Name", isRequired: true), + TextInputBuilder(customId: "base_url", style: TextInputStyle.short, label: "Base Url", isRequired: true), + TextInputBuilder(customId: "is_default", style: TextInputStyle.short, label: "Is Default (True/False)"), + ]); + + final newlyCreatedConfig = await Injector.appInstance.get().saveConfig( + KavitaConfig( + name: modalResponse['name']!, + basePath: modalResponse['base_url']!, + isDefault: modalResponse['is_default']?.toLowerCase() == 'true', + parentId: getParentIdFromContext(context), + ), + ); + + modalResponse + .respond(MessageBuilder(content: "Added new jellyfin instance with name: ${newlyCreatedConfig.name}")); + }), + checks: [kavitaFeatureCreateInstanceCommandCheck]), + ], + ), + ChatGroup( + 'user', + 'User related kavita commands', + children: [ + ChatCommand( + "login", + "Login user into given kavita instance", + id('kavita-user-login', + (InteractionChatContext context, @Description('Kavita instance to be used') KavitaConfig config) async { + final kavitaModule = Injector.appInstance.get(); + + final modalResult = await context.getModal(title: "Login to Kavita", components: [ + TextInputBuilder(customId: 'username', style: TextInputStyle.short, label: 'Username', isRequired: true), + TextInputBuilder(customId: 'password', style: TextInputStyle.short, label: 'Password', isRequired: true), + ]); + + final apiLoginResult = await kavitaModule + .createUnauthenticatedClient(config) + .login(modalResult['username']!, modalResult['password']!); + + await kavitaModule.login(config, apiLoginResult, context.user.id); + + return context.respond(MessageBuilder(content: "Logged in successfully!")); + }), + ) + ], + ), + ChatCommand( + 'search', + 'Search kavita library', + id('kavita-test', (ChatContext context, @Description('Query string to search content with') String query, + [@Description('Kavita instance to be used. Default if not provided') KavitaUserConfig? config]) async { + final client = await getKavitaClient(config, context); + + final items = await client.searchSeries(query); + final paginator = await pagination.builders(await getSearchEmbedPages(items, client).toList()); + + return context.respond(paginator); + })), + ChatCommand( + 'read', + 'Read series', + id('kavita-read', (ChatContext context, int seriesId, + [@Description('Whether save reading progress to kavita') bool saveReadProgress = true, + @Description('Kavita instance to be used. Default if not provided') KavitaUserConfig? config]) async { + final client = await getKavitaClient(config, context); + + final continuePoint = await client.getContinuePoint(seriesId); + if (continuePoint.isBook) { + return context.respond(MessageBuilder(content: "Books are not currently supported.")); + } + + final paginator = await pagination.factories( + await generateReadingPaginationFactories(continuePoint, client, seriesId, saveReadProgress).toList(), + startIndex: continuePoint.pagesRead, + userId: context.user.id); + + return context.respond(paginator); + }), + ), +]); diff --git a/lib/src/converter.dart b/lib/src/converter.dart index 40f4e51..c23b6ae 100644 --- a/lib/src/converter.dart +++ b/lib/src/converter.dart @@ -6,13 +6,17 @@ import 'package:nyxx_commands/nyxx_commands.dart'; import 'package:running_on_dart/src/models/docs.dart'; import 'package:running_on_dart/src/models/feature_settings.dart'; import 'package:running_on_dart/src/models/jellyfin_config.dart'; +import 'package:running_on_dart/src/models/kavita.dart'; import 'package:running_on_dart/src/models/reminder.dart'; import 'package:running_on_dart/src/modules/docs.dart'; import 'package:running_on_dart/src/modules/jellyfin.dart'; +import 'package:running_on_dart/src/modules/kavita.dart'; import 'package:running_on_dart/src/modules/reminder.dart'; import 'package:running_on_dart/src/modules/tag.dart'; import 'package:running_on_dart/src/repository/jellyfin_config.dart'; +import 'package:running_on_dart/src/repository/kavita.dart'; import 'package:running_on_dart/src/settings.dart'; +import 'package:running_on_dart/src/util/util.dart'; import 'models/tag.dart'; @@ -72,6 +76,23 @@ final jellyfinConfigUserConverter = Converter( .map((config) => CommandOptionChoiceBuilder(name: config.name, value: config.name)), ); +final kavitaUserConfigsConverter = Converter( + (view, context) async { + return Injector.appInstance.get().fetchGetUserConfigWithFallback( + userId: context.user.id, parentId: context.guild?.id ?? context.user.id, instanceName: view.getQuotedWord()); + }, + autocompleteCallback: (context) async => + (await Injector.appInstance.get().findAllForParent(getParentIdFromContext(context).toString())) + .map((config) => CommandOptionChoiceBuilder(name: config.name, value: config.name)), +); + +String stringifyKavitaConfig(KavitaConfig config) => config.name; +Future> getKavitaConfigs(ContextData context) => + Injector.appInstance.get().findAllForParent(getParentIdFromContext(context).toString()); + +const kavitaConfigConverter = + SimpleConverter(provider: getKavitaConfigs, stringify: stringifyKavitaConfig); + Future> getJellyfinConfigs(ContextData context) => Injector.appInstance .get() .getConfigsForParent((context.guild?.id ?? context.user.id).toString()); diff --git a/lib/src/init.dart b/lib/src/init.dart index 584bcdb..666e1cf 100644 --- a/lib/src/init.dart +++ b/lib/src/init.dart @@ -4,6 +4,7 @@ import 'package:running_on_dart/src/modules/bot_start_duration.dart'; import 'package:running_on_dart/src/modules/docs.dart'; import 'package:running_on_dart/src/modules/jellyfin.dart'; import 'package:running_on_dart/src/modules/join_logs.dart'; +import 'package:running_on_dart/src/modules/kavita.dart'; import 'package:running_on_dart/src/modules/mentions.dart'; import 'package:running_on_dart/src/modules/mod_log.dart'; import 'package:running_on_dart/src/modules/poop_name.dart'; @@ -11,6 +12,7 @@ import 'package:running_on_dart/src/modules/reminder.dart'; import 'package:running_on_dart/src/modules/tag.dart'; import 'package:running_on_dart/src/repository/feature_settings.dart'; import 'package:running_on_dart/src/repository/jellyfin_config.dart'; +import 'package:running_on_dart/src/repository/kavita.dart'; import 'package:running_on_dart/src/repository/reminder.dart'; import 'package:running_on_dart/src/repository/tag.dart'; import 'package:running_on_dart/src/services/db.dart'; @@ -24,6 +26,7 @@ Future setupContainer(NyxxGateway client) async { ..registerSingleton(() => JellyfinConfigRepository()) ..registerSingleton(() => ReminderRepository()) ..registerSingleton(() => TagRepository()) + ..registerSingleton(() => KavitaRepository()) ..registerSingleton(() => FeatureSettingsService()) ..registerSingleton(() => BotStartDuration()) ..registerSingleton(() => PoopNameModule()) @@ -33,7 +36,8 @@ Future setupContainer(NyxxGateway client) async { ..registerSingleton(() => TagModule()) ..registerSingleton(() => DocsModule()) ..registerSingleton(() => JellyfinModuleV2()) - ..registerSingleton(() => MentionsMonitoringModule()); + ..registerSingleton(() => MentionsMonitoringModule()) + ..registerSingleton(() => KavitaModule()); await Injector.appInstance.get().init(); await Injector.appInstance.get().init(); diff --git a/lib/src/models/feature_settings.dart b/lib/src/models/feature_settings.dart index 7b773a9..22872d5 100644 --- a/lib/src/models/feature_settings.dart +++ b/lib/src/models/feature_settings.dart @@ -12,7 +12,8 @@ enum Setting { joinLogs('join_logs', 'Logs member join events into specified channel', true, DataType.channelMention), modLogs('mod_logs', 'Logs administration event into specified channel', true, DataType.channelMention), jellyfin('jellyfin', 'Allows usage of jellyfin commands', true, DataType.json), - mentions('mentions', 'Monitors messages for mention abuse', false); + mentions('mentions', 'Monitors messages for mention abuse', false), + kavita('kavita', 'Allows usage of jellyfin command', true, DataType.json); /// name of setting final String name; diff --git a/lib/src/models/kavita.dart b/lib/src/models/kavita.dart new file mode 100644 index 0000000..ccc302c --- /dev/null +++ b/lib/src/models/kavita.dart @@ -0,0 +1,60 @@ +import 'package:nyxx/nyxx.dart'; + +class KavitaUserConfig { + static String tableName = 'kavita_user_configs'; + + final Snowflake userId; + final String authToken; + final String apiKey; + final int kavitaConfigId; + + KavitaConfig? config; + int? id; + + KavitaUserConfig( + {required this.userId, required this.authToken, required this.apiKey, required this.kavitaConfigId, this.id}); + + factory KavitaUserConfig.fromDatabaseRow(Map row) { + return KavitaUserConfig( + userId: Snowflake.parse(row['user_id']), + authToken: row['auth_token'], + apiKey: row['api_key'], + kavitaConfigId: row['kavita_config_id'], + id: row['id'], + ); + } + + factory KavitaUserConfig.fromDatabaseRowWithConfig(Map row) { + return KavitaUserConfig.fromDatabaseRow(row)..config = KavitaConfig.fromDatabaseRow(row); + } +} + +class KavitaConfig { + static String tableName = 'kavita_configs'; + + final String name; + final String basePath; + final bool isDefault; + final Snowflake parentId; + + /// The ID of this config, or `null` if this config has not yet been added to the database. + int? id; + + KavitaConfig({ + required this.name, + required this.basePath, + required this.isDefault, + required this.parentId, + this.id, + }); + + factory KavitaConfig.fromDatabaseRow(Map row) { + return KavitaConfig( + id: row['id'] as int?, + name: row['name'], + basePath: row['base_path'], + isDefault: row['is_default'] as bool, + parentId: Snowflake.parse(row['parent_id']), + ); + } +} diff --git a/lib/src/modules/kavita.dart b/lib/src/modules/kavita.dart new file mode 100644 index 0000000..7ef5ca6 --- /dev/null +++ b/lib/src/modules/kavita.dart @@ -0,0 +1,270 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:http/http.dart' as http; + +import 'package:injector/injector.dart'; +import 'package:nyxx/nyxx.dart'; +import 'package:running_on_dart/src/models/kavita.dart'; +import 'package:running_on_dart/src/repository/kavita.dart'; + +class LoginResult { + final String token; + final String apiKey; + + LoginResult({required this.token, required this.apiKey}); + + factory LoginResult.fromJson(Map raw) { + return LoginResult(token: raw['token'], apiKey: raw['apiKey']); + } +} + +class SeriesItem { + final int seriesId; + final String name; + final String originalName; + final int format; + final String libraryName; + final int libraryId; + + SeriesItem( + {required this.seriesId, + required this.name, + required this.originalName, + required this.format, + required this.libraryName, + required this.libraryId}); + + factory SeriesItem.fromJson(Map raw) { + return SeriesItem( + seriesId: raw['seriesId'], + name: raw['name'], + originalName: raw['originalName'], + format: raw['format'], + libraryName: raw['libraryName'], + libraryId: raw['libraryId'], + ); + } +} + +class ContinuePoint { + final int id; + final int pagesRead; + final int pages; + final int volumeId; + final bool isBook; + + int get chapterId => id; + + ContinuePoint( + {required this.id, required this.pagesRead, required this.pages, required this.volumeId, this.isBook = false}); + + factory ContinuePoint.fromJson(Map raw) { + return ContinuePoint( + id: raw['id'], + pagesRead: raw['pagesRead'], + pages: raw['pages'], + volumeId: raw['volumeId'], + isBook: (raw['files'] as List?)?.firstOrNull?['format'] == 3, + ); + } +} + +class AuthenticatedKavitaClient { + final String baseUrl; + final String token; + final String apiKey; + + late final Map _headers; + + AuthenticatedKavitaClient({required this.baseUrl, required this.token, required this.apiKey}) { + _headers = {'Accept': 'application/json', 'Content-Type': 'application/json'}; + } + + Future getChapterImage(int chapterId, int page) async { + final result = await _get("/api/Reader/image", + parameters: {"chapterId": chapterId.toString(), "page": page.toString()}, authApiKey: true); + + return result.bodyBytes; + } + + Future> searchSeries(String query) async { + final result = await _get("/api/Search/search", + parameters: { + 'queryString': query, + 'includeChapterAndFiles': false.toString(), + }, + authToken: true); + + final body = jsonDecode(result.body) as Map; + + return (body['series'] as List).map((e) => SeriesItem.fromJson(e as Map)); + } + + Future saveContinuePoint(int seriesId, int volumeId, int chapterId, int page) async { + final result = await _post('/api/Reader/progress', + body: { + 'seriesId': seriesId, + 'volumeId': volumeId, + 'chapterId': chapterId, + 'pageNum': page, + }, + authToken: true); + + return result.body == 'true'; + } + + Future getContinuePoint(int seriesId) async { + final result = await _get("/api/reader/continue-point", + parameters: { + 'seriesId': seriesId.toString(), + }, + authToken: true); + + final body = jsonDecode(result.body) as Map; + return ContinuePoint.fromJson(body); + } + + Future getBookPage(int chapterId, int page) async { + final result = + await _get("/api/Book/$chapterId/book-page", parameters: {"page": page.toString()}, authApiKey: true); + + return result.body; + } + + Future getNextChapter(int seriesId, int volumeId, int currentChapterId) async { + final result = await _get("/api/Reader/next-chapter", + parameters: { + 'seriesId': seriesId.toString(), + 'volumeId': volumeId.toString(), + 'currentChapterId': currentChapterId.toString(), + }, + authToken: true); + + return int.parse(result.body); + } + + Future getChapterCover(int chapterId) async { + final result = + await _get("/api/Image/chapter-cover", parameters: {"chapterId": chapterId.toString()}, authApiKey: true); + + return result.bodyBytes; + } + + Future getSeriesCover(int seriesId) async { + final result = + await _get("/api/Image/series-cover", parameters: {"seriesId": seriesId.toString()}, authApiKey: true); + + return result.bodyBytes; + } + + Future _get(String path, + {Map parameters = const {}, bool authToken = false, bool authApiKey = false}) async { + final uri = + Uri.parse('$baseUrl$path').replace(queryParameters: _makeQueryParameters(parameters, authApiKey: authApiKey)); + + return await http.get(uri, headers: _makeHeaders(authToken: authToken)); + } + + Future _post(String path, + {Object? body, + Map parameters = const {}, + bool authToken = false, + bool authApiKey = false}) async { + return await http.post( + Uri.parse('$baseUrl$path').replace(queryParameters: _makeQueryParameters(parameters, authApiKey: authApiKey)), + headers: _makeHeaders(authToken: authToken), + body: jsonEncode(body)); + } + + Map _makeQueryParameters(Map parameters, {bool authApiKey = false}) => { + ...parameters, + if (authApiKey) 'apiKey': apiKey, + }; + + Map _makeHeaders({bool authToken = false}) => { + ..._headers, + if (authToken) 'Authorization': 'Bearer $token', + }; +} + +class UnauthenticatedKavitaClient { + final String baseUrl; + + late final Map _headers; + + UnauthenticatedKavitaClient({required this.baseUrl}) { + _headers = {'Accept': 'application/json', 'Content-Type': 'application/json'}; + } + + Future login(String username, String password) async { + final result = await _post("/api/Account/login", body: { + "username": username, + "password": password, + }); + + final body = jsonDecode(result.body); + + return LoginResult.fromJson(body); + } + + Future _post(String path, {Object? body}) async { + return await http.post(Uri.parse('$baseUrl$path'), headers: _headers, body: jsonEncode(body)); + } +} + +class KavitaModule { + final KavitaRepository _kavitaRepository = Injector.appInstance.get(); + + Future getJellyfinConfig(String name, Snowflake parentId) { + return _kavitaRepository.find(name, parentId.toString()); + } + + Future getJellyfinDefaultConfig(Snowflake parentId) { + return _kavitaRepository.findDefault(parentId.toString()); + } + + Future getUserConfigForConfig(Snowflake userId, KavitaConfig config) async { + final userConfig = await _kavitaRepository.findUserConfigForConfig(userId.toString(), config.id!); + userConfig?.config = config; + + return userConfig; + } + + Future fetchGetUserConfigWithFallback( + {required Snowflake userId, required Snowflake parentId, String? instanceName}) async { + final config = instanceName != null + ? await getJellyfinConfig(instanceName, parentId) + : await getJellyfinDefaultConfig(parentId); + if (config == null) { + return null; + } + + final userConfig = await getUserConfigForConfig(userId, config); + if (userConfig == null) { + return null; + } + + return userConfig; + } + + UnauthenticatedKavitaClient createUnauthenticatedClient(KavitaConfig config) { + return UnauthenticatedKavitaClient(baseUrl: config.basePath); + } + + AuthenticatedKavitaClient createAuthenticatedClient(KavitaUserConfig config) { + if (config.config == null) { + throw Exception(); + } + + return AuthenticatedKavitaClient(baseUrl: config.config!.basePath, token: config.authToken, apiKey: config.apiKey); + } + + Future login(KavitaConfig config, LoginResult loginResult, Snowflake userId) { + final userConfig = KavitaUserConfig( + userId: userId, authToken: loginResult.token, apiKey: loginResult.apiKey, kavitaConfigId: config.id!) + ..config = config; + + return _kavitaRepository.saveUserConfig(userConfig); + } +} diff --git a/lib/src/modules/mentions.dart b/lib/src/modules/mentions.dart index 1ecd1d6..5943054 100644 --- a/lib/src/modules/mentions.dart +++ b/lib/src/modules/mentions.dart @@ -81,8 +81,6 @@ class MentionsMonitoringModule implements RequiresInitialization { (member.permissions?.canManageMessages ?? false) || (member.permissions?.canManageChannels ?? false); if (shouldBeSkippedByPermissions) { - _logger.info( - "Detected spamming from user: ${member.user?.username}, id: ${member.id}. Skipping since can manage guild."); return; } diff --git a/lib/src/repository/kavita.dart b/lib/src/repository/kavita.dart new file mode 100644 index 0000000..506f196 --- /dev/null +++ b/lib/src/repository/kavita.dart @@ -0,0 +1,119 @@ +import 'package:injector/injector.dart'; +import 'package:running_on_dart/src/models/kavita.dart'; +import 'package:running_on_dart/src/services/db.dart'; +import 'package:running_on_dart/src/util/query_builder.dart'; + +class KavitaRepository { + final DatabaseService _database = Injector.appInstance.get(); + + Future> findAllForParent(String parentId) async { + final query = SelectQuery.selectAll(KavitaConfig.tableName)..andWhere("parent_id = @parent_id"); + final result = await _database.executeQuery(query, parameters: {'parent_id': parentId}); + + return result.map((row) => KavitaConfig.fromDatabaseRow(row.toColumnMap())); + } + + Future find(String name, String parentId) async { + final query = SelectQuery.selectAll(KavitaConfig.tableName) + ..andWhere("parent_id = @parent_id") + ..andWhere("name = @name"); + + final result = await _database.executeQuery(query, parameters: {'parent_id': parentId, 'name': name}); + if (result.isEmpty) { + return null; + } + + return KavitaConfig.fromDatabaseRow(result.first.toColumnMap()); + } + + Future findDefault(String parentId) async { + final query = SelectQuery.selectAll(KavitaConfig.tableName) + ..andWhere("parent_id = @parent_id") + ..andWhere("is_default = 1::bool"); + + final result = await _database.executeQuery(query, parameters: {'parent_id': parentId}); + if (result.isEmpty) { + return null; + } + + return KavitaConfig.fromDatabaseRow(result.first.toColumnMap()); + } + + Future findUserConfigForConfig(String userId, int configId) async { + final query = SelectQuery.selectAll(KavitaUserConfig.tableName) + ..andWhere("kavita_config_id = @config_id") + ..andWhere('user_id = @user_id'); + + final result = await _database.executeQuery(query, parameters: {'config_id': configId, 'user_id': userId}); + if (result.isEmpty) { + return null; + } + + return KavitaUserConfig.fromDatabaseRow(result.first.toColumnMap()); + } + + Future findUserConfig(String configName, String parentId, String userId) async { + final query = SelectQuery.selectAll(KavitaConfig.tableName, alias: "c") + ..select("uc.*") + ..addJoin(KavitaUserConfig.tableName, "uc", ["uc.kavita_config_id = c.id", "uc.user_id = @userId"]) + ..andWhere("c.name = @configName") + ..andWhere("c.parent_id = @parentId"); + + final result = await _database.executeQuery(query, parameters: { + 'configName': configName, + 'parentId': parentId, + 'userId': userId, + }); + + if (result.isEmpty) { + return null; + } + + return KavitaUserConfig.fromDatabaseRowWithConfig(result.first.toColumnMap()); + } + + Future saveConfig(KavitaConfig config) async { + final query = InsertQuery(KavitaConfig.tableName) + ..addNamedInsert('name') + ..addNamedInsert('base_path') + ..addNamedInsert('is_default') + ..addNamedInsert('parent_id') + ..addReturning('id'); + + final result = await _database.executeQuery(query, parameters: { + 'name': config.name, + 'base_path': config.basePath, + 'is_default': config.isDefault, + 'parent_id': config.parentId.toString(), + }); + + config.id = result.first.first as int; + return config; + } + + Future saveUserConfig(KavitaUserConfig userConfig) async { + final query = InsertQuery(KavitaUserConfig.tableName) + ..addNamedInsert("user_id") + ..addNamedInsert("auth_token") + ..addNamedInsert("api_key") + ..addNamedInsert("kavita_config_id") + ..onConflict('kavita_user_configs_user_id_unique', { + 'auth_token': '@auth_token', + 'api_key': '@api_key', + }, [ + '${KavitaUserConfig.tableName}.user_id = @user_id', + '${KavitaUserConfig.tableName}.kavita_config_id = @kavita_config_id' + ]) + ..addReturning('id'); + + final result = await _database.executeQuery(query, parameters: { + 'user_id': userConfig.userId.toString(), + 'auth_token': userConfig.authToken, + 'api_key': userConfig.apiKey, + 'kavita_config_id': userConfig.kavitaConfigId, + }); + + userConfig.id = result.first.first as int; + return userConfig; + } +} diff --git a/lib/src/services/db.dart b/lib/src/services/db.dart index 3428e85..1bf0919 100644 --- a/lib/src/services/db.dart +++ b/lib/src/services/db.dart @@ -171,7 +171,36 @@ class DatabaseService implements RequiresInitialization { ..enqueueMigration("2.11", 'CREATE UNIQUE INDEX idx_jellyfin_configs_user_id ON jellyfin_user_configs(user_id, jellyfin_config_id);') ..enqueueMigration("2.12", - 'ALTER TABLE jellyfin_user_configs ADD CONSTRAINT jellyfin_configs_user_id_unique UNIQUE (user_id, jellyfin_config_id);'); + 'ALTER TABLE jellyfin_user_configs ADD CONSTRAINT jellyfin_configs_user_id_unique UNIQUE (user_id, jellyfin_config_id);') + ..enqueueMigration("2.13", ''' + CREATE TABLE kavita_configs ( + id SERIAL PRIMARY KEY, + name VARCHAR NOT NULL, + base_path VARCHAR NOT NULL, + is_default BOOLEAN NOT NULL DEFAULT FALSE, + parent_id VARCHAR NOT NULL + ); + ''') + ..enqueueMigration( + "2.14", 'CREATE UNIQUE INDEX idx_kavita_configs_unique_name ON kavita_configs(name, parent_id);') + ..enqueueMigration("2.15", + 'CREATE UNIQUE INDEX idx_kavita_configs_unique_default ON kavita_configs(parent_id, is_default) WHERE is_default = TRUE;') + ..enqueueMigration("2.16", ''' + CREATE TABLE kavita_user_configs ( + id SERIAL PRIMARY KEY, + user_id VARCHAR NOT NULL, + auth_token VARCHAR NOT NULL, + api_key VARCHAR NOT NULL, + kavita_config_id INT NOT NULL, + CONSTRAINT fk_kavita_configs + FOREIGN KEY(kavita_config_id) + REFERENCES kavita_configs(id) + ); + ''') + ..enqueueMigration( + "2.17", 'CREATE UNIQUE INDEX idx_kavita_user_configs_id ON kavita_user_configs(user_id, kavita_config_id);') + ..enqueueMigration("2.18", + 'ALTER TABLE kavita_user_configs ADD CONSTRAINT kavita_user_configs_user_id_unique UNIQUE (user_id, kavita_config_id);'); await migrator.runMigrations(); diff --git a/lib/src/util/kavita.dart b/lib/src/util/kavita.dart new file mode 100644 index 0000000..87311d9 --- /dev/null +++ b/lib/src/util/kavita.dart @@ -0,0 +1,53 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:intl/intl.dart'; +import 'package:nyxx/nyxx.dart'; +import 'package:running_on_dart/src/modules/kavita.dart'; + +final pageNumberFormat = NumberFormat('000'); + +Stream getSearchEmbedPages(Iterable items, AuthenticatedKavitaClient client) async* { + for (final itemSlice in items.slices(2)) { + final attachments = []; + + final embeds = Stream.fromIterable(itemSlice).asyncMap((item) async { + final attachment = + AttachmentBuilder(data: await client.getSeriesCover(item.seriesId), fileName: 'series-cover.jpg'); + attachments.add(attachment); + + return EmbedBuilder( + title: '${item.name} (${item.seriesId})', + fields: [ + EmbedFieldBuilder(name: 'Library', value: item.libraryName, isInline: true), + ], + thumbnail: EmbedThumbnailBuilder(url: Uri.parse('attachment://series-cover.jpg')), + ); + }); + + yield MessageBuilder( + embeds: await embeds.toList(), + attachments: attachments, + ); + } +} + +Stream Function()> generateReadingPaginationFactories( + ContinuePoint continuePoint, AuthenticatedKavitaClient client, int seriesId, bool saveReadProgress) async* { + for (var i = 0; i < continuePoint.pages; i += 1) { + yield () { + if (saveReadProgress) { + client.saveContinuePoint(seriesId, continuePoint.volumeId, continuePoint.chapterId, i + 1); + } + + return generateReadingPage(i, continuePoint.chapterId, client); + }; + } +} + +Future generateReadingPage(int page, int chapterId, AuthenticatedKavitaClient client) async { + final pageData = await client.getChapterImage(chapterId, page); + + return MessageBuilder( + attachments: [AttachmentBuilder(data: pageData, fileName: '${pageNumberFormat.format(page)}.jpg')]); +} diff --git a/lib/src/util/query_builder.dart b/lib/src/util/query_builder.dart index a300a26..4c93ab2 100644 --- a/lib/src/util/query_builder.dart +++ b/lib/src/util/query_builder.dart @@ -86,6 +86,30 @@ class _Join { String build() => "$joinType $target $targetAlias ON ${buildWheres(conditions, 'AND')}"; } +class _InsertOnConflict { + final String constraintName; + final Map _sets; + final List _wheres; + + _InsertOnConflict(this.constraintName, this._sets, this._wheres); + + String build() { + if (_sets.isEmpty || _wheres.isEmpty) { + throw QueryBuilderException("Insert on conflict cannot have empty set or where statements"); + } + + return "ON CONFLICT ON CONSTRAINT $constraintName DO UPDATE SET ${buildSets(_sets)}" + " WHERE ${buildWheres(_wheres, 'AND')}"; + } +} + +mixin _SetQuery implements Query { + final Map _sets = {}; + + void addSet(String name, String value) => _sets[name] = value; + void addNamedSet(String name) => _sets[name] = "@$name"; +} + mixin _JoinQuery implements Query { final List<_Join> _joins = []; @@ -106,12 +130,15 @@ mixin _JoinQuery implements Query { class InsertQuery extends Query { final Map _inserts = {}; final List _returnings = []; + _InsertOnConflict? _onConflict; InsertQuery(super.from, {super.alias}); void addInsert(String name, String value) => _inserts[name] = value; void addNamedInsert(String name) => _inserts[name] = '@$name'; void addReturning(String name) => _returnings.add(name); + void onConflict(String constraintName, Map sets, List wheres) => + _onConflict = _InsertOnConflict(constraintName, sets, wheres); @override Sql build() { @@ -124,6 +151,11 @@ class InsertQuery extends Query { buffer.write(values); buffer.write(")"); + if (_onConflict != null) { + buffer.write(" "); + buffer.write(_onConflict!.build()); + } + if (_returnings.isNotEmpty) { buffer.write(" RETURNING "); buffer.write(buildReturnings(_returnings)); @@ -135,14 +167,9 @@ class InsertQuery extends Query { } } -class UpdateQuery extends Query with _WhereQuery { - final Map _sets = {}; - +class UpdateQuery extends Query with _WhereQuery, _SetQuery { UpdateQuery(super.from, {super.alias}); - void addSet(String name, String value) => _sets[name] = value; - void addNamedSet(String name) => _sets[name] = "@$name"; - @override Sql build() { if (_andWheres.isEmpty && _orWheres.isEmpty) { diff --git a/lib/src/util/util.dart b/lib/src/util/util.dart index ee1c3d4..a4d5d37 100644 --- a/lib/src/util/util.dart +++ b/lib/src/util/util.dart @@ -69,4 +69,4 @@ Map getModalDataIndexed(List components) { .map((textInputComponent) => MapEntry(textInputComponent.customId, textInputComponent.value))); } -Snowflake getParentIdFromContext(CommandContext context) => context.guild?.id ?? context.user.id; +Snowflake getParentIdFromContext(ContextData context) => context.guild?.id ?? context.user.id; diff --git a/test/query_builder_test.dart b/test/query_builder_test.dart index d9bdac6..5408378 100644 --- a/test/query_builder_test.dart +++ b/test/query_builder_test.dart @@ -113,6 +113,17 @@ void main() { expect(query.build().asString(), "INSERT INTO test (name,model) VALUES (moron,@model) RETURNING id;"); }); + + test("on conflict", () { + final query = InsertQuery("test") + ..addInsert("name", "moron") + ..addNamedInsert("model") + ..onConflict("test_constraint", {'model': "@model"}, ['id = @id']) + ..addReturning("id"); + + expect(query.build().asString(), + "INSERT INTO test (name,model) VALUES (moron,@model) ON CONFLICT ON CONSTRAINT test_constraint DO UPDATE SET model = @model WHERE id = @id RETURNING id;"); + }); }); group("Delete tests", () {