diff --git a/src/clients/jellyfin/jellyfin.search.service.ts b/src/clients/jellyfin/jellyfin.search.service.ts index 873bd12..dc3a56b 100644 --- a/src/clients/jellyfin/jellyfin.search.service.ts +++ b/src/clients/jellyfin/jellyfin.search.service.ts @@ -2,9 +2,11 @@ import { Injectable } from '@nestjs/common'; import { JellyfinService } from './jellyfin.service'; import { SearchHint } from '@jellyfin/sdk/lib/generated-client/models'; -import { getSearchApi } from '@jellyfin/sdk/lib/utils/api/search-api'; import { getItemsApi } from '@jellyfin/sdk/lib/utils/api/items-api'; +import { getPlaylistsApi } from '@jellyfin/sdk/lib/utils/api/playlists-api'; +import { getSearchApi } from '@jellyfin/sdk/lib/utils/api/search-api'; import { Logger } from '@nestjs/common/services'; +import { JellyfinAudioPlaylist } from '../../models/jellyfinAudioItems'; @Injectable() export class JellyfinSearchService { @@ -20,16 +22,39 @@ export class JellyfinSearchService { const searchApi = getSearchApi(api); const { data: { SearchHints, TotalRecordCount }, + status, } = await searchApi.get({ searchTerm: searchTerm, - mediaTypes: ['Audio', 'Album'], + mediaTypes: ['Audio', 'MusicAlbum', 'Playlist'], }); + if (status !== 200) { + this.logger.error(`Jellyfin Search failed with status code ${status}`); + return []; + } + this.logger.debug(`Found ${TotalRecordCount} results for '${searchTerm}'`); return SearchHints; } + async getPlaylistById(id: string): Promise { + const api = this.jellyfinService.getApi(); + const searchApi = getPlaylistsApi(api); + + const axiosResponse = await searchApi.getPlaylistItems({ + userId: this.jellyfinService.getUserId(), + playlistId: id, + }); + + if (axiosResponse.status !== 200) { + this.logger.error(`Jellyfin Search failed with status code ${status}`); + return new JellyfinAudioPlaylist(); + } + + return axiosResponse.data as JellyfinAudioPlaylist; + } + async getById(id: string): Promise { const api = this.jellyfinService.getApi(); diff --git a/src/clients/jellyfin/jellyfin.stream.builder.service.ts b/src/clients/jellyfin/jellyfin.stream.builder.service.ts index a2a61ca..41741de 100644 --- a/src/clients/jellyfin/jellyfin.stream.builder.service.ts +++ b/src/clients/jellyfin/jellyfin.stream.builder.service.ts @@ -1,15 +1,13 @@ import { Injectable, Logger } from '@nestjs/common'; import { JellyfinService } from './jellyfin.service'; -import { getUniversalAudioApi } from '@jellyfin/sdk/lib/utils/api/universal-audio-api'; - @Injectable() export class JellyfinStreamBuilderService { private readonly logger = new Logger(JellyfinStreamBuilderService.name); constructor(private readonly jellyfinService: JellyfinService) {} - async buildStreamUrl(jellyfinItemId: string, bitrate: number) { + buildStreamUrl(jellyfinItemId: string, bitrate: number) { const api = this.jellyfinService.getApi(); this.logger.debug( diff --git a/src/commands/play.comands.ts b/src/commands/play.comands.ts index dbc030a..ce23dfb 100644 --- a/src/commands/play.comands.ts +++ b/src/commands/play.comands.ts @@ -21,12 +21,14 @@ import { TrackRequestDto } from '../models/track-request.dto'; import { DiscordMessageService } from '../clients/discord/discord.message.service'; -import { formatDuration, intervalToDuration } from 'date-fns'; import { DiscordVoiceService } from '../clients/discord/discord.voice.service'; import { JellyfinStreamBuilderService } from '../clients/jellyfin/jellyfin.stream.builder.service'; +import { + BaseJellyfinAudioPlayable, + searchResultAsJellyfinAudio, +} from '../models/jellyfinAudioItems'; import { PlaybackService } from '../playback/playback.service'; import { Constants } from '../utils/constants'; -import { trimStringToFixedLength } from '../utils/stringUtils'; @Command({ name: 'play', @@ -51,8 +53,18 @@ export class PlayItemCommand executionContext: TransformedCommandExecutionContext, ): Promise { const items = await this.jellyfinSearchService.search(dto.search); + const parsedItems = await Promise.all( + items.map( + async (item) => + await searchResultAsJellyfinAudio( + this.logger, + this.jellyfinSearchService, + item, + ), + ), + ); - if (items.length < 1) { + if (parsedItems.length < 1) { return { embeds: [ this.discordMessageService.buildErrorMessage({ @@ -63,15 +75,13 @@ export class PlayItemCommand }; } - const firstItems = items.slice(0, 10); + const firstItems = parsedItems.slice(0, 10); - const lines: string[] = firstItems.map( - (item) => - `:white_small_square: ${trimStringToFixedLength( - this.markSearchTermOverlap(item.Name, dto.search), - 30, - )} [${item.Artists.join(', ')}] *(${item.Type})*`, - ); + const lines: string[] = firstItems.map((item, index) => { + let line = `${index + 1}. `; + line += item.prettyPrint(dto.search); + return line; + }); let description = 'I have found **' + @@ -87,22 +97,11 @@ export class PlayItemCommand description += '\n\n' + lines.join('\n'); - const emojiForType = (type: string) => { - switch (type) { - case 'Audio': - return '🎵'; - case 'Playlist': - return '📚'; - default: - return undefined; - } - }; - const selectOptions: { label: string; value: string; emoji?: string }[] = firstItems.map((item) => ({ - label: `${item.Name} [${item.Artists.join(', ')}]`, - value: item.Id, - emoji: emojiForType(item.Type), + label: item.prettyPrint(dto.search), + value: item.getValueId(), + emoji: item.getEmoji(), })); return { @@ -148,10 +147,6 @@ export class PlayItemCommand return; } - const item = await this.jellyfinSearchService.getById( - interaction.values[0], - ); - const guildMember = interaction.member as GuildMember; const tryResult = @@ -174,51 +169,73 @@ export class PlayItemCommand const bitrate = guildMember.voice.channel.bitrate; - const stream = await this.jellyfinStreamBuilder.buildStreamUrl( - item.Id, + const valueParts = interaction.values[0].split('_'); + const type = valueParts[0]; + const id = valueParts[1]; + + switch (type) { + case 'track': + const item = await this.jellyfinSearchService.getById(id); + const addedIndex = this.enqueueSingleTrack( + item as BaseJellyfinAudioPlayable, + bitrate, + ); + interaction.update({ + embeds: [ + this.discordMessageService.buildMessage({ + title: item.Name, + description: `Your track was added to the position ${addedIndex} in the playlist`, + }), + ], + components: [], + }); + break; + case 'playlist': + const playlist = await this.jellyfinSearchService.getPlaylistById(id); + playlist.Items.forEach((item) => { + this.enqueueSingleTrack(item as BaseJellyfinAudioPlayable, bitrate); + }); + interaction.update({ + embeds: [ + this.discordMessageService.buildMessage({ + title: `Added ${playlist.TotalRecordCount} items from your playlist`, + }), + ], + components: [], + }); + break; + default: + interaction.update({ + 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; + } + } + + private enqueueSingleTrack( + jellyfinPlayable: BaseJellyfinAudioPlayable, + bitrate: number, + ) { + const stream = this.jellyfinStreamBuilder.buildStreamUrl( + jellyfinPlayable.Id, bitrate, ); - const milliseconds = item.RunTimeTicks / 10000; + const milliseconds = jellyfinPlayable.RunTimeTicks / 10000; - const duration = formatDuration( - intervalToDuration({ - start: milliseconds, - end: 0, - }), - ); - - const addedIndex = this.playbackService.eneuqueTrack({ - jellyfinId: item.Id, - name: item.Name, + return this.playbackService.eneuqueTrack({ + jellyfinId: jellyfinPlayable.Id, + name: jellyfinPlayable.Name, durationInMilliseconds: milliseconds, streamUrl: stream, }); - - const artists = item.Artists.join(', '); - - await interaction.update({ - embeds: [ - this.discordMessageService.buildMessage({ - title: 'Jellyfin Search', - description: `**Duration**: ${duration}\n**Artists**: ${artists}\n\nTrack was added to the queue at position ${addedIndex}`, - }), - ], - components: [], - }); - } - - private 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/jellyfinAudioItems.ts b/src/models/jellyfinAudioItems.ts new file mode 100644 index 0000000..c358c19 --- /dev/null +++ b/src/models/jellyfinAudioItems.ts @@ -0,0 +1,197 @@ +import { 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'; + +import { Logger } from '@nestjs/common'; +import { v4 as uuidv4 } from 'uuid'; +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), + }, + ]; + } +} + +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 const searchResultAsJellyfinAudio = async ( + logger: Logger, + jellyfinSearchService: JellyfinSearchService, + searchHint: SearchHint, +) => { + switch (searchHint.Type) { + case 'Audio': + return await new JellyfinAudioItem().fromSearchHint( + jellyfinSearchService, + searchHint, + ); + case 'Playlist': + return await new JellyfinAudioPlaylist().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, + )}`; +};