diff --git a/bin/running_on_dart.dart b/bin/running_on_dart.dart index 9839375..61e7f36 100644 --- a/bin/running_on_dart.dart +++ b/bin/running_on_dart.dart @@ -36,11 +36,18 @@ void main() async { ..addConverter(manageableTagConverter) ..addConverter(durationConverter) ..addConverter(reminderConverter) - ..addConverter(packageDocsConverter); + ..addConverter(packageDocsConverter) + ..addConverter(jellyfinConfigConverter) + ..addConverter(jellyfinConfigUserConverter); commands.onCommandError.listen((error) { if (error is CheckFailedException) { error.context.respond(MessageBuilder(content: "Sorry, you can't use that command!")); + return; + } + + if (error is UncaughtException && error.exception is JellyfinConfigNotFoundException) { + error.context.respond(MessageBuilder(content: error.message)); } }); @@ -70,10 +77,10 @@ void main() async { ..registerSingleton(() => ModLogsModule()) ..registerSingleton(() => TagModule()) ..registerSingleton(() => DocsModule()) - ..registerSingleton(() => JellyfinModule()); + ..registerSingleton(() => JellyfinModuleV2()); await Injector.appInstance.get().init(); - await Injector.appInstance.get().init(); + await Injector.appInstance.get().init(); await Injector.appInstance.get().init(); await Injector.appInstance.get().init(); await Injector.appInstance.get().init(); diff --git a/lib/src/checks.dart b/lib/src/checks.dart index 08b5e11..3c71280 100644 --- a/lib/src/checks.dart +++ b/lib/src/checks.dart @@ -37,32 +37,6 @@ Future<(bool?, FeatureSetting?)> fetchAndCheckSetting(CommandContext context) as return (null, setting); } -final jellyfinFeatureUserCommandCheck = Check( - (CommandContext context) async { - final (checkResult, setting) = await fetchAndCheckSetting(context); - if (checkResult != null) { - return checkResult; - } - - final roleId = Snowflake.parse(setting!.dataAsJson!['user_commands_role']); - - return context.member!.roleIds.contains(roleId); - }, -); - -final jellyfinFeatureAdminCommandCheck = Check( - (CommandContext context) async { - final (checkResult, setting) = await fetchAndCheckSetting(context); - if (checkResult != null) { - return checkResult; - } - - final roleId = Snowflake.parse(setting!.dataAsJson!['admin_commands_role']); - - return context.member!.roleIds.contains(roleId); - }, -); - final jellyfinFeatureCreateInstanceCommandCheck = Check( (CommandContext context) async { final (checkResult, setting) = await fetchAndCheckSetting(context); @@ -72,6 +46,6 @@ final jellyfinFeatureCreateInstanceCommandCheck = Check( final roleId = Snowflake.parse(setting!.dataAsJson!['create_instance_role']); - return context.member!.roleIds.contains(roleId); + return (context.member?.permissions?.isAdministrator ?? false) || context.member!.roleIds.contains(roleId); }, ); diff --git a/lib/src/commands/jellyfin.dart b/lib/src/commands/jellyfin.dart index 7ff3e67..dafbb5f 100644 --- a/lib/src/commands/jellyfin.dart +++ b/lib/src/commands/jellyfin.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:collection/collection.dart'; import 'package:injector/injector.dart'; import 'package:intl/intl.dart'; @@ -6,267 +8,639 @@ import 'package:nyxx_commands/nyxx_commands.dart'; import 'package:nyxx_extensions/nyxx_extensions.dart'; import 'package:running_on_dart/running_on_dart.dart'; import 'package:running_on_dart/src/checks.dart'; +import 'package:running_on_dart/src/external/wizarr.dart'; import 'package:running_on_dart/src/models/jellyfin_config.dart'; -import 'package:running_on_dart/src/repository/jellyfin_config.dart'; import 'package:running_on_dart/src/util/jellyfin.dart'; import 'package:running_on_dart/src/util/pipelines.dart'; +import 'package:running_on_dart/src/util/util.dart'; import 'package:tentacle/tentacle.dart'; final taskProgressFormat = NumberFormat("0.00"); -final jellyfin = ChatGroup( - "jellyfin", - "Jellyfin Testing Commands", - checks: [ - jellyfinFeatureEnabledCheck, - ], - children: [ - ChatGroup("tasks", "Run tasks on Jellyfin instance", children: [ - ChatCommand( - "run", - "Run given task", - id('jellyfin-tasks-run', (InteractionChatContext context, - [@Description("Instance to use. Default selected if not provided") - @UseConverter(jellyfinConfigConverter) - JellyfinConfig? config]) async { - final client = await Injector.appInstance.get().getClient((config?.name, context.guild!.id)); - if (client == null) { - return context.respond(MessageBuilder(content: "Invalid Jellyfin instance")); - } +String? valueOrNullIfNotDefault(String? value, [String ifNotDefault = 'Unlimited']) { + if (value == ifNotDefault) { + return null; + } - final selectMenuResult = await context - .getSelection(await client.getScheduledTasks(), MessageBuilder(content: 'Choose task to run!'), - toSelectMenuOption: (taskInfo) { - final label = - taskInfo.state != TaskState.idle ? "${taskInfo.name} [${taskInfo.state}]" : taskInfo.name.toString(); + return valueOrNull(value); +} - final description = (taskInfo.description?.length ?? 0) >= 100 - ? "${taskInfo.description?.substring(0, 97)}..." - : taskInfo.description; +Future getJellyfinClient(JellyfinConfigUser? config, ChatContext context) async { + config ??= await Injector.appInstance + .get() + .fetchGetUserConfigWithFallback(userId: context.user.id, parentId: context.guild?.id ?? context.user.id); + return Injector.appInstance.get().createJellyfinClientAuthenticated(config); +} - return SelectMenuOptionBuilder(label: label, value: taskInfo.id!, description: description); - }, authorOnly: true); +final jellyfin = ChatGroup("jellyfin", "Jellyfin Testing Commands", checks: [ + jellyfinFeatureEnabledCheck, +], children: [ + ChatGroup("wizarr", "Wizarr related commands", children: [ + ChatCommand( + "redeem-invitation", + "Redeem invitation code", + id("jellyfin-wizarr-redeem-invitation", (InteractionChatContext context, String code, + [@Description("Instance to use. Default selected if not provided") JellyfinConfig? config]) async { + final client = await Injector.appInstance + .get() + .fetchGetWizarrClientWithFallback(originalConfig: config, parentId: context.guild?.id ?? context.user.id); - Pipeline.fromUpdateContext( - messageSupplier: (messageBuilder) => context.interaction.updateOriginalResponse(messageBuilder), - tasks: [ - Task( - runCallback: () => client.startTask(selectMenuResult.id!), - updateCallback: () async { - final scheduledTask = (await client.getScheduledTasks()) - .firstWhereOrNull((taskInfo) => taskInfo.id == selectMenuResult.id); - if (scheduledTask == null || scheduledTask.state == TaskState.idle) { - return (true, null); - } + return await context.respond( + getWizarrRedeemInvitationMessageBuilder( + client, code, context.user.id, context.guild?.id ?? context.user.id, client.configName), + level: ResponseLevel.private); + }), + ), + ChatCommand( + "create-invitation", + "Create wizarr invitation", + id("jellyfin-wizarr-create-invitation", (InteractionChatContext context, + [@Description('Inform user about invitation') User? user, + @Description("Instance to use. Default selected if not provided") JellyfinConfigUser? config]) async { + final jellyfinClient = await getJellyfinClient(config, context); + final currentUser = await jellyfinClient.getCurrentUser(); - return ( - false, - "Running `${scheduledTask.name!}` - ${taskProgressFormat.format(scheduledTask.currentProgressPercentage!)}%" - ); - }), - ], - updateInterval: Duration(seconds: 2), - ).execute(); - }), - ), - ]), - ChatGroup( - "user", - "Jellyfin user related commands", - children: [ - ChatCommand( - "allow-registration", - "Allows user to register into jellyfin instance", - id('jellyfin-user-allow-registration', - (ChatContext context, @Description("User to allow registration to") User user, - [@Description("Allowed libraries for user. Comma separated") String? allowedLibraries, - @Description("Instance to use. Default selected if not provided") - @UseConverter(jellyfinConfigConverter) - JellyfinConfig? config]) async { - final client = - await Injector.appInstance.get().getClient((config?.name, context.guild!.id)); - if (client == null) { - return context.respond(MessageBuilder(content: "Invalid Jellyfin instance")); - } + if (!(currentUser.policy?.isAdministrator ?? false)) { + return context.respond( + MessageBuilder(content: "This command can use only logged jellyfin users with administrator privileges."), + level: ResponseLevel.private); + } - final allowedLibrariesList = - allowedLibraries != null ? allowedLibraries.split(',').map((str) => str.trim()).toList() : []; + final wizarrClient = await Injector.appInstance.get().fetchGetWizarrClientWithFallback( + originalConfig: jellyfinClient.configUser.config!, parentId: context.guild?.id ?? context.user.id); - Injector.appInstance - .get() - .addUserToAllowedForRegistration(client.name, user.id, allowedLibrariesList); + final librariesMap = Map.fromEntries( + (await wizarrClient.getAvailableLibraries()).map((library) => MapEntry(library.name, library.id))); - return context.respond(MessageBuilder( - content: '${user.mention} can now create new jellyfin account using `/jellyfin user register`')); - }), - checks: [ - jellyfinFeatureAdminCommandCheck, - ], - ), - ChatCommand( - "register", - "Allows to self register to jellyfin instance. Given administrator allowed action", - id('jellyfin-user-register', (InteractionChatContext context, - [@Description("Instance to use. Default selected if not provided") - @UseConverter(jellyfinConfigConverter) - JellyfinConfig? config]) async { - final client = - await Injector.appInstance.get().getClient((config?.name, context.guild!.id)); - if (client == null) { - return context.respond(MessageBuilder(content: "Invalid Jellyfin instance")); - } - - final (isAllowed, allowedLibraries) = - Injector.appInstance.get().isUserAllowedForRegistration(client.name, context.user.id); - if (!isAllowed) { - return context.respond(MessageBuilder( - content: - 'You have not been allowed to create new account on this jellyfin instance. Ask jellyfin administrator for permission.')); - } - - final modal = await context.getModal(title: "New user Form", components: [ - TextInputBuilder( - style: TextInputStyle.short, - customId: 'username', - label: 'User name', - minLength: 4, - isRequired: true), - TextInputBuilder( - style: TextInputStyle.short, - customId: 'password', - label: 'Password', - minLength: 8, - isRequired: true), - ]); - - final user = - await client.createUser(modal['username']!, modal['password']!, allowedLibraries: allowedLibraries); - if (user == null) { - return context.respond(MessageBuilder(content: 'Cannot create an user. Contact administrator')); - } - - return context - .respond(MessageBuilder(content: 'User created successfully. Login here: ${client.basePath}')); - }), - options: CommandOptions(defaultResponseLevel: ResponseLevel.private), - checks: [ - jellyfinFeatureUserCommandCheck, + final firstModal = await context.getModal(title: "Create Wizarr invitation", components: [ + TextInputBuilder( + customId: "code", + style: TextInputStyle.short, + label: "Invitation Code (6 characters)", + isRequired: true, + value: generateRandomString(6)), + TextInputBuilder( + customId: "expiration", + style: TextInputStyle.short, + label: "Invitation expiration (or Unlimited)", + isRequired: true, + value: '1 Day'), + TextInputBuilder( + customId: "unlimited_usage", + style: TextInputStyle.short, + label: "Allow unlimited usages (True/False)", + isRequired: true, + value: 'False'), + ]); + + final message = await context.respond( + MessageBuilder(content: "Click to open second modal and continue", components: [ + ActionRowBuilder(components: [ + ButtonBuilder.primary( + customId: ComponentId.generate(allowedUser: context.user.id).toString(), label: 'Open modal') + ]) ]), - ], + level: ResponseLevel.private); + await context.getButtonPress(message); + + final secondModal = await context.getModal(title: 'Create Wizarr invitation', components: [ + TextInputBuilder( + customId: "simultaneous_logins_max_number", + style: TextInputStyle.short, + label: "Maximum Number of Simultaneous Logins", + isRequired: true, + value: 'Unlimited'), + TextInputBuilder( + customId: "account_duration", + style: TextInputStyle.short, + label: "User Account Duration", + isRequired: true, + value: 'Unlimited'), + ]); + + final librariesSelection = await context.getMultiSelection( + librariesMap.entries.map((entry) => entry.key).toList(), + MessageBuilder(content: 'Select wanted libraries to finish code creation'), + level: ResponseLevel.private, + authorOnly: true); + + final accountDuration = + getDurationFromStringOrDefault(valueOrNullIfNotDefault(secondModal['account_duration']), Duration(days: 1)); + final expiresDuration = getDurationFromStringOrDefault(valueOrNullIfNotDefault(firstModal['expiration']), null); + final code = firstModal['code']!; + + final createInvitationRequest = CreateInvitationRequest( + code: code, + expires: accountDuration, + duration: expiresDuration, + specificLibraries: librariesSelection.map((libraryName) => librariesMap[libraryName]).nonNulls.toList(), + unlimited: firstModal['unlimited_usage']?.toLowerCase() == 'true', + sessions: int.tryParse(secondModal['simultaneous_logins_max_number']!) ?? 0, + ); + + final result = await wizarrClient.createInvitation(createInvitationRequest); + + if (result) { + var messageToUserSent = ''; + if (user != null) { + messageToUserSent = ' Message to user ${user.mention} sent.'; + + (await user.manager.createDm(user.id)).sendMessage(getWizarrRedeemInvitationMessageBuilder( + wizarrClient, + code, + user.id, + context.guild?.id ?? context.user.id, + wizarrClient.configName, + )); + } + + return context.respond( + MessageBuilder( + content: 'Invitation with code: `${createInvitationRequest.code}` created.$messageToUserSent'), + level: ResponseLevel.private); + } + + return context.respond(MessageBuilder(content: 'Cannot create invitation. Contact administrator.'), + level: ResponseLevel.private); + }), ), + ]), + ChatGroup("sonarr", "Sonarr related commands", children: [ ChatCommand( - "search", - "Search instance for content", - id('jellyfin-search', (ChatContext context, @Description("Term to search jellyfin for") String searchTerm, - [@Description("Include episodes when searching") bool includeEpisodes = false, - @Description("Query limit") int limit = 15, - @Description("Instance to use. Default selected if not provided") - @UseConverter(jellyfinConfigConverter) - JellyfinConfig? config]) async { - final client = await Injector.appInstance.get().getClient((config?.name, context.guild!.id)); - if (client == null) { - return context.respond(MessageBuilder(content: "Invalid Jellyfin instance")); + "calendar", + "Show upcoming episodes", + id("jellyfin-sonarr-calendar", (ChatContext context, + [@Description("Instance to use. Default selected if not provided") JellyfinConfigUser? config]) async { + final client = await Injector.appInstance.get().fetchGetSonarrClientWithFallback( + originalConfig: config?.config, parentId: context.guild?.id ?? context.user.id); + + final calendarItems = await client.fetchCalendar(end: DateTime.now().add(Duration(days: 7))); + final embeds = getSonarrCalendarEmbeds(calendarItems); + + final paginator = await pagination.builders(spliceEmbedsForMessageBuilders(embeds).toList()); + context.respond(paginator); + }), + ), + ]), + ChatGroup( + "user", + "Jellyfin user related commands", + children: [ + // ChatCommand( + // "allow-registration", + // "Allows user to register into jellyfin instance", + // id('jellyfin-user-allow-registration', + // (ChatContext context, @Description("User to allow registration to") User user, + // [@Description("Allowed libraries for user. Comma separated") String? allowedLibraries, + // @Description("Instance to use. Default selected if not provided") + // @UseConverter(jellyfinConfigConverter) + // JellyfinConfig? config]) async { + // final client = + // await Injector.appInstance.get().getClient((config?.name, context.guild!.id)); + // if (client == null) { + // return context.respond(MessageBuilder(content: "Invalid Jellyfin instance")); + // } + // + // final allowedLibrariesList = + // allowedLibraries != null ? allowedLibraries.split(',').map((str) => str.trim()).toList() : []; + // + // Injector.appInstance + // .get() + // .addUserToAllowedForRegistration(client.name, user.id, allowedLibrariesList); + // + // return context.respond(MessageBuilder( + // content: '${user.mention} can now create new jellyfin account using `/jellyfin user register`')); + // }), + // checks: [ + // jellyfinFeatureAdminCommandCheck, + // ], + // ), + // ChatCommand( + // "register", + // "Allows to self register to jellyfin instance. Given administrator allowed action", + // id('jellyfin-user-register', (InteractionChatContext context, + // [@Description("Instance to use. Default selected if not provided") + // @UseConverter(jellyfinConfigConverter) + // JellyfinConfig? config]) async { + // final client = + // await Injector.appInstance.get().getClient((config?.name, context.guild!.id)); + // if (client == null) { + // return context.respond(MessageBuilder(content: "Invalid Jellyfin instance")); + // } + // + // final (isAllowed, allowedLibraries) = + // Injector.appInstance.get().isUserAllowedForRegistration(client.name, context.user.id); + // if (!isAllowed) { + // return context.respond(MessageBuilder( + // content: + // 'You have not been allowed to create new account on this jellyfin instance. Ask jellyfin administrator for permission.')); + // } + // + // final modal = await context.getModal(title: "New user Form", components: [ + // TextInputBuilder( + // style: TextInputStyle.short, + // customId: 'username', + // label: 'User name', + // minLength: 4, + // isRequired: true), + // TextInputBuilder( + // style: TextInputStyle.short, + // customId: 'password', + // label: 'Password', + // minLength: 8, + // isRequired: true), + // ]); + // + // final user = + // await client.createUser(modal['username']!, modal['password']!, allowedLibraries: allowedLibraries); + // if (user == null) { + // return context.respond(MessageBuilder(content: 'Cannot create an user. Contact administrator')); + // } + // + // return context + // .respond(MessageBuilder(content: 'User created successfully. Login here: ${client.basePath}')); + // }), + // options: CommandOptions(defaultResponseLevel: ResponseLevel.private), + // checks: [ + // jellyfinFeatureUserCommandCheck, + // ]), + ChatCommand( + "login", + "Login with password to given jellyfin instance", + id("jellyfin-user-login", (InteractionChatContext context, JellyfinConfig config) async { + final client = Injector.appInstance.get().createJellyfinClientAnonymous(config); + + final loginDataModal = + await context.getModal(title: "Login to jellyfin instance (${config.name})", components: [ + TextInputBuilder(customId: 'username', style: TextInputStyle.short, label: 'Username', isRequired: true), + TextInputBuilder(customId: 'password', style: TextInputStyle.short, label: 'Password', isRequired: true), + ]); + + final loginCallResult = + await client.loginByPassword(loginDataModal['username']!, loginDataModal['password']!); + final loginResult = + await Injector.appInstance.get().login(config, loginCallResult, context.user.id); + + if (loginResult) { + return context.respond(MessageBuilder(content: "Logged in successfully!")); } - final resultsWithoutEpisodes = - await client.searchItems(searchTerm, includeEpisodes: includeEpisodes, limit: limit); - final results = [ - ...resultsWithoutEpisodes, - if (resultsWithoutEpisodes.length < 4) - ...await client.searchItems(searchTerm, - limit: limit - resultsWithoutEpisodes.length, - includeEpisodes: true, - includeMovies: false, - includeSeries: false), - ]; - - final paginator = await pagination.builders(await buildMediaInfoBuilders(results, client).toList()); - return context.respond(paginator); + return context.respond(MessageBuilder(content: "Cannot login. Contact with bot admin!")); }), - checks: [ - jellyfinFeatureUserCommandCheck, - ]), - ChatCommand( - "current-sessions", - "Displays current sessions", - id("jellyfin-current-sessions", (ChatContext context, - [@Description("Instance to use. Default selected if not provided") - @UseConverter(jellyfinConfigConverter) - JellyfinConfig? config]) async { - final client = await Injector.appInstance.get().getClient((config?.name, context.guild!.id)); - if (client == null) { - return context.respond(MessageBuilder(content: "Invalid Jellyfin instance")); - } + ), + ChatCommand( + "login-quick-connect", + "Login with Quick Connect to given jellyfin instance", + id("jellyfin-user-login-quick-connect", (InteractionChatContext context, JellyfinConfig config) async { + final client = Injector.appInstance.get().createJellyfinClientAnonymous(config); - final currentSessions = await client.getCurrentSessions(); - if (currentSessions.isEmpty) { - return context.respond(MessageBuilder(content: "No one watching currently")); - } + final initiationResult = await client.initiateLoginByQuickConnect(); + + await context.respond( + MessageBuilder(content: "Quick Connect code: `${initiationResult.code}`. Waiting for confirmation..."), + level: ResponseLevel.private); + Timer.periodic(Duration(seconds: 2), (Timer timer) async { + if (timer.tick > 30) { + context.interaction + .updateOriginalResponse(MessageUpdateBuilder(content: "Cannot login. Took too long to confirm code")); + timer.cancel(); + } + + final isConfirmed = await client.checkQuickConnectStatus(initiationResult); + if (!isConfirmed) { + return; + } + + timer.cancel(); + + final finishResult = await client.finishLoginByQuickConnect(initiationResult); + if (finishResult == null) { + context.interaction + .updateOriginalResponse(MessageUpdateBuilder(content: "Cannot login. Contact with bot admin!")); + return; + } + + final loginResult = + await Injector.appInstance.get().login(config, finishResult, context.user.id); + if (loginResult) { + context.interaction.updateOriginalResponse(MessageUpdateBuilder(content: "Logged in successfully!")); + return; + } - final embeds = currentSessions.map((sessionInfo) => buildSessionEmbed(sessionInfo, client)).nonNulls.toList(); - context.respond(MessageBuilder(embeds: embeds)); + context.interaction + .updateOriginalResponse(MessageUpdateBuilder(content: "Cannot login. Contact with bot admin!")); + }); }), - checks: [ - jellyfinFeatureUserCommandCheck, - ]), + ), + ChatCommand( + "current-user", + "Display info about current jellyfin user", + id('jellyfin-user-current-user', (ChatContext context, + [@Description("Instance to use. Default selected if not provided") JellyfinConfigUser? config]) async { + final client = await getJellyfinClient(config, context); + + final currentUser = await client.getCurrentUser(); + context.respond(MessageBuilder(embeds: [getUserInfoEmbed(currentUser, client)])); + }), + ), + ], + ), + ChatCommand( + "search", + "Search instance for content", + id('jellyfin-search', (ChatContext context, @Description("Term to search jellyfin for") String searchTerm, + [@Description("Include episodes when searching") bool includeEpisodes = false, + @Description("Query limit") int limit = 15, + @Description("Instance to use. Default selected if not provided") JellyfinConfigUser? config]) async { + final client = await getJellyfinClient(config, context); + + final resultsWithoutEpisodes = + await client.searchItems(searchTerm, includeEpisodes: includeEpisodes, limit: limit); + final results = [ + ...resultsWithoutEpisodes, + if (resultsWithoutEpisodes.length < 4) + ...await client.searchItems(searchTerm, + limit: limit - resultsWithoutEpisodes.length, + includeEpisodes: true, + includeMovies: false, + includeSeries: false), + ]; + + final paginator = await pagination.builders(await buildMediaInfoBuilders(results, client).toList()); + return context.respond(paginator); + }), + ), + ChatCommand( + "current-sessions", + "Displays current sessions", + id("jellyfin-current-sessions", (ChatContext context, + [@Description("Instance to use. Default selected if not provided") JellyfinConfigUser? config]) async { + final client = await getJellyfinClient(config, context); + + final currentSessions = await client.getCurrentSessions(); + if (currentSessions.isEmpty) { + return context.respond(MessageBuilder(content: "No one watching currently")); + } + + final embeds = currentSessions.map((sessionInfo) => buildSessionEmbed(sessionInfo, client)).nonNulls.toList(); + context.respond(MessageBuilder(embeds: embeds)); + }), + ), + ChatGroup("settings", "Settings for jellyfin", children: [ ChatCommand( 'add-instance', - "Add new instance to config", - id("jellyfin-new-instance", (InteractionChatContext context) async { + "Add new jellyfin instance", + id("jellyfin-settings-new-instance", (InteractionChatContext context) async { final modalResponse = await context.getModal(title: "New Instance Configuration", components: [ - TextInputBuilder(customId: "name", style: TextInputStyle.short, label: "Instance Name"), - TextInputBuilder(customId: "base_url", style: TextInputStyle.short, label: "Base Url"), - TextInputBuilder(customId: "api_token", style: TextInputStyle.short, label: "API Token"), + 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 config = await Injector.appInstance.get().createJellyfinConfig( - modalResponse['name']!, - modalResponse['base_url']!, - modalResponse['api_token']!, - modalResponse['is_default']?.toLowerCase() == 'true', - modalResponse.guild?.id ?? modalResponse.user.id, - ); + final message = await context.respond( + MessageBuilder(content: "Click to open second modal", components: [ + ActionRowBuilder(components: [ + ButtonBuilder.primary( + customId: ComponentId.generate(allowedUser: context.user.id).toString(), label: 'Open modal') + ]) + ]), + level: ResponseLevel.private); + await context.getButtonPress(message); + + final secondModalResponse = await context.getModal(title: "New Instance Configuration Pt. 2", components: [ + TextInputBuilder(customId: "sonarr_base_url", style: TextInputStyle.short, label: "API Token"), + TextInputBuilder(customId: "sonarr_token", style: TextInputStyle.short, label: "API Token"), + TextInputBuilder(customId: "wizarr_base_url", style: TextInputStyle.short, label: "API Token"), + TextInputBuilder(customId: "wizarr_token", style: TextInputStyle.short, label: "API Token"), + ]); + + final config = JellyfinConfig( + name: modalResponse['name']!, + basePath: modalResponse['base_url']!, + isDefault: modalResponse['is_default']?.toLowerCase() == 'true', + parentId: modalResponse.guild?.id ?? modalResponse.user.id, + sonarrBasePath: valueOrNull(secondModalResponse['sonarr_base_url']), + sonarrToken: valueOrNull(secondModalResponse['sonarr_token']), + wizarrBasePath: valueOrNull(secondModalResponse['wizarr_base_url']), + wizarrToken: valueOrNull(secondModalResponse['wizarr_token']), + ); - modalResponse.respond(MessageBuilder(content: "Added new jellyfin instance with name: ${config.name}")); + final newlyCreatedConfig = await Injector.appInstance.get().createJellyfinConfig(config); + + modalResponse + .respond(MessageBuilder(content: "Added new jellyfin instance with name: ${newlyCreatedConfig.name}")); }), checks: [ jellyfinFeatureCreateInstanceCommandCheck, ]), ChatCommand( - "transfer-config", - "Transfers jellyfin instance config to another guild", - id("jellyfin-transfer-config", ( - ChatContext context, - @Description("Name of instance") @UseConverter(jellyfinConfigConverter) JellyfinConfig config, - @Description("Guild or user id to copy to") Snowflake targetParentId, [ - @Description("Copy default flag?") bool copyDefaultFlag = false, - @Description("New name for config. Copied from original if not provided") String? configName, - ]) async { - final newConfig = await Injector.appInstance.get().createJellyfinConfig( - configName ?? config.name, - config.basePath, - config.token, - copyDefaultFlag && config.isDefault, - targetParentId); - - context.respond( - MessageBuilder(content: 'Copied config: "${newConfig.name}" to parent: "${newConfig.parentId}"')); + "edit-instance", + "Edit jellyfin instance", + id('jellyfin-settings-edit-instance', (InteractionChatContext context, + @Description("Instance to use. Default selected if not provided") JellyfinConfig config) async { + final modalResponse = await context.getModal(title: "Jellyfin Instance Edit Pt. 1", components: [ + TextInputBuilder( + customId: "base_url", + style: TextInputStyle.short, + label: "Base Url", + isRequired: true, + value: config.basePath), + ]); + + final message = await context.respond( + MessageBuilder(content: "Click to open second modal", components: [ + ActionRowBuilder(components: [ + ButtonBuilder.primary( + customId: ComponentId.generate(allowedUser: context.user.id).toString(), label: 'Open modal') + ]) + ]), + level: ResponseLevel.private); + await context.getButtonPress(message); + + final secondModalResponse = await context.getModal(title: "Jellyfin Instance Edit Pt. 2", components: [ + TextInputBuilder( + customId: "sonarr_base_url", + style: TextInputStyle.short, + label: "Sonarr base url", + value: config.sonarrBasePath, + isRequired: false, + ), + TextInputBuilder( + customId: "sonarr_token", + style: TextInputStyle.short, + label: "Sonarr Token", + value: config.sonarrToken, + isRequired: false), + TextInputBuilder( + customId: "wizarr_base_url", + style: TextInputStyle.short, + label: "Wizarr base url", + value: config.wizarrBasePath, + isRequired: false), + TextInputBuilder( + customId: "wizarr_token", + style: TextInputStyle.short, + label: "Wizarr Token", + value: config.wizarrToken, + isRequired: false), + ]); + + final editedConfig = JellyfinConfig( + name: config.name, + basePath: modalResponse['base_url']!, + isDefault: config.isDefault, + parentId: config.parentId, + sonarrBasePath: valueOrNull(secondModalResponse['sonarr_base_url']), + sonarrToken: valueOrNull(secondModalResponse['sonarr_token']), + wizarrBasePath: valueOrNull(secondModalResponse['wizarr_base_url']), + wizarrToken: valueOrNull(secondModalResponse['wizarr_token']), + id: config.id, + ); + + Injector.appInstance.get().updateJellyfinConfig(editedConfig); + + return modalResponse.respond(MessageBuilder(content: 'Successfully updated jellyfin config')); }), checks: [ - jellyfinFeatureAdminCommandCheck, + jellyfinFeatureCreateInstanceCommandCheck, ]), + // ChatCommand( + // "transfer-config", + // "Transfers jellyfin instance config to another guild", + // id("jellyfin-settings-transfer-config", ( + // ChatContext context, + // @Description("Name of instance") @UseConverter(jellyfinConfigConverter) JellyfinConfig config, + // @Description("Guild or user id to copy to") Snowflake targetParentId, [ + // @Description("Copy default flag?") bool copyDefaultFlag = false, + // @Description("New name for config. Copied from original if not provided") String? configName, + // ]) async { + // final newConfig = + // await Injector.appInstance.get().createJellyfinConfig(JellyfinConfig( + // name: configName ?? config.name, + // basePath: config.basePath, + // token: config.token, + // isDefault: copyDefaultFlag && config.isDefault, + // parentId: targetParentId, + // sonarrBasePath: config.sonarrBasePath, + // sonarrToken: config.sonarrToken, + // wizarrBasePath: config.wizarrBasePath, + // wizarrToken: config.wizarrToken, + // )); + // + // context.respond( + // MessageBuilder(content: 'Copied config: "${newConfig.name}" to parent: "${newConfig.parentId}"')); + // }), + // checks: [ + // jellyfinFeatureAdminCommandCheck, + // ]), + // ChatCommand( + // "remove-config", + // "Removes config from current guild", + // id("jellyfin-settings-remove-config", (ChatContext context, + // @Description("Name of instance") @UseConverter(jellyfinConfigConverter) JellyfinConfig config) async { + // await Injector.appInstance.get().deleteJellyfinConfig(config); + // + // context.respond(MessageBuilder(content: 'Delete config with name: "${config.name}"')); + // }), + // checks: [ + // jellyfinFeatureAdminCommandCheck, + // ]), + ]), + ChatGroup("util", "Util commands for jellyfin", children: [ ChatCommand( - "remove-config", - "Removes config from current guild", - id("jellyfin-remove-config", (ChatContext context, - @Description("Name of instance") @UseConverter(jellyfinConfigConverter) JellyfinConfig config) async { - await Injector.appInstance.get().deleteJellyfinConfig(config); + "complete-refresh", + "Do a complete refresh of jellyfin instance content", + id("jellyfin-util-complete-refresh", (ChatContext context, + [@Description("Instance to use. Default selected if not provided") JellyfinConfigUser? config]) async { + final client = await getJellyfinClient(config, context); - context.respond(MessageBuilder(content: 'Delete config with name: "${config.name}"')); - }), - checks: [ - jellyfinFeatureAdminCommandCheck, - ]), - ], -); + final availableTasks = await client.getScheduledTasks(); + final scanMediaLibraryTask = availableTasks.firstWhere((task) => task.key == 'RefreshLibrary'); + final subtitleExtractTask = availableTasks.firstWhereOrNull((task) => task.key == 'ExtractSubtitles'); + + Pipeline( + name: 'Complete Library Refresh', + description: "", + tasks: [ + Task( + runCallback: () => client.startTask(scanMediaLibraryTask.id!), + updateCallback: () async { + final scheduledTask = (await client.getScheduledTasks()) + .firstWhereOrNull((taskInfo) => taskInfo.id == scanMediaLibraryTask.id); + if (scheduledTask == null || scheduledTask.state == TaskState.idle) { + return (true, null); + } + + return ( + false, + "Running `${scheduledTask.name!}` - ${taskProgressFormat.format(scheduledTask.currentProgressPercentage!)}%" + ); + }), + if (subtitleExtractTask != null) + Task( + runCallback: () => client.startTask(subtitleExtractTask.id!), + updateCallback: () async { + final scheduledTask = (await client.getScheduledTasks()) + .firstWhereOrNull((taskInfo) => taskInfo.id == subtitleExtractTask.id); + if (scheduledTask == null || scheduledTask.state == TaskState.idle) { + return (true, null); + } + + return ( + false, + "Running `${scheduledTask.name!}` - ${taskProgressFormat.format(scheduledTask.currentProgressPercentage!)}%" + ); + }) + ], + updateInterval: Duration(seconds: 2), + ).forCreateContext(messageSupplier: (messageBuilder) => context.respond(messageBuilder)).execute(); + }), + ), + ChatCommand( + "run", + "Run given task", + id('jellyfin-tasks-run', (InteractionChatContext context, + [@Description("Instance to use. Default selected if not provided") JellyfinConfigUser? config]) async { + final client = await getJellyfinClient(config, context); + + final selectMenuResult = await context + .getSelection(await client.getScheduledTasks(), MessageBuilder(content: 'Choose task to run!'), + toSelectMenuOption: (taskInfo) { + final label = + taskInfo.state != TaskState.idle ? "${taskInfo.name} [${taskInfo.state}]" : taskInfo.name.toString(); + + final description = (taskInfo.description?.length ?? 0) >= 100 + ? "${taskInfo.description?.substring(0, 97)}..." + : taskInfo.description; + + return SelectMenuOptionBuilder(label: label, value: taskInfo.id!, description: description); + }, authorOnly: true); + + Pipeline( + name: selectMenuResult.name!, + description: "", + tasks: [ + Task( + runCallback: () => client.startTask(selectMenuResult.id!), + updateCallback: () async { + final scheduledTask = (await client.getScheduledTasks()) + .firstWhereOrNull((taskInfo) => taskInfo.id == selectMenuResult.id); + if (scheduledTask == null || scheduledTask.state == TaskState.idle) { + return (true, null); + } + + return ( + false, + "Running `${scheduledTask.name!}` - ${taskProgressFormat.format(scheduledTask.currentProgressPercentage!)}%" + ); + }), + ], + updateInterval: Duration(seconds: 2), + ) + .forUpdateContext( + messageSupplier: (messageBuilder) => context.interaction.updateOriginalResponse(messageBuilder)) + .execute(); + }), + ), + ]), +]); diff --git a/lib/src/commands/reminder.dart b/lib/src/commands/reminder.dart index a79b52d..17f38e8 100644 --- a/lib/src/commands/reminder.dart +++ b/lib/src/commands/reminder.dart @@ -109,10 +109,7 @@ final reminder = ChatGroup( 'clear', 'Remove all your reminders', id('reminder-clear', (ChatContext context) async { - await Future.wait(Injector.appInstance - .get() - .getUserReminders(context.user.id) - .map((reminder) => Injector.appInstance.get().removeReminder(reminder))); + Injector.appInstance.get().removeAllRemindersForUser(context.user.id); await context.respond(MessageBuilder(content: 'Successfully cleared all your reminders.')); }), diff --git a/lib/src/converter.dart b/lib/src/converter.dart index 212d425..40f4e51 100644 --- a/lib/src/converter.dart +++ b/lib/src/converter.dart @@ -8,6 +8,7 @@ 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/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/reminder.dart'; import 'package:running_on_dart/src/modules/tag.dart'; import 'package:running_on_dart/src/repository/jellyfin_config.dart'; @@ -60,8 +61,20 @@ const manageableTagConverter = SimpleConverter( stringify: stringifyTag, ); -Future> getJellyfinConfigs(ContextData context) => - Injector.appInstance.get().getConfigsForGuild(context.guild!.id); +final jellyfinConfigUserConverter = 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() + .getConfigsForParent((context.guild?.id ?? context.user.id).toString())) + .map((config) => CommandOptionChoiceBuilder(name: config.name, value: config.name)), +); + +Future> getJellyfinConfigs(ContextData context) => Injector.appInstance + .get() + .getConfigsForParent((context.guild?.id ?? context.user.id).toString()); String stringifyJellyfinConfig(JellyfinConfig config) => config.name; diff --git a/lib/src/external/sonarr.dart b/lib/src/external/sonarr.dart new file mode 100644 index 0000000..db889e3 --- /dev/null +++ b/lib/src/external/sonarr.dart @@ -0,0 +1,95 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +String _boolToString(bool boolValue) => boolValue ? 'true' : 'false'; + +class Image { + final String coverType; + final String remoteUrl; + + Image({required this.coverType, required this.remoteUrl}); + + factory Image.parseJson(Map data) { + return Image(coverType: data['coverType'], remoteUrl: data['remoteUrl']); + } +} + +class Series { + final String title; + final String status; + final String overview; + final int runtime; + final Iterable images; + + Series( + {required this.title, required this.status, required this.overview, required this.runtime, required this.images}); + + factory Series.parseJson(Map data) { + return Series( + title: data['title'] as String, + status: data['status'] as String, + overview: data['overview'] as String, + runtime: data['runtime'] as int, + images: (data['images'] as List).map((imageJson) => Image.parseJson(imageJson as Map)), + ); + } +} + +class CalendarItem { + final int seriesId; + final int seasonNumber; + final int episodeNumber; + final String title; + final DateTime airDateUtc; + final String? overview; + final Series series; + + CalendarItem( + {required this.seriesId, + required this.seasonNumber, + required this.episodeNumber, + required this.title, + required this.airDateUtc, + required this.overview, + required this.series}); + + factory CalendarItem.parseJson(Map data) { + return CalendarItem( + seriesId: data['seriesId'] as int, + seasonNumber: data['seasonNumber'] as int, + episodeNumber: data['episodeNumber'] as int, + title: data['title'] as String, + airDateUtc: DateTime.parse(data['airDateUtc']), + overview: data['overview'] as String?, + series: Series.parseJson(data['series'] as Map), + ); + } +} + +class SonarrClient { + final String baseUrl; + + late final Map _headers; + + SonarrClient({required this.baseUrl, required String token}) { + _headers = {"X-Api-Key": token, 'Accept': 'application/json', 'Content-Type': 'application/json'}; + } + + Future> fetchCalendar({DateTime? start, DateTime? end, bool? includeSeries = true}) async { + final response = await _get("/api/v3/calendar", parameters: { + if (start != null) 'start': start.toIso8601String(), + if (end != null) 'end': end.toIso8601String(), + if (includeSeries != null) 'includeSeries': _boolToString(includeSeries), + }); + + final body = jsonDecode(response.body) as List; + return body.map((element) => CalendarItem.parseJson(element as Map)).toList(); + } + + Future _get(String path, {Map parameters = const {}}) async { + final uri = Uri.parse('$baseUrl$path').replace(queryParameters: parameters); + + return await http.get(uri, headers: _headers); + } +} diff --git a/lib/src/external/wizarr.dart b/lib/src/external/wizarr.dart new file mode 100644 index 0000000..f137d84 --- /dev/null +++ b/lib/src/external/wizarr.dart @@ -0,0 +1,181 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:http/http.dart' as http; + +class InvitationValidationResult { + String username; + + InvitationValidationResult({required this.username}); + + factory InvitationValidationResult.parseJson(Map data) { + return InvitationValidationResult(username: data['username']); + } +} + +class Library { + final String id; + final String name; + + Library({required this.id, required this.name}); + + factory Library.parseJson(Map data) { + return Library(id: data['id'], name: data['name']); + } +} + +class CreateInvitationRequest { + final String code; + final Duration? expires; + final Duration? duration; + final List specificLibraries; + final bool unlimited; + final int sessions; + + CreateInvitationRequest( + {required this.code, + required this.expires, + required this.duration, + required this.specificLibraries, + required this.unlimited, + required this.sessions}); + + Map toBody() => { + 'code': code, + if (duration != null) 'duration': duration!.inMinutes.toString(), + if (expires != null) 'expires': expires!.inMinutes.toString(), + 'live_tv': 'false', + 'plex_allow_sync': 'false', + 'plex_home': 'false', + 'sessions': sessions.toString(), + 'unlimited': unlimited ? 'true' : 'false', + 'specific_libraries': jsonEncode(specificLibraries), + }; +} + +class WizarrClient { + final String baseUrl; + final String token; + final String configName; + + WizarrClient({required this.baseUrl, required this.token, required this.configName}); + + Future validateInvitation( + String code, String username, String password, String email) async { + var t = Random.secure().nextInt(100000); + + final tempSid = await _fetchSid(t); + _validateSid(++t, tempSid); + + final finalSid = await _fetchFinalSid(++t, tempSid); + + return _validateInvitation(code, username, password, email, finalSid); + } + + Future createInvitation(CreateInvitationRequest createInvitationRequest) async { + final response = await _postAuth(_getUri('/api/invitations'), createInvitationRequest.toBody(), encodeJson: false); + + if (response.statusCode < 300) { + return true; + } + + if (response is http.StreamedResponse) { + print(await response.stream.toStringStream().join('\n')); + } + return false; + } + + Future> getAvailableLibraries() async { + final response = await _getAuth(_getUri("/api/libraries")); + + final body = jsonDecode(response.body) as List; + + return body.map((element) => Library.parseJson(element as Map)).toList(); + } + + Future _validateInvitation( + String code, String username, String password, String email, String sid) async { + final result = await http.post(_getUri("/api/jellyfin"), body: { + "username": username, + "email": email, + "password": password, + "code": code, + "socket_id": sid, + }); + + final body = jsonDecode(result.body) as Map; + return InvitationValidationResult.parseJson(body); + } + + Future _fetchSid(int t) async { + final result = await http.get(_getUri("/socket.io/", parameters: { + "EIO": "4", + "transport": "polling", + "t": t.toString(), + })); + + final bodyString = result.body.substring(1); + final bodyJson = jsonDecode(bodyString); + + return bodyJson['sid']; + } + + Future _validateSid(int t, String sid) async { + final result = await http.post( + _getUri("/socket.io/", parameters: { + "EIO": "4", + "transport": "polling", + "t": t.toString(), + 'sid': sid, + }), + body: '40/jellyfin,'); + + if (result.body != 'OK') { + throw Exception("Cannot validate sid"); + } + } + + Future _fetchFinalSid(int t, String sid) async { + final result = await http.get(_getUri("/socket.io/", parameters: { + "EIO": "4", + "transport": "polling", + "t": t.toString(), + 'sid': sid, + })); + + final bodyString = result.body.replaceFirst('40/jellyfin,', ''); + final bodyJson = jsonDecode(bodyString); + + return bodyJson['sid']; + } + + Future _getAuth(Uri uri) => http.get(uri, headers: _getHeaders(includeAuth: true)); + Future _postAuth(Uri uri, Map body, {bool encodeJson = true}) { + if (encodeJson) { + return http.post(uri, headers: _getHeaders(includeAuth: true), body: jsonEncode(body)); + } + + final request = http.MultipartRequest('POST', uri) + ..headers.addAll(_getHeaders(includeAuth: true, includeContentType: false)) + ..fields.addAll(body.cast()); + + return request.send(); + } + + Map _getHeaders({bool includeAuth = false, bool includeContentType = true}) { + final headers = {}; + + if (includeAuth) { + headers.addAll({"Authorization": "Bearer $token"}); + } + + if (includeContentType) { + headers.addAll({'Accept': 'application/json', 'Content-Type': 'application/json'}); + } + + return headers; + } + + Uri _getUri(String path, {Map parameters = const {}}) => + Uri.parse('$baseUrl$path').replace(queryParameters: parameters); +} diff --git a/lib/src/models/jellyfin_config.dart b/lib/src/models/jellyfin_config.dart index 2d246ef..edf16f8 100644 --- a/lib/src/models/jellyfin_config.dart +++ b/lib/src/models/jellyfin_config.dart @@ -2,30 +2,64 @@ import 'package:nyxx/nyxx.dart'; Snowflake? parseSnowflakeOrNull(dynamic value) => value != null ? Snowflake.parse(value) : null; +class JellyfinConfigUser { + final Snowflake userId; + final String token; + final int jellyfinConfigId; + + int? id; + JellyfinConfig? config; + + JellyfinConfigUser({required this.userId, required this.token, required this.jellyfinConfigId, this.id}); + + factory JellyfinConfigUser.fromDatabaseRow(Map row) { + return JellyfinConfigUser( + userId: Snowflake.parse(row['user_id']), + token: row['token'] as String, + jellyfinConfigId: row['jellyfin_config_id'] as int, + id: row['id'] as int, + ); + } +} + class JellyfinConfig { final String name; final String basePath; - final String token; final bool isDefault; final Snowflake parentId; + final String? sonarrBasePath; + final String? sonarrToken; + + final String? wizarrBasePath; + final String? wizarrToken; + /// The ID of this config, or `null` if this config has not yet been added to the database. int? id; - JellyfinConfig( - {required this.name, - required this.basePath, - required this.token, - required this.isDefault, - required this.parentId}); + JellyfinConfig({ + required this.name, + required this.basePath, + required this.isDefault, + required this.parentId, + this.sonarrBasePath, + this.sonarrToken, + this.wizarrBasePath, + this.wizarrToken, + this.id, + }); factory JellyfinConfig.fromDatabaseRow(Map row) { return JellyfinConfig( + id: row['id'] as int?, name: row['name'], basePath: row['base_path'], - token: row['token'], isDefault: row['is_default'] as bool, parentId: Snowflake.parse(row['guild_id']), + sonarrBasePath: row['sonarr_base_path'] as String?, + sonarrToken: row['sonarr_token'] as String?, + wizarrBasePath: row['wizarr_base_path'] as String?, + wizarrToken: row['wizarr_token'] as String?, ); } } diff --git a/lib/src/modules/jellyfin.dart b/lib/src/modules/jellyfin.dart index d33868d..dc54f02 100644 --- a/lib/src/modules/jellyfin.dart +++ b/lib/src/modules/jellyfin.dart @@ -1,5 +1,8 @@ +import 'package:collection/collection.dart'; import 'package:injector/injector.dart'; import 'package:nyxx/nyxx.dart'; +import 'package:running_on_dart/src/external/sonarr.dart'; +import 'package:running_on_dart/src/external/wizarr.dart'; import 'package:running_on_dart/src/models/jellyfin_config.dart'; import 'package:running_on_dart/src/repository/jellyfin_config.dart'; import 'package:running_on_dart/src/util/util.dart'; @@ -8,29 +11,76 @@ import 'package:tentacle/src/auth/auth.dart' show AuthInterceptor; import 'package:dio/dio.dart' show RequestInterceptorHandler, RequestOptions; import 'package:built_collection/built_collection.dart'; -class CustomAuthInterceptor extends AuthInterceptor { - final String token; - - CustomAuthInterceptor(this.token); +MessageBuilder getWizarrRedeemInvitationMessageBuilder( + WizarrClient client, String code, Snowflake userId, Snowflake parentId, String configName) { + return MessageBuilder( + content: + "Redeem Wizarr invitation to jellyfin instance. Your code: `$code`. \nYou can also redeem later using slash command: `/jellyfin wizarr redeem-invitation`", + components: [ + ActionRowBuilder(components: [ + ButtonBuilder.link(url: Uri.parse("${client.baseUrl}/j/$code"), label: "Redeem code in browser"), + ButtonBuilder.primary( + customId: ReminderRedeemWizarrInvitationId.button( + userId: userId, code: code, parentId: parentId, configName: configName) + .toString(), + label: "Redeem here"), + ]) + ]); +} - @override - void onRequest(RequestOptions options, RequestInterceptorHandler handler) { - options.headers['Authorization'] = 'MediaBrowser Token="$token"'; +class ReminderRedeemWizarrInvitationId { + static String buttonIdentifier = 'ReminderRedeemWizarrInvitationButtonIdButton'; + static String modalIdentifier = 'ReminderRedeemWizarrInvitationButtonIdModal'; + + final String identifier; + final Snowflake userId; + final String code; + final Snowflake parentId; + final String configName; + + bool get isButton => identifier == buttonIdentifier; + bool get isModal => identifier == modalIdentifier; + + ReminderRedeemWizarrInvitationId( + {required this.identifier, + required this.userId, + required this.code, + required this.parentId, + required this.configName}); + factory ReminderRedeemWizarrInvitationId.button( + {required Snowflake userId, required String code, required Snowflake parentId, required String configName}) => + ReminderRedeemWizarrInvitationId( + identifier: buttonIdentifier, userId: userId, code: code, parentId: parentId, configName: configName); + factory ReminderRedeemWizarrInvitationId.modal( + {required Snowflake userId, required String code, required Snowflake parentId, required String configName}) => + ReminderRedeemWizarrInvitationId( + identifier: modalIdentifier, userId: userId, code: code, parentId: parentId, configName: configName); + + static ReminderRedeemWizarrInvitationId? parse(String idString) { + final idParts = idString.split("/"); + + if (idParts.isEmpty || ![buttonIdentifier, modalIdentifier].contains(idParts[0])) { + return null; + } - super.onRequest(options, handler); + return ReminderRedeemWizarrInvitationId( + identifier: idParts[0], + userId: Snowflake.parse(idParts[1]), + code: idParts[2], + parentId: Snowflake.parse(idParts[3]), + configName: idParts[4], + ); } -} -typedef JellyfinInstanceIdentity = (String? instanceName, Snowflake guildId); + @override + String toString() => "$identifier/$userId/$code/$parentId/$configName"; +} -class JellyfinClientWrapper { +class AuthenticatedJellyfinClient { final Tentacle jellyfinClient; - final JellyfinConfig config; - - String get basePath => config.basePath; - String get name => config.name; + final JellyfinConfigUser configUser; - JellyfinClientWrapper(this.jellyfinClient, this.config); + AuthenticatedJellyfinClient(this.jellyfinClient, this.configUser); Future> getCurrentSessions() async { final response = await jellyfinClient.getSessionApi().getSessions(activeWithinSeconds: 15); @@ -97,97 +147,274 @@ class JellyfinClientWrapper { return response.data?.toList() ?? []; } + Future getCurrentUser() async { + final response = await jellyfinClient.getUserApi().getCurrentUser(); + + return response.data!; + } + Future startTask(String taskId) => jellyfinClient.getScheduledTasksApi().startTask(taskId: taskId); + Uri getItemPrimaryImage(String itemId) => Uri.parse("${configUser.config?.basePath}/Items/$itemId/Images/Primary"); + Uri getJellyfinItemUrl(String itemId) => Uri.parse("${configUser.config?.basePath}/#/details?id=$itemId"); + Uri getUserImage(String userId, String imageTag) => + Uri.parse("${configUser.config?.basePath}/Users/$userId/Images/Primary?tag=$imageTag"); + Uri getUserProfile(String userId) => + Uri.parse('${configUser.config?.basePath}/web/#/userprofile.html?userId=$userId'); +} + +class AnonymousJellyfinClient { + final Tentacle jellyfinClient; + final JellyfinConfig config; + + AnonymousJellyfinClient({required this.jellyfinClient, required this.config}); + + Future loginByPassword(String username, String password) async { + final response = await jellyfinClient.getUserApi().authenticateUserByName( + authenticateUserByName: AuthenticateUserByName((builder) => builder + ..username = username + ..pw = password)); + + return response.data!; + } + + Future initiateLoginByQuickConnect() async { + final response = await jellyfinClient.getQuickConnectApi().initiateQuickConnect(); + + return response.data!; + } + + Future checkQuickConnectStatus(QuickConnectResult quickConnectResult) async { + final response = await jellyfinClient.getQuickConnectApi().getQuickConnectState(secret: quickConnectResult.secret!); + + return response.data?.authenticated ?? false; + } + + Future finishLoginByQuickConnect(QuickConnectResult quickConnectResult) async { + final response = await jellyfinClient.getUserApi().authenticateWithQuickConnect( + quickConnectDto: + QuickConnectDto((quickConnectBuilder) => quickConnectBuilder.secret = quickConnectResult.secret)); + + if (response.statusCode != 200) { + return null; + } + + return response.data!; + } +} + +class TokenAuthInterceptor extends AuthInterceptor { + final String token; + + TokenAuthInterceptor(this.token); + + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + options.headers['Authorization'] = 'MediaBrowser Token="$token"'; - Uri getItemPrimaryImage(String itemId) => Uri.parse("$basePath/Items/$itemId/Images/Primary"); + super.onRequest(options, handler); + } +} + +class AnonAuthInterceptor extends AuthInterceptor { + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + options.headers['Authorization'] = + 'MediaBrowser Client="Jellyfin Web", Device="Chrome", DeviceId="1234", Version="10.9.11"'; - Uri getJellyfinItemUrl(String itemId) => Uri.parse("$basePath/#/details?id=$itemId"); + super.onRequest(options, handler); + } } -class JellyfinModule implements RequiresInitialization { - final Map _jellyfinClients = {}; - final Map> _allowedUserRegistrations = {}; +class JellyfinConfigNotFoundException implements Exception { + final String message; + const JellyfinConfigNotFoundException(this.message); + + @override + String toString() => "JellyfinConfigNotFoundException: $message"; +} +class JellyfinModuleV2 implements RequiresInitialization { final JellyfinConfigRepository _jellyfinConfigRepository = Injector.appInstance.get(); + final NyxxGateway _client = Injector.appInstance.get(); @override Future init() async { - final defaultConfigs = await _jellyfinConfigRepository.getDefaultConfigs(); - for (final config in defaultConfigs) { - _createClientConfig(config); - } + _client.onMessageComponentInteraction + .where((event) => event.interaction.data.type == MessageComponentType.button) + .listen(_handleButtonInteractionForWizarrRedeemInvitation); + + _client.onModalSubmitInteraction.listen(_handleModalInteractionForWizarrRedeemInvitation); } - Future deleteJellyfinConfig(JellyfinConfig config) async { - _jellyfinClients.remove( - _getClientCacheIdentifier(config.parentId.toString(), config.name, config.isDefault), - ); + Future _handleModalInteractionForWizarrRedeemInvitation( + InteractionCreateEvent event) async { + final customId = ReminderRedeemWizarrInvitationId.parse(event.interaction.data.customId); + if (customId == null || !customId.isModal) { + return; + } - await _jellyfinConfigRepository.deleteConfig(config.id!); + if (customId.userId != event.interaction.user?.id) { + return event.interaction.respond(MessageBuilder(content: "Invalid interaction")); + } + + final modalComponents = event.interaction.data.components + .cast() + .map((row) => row.components) + .flattened + .cast(); + + final usernameComponent = modalComponents.firstWhere((component) => component.customId == 'username'); + final passwordComponent = modalComponents.firstWhere((component) => component.customId == 'password'); + final emailComponent = modalComponents.firstWhere((component) => component.customId == 'email'); + + final config = await getJellyfinConfig(customId.configName, customId.parentId); + final client = await fetchGetWizarrClientWithFallback(originalConfig: config, parentId: customId.parentId); + + final redeemResult = await client.validateInvitation( + customId.code, usernameComponent.value!, passwordComponent.value!, emailComponent.value!); + + event.interaction + .respond(MessageBuilder(content: "Invitation redeemed (username: ${redeemResult.username})", components: [ + ActionRowBuilder(components: [ + ButtonBuilder.link(url: Uri.parse(config!.basePath), label: "Go to Jellyfin"), + ButtonBuilder.link(url: Uri.parse('https://jellyfin.org/downloads'), label: "Download Jellyfin client"), + ]) + ])); } - Future getClient(JellyfinInstanceIdentity identity) async { - final cachedClientConfig = _getCachedClientConfig(identity); - if (cachedClientConfig != null) { - return cachedClientConfig; + Future _handleButtonInteractionForWizarrRedeemInvitation( + InteractionCreateEvent event) async { + final customId = ReminderRedeemWizarrInvitationId.parse(event.interaction.data.customId); + if (customId == null || !customId.isButton) { + return; } - final config = await _jellyfinConfigRepository.getByName(identity.$1!, identity.$2.toString()); - if (config == null) { - return null; + if (customId.userId != event.interaction.user?.id) { + return event.interaction.respond(MessageBuilder(content: "Invalid interaction")); } - return _createClientConfig(config); + event.interaction.respondModal(ModalBuilder( + customId: ReminderRedeemWizarrInvitationId.modal( + userId: customId.userId, + code: customId.code, + parentId: customId.parentId, + configName: customId.configName) + .toString(), + title: "Redeem wizarr code", + components: [ + ActionRowBuilder(components: [ + TextInputBuilder(customId: "username", style: TextInputStyle.short, label: "Username", isRequired: true), + ]), + ActionRowBuilder(components: [ + TextInputBuilder(customId: "password", style: TextInputStyle.short, label: "Password", isRequired: true), + ]), + ActionRowBuilder(components: [ + TextInputBuilder(customId: "email", style: TextInputStyle.short, label: "Email", isRequired: true), + ]), + ])); + } + + Future getJellyfinConfig(String name, Snowflake parentId) { + return _jellyfinConfigRepository.getByNameAndGuild(name, parentId.toString()); + } + + Future getJellyfinDefaultConfig(Snowflake parentId) { + return _jellyfinConfigRepository.getDefaultForParent(parentId.toString()); } - (bool, List) isUserAllowedForRegistration(String instanceName, Snowflake userId) { - final key = "$instanceName$userId"; + Future fetchGetWizarrClientWithFallback( + {required JellyfinConfig? originalConfig, required Snowflake parentId}) async { + final config = originalConfig ?? await getJellyfinDefaultConfig(parentId); + if (config == null) { + throw JellyfinConfigNotFoundException("Missing jellyfin config"); + } + + if (config.wizarrBasePath == null || config.wizarrToken == null) { + throw JellyfinConfigNotFoundException("Wizarr not configured!"); + } + + return WizarrClient(baseUrl: config.wizarrBasePath!, token: config.wizarrToken!, configName: config.name); + } - final allowed = _allowedUserRegistrations[key]; - if (allowed == null) { - return (false, []); + Future fetchGetSonarrClientWithFallback( + {required JellyfinConfig? originalConfig, required Snowflake parentId}) async { + final config = originalConfig ?? await getJellyfinDefaultConfig(parentId); + if (config == null) { + throw JellyfinConfigNotFoundException("Missing jellyfin config"); } - _allowedUserRegistrations.remove(key); + if (config.sonarrBasePath == null || config.sonarrToken == null) { + throw JellyfinConfigNotFoundException("Sonarr not configured!"); + } - return (true, allowed); + return SonarrClient(baseUrl: config.sonarrBasePath!, token: config.sonarrToken!); } - void addUserToAllowedForRegistration(String instanceName, Snowflake userId, List allowedLibraries) => - _allowedUserRegistrations["$instanceName$userId"] = allowedLibraries; + 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) { + throw JellyfinConfigNotFoundException("Missing jellyfin config"); + } - Future createJellyfinConfig( - String name, String basePath, String token, bool isDefault, Snowflake guildId) async { - final config = await _jellyfinConfigRepository.createJellyfinConfig(name, basePath, token, isDefault, guildId); - if (config.id == null) { - throw Error(); + final userConfig = await fetchJellyfinUserConfig(userId, config); + if (userConfig == null) { + throw JellyfinConfigNotFoundException("User not logged in."); } - _jellyfinClients[_getClientCacheIdentifier(config.parentId.toString(), config.name, config.isDefault)] = - _createClientConfig(config); + return userConfig; + } - return config; + Future fetchJellyfinUserConfig(Snowflake userId, JellyfinConfig config) async { + final userConfig = await _jellyfinConfigRepository.getUserConfig(userId.toString(), config.id!); + userConfig?.config = config; + + return userConfig; + } + + AnonymousJellyfinClient createJellyfinClientAnonymous(JellyfinConfig config) { + return AnonymousJellyfinClient( + jellyfinClient: Tentacle(basePathOverride: config.basePath, interceptors: [AnonAuthInterceptor()]), + config: config); } - JellyfinClientWrapper? _getCachedClientConfig(JellyfinInstanceIdentity identity) => - _jellyfinClients[_getClientCacheIdentifier(identity.$2.toString(), identity.$1)]; + AuthenticatedJellyfinClient createJellyfinClientAuthenticated(JellyfinConfigUser configUser) { + return AuthenticatedJellyfinClient( + Tentacle(basePathOverride: configUser.config!.basePath, interceptors: [TokenAuthInterceptor(configUser.token)]), + configUser); + } - JellyfinClientWrapper _createClientConfig(JellyfinConfig config) { - final client = Tentacle(basePathOverride: config.basePath, interceptors: [CustomAuthInterceptor(config.token)]); + Future login(JellyfinConfig config, AuthenticationResult authResult, Snowflake userId) async { + await _jellyfinConfigRepository.saveJellyfinConfigUser( + JellyfinConfigUser(userId: userId, token: authResult.accessToken!, jellyfinConfigId: config.id!), + ); - final clientConfig = JellyfinClientWrapper(client, config); + return true; + } - _jellyfinClients[_getClientCacheIdentifier(config.parentId.toString(), config.name, config.isDefault)] = - clientConfig; + Future loginWithPassword(JellyfinConfig config, String username, String password, Snowflake userId) async { + final client = createJellyfinClientAnonymous(config); - return clientConfig; + final response = await client.loginByPassword(username, password); + await _jellyfinConfigRepository.saveJellyfinConfigUser( + JellyfinConfigUser(userId: userId, token: response.accessToken!, jellyfinConfigId: config.id!), + ); + + return true; } - String _getClientCacheIdentifier(String guildId, String? instanceName, [bool isDefault = false]) { - if (instanceName != null && !isDefault) { - return "$guildId|$instanceName"; + Future createJellyfinConfig(JellyfinConfig config) async { + final createdConfig = await _jellyfinConfigRepository.createJellyfinConfig(config); + if (createdConfig.id == null) { + throw Error(); } - return guildId; + return createdConfig; + } + + Future updateJellyfinConfig(JellyfinConfig config) async { + return await _jellyfinConfigRepository.updateJellyfinConfig(config); } } diff --git a/lib/src/modules/reminder.dart b/lib/src/modules/reminder.dart index a89ccbf..3bf9cd3 100644 --- a/lib/src/modules/reminder.dart +++ b/lib/src/modules/reminder.dart @@ -34,6 +34,27 @@ class ReminderModuleComponentId { String toString() => "$identifier/$reminderId/$userId/${duration.inMinutes}"; } +class ReminderModuleClearComponentsId { + static String identifier = 'ReminderModuleClearComponentsId'; + + final Snowflake userId; + + ReminderModuleClearComponentsId({required this.userId}); + + static ReminderModuleClearComponentsId? parse(String idString) { + final idParts = idString.split("/"); + + if (idParts.isEmpty || idParts.first != identifier) { + return null; + } + + return ReminderModuleClearComponentsId(userId: Snowflake.parse(idParts[1])); + } + + @override + String toString() => "$identifier/$userId"; +} + class ReminderModule implements RequiresInitialization { final List reminders = []; @@ -96,7 +117,16 @@ class ReminderModule implements RequiresInitialization { .toList(); final messageBuilder = MessageBuilder( - content: content.toString(), replyId: reminder.messageId, components: [ActionRowBuilder(components: buttons)]); + content: content.toString(), + referencedMessage: + reminder.messageId != null ? MessageReferenceBuilder.reply(messageId: reminder.messageId!) : null, + components: [ + ActionRowBuilder(components: [ + ...buttons, + ButtonBuilder.secondary( + customId: ReminderModuleClearComponentsId(userId: reminder.userId).toString(), label: 'Confirm'), + ]) + ]); await channel.sendMessage(messageBuilder); } @@ -105,10 +135,34 @@ class ReminderModule implements RequiresInitialization { final data = event.interaction.data; final customId = ReminderModuleComponentId.parse(data.customId); - if (customId == null) { - return; + if (customId != null) { + return _handleReminderModuleComponentButtonAction(event, customId); + } + + final customIdClearAction = ReminderModuleClearComponentsId.parse(data.customId); + if (customIdClearAction != null) { + return _handleReminderModuleClearComponentButtonAction(event, customIdClearAction); + } + } + + Future _handleReminderModuleClearComponentButtonAction( + InteractionCreateEvent event, ReminderModuleClearComponentsId customId) async { + final targetUserId = event.interaction.member?.id ?? event.interaction.user?.id; + + if (targetUserId == null) { + return event.interaction + .respond(MessageBuilder(content: "Invalid interaction. Missing user id!"), isEphemeral: true); + } + + if (targetUserId != customId.userId) { + return event.interaction.respond(MessageBuilder(content: "You cannot use this button!"), isEphemeral: true); } + event.interaction.message?.update(MessageUpdateBuilder(components: [])); + } + + Future _handleReminderModuleComponentButtonAction( + InteractionCreateEvent event, ReminderModuleComponentId customId) async { final targetUserId = event.interaction.member?.id ?? event.interaction.user?.id; if (targetUserId == null) { @@ -151,6 +205,8 @@ class ReminderModule implements RequiresInitialization { reminders.remove(reminder); } + void removeAllRemindersForUser(Snowflake userId) => reminders.removeWhere((reminder) => reminder.userId == userId); + /// Get all the reminders for a specific user. Iterable getUserReminders(Snowflake userId) => reminders.where((reminder) => reminder.userId == userId); diff --git a/lib/src/repository/feature_settings.dart b/lib/src/repository/feature_settings.dart index b1b0161..a25fc56 100644 --- a/lib/src/repository/feature_settings.dart +++ b/lib/src/repository/feature_settings.dart @@ -1,5 +1,6 @@ import 'package:injector/injector.dart'; import 'package:nyxx/nyxx.dart'; +import 'package:postgres/postgres.dart'; import 'package:running_on_dart/src/models/feature_settings.dart'; import 'package:running_on_dart/src/services/db.dart'; @@ -7,20 +8,20 @@ class FeatureSettingsRepository { final _database = Injector.appInstance.get(); Future isEnabled(Setting setting, Snowflake guildId) async { - final result = await _database.getConnection().execute(''' - SELECT name FROM feature_settings WHERE name = @name AND guild_id = @guild_id - ''', parameters: { - 'name': setting.name, - 'guild_id': guildId.toString(), - }); + final result = await _database.getConnection().execute( + Sql.named('SELECT name FROM feature_settings WHERE name = @name AND guild_id = @guild_id'), + parameters: { + 'name': setting.name, + 'guild_id': guildId.toString(), + }); return result.isNotEmpty; } Future fetchSetting(Setting setting, Snowflake guildId) async { - final result = await _database.getConnection().execute(''' + final result = await _database.getConnection().execute(Sql.named(''' SELECT * FROM feature_settings WHERE name = @name AND guild_id = @guild_id - ''', parameters: { + '''), parameters: { 'name': setting.name, 'guild_id': guildId.toString(), }); @@ -43,16 +44,16 @@ class FeatureSettingsRepository { /// Fetch all settings for all guilds from the database. Future> fetchSettingsForGuild(Snowflake guild) async { - final result = await _database.getConnection().execute(''' + final result = await _database.getConnection().execute(Sql.named(''' SELECT * FROM feature_settings WHERE guild_id = @guildId; - ''', parameters: {'guildId': guild.toString()}); + '''), parameters: {'guildId': guild.toString()}); return result.map((row) => row.toColumnMap()).map(FeatureSetting.fromRow); } /// Enable or update a setting in the database. Future enableSetting(FeatureSetting setting) async { - await _database.getConnection().execute(''' + await _database.getConnection().execute(Sql.named(''' INSERT INTO feature_settings ( name, guild_id, @@ -71,7 +72,7 @@ class FeatureSettingsRepository { additional_data = @additional_data WHERE feature_settings.guild_id = @guild_id AND feature_settings.name = @name - ''', substitutionValues: { + '''), parameters: { 'name': setting.setting.name, 'guild_id': setting.guildId.toString(), 'add_date': setting.addedAt, @@ -82,9 +83,9 @@ class FeatureSettingsRepository { /// Disable a setting in (remove it from) the database. Future disableSetting(FeatureSetting setting) async { - await _database.getConnection().execute(''' + await _database.getConnection().execute(Sql.named(''' DELETE FROM feature_settings WHERE name = @name AND guild_id = @guild_id - ''', parameters: { + '''), parameters: { 'name': setting.setting.name, 'guild_id': setting.guildId.toString(), }); diff --git a/lib/src/repository/jellyfin_config.dart b/lib/src/repository/jellyfin_config.dart index a022868..01e0ea5 100644 --- a/lib/src/repository/jellyfin_config.dart +++ b/lib/src/repository/jellyfin_config.dart @@ -1,33 +1,35 @@ import 'package:injector/injector.dart'; import 'package:nyxx/nyxx.dart'; +import 'package:postgres/postgres.dart'; import 'package:running_on_dart/src/models/jellyfin_config.dart'; import 'package:running_on_dart/src/services/db.dart'; class JellyfinConfigRepository { final _database = Injector.appInstance.get(); - Future deleteConfig(int id) async { - await _database - .getConnection() - .execute('DELETE FROM jellyfin_configs WHERE id = @id', parameters: {'id': id}); - } - - Future> getDefaultConfigs() async { - final result = await _database.getConnection().execute('SELECT * FROM jellyfin_configs WHERE is_default = 1::bool'); + Future> getConfigsForParent(String parentId) async { + final result = await _database.getConnection().execute( + Sql.named('SELECT * FROM jellyfin_configs WHERE guild_id = @parentId'), + parameters: {'parentId': parentId}); return result.map((row) => row.toColumnMap()).map(JellyfinConfig.fromDatabaseRow); } - Future> getConfigsForGuild(Snowflake guildId) async { - final result = await _database.getConnection().execute('SELECT * FROM jellyfin_configs WHERE guild_id = @guildId', - parameters: {'guildId': guildId.toString()}); + Future getDefaultForParent(String parentId) async { + final result = await _database.getConnection().execute( + Sql.named('SELECT * FROM jellyfin_configs WHERE is_default = 1::bool AND guild_id = @parentId LIMIT 1'), + parameters: {'parentId': parentId}); - return result.map((row) => row.toColumnMap()).map(JellyfinConfig.fromDatabaseRow); + if (result.isEmpty) { + return null; + } + + return JellyfinConfig.fromDatabaseRow(result.first.toColumnMap()); } - Future getByName(String name, String guildId) async { + Future getByNameAndGuild(String name, String guildId) async { final result = await _database.getConnection().execute( - 'SELECT * FROM jellyfin_configs WHERE name = @name AND guild_id = @guildId', + Sql.named('SELECT * FROM jellyfin_configs WHERE name = @name AND guild_id = @guildId'), parameters: {'name': name, 'guildId': guildId}); if (result.isEmpty) { @@ -37,31 +39,94 @@ class JellyfinConfigRepository { return JellyfinConfig.fromDatabaseRow(result.first.toColumnMap()); } - Future createJellyfinConfig( - String name, String basePath, String token, bool isDefault, Snowflake guildId) async { - final config = - JellyfinConfig(name: name, basePath: basePath, token: token, isDefault: isDefault, parentId: guildId); + Future saveJellyfinConfigUser(JellyfinConfigUser configUser) async { + final result = await _database.getConnection().execute(Sql.named(''' + INSERT INTO jellyfin_user_configs ( + user_id, + token, + jellyfin_config_id + ) VALUES ( + @user_id, + @token, + @jellyfin_config_id + ) ON CONFLICT ON CONSTRAINT jellyfin_configs_user_id_unique DO UPDATE SET + token = @token + WHERE + jellyfin_user_configs.user_id = @user_id AND jellyfin_user_configs.jellyfin_config_id = @jellyfin_config_id + RETURNING id; + '''), parameters: { + 'user_id': configUser.userId.toString(), + 'token': configUser.token, + 'jellyfin_config_id': configUser.jellyfinConfigId, + }); + + configUser.id = result.first.first as int; + return configUser; + } + + Future getUserConfig(String userId, int configId) async { + final result = await _database.getConnection().execute( + Sql.named('SELECT * FROM jellyfin_user_configs WHERE user_id = @userId AND jellyfin_config_id = @configId'), + parameters: {'userId': userId, 'configId': configId}); + + if (result.isEmpty) { + return null; + } + + return JellyfinConfigUser.fromDatabaseRow(result.first.toColumnMap()); + } + + Future updateJellyfinConfig(JellyfinConfig config) async { + await _database.getConnection().execute(Sql.named(''' + UPDATE jellyfin_configs + SET + base_path = @base_path, + sonarr_base_path = @sonarr_base_path, + sonarr_token = @sonarr_token, + wizarr_base_path = @wizarr_base_path, + wizarr_token = @wizarr_token + WHERE id = @id + '''), parameters: { + 'base_path': config.basePath, + 'sonarr_base_path': config.sonarrBasePath, + 'sonarr_token': config.sonarrToken, + 'wizarr_base_path': config.wizarrBasePath, + 'wizarr_token': config.wizarrToken, + 'id': config.id, + }); + } - final result = await _database.getConnection().execute(''' + Future createJellyfinConfig(JellyfinConfig config) async { + final result = await _database.getConnection().execute(Sql.named(''' INSERT INTO jellyfin_configs ( name, base_path, - token, is_default, - guild_id + guild_id, + sonarr_base_path, + sonarr_token, + wizarr_base_path, + wizarr_token ) VALUES ( @name, - @base_path, + @basePath, @token, - @is_default, - @guild_id + @isDefault, + @parentId, + @sonarrBasePath, + @sonarrToken, + @wizarrBasePath, + @wizarrToken ) RETURNING id; - ''', parameters: { + '''), parameters: { 'name': config.name, 'base_path': config.basePath, - 'token': config.token, 'is_default': config.isDefault, - 'guild_id': config.parentId.toString(), + 'parentId': config.parentId.toString(), + 'sonarrBasePath': config.sonarrBasePath, + 'sonarrToken': config.sonarrToken, + 'wizarrBasePath': config.wizarrBasePath, + 'wizarrToken': config.wizarrToken, }); config.id = result.first.first as int; diff --git a/lib/src/repository/reminder.dart b/lib/src/repository/reminder.dart index 61d29dd..587874b 100644 --- a/lib/src/repository/reminder.dart +++ b/lib/src/repository/reminder.dart @@ -1,5 +1,6 @@ import 'package:injector/injector.dart'; import 'package:logging/logging.dart'; +import 'package:postgres/postgres.dart'; import 'package:running_on_dart/running_on_dart.dart'; import 'package:running_on_dart/src/models/reminder.dart'; @@ -8,8 +9,9 @@ class ReminderRepository { final _database = Injector.appInstance.get(); Future fetchReminder(int id) async { - final result = - await _database.getConnection().execute('SELECT * FROM reminders WHERE id = @id', parameters: {'id': id}); + final result = await _database + .getConnection() + .execute(Sql.named('SELECT * FROM reminders WHERE id = @id'), parameters: {'id': id}); if (result.isEmpty || result.length > 1) { throw Exception("Empty or multiple reminder with same id"); } @@ -32,7 +34,7 @@ class ReminderRepository { return; } - await _database.getConnection().execute('DELETE FROM reminders WHERE id = @id', parameters: { + await _database.getConnection().execute(Sql.named('DELETE FROM reminders WHERE id = @id'), parameters: { 'id': id, }); } @@ -44,7 +46,7 @@ class ReminderRepository { return reminder; } - final result = await _database.getConnection().execute(''' + final result = await _database.getConnection().execute(Sql.named(''' INSERT INTO reminders ( user_id, channel_id, @@ -60,7 +62,7 @@ class ReminderRepository { @add_date, @message ) RETURNING id; - ''', parameters: { + '''), parameters: { 'user_id': reminder.userId.toString(), 'channel_id': reminder.channelId.toString(), 'message_id': reminder.messageId?.toString(), diff --git a/lib/src/repository/tag.dart b/lib/src/repository/tag.dart index 0a80c73..42c4f69 100644 --- a/lib/src/repository/tag.dart +++ b/lib/src/repository/tag.dart @@ -1,5 +1,6 @@ import 'package:injector/injector.dart'; import 'package:logging/logging.dart'; +import 'package:postgres/postgres.dart'; import 'package:running_on_dart/running_on_dart.dart'; import 'package:running_on_dart/src/models/tag.dart'; @@ -10,17 +11,17 @@ class TagRepository { /// Fetch all existing tags from the database. Future> fetchAllActiveTags() async { - final result = await _database.getConnection().execute(''' + final result = await _database.getConnection().execute(Sql.named(''' SELECT * FROM tags WHERE enabled = TRUE; - '''); + ''')); return result.map((row) => row.toColumnMap()).map(Tag.fromRow); } Future> fetchActiveTagsByName(String nameQuery) async { - final result = await _database.getConnection().execute(''' + final result = await _database.getConnection().execute(Sql.named(''' SELECT * FROM tags WHERE enabled = TRUE AND name LIKE '%@nameQuery%'; - ''', parameters: {'name': nameQuery}); + '''), parameters: {'name': nameQuery}); return result.map((row) => row.toColumnMap()).map(Tag.fromRow); } @@ -33,9 +34,9 @@ class TagRepository { return; } - await _database.getConnection().execute(''' + await _database.getConnection().execute(Sql.named(''' UPDATE tags SET enabled = FALSE WHERE id = @id; - ''', parameters: { + '''), parameters: { 'id': id, }); } @@ -47,7 +48,7 @@ class TagRepository { return; } - final result = await _database.getConnection().execute(''' + final result = await _database.getConnection().execute(Sql.named(''' INSERT INTO tags ( name, content, @@ -61,7 +62,7 @@ class TagRepository { @guild_id, @author_id ) RETURNING id; - ''', parameters: { + '''), parameters: { 'name': tag.name, 'content': tag.content, 'enabled': tag.enabled, @@ -78,7 +79,7 @@ class TagRepository { return addTag(tag); } - await _database.getConnection().execute(''' + await _database.getConnection().execute(Sql.named(''' UPDATE tags SET name = @name, content = @content, @@ -87,7 +88,7 @@ class TagRepository { author_id = @author_id WHERE id = @id - ''', parameters: { + '''), parameters: { 'id': tag.id, 'name': tag.name, 'content': tag.content, @@ -98,15 +99,15 @@ class TagRepository { } Future> fetchTagUsage() async { - final result = await _database.getConnection().execute(''' + final result = await _database.getConnection().execute(Sql.named(''' SELECT tu.* FROM tag_usage tu JOIN tags t ON t.id = tu.command_id AND t.enabled = TRUE; - '''); + ''')); return result.map((row) => row.toColumnMap()).map(TagUsedEvent.fromRow); } Future registerTagUsedEvent(TagUsedEvent event) async { - await _database.getConnection().execute(''' + await _database.getConnection().execute(Sql.named(''' INSERT INTO tag_usage ( command_id, use_date, @@ -116,7 +117,7 @@ class TagRepository { @use_date, @hidden ) - ''', parameters: { + '''), parameters: { 'tag_id': event.tagId, 'use_date': event.usedAt, 'hidden': event.hidden, diff --git a/lib/src/services/db.dart b/lib/src/services/db.dart index fd4fa0b..95d84d5 100644 --- a/lib/src/services/db.dart +++ b/lib/src/services/db.dart @@ -38,18 +38,19 @@ class DatabaseService implements RequiresInitialization { _logger.info('Connecting to database'); _connection = await Connection.open( - Endpoint( - host: host, - port: port, - database: databaseName, - username: user, - password: password, - ) - ); + Endpoint( + host: host, + port: port, + database: databaseName, + username: user, + password: password, + ), + settings: ConnectionSettings(sslMode: SslMode.disable)); _logger.info('Running database migrations'); - final migrator = MigentMigrationRunner(connection: _connection, databaseName: databaseName, migrationAccess: MemoryMigrationAccess()) + final migrator = MigentMigrationRunner( + connection: _connection, databaseName: databaseName, migrationAccess: MemoryMigrationAccess()) ..enqueueMigration('1', ''' CREATE TABLE tags ( id SERIAL PRIMARY KEY, @@ -115,7 +116,7 @@ class DatabaseService implements RequiresInitialization { ALTER TABLE reminders ALTER COLUMN message TYPE VARCHAR(200) ''') ..enqueueMigration('1.8', ''' - ALTER TABLE reminders ADD COLUMN active BOOLEAN NOT NULL; + ALTER TABLE reminders ADD COLUMN active BOOLEAN NOT NULL; ''') ..enqueueMigration('1.9', ''' CREATE INDEX name_trgm_idx ON tags USING gin (name gin_trgm_ops); @@ -141,7 +142,35 @@ class DatabaseService implements RequiresInitialization { ); CREATE UNIQUE INDEX idx_jellyfin_configs_unique_name ON jellyfin_configs(name, guild_id); CREATE UNIQUE INDEX idx_jellyfin_configs_unique_default ON jellyfin_configs(guild_id, is_default) WHERE is_default = TRUE; - '''); + ''') + ..enqueueMigration("2.5", ''' + ALTER TABLE jellyfin_configs ADD COLUMN sonarr_base_path VARCHAR DEFAULT NULL; + ''') + ..enqueueMigration("2.6", ''' + ALTER TABLE jellyfin_configs ADD COLUMN sonarr_token VARCHAR DEFAULT NULL; + ''') + ..enqueueMigration("2.7", ''' + ALTER TABLE jellyfin_configs ADD COLUMN wizarr_base_path VARCHAR DEFAULT NULL; + ''') + ..enqueueMigration("2.8", ''' + ALTER TABLE jellyfin_configs ADD COLUMN wizarr_token VARCHAR DEFAULT NULL; + ''') + ..enqueueMigration("2.9", 'ALTER TABLE jellyfin_configs DROP COLUMN token') + ..enqueueMigration("2.10", ''' + CREATE TABLE jellyfin_user_configs ( + id SERIAL PRIMARY KEY, + user_id VARCHAR NOT NULL, + token VARCHAR NOT NULL, + jellyfin_config_id INT NOT NULL, + CONSTRAINT fk_jellyfin_configs + FOREIGN KEY(jellyfin_config_id) + REFERENCES jellyfin_configs(id) + ); + ''') + ..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);'); await migrator.runMigrations(); diff --git a/lib/src/util/custom_task.dart b/lib/src/util/custom_task.dart deleted file mode 100644 index fa44ab3..0000000 --- a/lib/src/util/custom_task.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'dart:async'; - -import 'package:nyxx/nyxx.dart'; - -typedef UpdateCallback = Future Function(MessageUpdateBuilder builder); -typedef TargetMessageCallback = Future Function(); - -class CustomTask { - final UpdateCallback updateCallback; - - CustomTask( - {required TargetMessageCallback targetMessageCallback, - required this.updateCallback, - required Duration updateInterval}) { - targetMessageCallback().then((message) { - Timer.periodic(updateInterval, (timer) async { - final builder = MessageUpdateBuilder(); - final result = await updateCallback(builder); - - await message.update(builder); - - if (result) { - timer.cancel(); - } - }); - }); - } -} diff --git a/lib/src/util/jellyfin.dart b/lib/src/util/jellyfin.dart index a7d7c50..60f6a08 100644 --- a/lib/src/util/jellyfin.dart +++ b/lib/src/util/jellyfin.dart @@ -1,7 +1,10 @@ import 'package:collection/collection.dart'; import 'package:intl/intl.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/external/sonarr.dart'; +import 'package:running_on_dart/src/external/wizarr.dart'; import 'package:running_on_dart/src/modules/jellyfin.dart'; import 'package:running_on_dart/src/util/util.dart'; import 'package:tentacle/tentacle.dart'; @@ -12,6 +15,12 @@ final itemCriticRatingNumberFormat = NumberFormat("00"); Duration parseDurationFromTicks(int ticks) => Duration(microseconds: ticks ~/ 10); +String formatSeriesEpisodeString(int seriesNumber, int episodeNumber) => + 'S${episodeSeriesNumberFormat.format(seriesNumber)}E${episodeSeriesNumberFormat.format(episodeNumber)}'; + +String formatShortDateTimeWithRelative(DateTime dateTime) => + "${dateTime.format(TimestampStyle.shortDateTime)} (${dateTime.format(TimestampStyle.relativeTime)})"; + String formatProgress(int currentPositionTicks, int totalTicks) { final progressPercentage = currentPositionTicks / totalTicks * 100; @@ -21,10 +30,26 @@ String formatProgress(int currentPositionTicks, int totalTicks) { return "${currentPositionDuration.formatShort()}/${totalDuration.formatShort()} (${progressPercentage.toStringAsFixed(2)}%)"; } +Iterable getSonarrCalendarEmbeds(Iterable calendarItems) sync* { + for (final item in calendarItems.take(5)) { + final seriesPosterUrl = item.series.images.firstWhereOrNull((image) => image.coverType == 'poster'); + + yield EmbedBuilder( + title: '${formatSeriesEpisodeString(item.seasonNumber, item.episodeNumber)} ${item.title} (${item.series.title})', + description: item.overview, + fields: [ + EmbedFieldBuilder(name: "Air date", value: formatShortDateTimeWithRelative(item.airDateUtc), isInline: false), + EmbedFieldBuilder(name: "Avg runtime", value: "${item.series.runtime} mins", isInline: true), + ], + thumbnail: seriesPosterUrl != null ? EmbedThumbnailBuilder(url: Uri.parse(seriesPosterUrl.remoteUrl)) : null, + ); + } +} + Iterable getMediaInfoEmbedFields(Iterable mediaStreams) sync* { for (final mediaStream in mediaStreams) { final bitrate = ((mediaStream.bitRate ?? 0) / 1024 / 1024).toStringAsFixed(2); - final trackTitle = mediaStream.title ?? mediaStream.displayTitle; + final trackTitle = (mediaStream.title ?? mediaStream.displayTitle)?.replaceFirst("- Default", "").trim(); yield EmbedFieldBuilder( name: "Media Info (${mediaStream.type!.name})", value: "$trackTitle ($bitrate Mbps)", isInline: true); @@ -37,7 +62,7 @@ EmbedFieldBuilder getExternalUrlsEmbedField(Iterable externalUrls) return EmbedFieldBuilder(name: "External Urls", value: fieldValue.toString(), isInline: false); } -EmbedBuilder? buildSessionEmbed(SessionInfo sessionInfo, JellyfinClientWrapper client) { +EmbedBuilder? buildSessionEmbed(SessionInfo sessionInfo, AuthenticatedJellyfinClient client) { final nowPlayingItem = sessionInfo.nowPlayingItem; if (nowPlayingItem == null) { return null; @@ -61,7 +86,7 @@ EmbedBuilder? buildSessionEmbed(SessionInfo sessionInfo, JellyfinClientWrapper c ...getMediaInfoEmbedFields(primaryMediaStreams), ]; - final footer = EmbedFooterBuilder(text: "Jellyfin instance: ${client.name}"); + final footer = EmbedFooterBuilder(text: "Jellyfin instance: ${client.configUser.config!.name}"); final author = EmbedAuthorBuilder( name: '${sessionInfo.userName} on ${sessionInfo.deviceName}', iconUrl: sessionInfo.userPrimaryImageTag != null ? client.getItemPrimaryImage(sessionInfo.userId!) : null, @@ -111,7 +136,7 @@ EmbedBuilder? buildSessionEmbed(SessionInfo sessionInfo, JellyfinClientWrapper c return null; } -Stream buildMediaInfoBuilders(List items, JellyfinClientWrapper client) async* { +Stream buildMediaInfoBuilders(List items, AuthenticatedJellyfinClient client) async* { for (final slice in items.slices(2)) { final messageBuilder = MessageBuilder(embeds: []); @@ -128,7 +153,7 @@ Stream buildMediaInfoBuilders(List items, JellyfinC } } -EmbedBuilder? buildMediaEmbedBuilder(BaseItemDto item, JellyfinClientWrapper client) { +EmbedBuilder? buildMediaEmbedBuilder(BaseItemDto item, AuthenticatedJellyfinClient client) { final criticRating = item.criticRating != null ? "${itemCriticRatingNumberFormat.format(item.criticRating)}%" : '?'; final communityRating = item.communityRating != null ? itemRatingNumberFormat.format(item.communityRating) : '?'; final rating = "$communityRating / $criticRating"; @@ -181,3 +206,23 @@ EmbedBuilder? buildMediaEmbedBuilder(BaseItemDto item, JellyfinClientWrapper cli return null; } + +EmbedBuilder getUserInfoEmbed(UserDto currentUser, AuthenticatedJellyfinClient client) { + final thumbnail = currentUser.primaryImageTag != null + ? EmbedThumbnailBuilder(url: client.getUserImage(currentUser.id!, currentUser.primaryImageTag!)) + : null; + + return EmbedBuilder( + thumbnail: thumbnail, + title: currentUser.name, + fields: [ + EmbedFieldBuilder( + name: "Last login", value: formatShortDateTimeWithRelative(currentUser.lastLoginDate!), isInline: true), + EmbedFieldBuilder( + name: "Last activity", value: formatShortDateTimeWithRelative(currentUser.lastActivityDate!), isInline: true), + EmbedFieldBuilder( + name: "Is admin?", value: currentUser.policy?.isAdministrator == true ? 'true' : 'false', isInline: true), + EmbedFieldBuilder(name: "Links", value: '[Profile](${client.getUserProfile(currentUser.id!)})', isInline: false) + ], + ); +} diff --git a/lib/src/util/pipelines.dart b/lib/src/util/pipelines.dart index af5ee0f..326a009 100644 --- a/lib/src/util/pipelines.dart +++ b/lib/src/util/pipelines.dart @@ -10,8 +10,12 @@ typedef MessageSupplier = Future Function(); typedef UpdateCallback = Future<(bool, String?)> Function(); typedef RunCallback = Future Function(); -EmbedBuilder getInitialEmbed(int taskAmount) => - EmbedBuilder(title: 'Task 1 of $taskAmount', description: 'Starting...'); +EmbedBuilder getInitialEmbed(int taskAmount, String pipelineName) => EmbedBuilder( + title: getEmbedTitle(1, taskAmount), + description: 'Starting...', + author: EmbedAuthorBuilder(name: "Pipeline `$pipelineName`")); + +String getEmbedTitle(int index, int length) => 'Task $index of $length'; class Task { final UpdateCallback updateCallback; @@ -33,7 +37,7 @@ class InternalTask { Timer.periodic(updateInterval, (timer) async { final (finished, currentStatus) = await updateCallback(); - embed.description = currentStatus.toString(); + embed.description = currentStatus ?? '...'; await targetMessage.update(MessageUpdateBuilder(embeds: [embed])); if (finished) { @@ -47,47 +51,54 @@ class InternalTask { } class Pipeline { + final String name; + final String description; final List tasks; - final MessageSupplier messageSupplier; final Duration updateInterval; - late EmbedBuilder embed; - - Pipeline({required this.messageSupplier, required this.tasks, required this.updateInterval, required this.embed}); + Pipeline({required this.name, required this.description, required this.tasks, required this.updateInterval}); - factory Pipeline.fromCreateContext( - {required TargetMessageSupplier messageSupplier, required List tasks, required Duration updateInterval}) { - final embed = getInitialEmbed(tasks.length); + InternalPipeline forCreateContext({required TargetMessageSupplier messageSupplier}) { + final embed = getInitialEmbed(tasks.length, name); - return Pipeline( + return InternalPipeline( messageSupplier: () => messageSupplier(MessageBuilder(embeds: [embed], components: [], content: null)), tasks: tasks, updateInterval: updateInterval, embed: embed); } - factory Pipeline.fromUpdateContext( - {required TargetUpdateMessageSupplier messageSupplier, - required List tasks, - required Duration updateInterval}) { - final embed = getInitialEmbed(tasks.length); + InternalPipeline forUpdateContext({required TargetUpdateMessageSupplier messageSupplier}) { + final embed = getInitialEmbed(tasks.length, name); - return Pipeline( + return InternalPipeline( messageSupplier: () => messageSupplier(MessageUpdateBuilder(embeds: [embed], components: [], content: null)), tasks: tasks, updateInterval: updateInterval, embed: embed, ); } +} + +class InternalPipeline { + final List tasks; + final MessageSupplier messageSupplier; + final Duration updateInterval; + + late EmbedBuilder embed; + + InternalPipeline( + {required this.messageSupplier, required this.tasks, required this.updateInterval, required this.embed}); Future execute() async { final message = await messageSupplier(); final timer = Stopwatch()..start(); - for (final task in tasks) { + for (final (index, task) in tasks.indexed) { final internalTask = InternalTask(targetMessage: message, updateCallback: task.updateCallback, updateInterval: updateInterval); + embed.title = getEmbedTitle(index + 1, tasks.length); task.runCallback(); await internalTask.execute(embed); } diff --git a/lib/src/util/util.dart b/lib/src/util/util.dart index a9e0c8f..04e4fe3 100644 --- a/lib/src/util/util.dart +++ b/lib/src/util/util.dart @@ -1,9 +1,12 @@ import 'dart:io'; import 'dart:math'; +import 'package:collection/collection.dart'; +import 'package:human_duration_parser/human_duration_parser.dart'; import 'package:nyxx/nyxx.dart'; final random = Random(); +const _chars = 'AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz1234567890'; DiscordColor getRandomColor() { return DiscordColor.fromRgb(random.nextInt(255), random.nextInt(255), random.nextInt(255)); @@ -24,3 +27,34 @@ extension DurationFromTicks on Duration { abstract class RequiresInitialization { Future init(); } + +String? valueOrNull(String? value) { + if (value == null) { + return null; + } + + final trimmedValue = value.trim(); + if (trimmedValue.isEmpty) { + return null; + } + + return value; +} + +String generateRandomString(int length) => + String.fromCharCodes(Iterable.generate(length, (_) => _chars.codeUnitAt(random.nextInt(_chars.length)))) + .toUpperCase(); + +Iterable spliceEmbedsForMessageBuilders(Iterable embeds, [int sliceSize = 2]) sync* { + for (final splicedEmbeds in embeds.slices(sliceSize)) { + yield MessageBuilder(embeds: splicedEmbeds); + } +} + +Duration? getDurationFromStringOrDefault(String? durationString, Duration? defaultDuration) { + if (durationString == null) { + return defaultDuration; + } + + return parseStringToDuration(durationString) ?? defaultDuration; +} \ No newline at end of file