From 12065e6c90ac640f01f8b7a372f558372c56ff47 Mon Sep 17 00:00:00 2001 From: Manuel <30572287+manuel-rw@users.noreply.github.com> Date: Sun, 5 Mar 2023 16:58:03 +0100 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Add=20autocomplete=20for?= =?UTF-8?q?=20search=20(#100)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../discord/discord.message.service.ts | 14 +- src/clients/discord/discord.voice.service.ts | 7 + .../jellyfin/jellyfin.search.service.ts | 108 ++++-- .../jellyfin/jellyfin.websocket.service.ts | 21 +- src/commands/play.comands.ts | 323 +++++------------- src/commands/playlist.command.ts | 10 +- src/models/jellyfinAudioItems.ts | 248 -------------- src/models/search/AlbumSearchHint.ts | 26 ++ src/models/search/PlaylistSearchHint.ts | 30 ++ src/models/search/SearchHint.ts | 38 +++ src/models/shared/GenericPlaylist.ts | 4 + src/models/shared/GenericTrack.ts | 15 +- src/models/track-request.dto.ts | 39 ++- src/playback/playback.service.ts | 2 +- src/utils/remoteImages/remoteImages.spec.ts | 29 -- src/utils/remoteImages/remoteImages.ts | 25 -- 16 files changed, 305 insertions(+), 634 deletions(-) delete mode 100644 src/models/jellyfinAudioItems.ts create mode 100644 src/models/search/AlbumSearchHint.ts create mode 100644 src/models/search/PlaylistSearchHint.ts create mode 100644 src/models/search/SearchHint.ts delete mode 100644 src/utils/remoteImages/remoteImages.spec.ts delete mode 100644 src/utils/remoteImages/remoteImages.ts diff --git a/src/clients/discord/discord.message.service.ts b/src/clients/discord/discord.message.service.ts index 74ef51b..3b76577 100644 --- a/src/clients/discord/discord.message.service.ts +++ b/src/clients/discord/discord.message.service.ts @@ -1,8 +1,8 @@ import { Injectable } from '@nestjs/common'; -import { APIEmbed, EmbedBuilder } from 'discord.js'; -import { DefaultJellyfinColor, ErrorJellyfinColor } from '../../types/colors'; -import { formatRFC7231 } from 'date-fns'; +import { APIEmbed, EmbedBuilder } from 'discord.js'; + +import { DefaultJellyfinColor, ErrorJellyfinColor } from '../../types/colors'; import { Constants } from '../../utils/constants'; @Injectable() @@ -14,7 +14,6 @@ export class DiscordMessageService { title: string; description?: string; }): APIEmbed { - const date = formatRFC7231(new Date()); return this.buildMessage({ title: title, description: description, @@ -25,7 +24,7 @@ export class DiscordMessageService { iconURL: Constants.Design.Icons.ErrorIcon, }) .setFooter({ - text: `${date} - Report an issue: ${Constants.Links.ReportIssue}`, + text: `Report this issue: ${Constants.Links.ReportIssue}`, }) .setColor(ErrorJellyfinColor); }, @@ -43,17 +42,12 @@ export class DiscordMessageService { authorUrl?: string; mixin?: (embedBuilder: EmbedBuilder) => EmbedBuilder; }): APIEmbed { - const date = formatRFC7231(new Date()); - let embedBuilder = new EmbedBuilder() .setColor(DefaultJellyfinColor) .setAuthor({ name: title, iconURL: Constants.Design.Icons.JellyfinLogo, url: authorUrl, - }) - .setFooter({ - text: `${date}`, }); if (description !== undefined && description.length >= 1) { diff --git a/src/clients/discord/discord.voice.service.ts b/src/clients/discord/discord.voice.service.ts index 48a6b67..aae3abe 100644 --- a/src/clients/discord/discord.voice.service.ts +++ b/src/clients/discord/discord.voice.service.ts @@ -100,6 +100,7 @@ export class DiscordVoiceService { } playResource(resource: AudioResource) { + this.logger.debug(`Playing audio resource with volume ${resource.volume}`); this.createAndReturnOrGetAudioPlayer().play(resource); } @@ -198,6 +199,12 @@ export class DiscordVoiceService { } private createAndReturnOrGetAudioPlayer() { + if (this.voiceConnection === undefined) { + throw new Error( + 'Voice connection has not been initialized and audio player can\t be created', + ); + } + if (this.audioPlayer === undefined) { this.logger.debug( `Initialized new instance of AudioPlayer because it has not been defined yet`, diff --git a/src/clients/jellyfin/jellyfin.search.service.ts b/src/clients/jellyfin/jellyfin.search.service.ts index dfc56b6..7ec3f34 100644 --- a/src/clients/jellyfin/jellyfin.search.service.ts +++ b/src/clients/jellyfin/jellyfin.search.service.ts @@ -1,7 +1,7 @@ import { BaseItemKind, RemoteImageResult, - SearchHint, + SearchHint as JellyfinSearchHint, } from '@jellyfin/sdk/lib/generated-client/models'; import { getItemsApi } from '@jellyfin/sdk/lib/utils/api/items-api'; import { getPlaylistsApi } from '@jellyfin/sdk/lib/utils/api/playlists-api'; @@ -11,10 +11,9 @@ import { getSearchApi } from '@jellyfin/sdk/lib/utils/api/search-api'; import { Injectable } from '@nestjs/common'; import { Logger } from '@nestjs/common/services'; -import { - JellyfinAudioPlaylist, - JellyfinMusicAlbum, -} from '../../models/jellyfinAudioItems'; +import { AlbumSearchHint } from '../../models/search/AlbumSearchHint'; +import { PlaylistSearchHint } from '../../models/search/PlaylistSearchHint'; +import { SearchHint } from '../../models/search/SearchHint'; import { JellyfinService } from './jellyfin.service'; @@ -24,35 +23,50 @@ export class JellyfinSearchService { constructor(private readonly jellyfinService: JellyfinService) {} - async search(searchTerm: string): Promise { + async searchItem( + searchTerm: string, + limit?: number, + includeItemTypes: BaseItemKind[] = [ + BaseItemKind.Audio, + BaseItemKind.MusicAlbum, + BaseItemKind.Playlist, + ], + ): Promise { const api = this.jellyfinService.getApi(); - - this.logger.debug(`Searching for '${searchTerm}'`); - const searchApi = getSearchApi(api); - const { - data: { SearchHints, TotalRecordCount }, - status, - } = await searchApi.get({ - searchTerm: searchTerm, - includeItemTypes: [ - BaseItemKind.Audio, - BaseItemKind.MusicAlbum, - BaseItemKind.Playlist, - ], - }); - if (status !== 200) { - this.logger.error(`Jellyfin Search failed with status code ${status}`); - return []; + if (includeItemTypes.length === 0) { + this.logger.warn( + `Included item types are empty. This may lead to unwanted results`, + ); } - this.logger.debug(`Found ${TotalRecordCount} results for '${searchTerm}'`); + try { + const { data, status } = await searchApi.get({ + searchTerm: searchTerm, + includeItemTypes: includeItemTypes, + limit: limit, + }); - return SearchHints; + if (status !== 200) { + this.logger.error( + `Jellyfin Search failed with status code ${status}: ${data}`, + ); + return []; + } + + const { SearchHints } = data; + + return SearchHints.map((hint) => this.transformToSearchHint(hint)).filter( + (x) => x !== null, + ); + } catch (err) { + this.logger.error(`Failed to search on Jellyfin: ${err}`); + return []; + } } - async getPlaylistById(id: string): Promise { + async getPlaylistitems(id: string): Promise { const api = this.jellyfinService.getApi(); const searchApi = getPlaylistsApi(api); @@ -65,13 +79,15 @@ export class JellyfinSearchService { this.logger.error( `Jellyfin Search failed with status code ${axiosResponse.status}`, ); - return new JellyfinAudioPlaylist(); + return []; } - return axiosResponse.data as JellyfinAudioPlaylist; + return axiosResponse.data.Items.map((hint) => + SearchHint.constructFromHint(hint), + ); } - async getItemsByAlbum(albumId: string): Promise { + async getAlbumItems(albumId: string): Promise { const api = this.jellyfinService.getApi(); const searchApi = getSearchApi(api); const axiosResponse = await searchApi.get({ @@ -85,19 +101,25 @@ export class JellyfinSearchService { this.logger.error( `Jellyfin Search failed with status code ${axiosResponse.status}`, ); - return new JellyfinMusicAlbum(); + return []; } - return axiosResponse.data as JellyfinMusicAlbum; + return axiosResponse.data.SearchHints.map((hint) => + SearchHint.constructFromHint(hint), + ); } - async getById(id: string): Promise { + async getById( + id: string, + includeItemTypes: BaseItemKind[], + ): Promise | undefined { const api = this.jellyfinService.getApi(); const searchApi = getItemsApi(api); const { data } = await searchApi.getItems({ ids: [id], userId: this.jellyfinService.getUserId(), + includeItemTypes: includeItemTypes, }); if (data.Items.length !== 1) { @@ -105,10 +127,10 @@ export class JellyfinSearchService { return null; } - return data.Items[0]; + return this.transformToSearchHint(data.Items[0]); } - async getRemoteImageById(id: string): Promise { + async getRemoteImageById(id: string, limit = 20): Promise { const api = this.jellyfinService.getApi(); const remoteImageApi = getRemoteImageApi(api); @@ -116,7 +138,7 @@ export class JellyfinSearchService { const axiosReponse = await remoteImageApi.getRemoteImages({ itemId: id, includeAllLanguages: true, - limit: 20, + limit: limit, }); if (axiosReponse.status !== 200) { @@ -139,4 +161,20 @@ export class JellyfinSearchService { }; } } + + private transformToSearchHint(jellyifnHint: JellyfinSearchHint) { + switch (jellyifnHint.Type) { + case BaseItemKind[BaseItemKind.Audio]: + return SearchHint.constructFromHint(jellyifnHint); + case BaseItemKind[BaseItemKind.MusicAlbum]: + return AlbumSearchHint.constructFromHint(jellyifnHint); + case BaseItemKind[BaseItemKind.Playlist]: + return PlaylistSearchHint.constructFromHint(jellyifnHint); + default: + this.logger.warn( + `Received unexpected item type from Jellyfin search: ${jellyifnHint.Type}`, + ); + return null; + } + } } diff --git a/src/clients/jellyfin/jellyfin.websocket.service.ts b/src/clients/jellyfin/jellyfin.websocket.service.ts index 91876b7..a102965 100644 --- a/src/clients/jellyfin/jellyfin.websocket.service.ts +++ b/src/clients/jellyfin/jellyfin.websocket.service.ts @@ -104,26 +104,7 @@ export class JellyfinWebSocketService implements OnModuleDestroy { data.getSelection = PlayNowCommand.prototype.getSelection; const ids = data.getSelection(); - this.logger.debug( - `Adding ${ids.length} ids to the queue using controls from the websocket`, - ); - - const tracks = ids.map(async (id) => { - try { - const hint = await this.jellyfinSearchService.getById(id); - return { - id: id, - name: hint.Name, - duration: hint.RunTimeTicks / 10000, - remoteImages: {}, - } as GenericTrack; - } catch (err) { - this.logger.error('TODO'); - } - }); - const resolvedTracks = await Promise.all(tracks); - const playlist = this.playbackService.getPlaylistOrDefault(); - playlist.enqueueTracks(resolvedTracks); + // TODO: Implement this again break; case SessionMessageType[SessionMessageType.Playstate]: const sendPlaystateCommandRequest = diff --git a/src/commands/play.comands.ts b/src/commands/play.comands.ts index 4fc7341..e6a8cff 100644 --- a/src/commands/play.comands.ts +++ b/src/commands/play.comands.ts @@ -7,33 +7,26 @@ import { On, } from '@discord-nestjs/core'; -import { RemoteImageResult } from '@jellyfin/sdk/lib/generated-client/models'; +import { RemoteImageInfo } from '@jellyfin/sdk/lib/generated-client/models'; import { Injectable } from '@nestjs/common'; import { Logger } from '@nestjs/common/services'; import { CommandInteraction, - ComponentType, Events, GuildMember, Interaction, InteractionReplyOptions, } from 'discord.js'; -import { - BaseJellyfinAudioPlayable, - searchResultAsJellyfinAudio, -} from '../models/jellyfinAudioItems'; -import { TrackRequestDto } from '../models/track-request.dto'; +import { SearchType, TrackRequestDto } from '../models/track-request.dto'; import { PlaybackService } from '../playback/playback.service'; +import { formatMillisecondsAsHumanReadable } from '../utils/timeUtils'; import { DiscordMessageService } from '../clients/discord/discord.message.service'; import { DiscordVoiceService } from '../clients/discord/discord.voice.service'; import { JellyfinSearchService } from '../clients/jellyfin/jellyfin.search.service'; -import { JellyfinStreamBuilderService } from '../clients/jellyfin/jellyfin.stream.builder.service'; -import { GenericTrack } from '../models/shared/GenericTrack'; -import { chooseSuitableRemoteImage } from '../utils/remoteImages/remoteImages'; -import { trimStringToFixedLength } from '../utils/stringUtils/stringUtils'; +import { SearchHint } from '../models/search/SearchHint'; @Injectable() @Command({ @@ -48,7 +41,6 @@ export class PlayItemCommand { private readonly discordMessageService: DiscordMessageService, private readonly discordVoiceService: DiscordVoiceService, private readonly playbackService: PlaybackService, - private readonly jellyfinStreamBuilder: JellyfinStreamBuilderService, ) {} @Handler() @@ -58,279 +50,118 @@ export class PlayItemCommand { ): Promise { await interaction.deferReply(); - const items = await this.jellyfinSearchService.search(dto.search); - const parsedItems = await Promise.all( - items.map( - async (item) => - await searchResultAsJellyfinAudio( - this.logger, - this.jellyfinSearchService, - item, - ), - ), - ); + const baseItems = TrackRequestDto.getBaseItemKinds(dto.type); - if (parsedItems.length === 0) { + let item: SearchHint; + if (dto.name.startsWith('native-')) { + item = await this.jellyfinSearchService.getById( + dto.name.replace('native-', ''), + baseItems, + ); + } else { + item = ( + await this.jellyfinSearchService.searchItem(dto.name, 1, baseItems) + ).find((x) => x); + } + + if (!item) { await interaction.followUp({ embeds: [ - this.discordMessageService.buildErrorMessage({ - title: 'No results for your search query found', - description: `I was not able to find any matches for your query \`\`${dto.search}\`\`. Please check that I have access to the desired libraries and that your query is not misspelled`, + this.discordMessageService.buildMessage({ + title: 'No results found', + description: `- Check for any misspellings\n- Grant me access to your desired libraries\n- Avoid special characters`, }), ], }); return; } - const firstItems = parsedItems.slice(0, 10); - - const lines: string[] = firstItems.map((item, index) => { - let line = `${index + 1}. `; - line += item.prettyPrint(dto.search); - return line; - }); - - let description = - 'I have found **' + - items.length + - '** results for your search ``' + - dto.search + - '``.'; - - if (items.length > 10) { - description += - '\nSince the results exceed 10 items, I truncated them for better readability.'; - } - - description += '\n\n' + lines.join('\n'); - - const selectOptions: { label: string; value: string; emoji?: string }[] = - firstItems.map((item) => ({ - label: item.prettyPrint(dto.search).replace(/\*/g, ''), - value: item.getValueId(), - emoji: item.getEmoji(), - })); - - await interaction.followUp({ - embeds: [ - this.discordMessageService.buildMessage({ - title: 'Jellyfin Search Results', - description: description, - }), - ], - components: [ - { - type: ComponentType.ActionRow, - components: [ - { - type: ComponentType.StringSelect, - customId: 'searchItemSelect', - options: selectOptions, - }, - ], - }, - ], - }); - } - - @On(Events.InteractionCreate) - async onStringSelect(interaction: Interaction) { - if (!interaction.isStringSelectMenu()) return; - - if (interaction.customId !== 'searchItemSelect') { - return; - } - - if (interaction.values.length !== 1) { - this.logger.warn( - `Failed to process interaction select with values [${interaction.values.length}]`, - ); - return; - } - - await interaction.deferUpdate(); - - await interaction.editReply({ - embeds: [ - this.discordMessageService.buildMessage({ - title: 'Applying your selection to the queue...', - description: `This may take a moment. Please wait`, - }), - ], - components: [], - }); - const guildMember = interaction.member as GuildMember; - this.logger.debug( - `Trying to join the voice channel of ${guildMember.displayName}`, - ); - const tryResult = this.discordVoiceService.tryJoinChannelAndEstablishVoiceConnection( guildMember, ); if (!tryResult.success) { - this.logger.warn( - `Unable to process select result because the member was not in a voice channcel`, - ); const replyOptions = tryResult.reply as InteractionReplyOptions; await interaction.editReply({ embeds: replyOptions.embeds, - content: undefined, - components: [], }); return; } - this.logger.debug('Successfully joined the voice channel'); + const tracks = await item.toTracks(this.jellyfinSearchService); + const reducedDuration = tracks.reduce( + (sum, item) => sum + item.duration, + 0, + ); + const enqueuedCount = this.playbackService + .getPlaylistOrDefault() + .enqueueTracks(tracks); - const valueParts = interaction.values[0].split('_'); + console.log(tracks); - if (valueParts.length !== 2) { - this.logger.error( - `Failed to extract interaction values from [${valueParts.join(',')}]`, - ); + const remoteImage: RemoteImageInfo | undefined = tracks + .map((x) => x.getRemoteImage()) + .find((x) => true); + + await interaction.followUp({ + embeds: [ + this.discordMessageService.buildMessage({ + title: `Added ${enqueuedCount} tracks to your playlist (${formatMillisecondsAsHumanReadable( + reducedDuration, + )})`, + mixin(embedBuilder) { + if (!remoteImage) { + return embedBuilder; + } + return embedBuilder.setThumbnail(remoteImage.Url); + }, + }), + ], + }); + } + + @On(Events.InteractionCreate) + async onAutocomplete(interaction: Interaction) { + if (!interaction.isAutocomplete()) { return; } - const type = valueParts[0]; - const id = valueParts[1]; + const focusedAutoCompleteAction = interaction.options.getFocused(true); + const typeIndex: number | null = interaction.options.getInteger('type'); + const type = Object.values(SearchType)[typeIndex] as SearchType; + const searchQuery = focusedAutoCompleteAction.value; - if (!id) { - this.logger.warn( - `Failed because ID could not be extracted from interaction`, + if (!searchQuery || searchQuery.length < 1) { + await interaction.respond([]); + this.logger.debug( + 'Did not attempt a search, because the auto-complete option was empty', ); return; } this.logger.debug( - `Searching for the content using the values [${interaction.values.join( - ', ', - )}]`, + `Initiating auto-complete search for query '${searchQuery}' with type '${type}'`, ); - switch (type) { - case 'track': - const item = await this.jellyfinSearchService.getById(id); - const remoteImagesOfCurrentAlbum = - await this.jellyfinSearchService.getRemoteImageById(item.AlbumId); - const trackRemoteImage = chooseSuitableRemoteImage( - remoteImagesOfCurrentAlbum, - ); - const addedIndex = this.enqueueSingleTrack( - item as BaseJellyfinAudioPlayable, - remoteImagesOfCurrentAlbum, - ); - await interaction.editReply({ - embeds: [ - this.discordMessageService.buildMessage({ - title: item.Name, - description: `Your track was added to the position ${addedIndex} in the playlist`, - mixin(embedBuilder) { - if (trackRemoteImage === undefined) { - return embedBuilder; - } + const hints = await this.jellyfinSearchService.searchItem( + searchQuery, + 20, + TrackRequestDto.getBaseItemKinds(type), + ); - return embedBuilder.setThumbnail(trackRemoteImage.Url); - }, - }), - ], - components: [], - }); - break; - case 'album': - const album = await this.jellyfinSearchService.getItemsByAlbum(id); - const remoteImages = - await this.jellyfinSearchService.getRemoteImageById(id); - const albumRemoteImage = chooseSuitableRemoteImage(remoteImages); - album.SearchHints.forEach((item) => { - this.enqueueSingleTrack( - item as BaseJellyfinAudioPlayable, - remoteImages, - ); - }); - await interaction.editReply({ - embeds: [ - this.discordMessageService.buildMessage({ - title: `Added ${album.TotalRecordCount} items from your album`, - description: `${album.SearchHints.map((item) => - trimStringToFixedLength(item.Name, 20), - ).join(', ')}`, - mixin(embedBuilder) { - if (albumRemoteImage === undefined) { - return embedBuilder; - } - - return embedBuilder.setThumbnail(albumRemoteImage.Url); - }, - }), - ], - components: [], - }); - break; - case 'playlist': - const playlist = await this.jellyfinSearchService.getPlaylistById(id); - const addedRemoteImages: RemoteImageResult = {}; - for (let index = 0; index < playlist.Items.length; index++) { - const item = playlist.Items[index]; - const remoteImages = - await this.jellyfinSearchService.getRemoteImageById(id); - addedRemoteImages.Images.concat(remoteImages.Images); - this.enqueueSingleTrack( - item as BaseJellyfinAudioPlayable, - remoteImages, - ); - } - const bestPlaylistRemoteImage = - chooseSuitableRemoteImage(addedRemoteImages); - await interaction.editReply({ - embeds: [ - this.discordMessageService.buildMessage({ - title: `Added ${playlist.TotalRecordCount} items from your playlist`, - description: `${playlist.Items.map((item) => - trimStringToFixedLength(item.Name, 20), - ).join(', ')}`, - mixin(embedBuilder) { - if (bestPlaylistRemoteImage === undefined) { - return embedBuilder; - } - - return embedBuilder.setThumbnail(bestPlaylistRemoteImage.Url); - }, - }), - ], - components: [], - }); - break; - default: - await interaction.editReply({ - embeds: [ - this.discordMessageService.buildErrorMessage({ - title: 'Unable to process your selection', - description: `Sorry. I don't know the type you selected: \`\`${type}\`\`. Please report this bug to the developers.\n\nDebug Information: \`\`${interaction.values.join( - ', ', - )}\`\``, - }), - ], - components: [], - }); - break; + if (hints.length === 0) { + await interaction.respond([]); + return; } - } - private enqueueSingleTrack( - jellyfinPlayable: BaseJellyfinAudioPlayable, - remoteImageResult: RemoteImageResult, - ) { - return this.playbackService - .getPlaylistOrDefault() - .enqueueTracks([ - GenericTrack.constructFromJellyfinPlayable( - jellyfinPlayable, - remoteImageResult, - ), - ]); + await interaction.respond( + hints.map((hint) => ({ + name: hint.toString(), + value: `native-${hint.getId()}`, + })), + ); } } diff --git a/src/commands/playlist.command.ts b/src/commands/playlist.command.ts index 8b14c55..7908beb 100644 --- a/src/commands/playlist.command.ts +++ b/src/commands/playlist.command.ts @@ -25,21 +25,21 @@ export class PlaylistCommand { async handler(@IA() interaction: CommandInteraction): Promise { const playlist = this.playbackService.getPlaylistOrDefault(); - if (!playlist || playlist.tracks.length === 0) { + if (playlist.isEmpty()) { await interaction.reply({ embeds: [ this.discordMessageService.buildMessage({ title: 'Your Playlist', description: - 'You do not have any tracks in your playlist.\nUse the play command to add new tracks to your playlist', + 'You do not have any tracks in your playlist.\nUse the ``/play`` command to add new tracks to your playlist', }), ], + ephemeral: true, }); return; } const tracklist = playlist.tracks - .slice(0, 10) .map((track, index) => { const isCurrent = track === playlist.getActiveTrack(); @@ -52,13 +52,12 @@ export class PlaylistCommand { point += '\n'; point += Constants.Design.InvisibleSpace.repeat(2); - point += 'Duration: '; point += formatMillisecondsAsHumanReadable(track.getDuration()); return point; }) + .slice(0, 10) .join('\n'); - // const remoteImage = chooseSuitableRemoteImageFromTrack(playlist.getActiveTrack()); const remoteImage = undefined; await interaction.reply({ @@ -75,6 +74,7 @@ export class PlaylistCommand { }, }), ], + ephemeral: true, }); } diff --git a/src/models/jellyfinAudioItems.ts b/src/models/jellyfinAudioItems.ts deleted file mode 100644 index 7f94708..0000000 --- a/src/models/jellyfinAudioItems.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { - BaseItemKind, - SearchHint, -} from '@jellyfin/sdk/lib/generated-client/models'; -import { JellyfinStreamBuilderService } from '../clients/jellyfin/jellyfin.stream.builder.service'; -import { Track } from '../types/track'; -import { trimStringToFixedLength } from '../utils/stringUtils/stringUtils'; - -import { Logger } from '@nestjs/common'; -import { JellyfinSearchService } from '../clients/jellyfin/jellyfin.search.service'; - -export interface BaseJellyfinAudioPlayable { - /** - * The primary identifier of the item - */ - Id: string; - - /** - * The name of the item - */ - Name: string; - - /** - * The runtime in ticks. 10'000 ticks equal one second - */ - RunTimeTicks: number; - - fromSearchHint( - jellyfinSearchService: JellyfinSearchService, - searchHint: SearchHint, - ): Promise; - - fetchTracks( - jellyfinStreamBuilder: JellyfinStreamBuilderService, - bitrate: number, - ): Track[]; - - prettyPrint(search: string): string; - - getId(): string; - - getValueId(): string; - - getEmoji(): string; -} - -export class JellyfinAudioItem implements BaseJellyfinAudioPlayable { - Id: string; - Name: string; - RunTimeTicks: number; - ItemId: string; - - /** - * The year, when this was produced. Usually something like 2021 - */ - ProductionYear?: number; - - Album?: string; - - AlbumId?: string; - - AlbumArtist?: string; - - Artists?: string[]; - - getValueId(): string { - return `track_${this.getId()}`; - } - async fromSearchHint( - jellyfinSearchService: JellyfinSearchService, - searchHint: SearchHint, - ): Promise { - this.Id = searchHint.Id; - this.ItemId = searchHint.ItemId; - this.Name = searchHint.Name; - this.RunTimeTicks = searchHint.RunTimeTicks; - this.Album = searchHint.Album; - this.AlbumArtist = searchHint.AlbumArtist; - this.AlbumId = searchHint.AlbumId; - this.Artists = searchHint.Artists; - return this; - } - - getEmoji(): string { - return '🎵'; - } - - getId(): string { - return this.Id; - } - - prettyPrint(search: string): string { - let line = trimStringToFixedLength( - markSearchTermOverlap(this.Name, search), - 30, - ); - if (this.Artists !== undefined && this.Artists.length > 0) { - line += ` [${this.Artists.join(', ')}]`; - } - line += ` *(Audio)*`; - return line; - } - - fetchTracks( - jellyfinStreamBuilder: JellyfinStreamBuilderService, - bitrate: number, - ): Track[] { - return [ - { - name: this.Name, - durationInMilliseconds: this.RunTimeTicks / 1000, - jellyfinId: this.Id, - streamUrl: jellyfinStreamBuilder.buildStreamUrl(this.Id, bitrate), - remoteImages: {}, - }, - ]; - } -} - -export class JellyfinAudioPlaylist implements BaseJellyfinAudioPlayable { - getValueId(): string { - return `playlist_${this.getId()}`; - } - async fromSearchHint( - jellyfinSearchService: JellyfinSearchService, - searchHint: SearchHint, - ): Promise { - this.Id = searchHint.Id; - this.Name = searchHint.Name; - this.RunTimeTicks = searchHint.RunTimeTicks; - const playlist = await jellyfinSearchService.getPlaylistById(searchHint.Id); - this.Items = playlist.Items; - this.TotalRecordCount = playlist.TotalRecordCount; - return this; - } - - getEmoji(): string { - return '📚'; - } - - getId(): string { - return this.Id; - } - - prettyPrint(search: string): string { - return `${markSearchTermOverlap(this.Name, search)} (${ - this.TotalRecordCount - } items) (Playlist)`; - } - - fetchTracks( - jellyfinStreamBuilder: JellyfinStreamBuilderService, - bitrate: number, - ): Track[] { - return this.Items.flatMap((item) => - item.fetchTracks(jellyfinStreamBuilder, bitrate), - ); - } - - Id: string; - Name: string; - RunTimeTicks: number; - Items: JellyfinAudioItem[]; - TotalRecordCount: number; -} - -export class JellyfinMusicAlbum implements BaseJellyfinAudioPlayable { - Id: string; - Name: string; - RunTimeTicks: number; - SearchHints: JellyfinAudioItem[]; - TotalRecordCount: number; - - async fromSearchHint( - jellyfinSearchService: JellyfinSearchService, - searchHint: SearchHint, - ): Promise { - this.Id = searchHint.Id; - this.Name = searchHint.Name; - this.RunTimeTicks = searchHint.RunTimeTicks; - const album = await jellyfinSearchService.getItemsByAlbum(searchHint.Id); - this.SearchHints = album.SearchHints; - this.TotalRecordCount = album.TotalRecordCount; - return this; - } - fetchTracks( - jellyfinStreamBuilder: JellyfinStreamBuilderService, - bitrate: number, - ): Track[] { - return this.SearchHints.flatMap((item) => - item.fetchTracks(jellyfinStreamBuilder, bitrate), - ); - } - prettyPrint(search: string): string { - return `${markSearchTermOverlap(this.Name, search)} (${ - this.TotalRecordCount - } items) (Album)`; - } - getId(): string { - return this.Id; - } - getValueId(): string { - return `album_${this.getId()}`; - } - getEmoji(): string { - return '📀'; - } -} - -export const searchResultAsJellyfinAudio = async ( - logger: Logger, - jellyfinSearchService: JellyfinSearchService, - searchHint: SearchHint, -) => { - switch (searchHint.Type) { - case BaseItemKind[BaseItemKind.Audio]: - return await new JellyfinAudioItem().fromSearchHint( - jellyfinSearchService, - searchHint, - ); - case BaseItemKind[BaseItemKind.Playlist]: - return await new JellyfinAudioPlaylist().fromSearchHint( - jellyfinSearchService, - searchHint, - ); - case BaseItemKind[BaseItemKind.MusicAlbum]: - return await new JellyfinMusicAlbum().fromSearchHint( - jellyfinSearchService, - searchHint, - ); - default: - logger.error( - `Failed to parse Jellyfin response for item type ${searchHint.Type}`, - ); - null; - } -}; - -export const markSearchTermOverlap = (value: string, searchTerm: string) => { - const startIndex = value.indexOf(searchTerm); - const actualValue = value.substring( - startIndex, - startIndex + 1 + searchTerm.length, - ); - return `${value.substring(0, startIndex)}**${actualValue}**${value.substring( - startIndex + 1 + actualValue.length, - )}`; -}; diff --git a/src/models/search/AlbumSearchHint.ts b/src/models/search/AlbumSearchHint.ts new file mode 100644 index 0000000..b1be3a5 --- /dev/null +++ b/src/models/search/AlbumSearchHint.ts @@ -0,0 +1,26 @@ +import { SearchHint as JellyfinSearchHint } from '@jellyfin/sdk/lib/generated-client/models'; + +import { GenericTrack } from '../shared/GenericTrack'; +import { JellyfinSearchService } from '../../clients/jellyfin/jellyfin.search.service'; + +import { SearchHint } from './SearchHint'; + +export class AlbumSearchHint extends SearchHint { + override toString(): string { + return `🎶 ${this.name}`; + } + + static constructFromHint(hint: JellyfinSearchHint) { + return new AlbumSearchHint(hint.Id, hint.Name, hint.RunTimeTicks / 10000); + } + + override async toTracks( + searchService: JellyfinSearchService, + ): Promise { + const albumItems = await searchService.getAlbumItems(this.id); + const tracks = albumItems.map(async (x) => + (await x.toTracks(searchService)).find((x) => x !== null), + ); + return await Promise.all(tracks); + } +} diff --git a/src/models/search/PlaylistSearchHint.ts b/src/models/search/PlaylistSearchHint.ts new file mode 100644 index 0000000..7d501f3 --- /dev/null +++ b/src/models/search/PlaylistSearchHint.ts @@ -0,0 +1,30 @@ +import { SearchHint as JellyfinSearchHint } from '@jellyfin/sdk/lib/generated-client/models'; + +import { GenericTrack } from '../shared/GenericTrack'; +import { JellyfinSearchService } from '../../clients/jellyfin/jellyfin.search.service'; + +import { SearchHint } from './SearchHint'; + +export class PlaylistSearchHint extends SearchHint { + override toString(): string { + return `🎧 ${this.name}`; + } + + static constructFromHint(hint: JellyfinSearchHint) { + return new PlaylistSearchHint( + hint.Id, + hint.Name, + hint.RunTimeTicks / 10000, + ); + } + + override async toTracks( + searchService: JellyfinSearchService, + ): Promise { + const playlistItems = await searchService.getPlaylistitems(this.id); + const tracks = playlistItems.map(async (x) => + (await x.toTracks(searchService)).find((x) => x !== null), + ); + return await Promise.all(tracks); + } +} diff --git a/src/models/search/SearchHint.ts b/src/models/search/SearchHint.ts new file mode 100644 index 0000000..462deda --- /dev/null +++ b/src/models/search/SearchHint.ts @@ -0,0 +1,38 @@ +import { SearchHint as JellyfinSearchHint } from '@jellyfin/sdk/lib/generated-client/models'; + +import { GenericTrack } from '../shared/GenericTrack'; +import { JellyfinSearchService } from '../../clients/jellyfin/jellyfin.search.service'; + +export class SearchHint { + constructor( + protected readonly id: string, + protected readonly name: string, + protected runtimeInMilliseconds: number, + ) {} + + toString() { + return `🎵 ${this.name}`; + } + + async toTracks( + searchService: JellyfinSearchService, + ): Promise { + const remoteImages = await searchService.getRemoteImageById(this.id); + return [ + new GenericTrack( + this.id, + this.name, + this.runtimeInMilliseconds, + remoteImages, + ), + ]; + } + + getId(): string { + return this.id; + } + + static constructFromHint(hint: JellyfinSearchHint) { + return new SearchHint(hint.Id, hint.Name, hint.RunTimeTicks / 10000); + } +} diff --git a/src/models/shared/GenericPlaylist.ts b/src/models/shared/GenericPlaylist.ts index 01c547f..13724b1 100644 --- a/src/models/shared/GenericPlaylist.ts +++ b/src/models/shared/GenericPlaylist.ts @@ -30,6 +30,10 @@ export class GenericPlaylist { return this.tracks[this.activeTrackIndex]; } + isEmpty(): boolean { + return this.tracks.length === 0; + } + hasActiveTrack(): boolean { return ( this.activeTrackIndex !== undefined && !this.isActiveTrackOutOfSync() diff --git a/src/models/shared/GenericTrack.ts b/src/models/shared/GenericTrack.ts index d9ffce7..6f0b92c 100644 --- a/src/models/shared/GenericTrack.ts +++ b/src/models/shared/GenericTrack.ts @@ -1,6 +1,5 @@ -import { RemoteImageResult } from '@jellyfin/sdk/lib/generated-client/models'; +import { RemoteImageInfo, RemoteImageResult } from '@jellyfin/sdk/lib/generated-client/models'; -import { BaseJellyfinAudioPlayable } from '../jellyfinAudioItems'; import { JellyfinStreamBuilderService } from '../../clients/jellyfin/jellyfin.stream.builder.service'; export class GenericTrack { @@ -45,15 +44,7 @@ export class GenericTrack { return streamBuilder.buildStreamUrl(this.id, 96000); } - static constructFromJellyfinPlayable( - playable: BaseJellyfinAudioPlayable, - remoteImages: RemoteImageResult | undefined, - ): GenericTrack { - return new GenericTrack( - playable.Id, - playable.Name, - playable.RunTimeTicks / 1000, - remoteImages, - ); + getRemoteImage(): RemoteImageInfo | undefined { + return this.remoteImages.Images.find((x) => true); } } diff --git a/src/models/track-request.dto.ts b/src/models/track-request.dto.ts index 8daf21e..811345a 100644 --- a/src/models/track-request.dto.ts +++ b/src/models/track-request.dto.ts @@ -1,6 +1,39 @@ -import { Param } from '@discord-nestjs/core'; +import { Choice, Param, ParamType } from '@discord-nestjs/core'; + +import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client/models'; + +export enum SearchType { + Audio = 0, + AudioAlbum = 1, + Playlist = 2, +} export class TrackRequestDto { - @Param({ required: true, description: 'Track name to search' }) - search: string; + @Param({ + required: true, + description: 'Item name on Jellyfin', + autocomplete: true, + }) + name: string; + + @Choice(SearchType) + @Param({ description: 'Desired item type', type: ParamType.INTEGER }) + type: SearchType | undefined; + + static getBaseItemKinds(type: SearchType | undefined) { + switch (type) { + case SearchType.Audio: + return [BaseItemKind.Audio]; + case SearchType.Playlist: + return [BaseItemKind.Playlist]; + case SearchType.AudioAlbum: + return [BaseItemKind.MusicAlbum]; + default: + return [ + BaseItemKind.Audio, + BaseItemKind.Playlist, + BaseItemKind.MusicAlbum, + ]; + } + } } diff --git a/src/playback/playback.service.ts b/src/playback/playback.service.ts index 18a7df4..5a0b282 100644 --- a/src/playback/playback.service.ts +++ b/src/playback/playback.service.ts @@ -1,5 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; -import { EventEmitter2, OnEvent } from '@nestjs/event-emitter'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { GenericPlaylist } from '../models/shared/GenericPlaylist'; diff --git a/src/utils/remoteImages/remoteImages.spec.ts b/src/utils/remoteImages/remoteImages.spec.ts deleted file mode 100644 index 050c060..0000000 --- a/src/utils/remoteImages/remoteImages.spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { ImageType } from '@jellyfin/sdk/lib/generated-client/models'; -import { chooseSuitableRemoteImageFromTrack } from './remoteImages'; - -describe('remoteImages', () => { - it('chooseSuitableRemoteImageFromTrack', () => { - const remoteImage = chooseSuitableRemoteImageFromTrack({ - name: 'Testing Music', - durationInMilliseconds: 6969, - jellyfinId: '7384783', - remoteImages: { - Images: [ - { - Type: ImageType.Primary, - Url: 'nice picture.png', - }, - { - Type: ImageType.Screenshot, - Url: 'not nice picture', - }, - ], - }, - streamUrl: 'http://jellyfin/example-stream', - }); - - expect(remoteImage).not.toBeNull(); - expect(remoteImage.Type).toBe(ImageType.Primary); - expect(remoteImage.Url).toBe('nice picture.png'); - }); -}); diff --git a/src/utils/remoteImages/remoteImages.ts b/src/utils/remoteImages/remoteImages.ts deleted file mode 100644 index bd1b7cb..0000000 --- a/src/utils/remoteImages/remoteImages.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { - ImageType, - RemoteImageInfo, - RemoteImageResult, -} from '@jellyfin/sdk/lib/generated-client/models'; -import { Track } from '../../types/track'; - -export const chooseSuitableRemoteImage = ( - remoteImageResult: RemoteImageResult, -): RemoteImageInfo | undefined => { - const primaryImages: RemoteImageInfo[] | undefined = - remoteImageResult.Images.filter((x) => x.Type === ImageType.Primary); - - if (primaryImages.length > 0) { - return primaryImages[0]; - } - - if (remoteImageResult.Images.length > 0) { - return remoteImageResult.Images[0]; - } -}; - -export const chooseSuitableRemoteImageFromTrack = (track: Track) => { - return chooseSuitableRemoteImage(track.remoteImages); -};