From 7ee1fe0791e36d06889294da1d26da9b9454304e Mon Sep 17 00:00:00 2001 From: Szymon Uglis Date: Fri, 11 Oct 2024 21:34:00 +0200 Subject: [PATCH 01/11] basic sonarr impl; fix postgres code --- lib/src/commands/jellyfin.dart | 118 +++++++++++++++++++---- lib/src/external/sonarr.dart | 97 +++++++++++++++++++ lib/src/models/jellyfin_config.dart | 27 ++++-- lib/src/modules/jellyfin.dart | 10 +- lib/src/repository/feature_settings.dart | 22 ++--- lib/src/repository/jellyfin_config.dart | 50 ++++++++-- lib/src/repository/reminder.dart | 9 +- lib/src/repository/tag.dart | 29 +++--- lib/src/services/db.dart | 27 ++++-- 9 files changed, 313 insertions(+), 76 deletions(-) create mode 100644 lib/src/external/sonarr.dart diff --git a/lib/src/commands/jellyfin.dart b/lib/src/commands/jellyfin.dart index 7ff3e67..cd6271a 100644 --- a/lib/src/commands/jellyfin.dart +++ b/lib/src/commands/jellyfin.dart @@ -14,6 +14,8 @@ import 'package:tentacle/tentacle.dart'; final taskProgressFormat = NumberFormat("0.00"); +String? _valueOrNull(String value) => value.trim().isEmpty ? null : value.trim(); + final jellyfin = ChatGroup( "jellyfin", "Jellyfin Testing Commands", @@ -211,28 +213,102 @@ final jellyfin = ChatGroup( ]), ChatCommand( 'add-instance', - "Add new instance to config", + "Add new jellyfin instance", id("jellyfin-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: "api_token", style: TextInputStyle.short, label: "API Token", isRequired: true), TextInputBuilder(customId: "is_default", style: TextInputStyle.short, label: "Is Default (True/False)"), + 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 = 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 config = JellyfinConfig( + name: modalResponse['name']!, + basePath: modalResponse['base_url']!, + token: modalResponse['api_token']!, + isDefault: modalResponse['is_default']?.toLowerCase() == 'true', + parentId: modalResponse.guild?.id ?? modalResponse.user.id, + sonarrBasePath: modalResponse['sonarr_base_url'], + sonarrToken: modalResponse['sonarr_token'], + wizarrBasePath: modalResponse['wizarr_base_url'], + wizarrToken: modalResponse['wizarr_token'], + ); + + final newlyCreatedConfig = await Injector.appInstance.get().createJellyfinConfig(config); - modalResponse.respond(MessageBuilder(content: "Added new jellyfin instance with name: ${config.name}")); + modalResponse + .respond(MessageBuilder(content: "Added new jellyfin instance with name: ${newlyCreatedConfig.name}")); }), checks: [ jellyfinFeatureCreateInstanceCommandCheck, ]), + ChatCommand( + "edit-instance", + "Edit jellyfin instance", + id('jellyfin-edit-instance', (InteractionChatContext context, + [@Description("Instance to use. Default selected if not provided") + @UseConverter(jellyfinConfigConverter) + JellyfinConfig? config]) async { + if (config == null) { + return context.respond(MessageBuilder(content: 'Invalid jellyfin config'), level: ResponseLevel.private); + } + + 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), + TextInputBuilder( + customId: "api_token", + style: TextInputStyle.short, + label: "API Token", + isRequired: true, + value: config.token), + ]); + + final secondModalResponse = await context.getModal(title: "Jellyfin Instance Edit Pt. 2", components: [ + TextInputBuilder( + customId: "sonarr_base_url", + style: TextInputStyle.short, + label: "API Token", + value: config.sonarrBasePath), + TextInputBuilder( + customId: "sonarr_token", style: TextInputStyle.short, label: "API Token", value: config.sonarrToken), + TextInputBuilder( + customId: "wizarr_base_url", + style: TextInputStyle.short, + label: "API Token", + value: config.wizarrBasePath), + TextInputBuilder( + customId: "wizarr_token", style: TextInputStyle.short, label: "API Token", value: config.wizarrToken), + ]); + + final editedConfig = JellyfinConfig( + name: config.name, + basePath: modalResponse['base_url']!, + token: modalResponse['api_token']!, + isDefault: config.isDefault, + parentId: config.parentId, + sonarrBasePath: secondModalResponse['sonarr_base_url'], + sonarrToken: secondModalResponse['sonarr_token'], + wizarrBasePath: secondModalResponse['wizarr_base_url'], + wizarrToken: secondModalResponse['wizarr_token'], + ); + editedConfig.id = config.id; + + Injector.appInstance.get().updateClientForConfig(editedConfig); + + return modalResponse.respond(MessageBuilder(content: 'Successfully updated jellyfin config')); + }), + checks: [ + jellyfinFeatureAdminCommandCheck, + ]), ChatCommand( "transfer-config", "Transfers jellyfin instance config to another guild", @@ -243,12 +319,18 @@ final jellyfin = ChatGroup( @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); + 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}"')); diff --git a/lib/src/external/sonarr.dart b/lib/src/external/sonarr.dart new file mode 100644 index 0000000..67dcc59 --- /dev/null +++ b/lib/src/external/sonarr.dart @@ -0,0 +1,97 @@ +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)), + ); + } +} + +class Calendar { + final int seriesId; + final int seasonNumber; + final int episodeNumber; + final String title; + final DateTime airDateUtc; + final String overview; + final Series series; + + Calendar( + {required this.seriesId, + required this.seasonNumber, + required this.episodeNumber, + required this.title, + required this.airDateUtc, + required this.overview, + required this.series}); + + factory Calendar.parseJson(Map data) { + return Calendar( + seriesId: data['seriesId'] as int, + seasonNumber: data['seasonNumber'] as int, + episodeNumber: data['episodeNumber'] as int, + title: data['title'] as String, + airDateUtc: DateTime.parse(data['seriesId']), + 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': 'application/json'}; + } + + Future fetchCalendar({DateTime? start, DateTime? end, bool? includeSeries = true}) async { + final responseBody = await _get("/api/v3/calendar", parameters: { + if (start != null) 'start': start.toIso8601String(), + if (end != null) 'end': end.toIso8601String(), + if (includeSeries != null) 'includeSeries': _boolToString(includeSeries), + }); + + return Calendar.parseJson(responseBody); + } + + Future> _get(String path, {Map parameters = const {}}) async { + final uri = Uri.parse('$baseUrl/$path'); + uri.queryParameters.addAll(parameters); + + final response = await http.get(uri, headers: _headers); + + return jsonDecode(response.body); + } +} diff --git a/lib/src/models/jellyfin_config.dart b/lib/src/models/jellyfin_config.dart index 2d246ef..ff1062e 100644 --- a/lib/src/models/jellyfin_config.dart +++ b/lib/src/models/jellyfin_config.dart @@ -9,15 +9,26 @@ class JellyfinConfig { 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.token, + required this.isDefault, + required this.parentId, + this.sonarrBasePath, + this.sonarrToken, + this.wizarrBasePath, + this.wizarrToken, + }); factory JellyfinConfig.fromDatabaseRow(Map row) { return JellyfinConfig( @@ -26,6 +37,10 @@ class JellyfinConfig { 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..07767d8 100644 --- a/lib/src/modules/jellyfin.dart +++ b/lib/src/modules/jellyfin.dart @@ -126,6 +126,11 @@ class JellyfinModule implements RequiresInitialization { await _jellyfinConfigRepository.deleteConfig(config.id!); } + Future updateClientForConfig(JellyfinConfig config) async { + await _jellyfinConfigRepository.updateJellyfinConfig(config); + _createClientConfig(config); + } + Future getClient(JellyfinInstanceIdentity identity) async { final cachedClientConfig = _getCachedClientConfig(identity); if (cachedClientConfig != null) { @@ -156,9 +161,8 @@ class JellyfinModule implements RequiresInitialization { void addUserToAllowedForRegistration(String instanceName, Snowflake userId, List allowedLibraries) => _allowedUserRegistrations["$instanceName$userId"] = allowedLibraries; - Future createJellyfinConfig( - String name, String basePath, String token, bool isDefault, Snowflake guildId) async { - final config = await _jellyfinConfigRepository.createJellyfinConfig(name, basePath, token, isDefault, guildId); + Future createJellyfinConfig(JellyfinConfig createdConfig) async { + final config = await _jellyfinConfigRepository.createJellyfinConfig(createdConfig); if (config.id == null) { throw Error(); } diff --git a/lib/src/repository/feature_settings.dart b/lib/src/repository/feature_settings.dart index b1b0161..e69069f 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,9 +8,8 @@ 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: { + 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(), }); @@ -18,9 +18,9 @@ class FeatureSettingsRepository { } 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 +43,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 +71,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 +82,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..1691eed 100644 --- a/lib/src/repository/jellyfin_config.dart +++ b/lib/src/repository/jellyfin_config.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/jellyfin_config.dart'; import 'package:running_on_dart/src/services/db.dart'; @@ -19,7 +20,7 @@ class JellyfinConfigRepository { } Future> getConfigsForGuild(Snowflake guildId) async { - final result = await _database.getConnection().execute('SELECT * FROM jellyfin_configs WHERE guild_id = @guildId', + final result = await _database.getConnection().execute(Sql.named('SELECT * FROM jellyfin_configs WHERE guild_id = @guildId'), parameters: {'guildId': guildId.toString()}); return result.map((row) => row.toColumnMap()).map(JellyfinConfig.fromDatabaseRow); @@ -27,7 +28,7 @@ class JellyfinConfigRepository { Future getByName(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 +38,60 @@ 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 updateJellyfinConfig(JellyfinConfig config) async { + await _database.getConnection().execute(Sql.named(''' + UPDATE jellyfin_configs + SET + base_path = @base_path, + token = @token, + 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, + 'token': config.token, + 'sonarr_base_path': config.sonarrBasePath, + 'sonarr_token': config.sonarrToken, + 'wizarr_base_path': config.wizarrBasePath, + 'wizarr_token': config.wizarrToken, + }); + } - 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, @token, @is_default, - @guild_id + @guild_id, + @sonarr_base_path, + @sonarr_token, + @wizarr_base_path, + @wizarr_token ) RETURNING id; - ''', parameters: { + '''), parameters: { 'name': config.name, 'base_path': config.basePath, 'token': config.token, 'is_default': config.isDefault, 'guild_id': config.parentId.toString(), + 'sonarr_base_path': config.sonarrBasePath, + 'sonarr_token': config.sonarrToken, + 'wizarr_base_path': config.wizarrBasePath, + 'wizarr_token': 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..21d7193 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'; @@ -9,7 +10,7 @@ class ReminderRepository { Future fetchReminder(int id) async { final result = - await _database.getConnection().execute('SELECT * FROM reminders WHERE id = @id', parameters: {'id': id}); + 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 +33,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 +45,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 +61,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..7fbdd17 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,6 +142,12 @@ 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.4", ''' + ALTER TABLE jellyfin_configs ADD COLUMN sonarr_base_path VARCHAR DEFAULT NULL; + ALTER TABLE jellyfin_configs ADD COLUMN sonarr_token VARCHAR DEFAULT NULL; + ALTER TABLE jellyfin_configs ADD COLUMN wizarr_base_path VARCHAR DEFAULT NULL; + ALTER TABLE jellyfin_configs ADD COLUMN wizarr_token VARCHAR DEFAULT NULL; '''); await migrator.runMigrations(); From d7c1b077ad10efd24ef0ed218e0a17979d56aad3 Mon Sep 17 00:00:00 2001 From: Szymon Uglis Date: Sat, 12 Oct 2024 23:25:02 +0200 Subject: [PATCH 02/11] Add sonarr calendar api call --- lib/src/commands/jellyfin.dart | 70 +++++++++++++++++++++--- lib/src/external/sonarr.dart | 34 ++++++------ lib/src/models/jellyfin_config.dart | 2 + lib/src/modules/jellyfin.dart | 13 ++++- lib/src/repository/feature_settings.dart | 9 +-- lib/src/repository/jellyfin_config.dart | 8 +-- lib/src/repository/reminder.dart | 5 +- lib/src/services/db.dart | 8 ++- lib/src/util/jellyfin.dart | 24 ++++++++ 9 files changed, 133 insertions(+), 40 deletions(-) diff --git a/lib/src/commands/jellyfin.dart b/lib/src/commands/jellyfin.dart index cd6271a..f57bc61 100644 --- a/lib/src/commands/jellyfin.dart +++ b/lib/src/commands/jellyfin.dart @@ -14,7 +14,13 @@ import 'package:tentacle/tentacle.dart'; final taskProgressFormat = NumberFormat("0.00"); -String? _valueOrNull(String value) => value.trim().isEmpty ? null : value.trim(); +// String? _valueOrNull(String value) => value.trim().isEmpty ? null : value.trim(); + +Iterable spliceEmbedsForMessageBuilders(Iterable embeds, [int sliceSize = 2]) sync* { + for (final splicedEmbeds in embeds.slices(sliceSize)) { + yield MessageBuilder(embeds: splicedEmbeds); + } +} final jellyfin = ChatGroup( "jellyfin", @@ -23,6 +29,31 @@ final jellyfin = ChatGroup( jellyfinFeatureEnabledCheck, ], children: [ + ChatGroup("sonarr", "Sonarr related commands", children: [ + ChatCommand( + "calendar", + "Show upcoming episodes", + id("jellyfin-sonarr-calendar", (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")); + } + + if (!client.isSonarrEnabled) { + return context.respond(MessageBuilder(content: "Sonarr not configured!")); + } + + final calendarItems = await client.getSonarrCalendar(end: DateTime.now().add(Duration(days: 7))); + final embeds = getSonarrCalendarEmbeds(calendarItems); + + final paginator = await pagination.builders(spliceEmbedsForMessageBuilders(embeds).toList()); + context.respond(paginator); + }), + ), + ]), ChatGroup("tasks", "Run tasks on Jellyfin instance", children: [ ChatCommand( "run", @@ -272,21 +303,42 @@ final jellyfin = ChatGroup( value: config.token), ]); + 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: "API Token", - value: config.sonarrBasePath), + customId: "sonarr_base_url", + style: TextInputStyle.short, + label: "Sonarr base url", + value: config.sonarrBasePath, + isRequired: false, + ), TextInputBuilder( - customId: "sonarr_token", style: TextInputStyle.short, label: "API Token", value: config.sonarrToken), + customId: "sonarr_token", + style: TextInputStyle.short, + label: "Sonarr Token", + value: config.sonarrToken, + isRequired: false), TextInputBuilder( customId: "wizarr_base_url", style: TextInputStyle.short, - label: "API Token", - value: config.wizarrBasePath), + label: "Wizarr base url", + value: config.wizarrBasePath, + isRequired: false), TextInputBuilder( - customId: "wizarr_token", style: TextInputStyle.short, label: "API Token", value: config.wizarrToken), + customId: "wizarr_token", + style: TextInputStyle.short, + label: "Wizarr Token", + value: config.wizarrToken, + isRequired: false), ]); final editedConfig = JellyfinConfig( diff --git a/lib/src/external/sonarr.dart b/lib/src/external/sonarr.dart index 67dcc59..db889e3 100644 --- a/lib/src/external/sonarr.dart +++ b/lib/src/external/sonarr.dart @@ -31,21 +31,21 @@ class Series { 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)), + images: (data['images'] as List).map((imageJson) => Image.parseJson(imageJson as Map)), ); } } -class Calendar { +class CalendarItem { final int seriesId; final int seasonNumber; final int episodeNumber; final String title; final DateTime airDateUtc; - final String overview; + final String? overview; final Series series; - Calendar( + CalendarItem( {required this.seriesId, required this.seasonNumber, required this.episodeNumber, @@ -54,14 +54,14 @@ class Calendar { required this.overview, required this.series}); - factory Calendar.parseJson(Map data) { - return Calendar( + 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['seriesId']), - overview: data['overview'] as String, + airDateUtc: DateTime.parse(data['airDateUtc']), + overview: data['overview'] as String?, series: Series.parseJson(data['series'] as Map), ); } @@ -73,25 +73,23 @@ class SonarrClient { late final Map _headers; SonarrClient({required this.baseUrl, required String token}) { - _headers = {"X-Api-Key": token, 'Accept': 'application/json', 'Content': 'application/json'}; + _headers = {"X-Api-Key": token, 'Accept': 'application/json', 'Content-Type': 'application/json'}; } - Future fetchCalendar({DateTime? start, DateTime? end, bool? includeSeries = true}) async { - final responseBody = await _get("/api/v3/calendar", parameters: { + 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), }); - return Calendar.parseJson(responseBody); + 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'); - uri.queryParameters.addAll(parameters); - - final response = await http.get(uri, headers: _headers); + Future _get(String path, {Map parameters = const {}}) async { + final uri = Uri.parse('$baseUrl$path').replace(queryParameters: parameters); - return jsonDecode(response.body); + return await http.get(uri, headers: _headers); } } diff --git a/lib/src/models/jellyfin_config.dart b/lib/src/models/jellyfin_config.dart index ff1062e..3cc4b0e 100644 --- a/lib/src/models/jellyfin_config.dart +++ b/lib/src/models/jellyfin_config.dart @@ -28,10 +28,12 @@ class JellyfinConfig { 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'], diff --git a/lib/src/modules/jellyfin.dart b/lib/src/modules/jellyfin.dart index 07767d8..3b08092 100644 --- a/lib/src/modules/jellyfin.dart +++ b/lib/src/modules/jellyfin.dart @@ -1,5 +1,6 @@ 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/models/jellyfin_config.dart'; import 'package:running_on_dart/src/repository/jellyfin_config.dart'; import 'package:running_on_dart/src/util/util.dart'; @@ -25,12 +26,17 @@ typedef JellyfinInstanceIdentity = (String? instanceName, Snowflake guildId); class JellyfinClientWrapper { final Tentacle jellyfinClient; + final SonarrClient? sonarrClient; final JellyfinConfig config; String get basePath => config.basePath; String get name => config.name; + bool get isSonarrEnabled => sonarrClient != null; - JellyfinClientWrapper(this.jellyfinClient, this.config); + JellyfinClientWrapper(this.jellyfinClient, this.config, this.sonarrClient); + + Future> getSonarrCalendar({DateTime? start, DateTime? end}) => + sonarrClient!.fetchCalendar(start: start, end: end, includeSeries: true); Future> getCurrentSessions() async { final response = await jellyfinClient.getSessionApi().getSessions(activeWithinSeconds: 15); @@ -178,8 +184,11 @@ class JellyfinModule implements RequiresInitialization { JellyfinClientWrapper _createClientConfig(JellyfinConfig config) { final client = Tentacle(basePathOverride: config.basePath, interceptors: [CustomAuthInterceptor(config.token)]); + final sonarrClient = config.sonarrBasePath != null && config.sonarrToken != null + ? SonarrClient(baseUrl: config.sonarrBasePath!, token: config.sonarrToken!) + : null; - final clientConfig = JellyfinClientWrapper(client, config); + final clientConfig = JellyfinClientWrapper(client, config, sonarrClient); _jellyfinClients[_getClientCacheIdentifier(config.parentId.toString(), config.name, config.isDefault)] = clientConfig; diff --git a/lib/src/repository/feature_settings.dart b/lib/src/repository/feature_settings.dart index e69069f..a25fc56 100644 --- a/lib/src/repository/feature_settings.dart +++ b/lib/src/repository/feature_settings.dart @@ -9,10 +9,11 @@ class FeatureSettingsRepository { Future isEnabled(Setting setting, Snowflake guildId) async { 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(), - }); + 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; } diff --git a/lib/src/repository/jellyfin_config.dart b/lib/src/repository/jellyfin_config.dart index 1691eed..810caeb 100644 --- a/lib/src/repository/jellyfin_config.dart +++ b/lib/src/repository/jellyfin_config.dart @@ -8,9 +8,7 @@ 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}); + await _database.getConnection().execute('DELETE FROM jellyfin_configs WHERE id = @id', parameters: {'id': id}); } Future> getDefaultConfigs() async { @@ -20,7 +18,8 @@ class JellyfinConfigRepository { } Future> getConfigsForGuild(Snowflake guildId) async { - final result = await _database.getConnection().execute(Sql.named('SELECT * FROM jellyfin_configs WHERE guild_id = @guildId'), + final result = await _database.getConnection().execute( + Sql.named('SELECT * FROM jellyfin_configs WHERE guild_id = @guildId'), parameters: {'guildId': guildId.toString()}); return result.map((row) => row.toColumnMap()).map(JellyfinConfig.fromDatabaseRow); @@ -56,6 +55,7 @@ class JellyfinConfigRepository { 'sonarr_token': config.sonarrToken, 'wizarr_base_path': config.wizarrBasePath, 'wizarr_token': config.wizarrToken, + 'id': config.id, }); } diff --git a/lib/src/repository/reminder.dart b/lib/src/repository/reminder.dart index 21d7193..587874b 100644 --- a/lib/src/repository/reminder.dart +++ b/lib/src/repository/reminder.dart @@ -9,8 +9,9 @@ class ReminderRepository { final _database = Injector.appInstance.get(); Future fetchReminder(int id) async { - final result = - await _database.getConnection().execute(Sql.named('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"); } diff --git a/lib/src/services/db.dart b/lib/src/services/db.dart index 7fbdd17..65ce7e7 100644 --- a/lib/src/services/db.dart +++ b/lib/src/services/db.dart @@ -143,10 +143,16 @@ 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.4", ''' + ..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; '''); diff --git a/lib/src/util/jellyfin.dart b/lib/src/util/jellyfin.dart index a7d7c50..46cef82 100644 --- a/lib/src/util/jellyfin.dart +++ b/lib/src/util/jellyfin.dart @@ -2,6 +2,7 @@ import 'package:collection/collection.dart'; import 'package:intl/intl.dart'; import 'package:nyxx/nyxx.dart'; import 'package:nyxx_extensions/nyxx_extensions.dart'; +import 'package:running_on_dart/src/external/sonarr.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 +13,9 @@ 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 formatProgress(int currentPositionTicks, int totalTicks) { final progressPercentage = currentPositionTicks / totalTicks * 100; @@ -21,6 +25,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: + "${item.airDateUtc.format(TimestampStyle.shortDateTime)} (${item.airDateUtc.format(TimestampStyle.relativeTime)})", + 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); From eb4ec2cdbf7815e1cedc77fb14647ec89bdaeaf2 Mon Sep 17 00:00:00 2001 From: Szymon Uglis Date: Sun, 13 Oct 2024 10:14:49 +0200 Subject: [PATCH 03/11] Move jellyfin admin commands to settings group --- lib/src/commands/jellyfin.dart | 318 +++++++++++++++++---------------- lib/src/util/custom_task.dart | 28 --- 2 files changed, 161 insertions(+), 185 deletions(-) delete mode 100644 lib/src/util/custom_task.dart diff --git a/lib/src/commands/jellyfin.dart b/lib/src/commands/jellyfin.dart index f57bc61..80e8b3b 100644 --- a/lib/src/commands/jellyfin.dart +++ b/lib/src/commands/jellyfin.dart @@ -14,6 +14,7 @@ import 'package:tentacle/tentacle.dart'; final taskProgressFormat = NumberFormat("0.00"); +// TODO: use it when fetching data from modal // String? _valueOrNull(String value) => value.trim().isEmpty ? null : value.trim(); Iterable spliceEmbedsForMessageBuilders(Iterable embeds, [int sliceSize = 2]) sync* { @@ -242,165 +243,168 @@ final jellyfin = ChatGroup( checks: [ jellyfinFeatureUserCommandCheck, ]), - ChatCommand( - 'add-instance', - "Add new jellyfin instance", - id("jellyfin-new-instance", (InteractionChatContext context) async { - final modalResponse = await context.getModal(title: "New Instance Configuration", components: [ - TextInputBuilder(customId: "name", style: TextInputStyle.short, label: "Instance Name", isRequired: true), - TextInputBuilder(customId: "base_url", style: TextInputStyle.short, label: "Base Url", isRequired: true), - TextInputBuilder(customId: "api_token", style: TextInputStyle.short, label: "API Token", isRequired: true), - TextInputBuilder(customId: "is_default", style: TextInputStyle.short, label: "Is Default (True/False)"), - 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']!, - token: modalResponse['api_token']!, - isDefault: modalResponse['is_default']?.toLowerCase() == 'true', - parentId: modalResponse.guild?.id ?? modalResponse.user.id, - sonarrBasePath: modalResponse['sonarr_base_url'], - sonarrToken: modalResponse['sonarr_token'], - wizarrBasePath: modalResponse['wizarr_base_url'], - wizarrToken: modalResponse['wizarr_token'], - ); - - final newlyCreatedConfig = await Injector.appInstance.get().createJellyfinConfig(config); - - modalResponse - .respond(MessageBuilder(content: "Added new jellyfin instance with name: ${newlyCreatedConfig.name}")); - }), - checks: [ - jellyfinFeatureCreateInstanceCommandCheck, - ]), - ChatCommand( - "edit-instance", - "Edit jellyfin instance", - id('jellyfin-edit-instance', (InteractionChatContext context, - [@Description("Instance to use. Default selected if not provided") - @UseConverter(jellyfinConfigConverter) - JellyfinConfig? config]) async { - if (config == null) { - return context.respond(MessageBuilder(content: 'Invalid jellyfin config'), level: ResponseLevel.private); - } + ChatGroup("settings", "Settings for jellyfin", children: [ + ChatCommand( + 'add-instance', + "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", isRequired: true), + TextInputBuilder(customId: "base_url", style: TextInputStyle.short, label: "Base Url", isRequired: true), + TextInputBuilder( + customId: "api_token", style: TextInputStyle.short, label: "API Token", isRequired: true), + TextInputBuilder(customId: "is_default", style: TextInputStyle.short, label: "Is Default (True/False)"), + 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']!, + token: modalResponse['api_token']!, + isDefault: modalResponse['is_default']?.toLowerCase() == 'true', + parentId: modalResponse.guild?.id ?? modalResponse.user.id, + sonarrBasePath: modalResponse['sonarr_base_url'], + sonarrToken: modalResponse['sonarr_token'], + wizarrBasePath: modalResponse['wizarr_base_url'], + wizarrToken: modalResponse['wizarr_token'], + ); + + final newlyCreatedConfig = await Injector.appInstance.get().createJellyfinConfig(config); + + modalResponse + .respond(MessageBuilder(content: "Added new jellyfin instance with name: ${newlyCreatedConfig.name}")); + }), + checks: [ + jellyfinFeatureCreateInstanceCommandCheck, + ]), + ChatCommand( + "edit-instance", + "Edit jellyfin instance", + id('jellyfin-settings-edit-instance', (InteractionChatContext context, + [@Description("Instance to use. Default selected if not provided") + @UseConverter(jellyfinConfigConverter) + JellyfinConfig? config]) async { + if (config == null) { + return context.respond(MessageBuilder(content: 'Invalid jellyfin config'), level: ResponseLevel.private); + } - 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), - TextInputBuilder( - customId: "api_token", + 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), + TextInputBuilder( + customId: "api_token", + style: TextInputStyle.short, + label: "API Token", + isRequired: true, + value: config.token), + ]); + + 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: "API Token", - isRequired: true, - value: config.token), - ]); - - 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']!, - token: modalResponse['api_token']!, - isDefault: config.isDefault, - parentId: config.parentId, - sonarrBasePath: secondModalResponse['sonarr_base_url'], - sonarrToken: secondModalResponse['sonarr_token'], - wizarrBasePath: secondModalResponse['wizarr_base_url'], - wizarrToken: secondModalResponse['wizarr_token'], - ); - editedConfig.id = config.id; - - Injector.appInstance.get().updateClientForConfig(editedConfig); - - return modalResponse.respond(MessageBuilder(content: 'Successfully updated jellyfin config')); - }), - checks: [ - jellyfinFeatureAdminCommandCheck, - ]), - 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(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-remove-config", (ChatContext context, - @Description("Name of instance") @UseConverter(jellyfinConfigConverter) JellyfinConfig config) async { - await Injector.appInstance.get().deleteJellyfinConfig(config); + 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']!, + token: modalResponse['api_token']!, + isDefault: config.isDefault, + parentId: config.parentId, + sonarrBasePath: secondModalResponse['sonarr_base_url'], + sonarrToken: secondModalResponse['sonarr_token'], + wizarrBasePath: secondModalResponse['wizarr_base_url'], + wizarrToken: secondModalResponse['wizarr_token'], + ); + editedConfig.id = config.id; + + Injector.appInstance.get().updateClientForConfig(editedConfig); + + return modalResponse.respond(MessageBuilder(content: 'Successfully updated jellyfin config')); + }), + checks: [ + jellyfinFeatureAdminCommandCheck, + ]), + 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, - ]), + context.respond(MessageBuilder(content: 'Delete config with name: "${config.name}"')); + }), + checks: [ + jellyfinFeatureAdminCommandCheck, + ]), + ]) ], ); 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(); - } - }); - }); - } -} From 52bcbfe9733f2d345976d9a271cd4514262b5c1e Mon Sep 17 00:00:00 2001 From: Szymon Uglis Date: Sun, 13 Oct 2024 10:34:38 +0200 Subject: [PATCH 04/11] Improve pipelines API --- lib/src/commands/jellyfin.dart | 10 +++++--- lib/src/util/pipelines.dart | 43 +++++++++++++++++++++------------- 2 files changed, 34 insertions(+), 19 deletions(-) diff --git a/lib/src/commands/jellyfin.dart b/lib/src/commands/jellyfin.dart index 80e8b3b..2ac5c65 100644 --- a/lib/src/commands/jellyfin.dart +++ b/lib/src/commands/jellyfin.dart @@ -81,8 +81,9 @@ final jellyfin = ChatGroup( return SelectMenuOptionBuilder(label: label, value: taskInfo.id!, description: description); }, authorOnly: true); - Pipeline.fromUpdateContext( - messageSupplier: (messageBuilder) => context.interaction.updateOriginalResponse(messageBuilder), + PipelineDefinition( + name: selectMenuResult.name!, + description: "", tasks: [ Task( runCallback: () => client.startTask(selectMenuResult.id!), @@ -100,7 +101,10 @@ final jellyfin = ChatGroup( }), ], updateInterval: Duration(seconds: 2), - ).execute(); + ) + .forUpdateContext( + messageSupplier: (messageBuilder) => context.interaction.updateOriginalResponse(messageBuilder)) + .execute(); }), ), ]), diff --git a/lib/src/util/pipelines.dart b/lib/src/util/pipelines.dart index af5ee0f..34b24b0 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; @@ -46,18 +50,17 @@ class InternalTask { } } -class Pipeline { +class PipelineDefinition { + 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}); + PipelineDefinition( + {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); + Pipeline forCreateContext({required TargetMessageSupplier messageSupplier}) { + final embed = getInitialEmbed(tasks.length, name); return Pipeline( messageSupplier: () => messageSupplier(MessageBuilder(embeds: [embed], components: [], content: null)), @@ -66,11 +69,8 @@ class Pipeline { embed: embed); } - factory Pipeline.fromUpdateContext( - {required TargetUpdateMessageSupplier messageSupplier, - required List tasks, - required Duration updateInterval}) { - final embed = getInitialEmbed(tasks.length); + Pipeline forUpdateContext({required TargetUpdateMessageSupplier messageSupplier}) { + final embed = getInitialEmbed(tasks.length, name); return Pipeline( messageSupplier: () => messageSupplier(MessageUpdateBuilder(embeds: [embed], components: [], content: null)), @@ -79,15 +79,26 @@ class Pipeline { embed: embed, ); } +} + +class Pipeline { + 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}); 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); } From c126e913b099f46de47056275ff74e243c2dbf62 Mon Sep 17 00:00:00 2001 From: Szymon Uglis Date: Sun, 13 Oct 2024 20:39:58 +0200 Subject: [PATCH 05/11] Improve pipelines API; --- lib/src/commands/jellyfin.dart | 68 ++++++++++++++++++++++++++++++---- lib/src/util/pipelines.dart | 18 ++++----- 2 files changed, 69 insertions(+), 17 deletions(-) diff --git a/lib/src/commands/jellyfin.dart b/lib/src/commands/jellyfin.dart index 2ac5c65..e8cf075 100644 --- a/lib/src/commands/jellyfin.dart +++ b/lib/src/commands/jellyfin.dart @@ -81,7 +81,7 @@ final jellyfin = ChatGroup( return SelectMenuOptionBuilder(label: label, value: taskInfo.id!, description: description); }, authorOnly: true); - PipelineDefinition( + Pipeline( name: selectMenuResult.name!, description: "", tasks: [ @@ -288,13 +288,9 @@ final jellyfin = ChatGroup( "edit-instance", "Edit jellyfin instance", id('jellyfin-settings-edit-instance', (InteractionChatContext context, - [@Description("Instance to use. Default selected if not provided") + @Description("Instance to use. Default selected if not provided") @UseConverter(jellyfinConfigConverter) - JellyfinConfig? config]) async { - if (config == null) { - return context.respond(MessageBuilder(content: 'Invalid jellyfin config'), level: ResponseLevel.private); - } - + JellyfinConfig config) async { final modalResponse = await context.getModal(title: "Jellyfin Instance Edit Pt. 1", components: [ TextInputBuilder( customId: "base_url", @@ -409,6 +405,62 @@ final jellyfin = ChatGroup( checks: [ jellyfinFeatureAdminCommandCheck, ]), - ]) + ]), + ChatGroup("util", "Util commands for jellyfin", children: [ + ChatCommand( + "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") + @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 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(); + }), + ) + ]), ], ); diff --git a/lib/src/util/pipelines.dart b/lib/src/util/pipelines.dart index 34b24b0..0b259d4 100644 --- a/lib/src/util/pipelines.dart +++ b/lib/src/util/pipelines.dart @@ -50,29 +50,28 @@ class InternalTask { } } -class PipelineDefinition { +class Pipeline { final String name; final String description; final List tasks; final Duration updateInterval; - PipelineDefinition( - {required this.name, required this.description, required this.tasks, required this.updateInterval}); + Pipeline({required this.name, required this.description, required this.tasks, required this.updateInterval}); - Pipeline forCreateContext({required TargetMessageSupplier messageSupplier}) { + 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); } - Pipeline forUpdateContext({required TargetUpdateMessageSupplier messageSupplier}) { + 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, @@ -81,14 +80,15 @@ class PipelineDefinition { } } -class Pipeline { +class InternalPipeline { 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}); + InternalPipeline( + {required this.messageSupplier, required this.tasks, required this.updateInterval, required this.embed}); Future execute() async { final message = await messageSupplier(); From c1562acf08ea6068f266a7115d8496babe76f863 Mon Sep 17 00:00:00 2001 From: Szymon Uglis Date: Mon, 14 Oct 2024 21:04:50 +0200 Subject: [PATCH 06/11] Rework jellyfin implementation --- bin/running_on_dart.dart | 13 +- lib/src/checks.dart | 26 - lib/src/commands/jellyfin.dart | 862 ++++++++++++------------ lib/src/converter.dart | 17 +- lib/src/models/jellyfin_config.dart | 23 +- lib/src/modules/jellyfin.dart | 212 +++--- lib/src/repository/jellyfin_config.dart | 123 ++-- lib/src/services/db.dart | 18 +- lib/src/util/jellyfin.dart | 8 +- 9 files changed, 721 insertions(+), 581 deletions(-) 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..d57c26b 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); diff --git a/lib/src/commands/jellyfin.dart b/lib/src/commands/jellyfin.dart index e8cf075..6c4b74c 100644 --- a/lib/src/commands/jellyfin.dart +++ b/lib/src/commands/jellyfin.dart @@ -23,415 +23,421 @@ Iterable spliceEmbedsForMessageBuilders(Iterable e } } -final jellyfin = ChatGroup( - "jellyfin", - "Jellyfin Testing Commands", - checks: [ - jellyfinFeatureEnabledCheck, - ], - children: [ - ChatGroup("sonarr", "Sonarr related commands", children: [ - ChatCommand( - "calendar", - "Show upcoming episodes", - id("jellyfin-sonarr-calendar", (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")); - } +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); +} - if (!client.isSonarrEnabled) { - return context.respond(MessageBuilder(content: "Sonarr not configured!")); +final jellyfin = ChatGroup("jellyfin", "Jellyfin Testing Commands", checks: [ + jellyfinFeatureEnabledCheck, +], children: [ + ChatGroup("sonarr", "Sonarr related commands", children: [ + ChatCommand( + "calendar", + "Show upcoming episodes", + id("jellyfin-sonarr-calendar", (ChatContext context, + [@Description("Instance to use. Default selected if not provided") JellyfinConfig? config]) async { + final client = await Injector.appInstance + .get() + .fetchGetSonarrClientWithFallback(originalConfig: 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 calendarItems = await client.getSonarrCalendar(end: DateTime.now().add(Duration(days: 7))); - final embeds = getSonarrCalendarEmbeds(calendarItems); - - final paginator = await pagination.builders(spliceEmbedsForMessageBuilders(embeds).toList()); - context.respond(paginator); + return context.respond(MessageBuilder(content: "Cannot login. Contact with bot admin!")); }), ), - ]), - 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")); - } - - 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(); + "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 initiationResult = await client.initiateLoginByQuickConnect(); + + final message = await context.respond(MessageBuilder( + content: "Quick Connect code: `${initiationResult.code}`. Click button after you confirm your login", + components: [ + ActionRowBuilder(components: [ + ButtonBuilder.primary( + customId: ComponentId.generate(allowedUser: context.user.id).toString(), label: "Confirm login"), + ]), + ])); + await context.getButtonPress(message); - final description = (taskInfo.description?.length ?? 0) >= 100 - ? "${taskInfo.description?.substring(0, 97)}..." - : taskInfo.description; + final finishResult = await client.finishLoginByQuickConnect(initiationResult); + if (finishResult == null) { + return context.respond(MessageBuilder(content: "Cannot login. Submitted too soon!")); + } - return SelectMenuOptionBuilder(label: label, value: taskInfo.id!, description: description); - }, authorOnly: true); + final loginResult = + await Injector.appInstance.get().login(config, finishResult, context.user.id); - 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); - } + if (loginResult) { + return context.respond(MessageBuilder(content: "Logged in successfully!")); + } - return ( - false, - "Running `${scheduledTask.name!}` - ${taskProgressFormat.format(scheduledTask.currentProgressPercentage!)}%" - ); - }), - ], - updateInterval: Duration(seconds: 2), - ) - .forUpdateContext( - messageSupplier: (messageBuilder) => context.interaction.updateOriginalResponse(messageBuilder)) - .execute(); + return context.respond(MessageBuilder(content: "Cannot login. Contact with bot admin!")); }), ), - ]), - 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( + "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( - "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")); - } - - 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); + 'add-instance', + "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", 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 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: secondModalResponse['sonarr_base_url'], + sonarrToken: secondModalResponse['sonarr_token'], + wizarrBasePath: secondModalResponse['wizarr_base_url'], + wizarrToken: secondModalResponse['wizarr_token'], + ); + + final newlyCreatedConfig = await Injector.appInstance.get().createJellyfinConfig(config); + + modalResponse + .respond(MessageBuilder(content: "Added new jellyfin instance with name: ${newlyCreatedConfig.name}")); }), checks: [ - jellyfinFeatureUserCommandCheck, + jellyfinFeatureCreateInstanceCommandCheck, ]), + // ChatCommand( + // "edit-instance", + // "Edit jellyfin instance", + // id('jellyfin-settings-edit-instance', (InteractionChatContext context, + // @Description("Instance to use. Default selected if not provided") + // @UseConverter(jellyfinConfigConverter) + // 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), + // TextInputBuilder( + // customId: "api_token", + // style: TextInputStyle.short, + // label: "API Token", + // isRequired: true, + // value: config.token), + // ]); + // + // 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']!, + // token: modalResponse['api_token']!, + // isDefault: config.isDefault, + // parentId: config.parentId, + // sonarrBasePath: secondModalResponse['sonarr_base_url'], + // sonarrToken: secondModalResponse['sonarr_token'], + // wizarrBasePath: secondModalResponse['wizarr_base_url'], + // wizarrToken: secondModalResponse['wizarr_token'], + // ); + // editedConfig.id = config.id; + // + // Injector.appInstance.get().updateClientForConfig(editedConfig); + // + // return modalResponse.respond(MessageBuilder(content: 'Successfully updated jellyfin config')); + // }), + // checks: [ + // jellyfinFeatureAdminCommandCheck, + // ]), + // 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( - "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")); - } - - 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)); - }), - checks: [ - jellyfinFeatureUserCommandCheck, - ]), - ChatGroup("settings", "Settings for jellyfin", children: [ - ChatCommand( - 'add-instance', - "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", isRequired: true), - TextInputBuilder(customId: "base_url", style: TextInputStyle.short, label: "Base Url", isRequired: true), - TextInputBuilder( - customId: "api_token", style: TextInputStyle.short, label: "API Token", isRequired: true), - TextInputBuilder(customId: "is_default", style: TextInputStyle.short, label: "Is Default (True/False)"), - 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']!, - token: modalResponse['api_token']!, - isDefault: modalResponse['is_default']?.toLowerCase() == 'true', - parentId: modalResponse.guild?.id ?? modalResponse.user.id, - sonarrBasePath: modalResponse['sonarr_base_url'], - sonarrToken: modalResponse['sonarr_token'], - wizarrBasePath: modalResponse['wizarr_base_url'], - wizarrToken: modalResponse['wizarr_token'], - ); - - final newlyCreatedConfig = await Injector.appInstance.get().createJellyfinConfig(config); - - modalResponse - .respond(MessageBuilder(content: "Added new jellyfin instance with name: ${newlyCreatedConfig.name}")); - }), - checks: [ - jellyfinFeatureCreateInstanceCommandCheck, - ]), - ChatCommand( - "edit-instance", - "Edit jellyfin instance", - id('jellyfin-settings-edit-instance', (InteractionChatContext context, - @Description("Instance to use. Default selected if not provided") - @UseConverter(jellyfinConfigConverter) - 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), - TextInputBuilder( - customId: "api_token", - style: TextInputStyle.short, - label: "API Token", - isRequired: true, - value: config.token), - ]); - - 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']!, - token: modalResponse['api_token']!, - isDefault: config.isDefault, - parentId: config.parentId, - sonarrBasePath: secondModalResponse['sonarr_base_url'], - sonarrToken: secondModalResponse['sonarr_token'], - wizarrBasePath: secondModalResponse['wizarr_base_url'], - wizarrToken: secondModalResponse['wizarr_token'], - ); - editedConfig.id = config.id; - - Injector.appInstance.get().updateClientForConfig(editedConfig); - - return modalResponse.respond(MessageBuilder(content: 'Successfully updated jellyfin config')); - }), - checks: [ - jellyfinFeatureAdminCommandCheck, - ]), - 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( - "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") - @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 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: [ + "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); + + 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(scanMediaLibraryTask.id!), + runCallback: () => client.startTask(subtitleExtractTask.id!), updateCallback: () async { final scheduledTask = (await client.getScheduledTasks()) - .firstWhereOrNull((taskInfo) => taskInfo.id == scanMediaLibraryTask.id); + .firstWhereOrNull((taskInfo) => taskInfo.id == subtitleExtractTask.id); if (scheduledTask == null || scheduledTask.state == TaskState.idle) { return (true, null); } @@ -440,27 +446,57 @@ final jellyfin = ChatGroup( 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(); - }), - ) - ]), - ], -); + }) + ], + 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/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/models/jellyfin_config.dart b/lib/src/models/jellyfin_config.dart index 3cc4b0e..edf16f8 100644 --- a/lib/src/models/jellyfin_config.dart +++ b/lib/src/models/jellyfin_config.dart @@ -2,10 +2,29 @@ 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; @@ -21,7 +40,6 @@ class JellyfinConfig { JellyfinConfig({ required this.name, required this.basePath, - required this.token, required this.isDefault, required this.parentId, this.sonarrBasePath, @@ -36,7 +54,6 @@ class 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?, diff --git a/lib/src/modules/jellyfin.dart b/lib/src/modules/jellyfin.dart index 3b08092..5fc4186 100644 --- a/lib/src/modules/jellyfin.dart +++ b/lib/src/modules/jellyfin.dart @@ -9,34 +9,11 @@ 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); - - @override - void onRequest(RequestOptions options, RequestInterceptorHandler handler) { - options.headers['Authorization'] = 'MediaBrowser Token="$token"'; - - super.onRequest(options, handler); - } -} - -typedef JellyfinInstanceIdentity = (String? instanceName, Snowflake guildId); - -class JellyfinClientWrapper { +class AuthenticatedJellyfinClient { final Tentacle jellyfinClient; - final SonarrClient? sonarrClient; - final JellyfinConfig config; - - String get basePath => config.basePath; - String get name => config.name; - bool get isSonarrEnabled => sonarrClient != null; + final JellyfinConfigUser configUser; - JellyfinClientWrapper(this.jellyfinClient, this.config, this.sonarrClient); - - Future> getSonarrCalendar({DateTime? start, DateTime? end}) => - sonarrClient!.fetchCalendar(start: start, end: end, includeSeries: true); + AuthenticatedJellyfinClient(this.jellyfinClient, this.configUser); Future> getCurrentSessions() async { final response = await jellyfinClient.getSessionApi().getSessions(activeWithinSeconds: 15); @@ -105,102 +82,165 @@ class JellyfinClientWrapper { Future startTask(String taskId) => jellyfinClient.getScheduledTasksApi().startTask(taskId: taskId); - Uri getItemPrimaryImage(String itemId) => Uri.parse("$basePath/Items/$itemId/Images/Primary"); + Uri getItemPrimaryImage(String itemId) => Uri.parse("${configUser.config?.basePath}/Items/$itemId/Images/Primary"); - Uri getJellyfinItemUrl(String itemId) => Uri.parse("$basePath/#/details?id=$itemId"); + Uri getJellyfinItemUrl(String itemId) => Uri.parse("${configUser.config?.basePath}/#/details?id=$itemId"); } -class JellyfinModule implements RequiresInitialization { - final Map _jellyfinClients = {}; - final Map> _allowedUserRegistrations = {}; +class AnonymousJellyfinClient { + final Tentacle jellyfinClient; + final JellyfinConfig config; - final JellyfinConfigRepository _jellyfinConfigRepository = Injector.appInstance.get(); + AnonymousJellyfinClient({required this.jellyfinClient, required this.config}); - @override - Future init() async { - final defaultConfigs = await _jellyfinConfigRepository.getDefaultConfigs(); - for (final config in defaultConfigs) { - _createClientConfig(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 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!; } +} - Future deleteJellyfinConfig(JellyfinConfig config) async { - _jellyfinClients.remove( - _getClientCacheIdentifier(config.parentId.toString(), config.name, config.isDefault), - ); +class TokenAuthInterceptor extends AuthInterceptor { + final String token; - await _jellyfinConfigRepository.deleteConfig(config.id!); + TokenAuthInterceptor(this.token); + + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + options.headers['Authorization'] = 'MediaBrowser Token="$token"'; + + super.onRequest(options, handler); } +} - Future updateClientForConfig(JellyfinConfig config) async { - await _jellyfinConfigRepository.updateJellyfinConfig(config); - _createClientConfig(config); +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"'; + + super.onRequest(options, handler); } +} - Future getClient(JellyfinInstanceIdentity identity) async { - final cachedClientConfig = _getCachedClientConfig(identity); - if (cachedClientConfig != null) { - return cachedClientConfig; - } +class JellyfinConfigNotFoundException implements Exception { + final String message; + const JellyfinConfigNotFoundException(this.message); - final config = await _jellyfinConfigRepository.getByName(identity.$1!, identity.$2.toString()); - if (config == null) { - return null; - } + @override + String toString() => "JellyfinConfigNotFoundException: $message"; +} + +class JellyfinModuleV2 implements RequiresInitialization { + final JellyfinConfigRepository _jellyfinConfigRepository = Injector.appInstance.get(); + + @override + Future init() async {} - return _createClientConfig(config); + Future getJellyfinConfig(String name, Snowflake parentId) { + return _jellyfinConfigRepository.getByNameAndGuild(name, parentId.toString()); } - (bool, List) isUserAllowedForRegistration(String instanceName, Snowflake userId) { - final key = "$instanceName$userId"; + Future getJellyfinDefaultConfig(Snowflake parentId) { + return _jellyfinConfigRepository.getDefaultForParent(parentId.toString()); + } - 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(JellyfinConfig createdConfig) async { - final config = await _jellyfinConfigRepository.createJellyfinConfig(createdConfig); - 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; + } + + Future fetchJellyfinUserConfig(Snowflake userId, JellyfinConfig config) async { + final userConfig = await _jellyfinConfigRepository.getUserConfig(userId.toString(), config.id!); + userConfig?.config = config; - return config; + return userConfig; } - JellyfinClientWrapper? _getCachedClientConfig(JellyfinInstanceIdentity identity) => - _jellyfinClients[_getClientCacheIdentifier(identity.$2.toString(), identity.$1)]; + AnonymousJellyfinClient createJellyfinClientAnonymous(JellyfinConfig config) { + return AnonymousJellyfinClient( + jellyfinClient: Tentacle(basePathOverride: config.basePath, interceptors: [AnonAuthInterceptor()]), + config: config); + } + + AuthenticatedJellyfinClient createJellyfinClientAuthenticated(JellyfinConfigUser configUser) { + return AuthenticatedJellyfinClient( + Tentacle(basePathOverride: configUser.config!.basePath, interceptors: [TokenAuthInterceptor(configUser.token)]), + configUser); + } + + Future login(JellyfinConfig config, AuthenticationResult authResult, Snowflake userId) async { + await _jellyfinConfigRepository.saveJellyfinConfigUser( + JellyfinConfigUser(userId: userId, token: authResult.accessToken!, jellyfinConfigId: config.id!), + ); - JellyfinClientWrapper _createClientConfig(JellyfinConfig config) { - final client = Tentacle(basePathOverride: config.basePath, interceptors: [CustomAuthInterceptor(config.token)]); - final sonarrClient = config.sonarrBasePath != null && config.sonarrToken != null - ? SonarrClient(baseUrl: config.sonarrBasePath!, token: config.sonarrToken!) - : null; + return true; + } - final clientConfig = JellyfinClientWrapper(client, config, sonarrClient); + Future loginWithPassword(JellyfinConfig config, String username, String password, Snowflake userId) async { + final client = createJellyfinClientAnonymous(config); - _jellyfinClients[_getClientCacheIdentifier(config.parentId.toString(), config.name, config.isDefault)] = - clientConfig; + final response = await client.loginByPassword(username, password); + await _jellyfinConfigRepository.saveJellyfinConfigUser( + JellyfinConfigUser(userId: userId, token: response.accessToken!, jellyfinConfigId: config.id!), + ); - return clientConfig; + 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; } } diff --git a/lib/src/repository/jellyfin_config.dart b/lib/src/repository/jellyfin_config.dart index 810caeb..d7c28c9 100644 --- a/lib/src/repository/jellyfin_config.dart +++ b/lib/src/repository/jellyfin_config.dart @@ -7,25 +7,27 @@ 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 { + Future getDefaultForParent(String parentId) async { final result = await _database.getConnection().execute( - Sql.named('SELECT * FROM jellyfin_configs WHERE guild_id = @guildId'), - parameters: {'guildId': guildId.toString()}); + 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( Sql.named('SELECT * FROM jellyfin_configs WHERE name = @name AND guild_id = @guildId'), parameters: {'name': name, 'guildId': guildId}); @@ -37,34 +39,70 @@ class JellyfinConfigRepository { return JellyfinConfig.fromDatabaseRow(result.first.toColumnMap()); } - Future updateJellyfinConfig(JellyfinConfig config) async { - await _database.getConnection().execute(Sql.named(''' - UPDATE jellyfin_configs - SET - base_path = @base_path, - token = @token, - sonarr_base_path = @sonarr_base_path, - sonarr_token = @sonarr_token, - wizarr_base_path = @wizarr_base_path, - wizarr_token = @wizarr_token - WHERE id = @id + 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: { - 'base_path': config.basePath, - 'token': config.token, - 'sonarr_base_path': config.sonarrBasePath, - 'sonarr_token': config.sonarrToken, - 'wizarr_base_path': config.wizarrBasePath, - 'wizarr_token': config.wizarrToken, - 'id': config.id, + '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, + // token = @token, + // 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, + // 'token': config.token, + // 'sonarr_base_path': config.sonarrBasePath, + // 'sonarr_token': config.sonarrToken, + // 'wizarr_base_path': config.wizarrBasePath, + // 'wizarr_token': config.wizarrToken, + // 'id': config.id, + // }); + // } + 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, sonarr_base_path, @@ -73,25 +111,24 @@ class JellyfinConfigRepository { wizarr_token ) VALUES ( @name, - @base_path, + @basePath, @token, - @is_default, - @guild_id, - @sonarr_base_path, - @sonarr_token, - @wizarr_base_path, - @wizarr_token + @isDefault, + @parentId, + @sonarrBasePath, + @sonarrToken, + @wizarrBasePath, + @wizarrToken ) RETURNING id; '''), parameters: { 'name': config.name, 'base_path': config.basePath, - 'token': config.token, 'is_default': config.isDefault, - 'guild_id': config.parentId.toString(), - 'sonarr_base_path': config.sonarrBasePath, - 'sonarr_token': config.sonarrToken, - 'wizarr_base_path': config.wizarrBasePath, - 'wizarr_token': config.wizarrToken, + '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/services/db.dart b/lib/src/services/db.dart index 65ce7e7..95d84d5 100644 --- a/lib/src/services/db.dart +++ b/lib/src/services/db.dart @@ -154,7 +154,23 @@ class DatabaseService implements RequiresInitialization { ''') ..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/jellyfin.dart b/lib/src/util/jellyfin.dart index 46cef82..3c268e3 100644 --- a/lib/src/util/jellyfin.dart +++ b/lib/src/util/jellyfin.dart @@ -61,7 +61,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; @@ -85,7 +85,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, @@ -135,7 +135,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: []); @@ -152,7 +152,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"; From b7cf313a905640a2e481d65f5f6b2147d2304742 Mon Sep 17 00:00:00 2001 From: Szymon Uglis Date: Tue, 15 Oct 2024 19:46:34 +0200 Subject: [PATCH 07/11] Rework jellyfin implementation --- lib/src/commands/jellyfin.dart | 62 ++++++++++++++++++++------------- lib/src/commands/reminder.dart | 5 +-- lib/src/modules/jellyfin.dart | 16 +++++++-- lib/src/modules/reminder.dart | 63 +++++++++++++++++++++++++++++++--- lib/src/util/jellyfin.dart | 25 ++++++++++++-- lib/src/util/pipelines.dart | 2 +- 6 files changed, 136 insertions(+), 37 deletions(-) diff --git a/lib/src/commands/jellyfin.dart b/lib/src/commands/jellyfin.dart index 6c4b74c..5600266 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'; @@ -7,7 +9,6 @@ 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/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:tentacle/tentacle.dart'; @@ -165,29 +166,44 @@ final jellyfin = ChatGroup("jellyfin", "Jellyfin Testing Commands", checks: [ final initiationResult = await client.initiateLoginByQuickConnect(); - final message = await context.respond(MessageBuilder( - content: "Quick Connect code: `${initiationResult.code}`. Click button after you confirm your login", - components: [ - ActionRowBuilder(components: [ - ButtonBuilder.primary( - customId: ComponentId.generate(allowedUser: context.user.id).toString(), label: "Confirm login"), - ]), - ])); - await context.getButtonPress(message); - - final finishResult = await client.finishLoginByQuickConnect(initiationResult); - if (finishResult == null) { - return context.respond(MessageBuilder(content: "Cannot login. Submitted too soon!")); - } - - final loginResult = - await Injector.appInstance.get().login(config, finishResult, context.user.id); - - if (loginResult) { - return context.respond(MessageBuilder(content: "Logged in successfully!")); - } + 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; + } + + context.interaction.updateOriginalResponse(MessageUpdateBuilder(content: "Cannot login. Contact with bot admin!")); + }); + }), + ), + 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); - return context.respond(MessageBuilder(content: "Cannot login. Contact with bot admin!")); + final currentUser = await client.getCurrentUser(); + context.respond(MessageBuilder(embeds: [getUserInfoEmbed(currentUser, client)])); }), ), ], 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/modules/jellyfin.dart b/lib/src/modules/jellyfin.dart index 5fc4186..baa2f67 100644 --- a/lib/src/modules/jellyfin.dart +++ b/lib/src/modules/jellyfin.dart @@ -80,11 +80,17 @@ class AuthenticatedJellyfinClient { return response.data?.toList() ?? []; } - Future startTask(String taskId) => jellyfinClient.getScheduledTasksApi().startTask(taskId: taskId); + Future getCurrentUser() async { + final response = await jellyfinClient.getUserApi().getCurrentUser(); - Uri getItemPrimaryImage(String itemId) => Uri.parse("${configUser.config?.basePath}/Items/$itemId/Images/Primary"); + 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 { @@ -108,6 +114,12 @@ class AnonymousJellyfinClient { 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: diff --git a/lib/src/modules/reminder.dart b/lib/src/modules/reminder.dart index a89ccbf..6c4b48f 100644 --- a/lib/src/modules/reminder.dart +++ b/lib/src/modules/reminder.dart @@ -34,6 +34,29 @@ 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 +119,15 @@ 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 +136,32 @@ 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) { @@ -131,7 +184,7 @@ class ReminderModule implements RequiresInitialization { return event.interaction.respond( MessageBuilder( content: - "Reminder extended ${customId.duration.inMinutes} minutes. Will trigger at: ${newReminder.triggerAt.format(TimestampStyle.longDateTime)}."), + "Reminder extended ${customId.duration.inMinutes} minutes. Will trigger at: ${newReminder.triggerAt.format(TimestampStyle.longDateTime)}."), isEphemeral: true); } @@ -151,6 +204,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/util/jellyfin.dart b/lib/src/util/jellyfin.dart index 3c268e3..a3beb91 100644 --- a/lib/src/util/jellyfin.dart +++ b/lib/src/util/jellyfin.dart @@ -16,6 +16,9 @@ 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; @@ -35,8 +38,7 @@ Iterable getSonarrCalendarEmbeds(Iterable calendarIt fields: [ EmbedFieldBuilder( name: "Air date", - value: - "${item.airDateUtc.format(TimestampStyle.shortDateTime)} (${item.airDateUtc.format(TimestampStyle.relativeTime)})", + value: formatShortDateTimeWithRelative(item.airDateUtc), isInline: false), EmbedFieldBuilder(name: "Avg runtime", value: "${item.series.runtime} mins", isInline: true), ], @@ -48,7 +50,7 @@ Iterable getSonarrCalendarEmbeds(Iterable calendarIt 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); @@ -205,3 +207,20 @@ EmbedBuilder? buildMediaEmbedBuilder(BaseItemDto item, AuthenticatedJellyfinClie 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) + ], + ); +} \ No newline at end of file diff --git a/lib/src/util/pipelines.dart b/lib/src/util/pipelines.dart index 0b259d4..326a009 100644 --- a/lib/src/util/pipelines.dart +++ b/lib/src/util/pipelines.dart @@ -37,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) { From 34eacad5e273f9e697443eb0fce17ae347a55672 Mon Sep 17 00:00:00 2001 From: Szymon Uglis Date: Thu, 17 Oct 2024 01:27:56 +0200 Subject: [PATCH 08/11] Implement wizarr client --- lib/src/commands/jellyfin.dart | 284 ++++++++++++++++-------- lib/src/external/wizarr.dart | 169 ++++++++++++++ lib/src/modules/jellyfin.dart | 19 ++ lib/src/repository/jellyfin_config.dart | 40 ++-- lib/src/util/jellyfin.dart | 13 +- lib/src/util/util.dart | 17 ++ 6 files changed, 430 insertions(+), 112 deletions(-) create mode 100644 lib/src/external/wizarr.dart diff --git a/lib/src/commands/jellyfin.dart b/lib/src/commands/jellyfin.dart index 5600266..b060657 100644 --- a/lib/src/commands/jellyfin.dart +++ b/lib/src/commands/jellyfin.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:collection/collection.dart'; +import 'package:human_duration_parser/human_duration_parser.dart'; import 'package:injector/injector.dart'; import 'package:intl/intl.dart'; import 'package:nyxx/nyxx.dart'; @@ -8,22 +9,37 @@ 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/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"); -// TODO: use it when fetching data from modal -// String? _valueOrNull(String value) => value.trim().isEmpty ? null : value.trim(); - 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; +} + +String? valueOrNullIfNotDefault(String? value, [String ifNotDefault = 'Unlimited']) { + if (value == ifNotDefault) { + return null; + } + + return valueOrNull(value); +} + Future getJellyfinClient(JellyfinConfigUser? config, ChatContext context) async { config ??= await Injector.appInstance .get() @@ -34,15 +50,113 @@ Future getJellyfinClient(JellyfinConfigUser? config 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") JellyfinConfigUser? config]) async { + final client = await Injector.appInstance + .get() + .fetchGetWizarrClientWithFallback(originalConfig: config?.config, parentId: context.guild?.id ?? context.user.id); + + final message = await context.respond(getWizarrInvitationCodeRedeemMessage(code, client, context.user.id), level: ResponseLevel.private); + + await context.getButtonPress(message); + final modalResult = await context.getModal(title: "Redeem wizarr code", components: [ + TextInputBuilder(customId: "username", style: TextInputStyle.short, label: "Username", isRequired: true), + TextInputBuilder(customId: "password", style: TextInputStyle.short, label: "Password", isRequired: true), + TextInputBuilder(customId: "email", style: TextInputStyle.short, label: "Email", isRequired: true), + ]); + final redeemResult = await client.validateInvitation(code, modalResult['username']!, modalResult['password']!, modalResult['email']!); + + context.respond( + MessageBuilder( + content: "Invitation redeemed (username: ${redeemResult.username})", + components: [ + ActionRowBuilder(components: [ + ButtonBuilder.link(url: Uri.parse(config!.config!.basePath), label: "Go to Jellyfin"), + ButtonBuilder.link(url: Uri.parse('https://jellyfin.org/downloads'), label: "Download Jellyfin client"), + ]) + ] + ) + ); + }), + ), + 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(); + + 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 wizarrClient = await Injector.appInstance.get().fetchGetWizarrClientWithFallback(originalConfig: jellyfinClient.configUser.config!, parentId: context.guild?.id ?? context.user.id); + + final librariesMap = Map.fromEntries((await wizarrClient.getAvailableLibraries()).map((library) => MapEntry(library.name, library.id))); + + 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'), + // TextInputBuilder(customId: "libraries", style: TextInputStyle.short, label: "Allowed Libraries", isRequired: true, value: librariesMap.entries.map((entry) => entry.key).join(",")), + ]); + + 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 createInvitationRequest = CreateInvitationRequest( + code: firstModal['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) { + final messageToUserSent = user != null ? ' Message to user ${user.mention} sent.' : ''; + 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( "calendar", "Show upcoming episodes", id("jellyfin-sonarr-calendar", (ChatContext context, - [@Description("Instance to use. Default selected if not provided") JellyfinConfig? config]) async { + [@Description("Instance to use. Default selected if not provided") JellyfinConfigUser? config]) async { final client = await Injector.appInstance .get() - .fetchGetSonarrClientWithFallback(originalConfig: config, parentId: context.guild?.id ?? context.user.id); + .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); @@ -282,10 +396,10 @@ final jellyfin = ChatGroup("jellyfin", "Jellyfin Testing Commands", checks: [ basePath: modalResponse['base_url']!, isDefault: modalResponse['is_default']?.toLowerCase() == 'true', parentId: modalResponse.guild?.id ?? modalResponse.user.id, - sonarrBasePath: secondModalResponse['sonarr_base_url'], - sonarrToken: secondModalResponse['sonarr_token'], - wizarrBasePath: secondModalResponse['wizarr_base_url'], - wizarrToken: secondModalResponse['wizarr_token'], + sonarrBasePath: valueOrNull(secondModalResponse['sonarr_base_url']), + sonarrToken: valueOrNull(secondModalResponse['sonarr_token']), + wizarrBasePath: valueOrNull(secondModalResponse['wizarr_base_url']), + wizarrToken: valueOrNull(secondModalResponse['wizarr_token']), ); final newlyCreatedConfig = await Injector.appInstance.get().createJellyfinConfig(config); @@ -296,86 +410,76 @@ final jellyfin = ChatGroup("jellyfin", "Jellyfin Testing Commands", checks: [ checks: [ jellyfinFeatureCreateInstanceCommandCheck, ]), - // ChatCommand( - // "edit-instance", - // "Edit jellyfin instance", - // id('jellyfin-settings-edit-instance', (InteractionChatContext context, - // @Description("Instance to use. Default selected if not provided") - // @UseConverter(jellyfinConfigConverter) - // 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), - // TextInputBuilder( - // customId: "api_token", - // style: TextInputStyle.short, - // label: "API Token", - // isRequired: true, - // value: config.token), - // ]); - // - // 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']!, - // token: modalResponse['api_token']!, - // isDefault: config.isDefault, - // parentId: config.parentId, - // sonarrBasePath: secondModalResponse['sonarr_base_url'], - // sonarrToken: secondModalResponse['sonarr_token'], - // wizarrBasePath: secondModalResponse['wizarr_base_url'], - // wizarrToken: secondModalResponse['wizarr_token'], - // ); - // editedConfig.id = config.id; - // - // Injector.appInstance.get().updateClientForConfig(editedConfig); - // - // return modalResponse.respond(MessageBuilder(content: 'Successfully updated jellyfin config')); - // }), - // checks: [ - // jellyfinFeatureAdminCommandCheck, - // ]), + ChatCommand( + "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: [ + jellyfinFeatureCreateInstanceCommandCheck, + ]), // ChatCommand( // "transfer-config", // "Transfers jellyfin instance config to another guild", @@ -515,4 +619,4 @@ final jellyfin = ChatGroup("jellyfin", "Jellyfin Testing Commands", checks: [ }), ), ]), -]); +]); \ No newline at end of file diff --git a/lib/src/external/wizarr.dart b/lib/src/external/wizarr.dart new file mode 100644 index 0000000..b46f73b --- /dev/null +++ b/lib/src/external/wizarr.dart @@ -0,0 +1,169 @@ +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; + + WizarrClient({required this.baseUrl, required this.token}); + + 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); +} \ No newline at end of file diff --git a/lib/src/modules/jellyfin.dart b/lib/src/modules/jellyfin.dart index baa2f67..af51735 100644 --- a/lib/src/modules/jellyfin.dart +++ b/lib/src/modules/jellyfin.dart @@ -1,6 +1,7 @@ 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'; @@ -178,6 +179,20 @@ class JellyfinModuleV2 implements RequiresInitialization { return _jellyfinConfigRepository.getDefaultForParent(parentId.toString()); } + 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!); + } + Future fetchGetSonarrClientWithFallback( {required JellyfinConfig? originalConfig, required Snowflake parentId}) async { final config = originalConfig ?? await getJellyfinDefaultConfig(parentId); @@ -255,4 +270,8 @@ class JellyfinModuleV2 implements RequiresInitialization { return createdConfig; } + + Future updateJellyfinConfig(JellyfinConfig config) async { + return await _jellyfinConfigRepository.updateJellyfinConfig(config); + } } diff --git a/lib/src/repository/jellyfin_config.dart b/lib/src/repository/jellyfin_config.dart index d7c28c9..01e0ea5 100644 --- a/lib/src/repository/jellyfin_config.dart +++ b/lib/src/repository/jellyfin_config.dart @@ -76,27 +76,25 @@ class JellyfinConfigRepository { 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, - // token = @token, - // 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, - // 'token': config.token, - // 'sonarr_base_path': config.sonarrBasePath, - // 'sonarr_token': config.sonarrToken, - // 'wizarr_base_path': config.wizarrBasePath, - // 'wizarr_token': config.wizarrToken, - // 'id': config.id, - // }); - // } + 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, + }); + } Future createJellyfinConfig(JellyfinConfig config) async { final result = await _database.getConnection().execute(Sql.named(''' diff --git a/lib/src/util/jellyfin.dart b/lib/src/util/jellyfin.dart index a3beb91..205e93c 100644 --- a/lib/src/util/jellyfin.dart +++ b/lib/src/util/jellyfin.dart @@ -1,8 +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'; @@ -223,4 +225,13 @@ EmbedBuilder getUserInfoEmbed(UserDto currentUser, AuthenticatedJellyfinClient c EmbedFieldBuilder(name: "Links", value: '[Profile](${client.getUserProfile(currentUser.id!)})', isInline: false) ], ); -} \ No newline at end of file +} + +MessageBuilder getWizarrInvitationCodeRedeemMessage(String code, WizarrClient client, Snowflake userId) { + return MessageBuilder(content: "Redeem Wizarr invitation (code: $code)", components: [ + ActionRowBuilder(components: [ + ButtonBuilder.link(url: Uri.parse("${client.baseUrl}/j/$code"), label: "Redeem code in browser"), + ButtonBuilder.primary(customId: ComponentId.generate(allowedUser: userId).toString(), label: "Redeem here"), + ]) + ]); +} diff --git a/lib/src/util/util.dart b/lib/src/util/util.dart index a9e0c8f..f90de99 100644 --- a/lib/src/util/util.dart +++ b/lib/src/util/util.dart @@ -4,6 +4,7 @@ import 'dart:math'; 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 +25,19 @@ 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(); \ No newline at end of file From 08dd3baf9f48e00c6e4973001bd9473a6cecd641 Mon Sep 17 00:00:00 2001 From: Szymon Uglis Date: Thu, 17 Oct 2024 19:48:16 +0200 Subject: [PATCH 09/11] Improve wizarr redeem flow --- lib/src/commands/jellyfin.dart | 43 +++++------- lib/src/external/wizarr.dart | 3 +- lib/src/modules/jellyfin.dart | 119 ++++++++++++++++++++++++++++++++- lib/src/util/jellyfin.dart | 9 --- 4 files changed, 137 insertions(+), 37 deletions(-) diff --git a/lib/src/commands/jellyfin.dart b/lib/src/commands/jellyfin.dart index b060657..b072319 100644 --- a/lib/src/commands/jellyfin.dart +++ b/lib/src/commands/jellyfin.dart @@ -57,32 +57,12 @@ final jellyfin = ChatGroup("jellyfin", "Jellyfin Testing Commands", checks: [ ChatCommand( "redeem-invitation", "Redeem invitation code", - id("jellyfin-wizarr-redeem-invitation", (InteractionChatContext context, String code, [@Description("Instance to use. Default selected if not provided") JellyfinConfigUser? config]) async { + 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?.config, parentId: context.guild?.id ?? context.user.id); + .fetchGetWizarrClientWithFallback(originalConfig: config, parentId: context.guild?.id ?? context.user.id); - final message = await context.respond(getWizarrInvitationCodeRedeemMessage(code, client, context.user.id), level: ResponseLevel.private); - - await context.getButtonPress(message); - final modalResult = await context.getModal(title: "Redeem wizarr code", components: [ - TextInputBuilder(customId: "username", style: TextInputStyle.short, label: "Username", isRequired: true), - TextInputBuilder(customId: "password", style: TextInputStyle.short, label: "Password", isRequired: true), - TextInputBuilder(customId: "email", style: TextInputStyle.short, label: "Email", isRequired: true), - ]); - final redeemResult = await client.validateInvitation(code, modalResult['username']!, modalResult['password']!, modalResult['email']!); - - context.respond( - MessageBuilder( - content: "Invitation redeemed (username: ${redeemResult.username})", - components: [ - ActionRowBuilder(components: [ - ButtonBuilder.link(url: Uri.parse(config!.config!.basePath), label: "Go to Jellyfin"), - ButtonBuilder.link(url: Uri.parse('https://jellyfin.org/downloads'), label: "Download Jellyfin client"), - ]) - ] - ) - ); + return await context.respond(getWizarrRedeemInvitationMessageBuilder(client, code, context.user.id, context.guild?.id ?? context.user.id, client.configName), level: ResponseLevel.private); }), ), ChatCommand( @@ -126,9 +106,10 @@ final jellyfin = ChatGroup("jellyfin", "Jellyfin Testing Commands", checks: [ 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: firstModal['code']!, + code: code, expires: accountDuration, duration: expiresDuration, specificLibraries: librariesSelection.map((libraryName) => librariesMap[libraryName]).nonNulls.toList(), @@ -139,7 +120,19 @@ final jellyfin = ChatGroup("jellyfin", "Jellyfin Testing Commands", checks: [ final result = await wizarrClient.createInvitation(createInvitationRequest); if (result) { - final messageToUserSent = user != null ? ' Message to user ${user.mention} sent.' : ''; + 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); } diff --git a/lib/src/external/wizarr.dart b/lib/src/external/wizarr.dart index b46f73b..626c8d8 100644 --- a/lib/src/external/wizarr.dart +++ b/lib/src/external/wizarr.dart @@ -50,8 +50,9 @@ class CreateInvitationRequest { class WizarrClient { final String baseUrl; final String token; + final String configName; - WizarrClient({required this.baseUrl, required this.token}); + 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); diff --git a/lib/src/modules/jellyfin.dart b/lib/src/modules/jellyfin.dart index af51735..a0aa407 100644 --- a/lib/src/modules/jellyfin.dart +++ b/lib/src/modules/jellyfin.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:injector/injector.dart'; import 'package:nyxx/nyxx.dart'; import 'package:running_on_dart/src/external/sonarr.dart'; @@ -10,6 +11,52 @@ import 'package:tentacle/src/auth/auth.dart' show AuthInterceptor; import 'package:dio/dio.dart' show RequestInterceptorHandler, RequestOptions; import 'package:built_collection/built_collection.dart'; +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"), + ]) + ]); +} + +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; + } + + return ReminderRedeemWizarrInvitationId( + identifier: idParts[0], + userId: Snowflake.parse(idParts[1]), + code: idParts[2], + parentId: Snowflake.parse(idParts[3]), + configName: idParts[4], + ); + } + + @override + String toString() => "$identifier/$userId/$code/$parentId/$configName"; +} + class AuthenticatedJellyfinClient { final Tentacle jellyfinClient; final JellyfinConfigUser configUser; @@ -167,9 +214,77 @@ class JellyfinConfigNotFoundException implements Exception { class JellyfinModuleV2 implements RequiresInitialization { final JellyfinConfigRepository _jellyfinConfigRepository = Injector.appInstance.get(); + final NyxxGateway _client = Injector.appInstance.get(); @override - Future init() async {} + Future init() async { + _client.onMessageComponentInteraction + .where((event) => event.interaction.data.type == MessageComponentType.button) + .listen(_handleButtonInteractionForWizarrRedeemInvitation); + + _client.onModalSubmitInteraction.listen(_handleModalInteractionForWizarrRedeemInvitation); + } + + Future _handleModalInteractionForWizarrRedeemInvitation(InteractionCreateEvent event) async { + final customId = ReminderRedeemWizarrInvitationId.parse(event.interaction.data.customId); + if (customId == null || !customId.isModal) { + return; + } + + 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 _handleButtonInteractionForWizarrRedeemInvitation(InteractionCreateEvent event) async { + final customId = ReminderRedeemWizarrInvitationId.parse(event.interaction.data.customId); + if (customId == null || !customId.isButton) { + return; + } + + if (customId.userId != event.interaction.user?.id) { + return event.interaction.respond(MessageBuilder(content: "Invalid interaction")); + } + + 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()); @@ -190,7 +305,7 @@ class JellyfinModuleV2 implements RequiresInitialization { throw JellyfinConfigNotFoundException("Wizarr not configured!"); } - return WizarrClient(baseUrl: config.wizarrBasePath!, token: config.wizarrToken!); + return WizarrClient(baseUrl: config.wizarrBasePath!, token: config.wizarrToken!, configName: config.name); } Future fetchGetSonarrClientWithFallback( diff --git a/lib/src/util/jellyfin.dart b/lib/src/util/jellyfin.dart index 205e93c..43401bc 100644 --- a/lib/src/util/jellyfin.dart +++ b/lib/src/util/jellyfin.dart @@ -226,12 +226,3 @@ EmbedBuilder getUserInfoEmbed(UserDto currentUser, AuthenticatedJellyfinClient c ], ); } - -MessageBuilder getWizarrInvitationCodeRedeemMessage(String code, WizarrClient client, Snowflake userId) { - return MessageBuilder(content: "Redeem Wizarr invitation (code: $code)", components: [ - ActionRowBuilder(components: [ - ButtonBuilder.link(url: Uri.parse("${client.baseUrl}/j/$code"), label: "Redeem code in browser"), - ButtonBuilder.primary(customId: ComponentId.generate(allowedUser: userId).toString(), label: "Redeem here"), - ]) - ]); -} From 6d0689ba29e9a99e35b1cb15a945837b49611903 Mon Sep 17 00:00:00 2001 From: Szymon Uglis Date: Thu, 17 Oct 2024 21:53:46 +0200 Subject: [PATCH 10/11] Format --- lib/src/commands/jellyfin.dart | 238 ++++++++++++++++++++------------- lib/src/external/wizarr.dart | 61 +++++---- lib/src/modules/jellyfin.dart | 110 +++++++++------ lib/src/modules/reminder.dart | 31 ++--- lib/src/util/jellyfin.dart | 18 +-- lib/src/util/util.dart | 5 +- 6 files changed, 275 insertions(+), 188 deletions(-) diff --git a/lib/src/commands/jellyfin.dart b/lib/src/commands/jellyfin.dart index b072319..38f7ec8 100644 --- a/lib/src/commands/jellyfin.dart +++ b/lib/src/commands/jellyfin.dart @@ -50,106 +50,144 @@ Future getJellyfinClient(JellyfinConfigUser? config 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); - - 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(); - - 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 wizarrClient = await Injector.appInstance.get().fetchGetWizarrClientWithFallback(originalConfig: jellyfinClient.configUser.config!, parentId: context.guild?.id ?? context.user.id); - - final librariesMap = Map.fromEntries((await wizarrClient.getAvailableLibraries()).map((library) => MapEntry(library.name, library.id))); - - 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'), - ]); + 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); - 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') - ]) - ]), + 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(); + + if (!(currentUser.policy?.isAdministrator ?? false)) { + return context.respond( + MessageBuilder(content: "This command can use only logged jellyfin users with administrator privileges."), 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'), - // TextInputBuilder(customId: "libraries", style: TextInputStyle.short, label: "Allowed Libraries", isRequired: true, value: librariesMap.entries.map((entry) => entry.key).join(",")), - ]); - - 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 wizarrClient = await Injector.appInstance.get().fetchGetWizarrClientWithFallback( + originalConfig: jellyfinClient.configUser.config!, parentId: context.guild?.id ?? context.user.id); - 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, - )); - } + final librariesMap = Map.fromEntries( + (await wizarrClient.getAvailableLibraries()).map((library) => MapEntry(library.name, library.id))); - return context.respond(MessageBuilder(content: 'Invitation with code: `${createInvitationRequest.code}` created.$messageToUserSent'), level: ResponseLevel.private); + 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: 'Cannot create invitation. Contact administrator.'), level: ResponseLevel.private); - }), - ), - ] - ), + 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( "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 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); @@ -273,10 +311,13 @@ final jellyfin = ChatGroup("jellyfin", "Jellyfin Testing Commands", checks: [ final initiationResult = await client.initiateLoginByQuickConnect(); - await context.respond(MessageBuilder(content: "Quick Connect code: `${initiationResult.code}`. Waiting for confirmation..."), level: ResponseLevel.private); + 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")); + context.interaction + .updateOriginalResponse(MessageUpdateBuilder(content: "Cannot login. Took too long to confirm code")); timer.cancel(); } @@ -289,24 +330,28 @@ final jellyfin = ChatGroup("jellyfin", "Jellyfin Testing Commands", checks: [ final finishResult = await client.finishLoginByQuickConnect(initiationResult); if (finishResult == null) { - context.interaction.updateOriginalResponse(MessageUpdateBuilder(content: "Cannot login. Contact with bot admin!")); + context.interaction + .updateOriginalResponse(MessageUpdateBuilder(content: "Cannot login. Contact with bot admin!")); return; } - final loginResult = await Injector.appInstance.get().login(config, finishResult, context.user.id); + final loginResult = + await Injector.appInstance.get().login(config, finishResult, context.user.id); if (loginResult) { context.interaction.updateOriginalResponse(MessageUpdateBuilder(content: "Logged in successfully!")); return; } - context.interaction.updateOriginalResponse(MessageUpdateBuilder(content: "Cannot login. Contact with bot admin!")); + context.interaction + .updateOriginalResponse(MessageUpdateBuilder(content: "Cannot login. Contact with bot admin!")); }); }), ), 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 { + 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(); @@ -403,10 +448,11 @@ final jellyfin = ChatGroup("jellyfin", "Jellyfin Testing Commands", checks: [ checks: [ jellyfinFeatureCreateInstanceCommandCheck, ]), - ChatCommand( + ChatCommand( "edit-instance", "Edit jellyfin instance", - id('jellyfin-settings-edit-instance', (InteractionChatContext context, @Description("Instance to use. Default selected if not provided") JellyfinConfig config) async { + 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", @@ -612,4 +658,4 @@ final jellyfin = ChatGroup("jellyfin", "Jellyfin Testing Commands", checks: [ }), ), ]), -]); \ No newline at end of file +]); diff --git a/lib/src/external/wizarr.dart b/lib/src/external/wizarr.dart index 626c8d8..f137d84 100644 --- a/lib/src/external/wizarr.dart +++ b/lib/src/external/wizarr.dart @@ -7,7 +7,7 @@ class InvitationValidationResult { String username; InvitationValidationResult({required this.username}); - + factory InvitationValidationResult.parseJson(Map data) { return InvitationValidationResult(username: data['username']); } @@ -18,7 +18,7 @@ class Library { final String name; Library({required this.id, required this.name}); - + factory Library.parseJson(Map data) { return Library(id: data['id'], name: data['name']); } @@ -32,19 +32,25 @@ class CreateInvitationRequest { 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}); + 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), - }; + '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 { @@ -54,7 +60,8 @@ class WizarrClient { WizarrClient({required this.baseUrl, required this.token, required this.configName}); - Future validateInvitation(String code, String username, String password, String email) async { + Future validateInvitation( + String code, String username, String password, String email) async { var t = Random.secure().nextInt(100000); final tempSid = await _fetchSid(t); @@ -86,7 +93,8 @@ class WizarrClient { return body.map((element) => Library.parseJson(element as Map)).toList(); } - Future _validateInvitation(String code, String username, String password, String email, String sid) async { + 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, @@ -113,12 +121,14 @@ class WizarrClient { } 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,'); + 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"); @@ -126,7 +136,7 @@ class WizarrClient { } Future _fetchFinalSid(int t, String sid) async { - final result = await http.get(_getUri("/socket.io/", parameters: { + final result = await http.get(_getUri("/socket.io/", parameters: { "EIO": "4", "transport": "polling", "t": t.toString(), @@ -138,7 +148,7 @@ class WizarrClient { 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) { @@ -166,5 +176,6 @@ class WizarrClient { return headers; } - Uri _getUri(String path, {Map parameters = const {}}) => Uri.parse('$baseUrl$path').replace(queryParameters: parameters); -} \ No newline at end of file + Uri _getUri(String path, {Map parameters = const {}}) => + Uri.parse('$baseUrl$path').replace(queryParameters: parameters); +} diff --git a/lib/src/modules/jellyfin.dart b/lib/src/modules/jellyfin.dart index a0aa407..dc54f02 100644 --- a/lib/src/modules/jellyfin.dart +++ b/lib/src/modules/jellyfin.dart @@ -11,13 +11,21 @@ import 'package:tentacle/src/auth/auth.dart' show AuthInterceptor; import 'package:dio/dio.dart' show RequestInterceptorHandler, RequestOptions; import 'package:built_collection/built_collection.dart'; -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"), - ]) - ]); +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"), + ]) + ]); } class ReminderRedeemWizarrInvitationId { @@ -33,9 +41,20 @@ class ReminderRedeemWizarrInvitationId { 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); + 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("/"); @@ -137,8 +156,10 @@ class AuthenticatedJellyfinClient { 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'); + 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 { @@ -225,7 +246,8 @@ class JellyfinModuleV2 implements RequiresInitialization { _client.onModalSubmitInteraction.listen(_handleModalInteractionForWizarrRedeemInvitation); } - Future _handleModalInteractionForWizarrRedeemInvitation(InteractionCreateEvent event) async { + Future _handleModalInteractionForWizarrRedeemInvitation( + InteractionCreateEvent event) async { final customId = ReminderRedeemWizarrInvitationId.parse(event.interaction.data.customId); if (customId == null || !customId.isModal) { return; @@ -235,7 +257,11 @@ class JellyfinModuleV2 implements RequiresInitialization { return event.interaction.respond(MessageBuilder(content: "Invalid interaction")); } - final modalComponents = event.interaction.data.components.cast().map((row) => row.components).flattened.cast(); + 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'); @@ -244,22 +270,20 @@ class JellyfinModuleV2 implements RequiresInitialization { 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!); + 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"), - ]) - ] - ) - ); + 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 _handleButtonInteractionForWizarrRedeemInvitation(InteractionCreateEvent event) async { + Future _handleButtonInteractionForWizarrRedeemInvitation( + InteractionCreateEvent event) async { final customId = ReminderRedeemWizarrInvitationId.parse(event.interaction.data.customId); if (customId == null || !customId.isButton) { return; @@ -270,20 +294,24 @@ class JellyfinModuleV2 implements RequiresInitialization { } 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), - ]), - ] - )); + 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) { diff --git a/lib/src/modules/reminder.dart b/lib/src/modules/reminder.dart index 6c4b48f..3bf9cd3 100644 --- a/lib/src/modules/reminder.dart +++ b/lib/src/modules/reminder.dart @@ -48,9 +48,7 @@ class ReminderModuleClearComponentsId { return null; } - return ReminderModuleClearComponentsId( - userId: Snowflake.parse(idParts[1]) - ); + return ReminderModuleClearComponentsId(userId: Snowflake.parse(idParts[1])); } @override @@ -119,15 +117,16 @@ class ReminderModule implements RequiresInitialization { .toList(); final messageBuilder = MessageBuilder( - 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'), - ]) - ] - ); + 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); } @@ -146,7 +145,8 @@ class ReminderModule implements RequiresInitialization { } } - Future _handleReminderModuleClearComponentButtonAction(InteractionCreateEvent event, ReminderModuleClearComponentsId customId) async { + Future _handleReminderModuleClearComponentButtonAction( + InteractionCreateEvent event, ReminderModuleClearComponentsId customId) async { final targetUserId = event.interaction.member?.id ?? event.interaction.user?.id; if (targetUserId == null) { @@ -161,7 +161,8 @@ class ReminderModule implements RequiresInitialization { event.interaction.message?.update(MessageUpdateBuilder(components: [])); } - Future _handleReminderModuleComponentButtonAction(InteractionCreateEvent event, ReminderModuleComponentId customId) async { + Future _handleReminderModuleComponentButtonAction( + InteractionCreateEvent event, ReminderModuleComponentId customId) async { final targetUserId = event.interaction.member?.id ?? event.interaction.user?.id; if (targetUserId == null) { @@ -184,7 +185,7 @@ class ReminderModule implements RequiresInitialization { return event.interaction.respond( MessageBuilder( content: - "Reminder extended ${customId.duration.inMinutes} minutes. Will trigger at: ${newReminder.triggerAt.format(TimestampStyle.longDateTime)}."), + "Reminder extended ${customId.duration.inMinutes} minutes. Will trigger at: ${newReminder.triggerAt.format(TimestampStyle.longDateTime)}."), isEphemeral: true); } diff --git a/lib/src/util/jellyfin.dart b/lib/src/util/jellyfin.dart index 43401bc..60f6a08 100644 --- a/lib/src/util/jellyfin.dart +++ b/lib/src/util/jellyfin.dart @@ -38,10 +38,7 @@ Iterable getSonarrCalendarEmbeds(Iterable calendarIt 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: "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, @@ -212,16 +209,19 @@ EmbedBuilder? buildMediaEmbedBuilder(BaseItemDto item, AuthenticatedJellyfinClie EmbedBuilder getUserInfoEmbed(UserDto currentUser, AuthenticatedJellyfinClient client) { final thumbnail = currentUser.primaryImageTag != null - ? EmbedThumbnailBuilder(url: client.getUserImage(currentUser.id!, 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: "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/util.dart b/lib/src/util/util.dart index f90de99..81363b0 100644 --- a/lib/src/util/util.dart +++ b/lib/src/util/util.dart @@ -39,5 +39,6 @@ String? valueOrNull(String? value) { return value; } -String generateRandomString(int length) => String.fromCharCodes(Iterable.generate( - length, (_) => _chars.codeUnitAt(random.nextInt(_chars.length)))).toUpperCase(); \ No newline at end of file +String generateRandomString(int length) => + String.fromCharCodes(Iterable.generate(length, (_) => _chars.codeUnitAt(random.nextInt(_chars.length)))) + .toUpperCase(); From eb18dba65484676c40ec50bcf12f4a1423ede9ff Mon Sep 17 00:00:00 2001 From: Szymon Uglis Date: Thu, 17 Oct 2024 21:59:50 +0200 Subject: [PATCH 11/11] Clean up code --- lib/src/checks.dart | 2 +- lib/src/commands/jellyfin.dart | 15 --------------- lib/src/util/util.dart | 16 ++++++++++++++++ 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/lib/src/checks.dart b/lib/src/checks.dart index d57c26b..3c71280 100644 --- a/lib/src/checks.dart +++ b/lib/src/checks.dart @@ -46,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 38f7ec8..dafbb5f 100644 --- a/lib/src/commands/jellyfin.dart +++ b/lib/src/commands/jellyfin.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:collection/collection.dart'; -import 'package:human_duration_parser/human_duration_parser.dart'; import 'package:injector/injector.dart'; import 'package:intl/intl.dart'; import 'package:nyxx/nyxx.dart'; @@ -18,20 +17,6 @@ import 'package:tentacle/tentacle.dart'; final taskProgressFormat = NumberFormat("0.00"); -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; -} - String? valueOrNullIfNotDefault(String? value, [String ifNotDefault = 'Unlimited']) { if (value == ifNotDefault) { return null; diff --git a/lib/src/util/util.dart b/lib/src/util/util.dart index 81363b0..04e4fe3 100644 --- a/lib/src/util/util.dart +++ b/lib/src/util/util.dart @@ -1,6 +1,8 @@ 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(); @@ -42,3 +44,17 @@ String? valueOrNull(String? 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