mirror of
https://github.com/informaticker/discord-jellyfin-bot.git
synced 2024-11-25 02:51:57 +01:00
♻️ Add autocomplete for search (#100)
This commit is contained in:
parent
08f9a6889e
commit
12065e6c90
@ -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) {
|
||||
|
@ -100,6 +100,7 @@ export class DiscordVoiceService {
|
||||
}
|
||||
|
||||
playResource(resource: AudioResource<unknown>) {
|
||||
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`,
|
||||
|
@ -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<SearchHint[]> {
|
||||
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: [
|
||||
async searchItem(
|
||||
searchTerm: string,
|
||||
limit?: number,
|
||||
includeItemTypes: BaseItemKind[] = [
|
||||
BaseItemKind.Audio,
|
||||
BaseItemKind.MusicAlbum,
|
||||
BaseItemKind.Playlist,
|
||||
],
|
||||
): Promise<SearchHint[]> {
|
||||
const api = this.jellyfinService.getApi();
|
||||
const searchApi = getSearchApi(api);
|
||||
|
||||
if (includeItemTypes.length === 0) {
|
||||
this.logger.warn(
|
||||
`Included item types are empty. This may lead to unwanted results`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const { data, status } = await searchApi.get({
|
||||
searchTerm: searchTerm,
|
||||
includeItemTypes: includeItemTypes,
|
||||
limit: limit,
|
||||
});
|
||||
|
||||
if (status !== 200) {
|
||||
this.logger.error(`Jellyfin Search failed with status code ${status}`);
|
||||
this.logger.error(
|
||||
`Jellyfin Search failed with status code ${status}: ${data}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
this.logger.debug(`Found ${TotalRecordCount} results for '${searchTerm}'`);
|
||||
const { SearchHints } = data;
|
||||
|
||||
return SearchHints;
|
||||
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<JellyfinAudioPlaylist> {
|
||||
async getPlaylistitems(id: string): Promise<SearchHint[]> {
|
||||
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<JellyfinMusicAlbum> {
|
||||
async getAlbumItems(albumId: string): Promise<SearchHint[]> {
|
||||
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<SearchHint> {
|
||||
async getById(
|
||||
id: string,
|
||||
includeItemTypes: BaseItemKind[],
|
||||
): Promise<SearchHint> | 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<RemoteImageResult> {
|
||||
async getRemoteImageById(id: string, limit = 20): Promise<RemoteImageResult> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 =
|
||||
|
@ -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<InteractionReplyOptions | string> {
|
||||
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);
|
||||
|
||||
let item: SearchHint;
|
||||
if (dto.name.startsWith('native-')) {
|
||||
item = await this.jellyfinSearchService.getById(
|
||||
dto.name.replace('native-', ''),
|
||||
baseItems,
|
||||
);
|
||||
|
||||
if (parsedItems.length === 0) {
|
||||
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`,
|
||||
}),
|
||||
],
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
item = (
|
||||
await this.jellyfinSearchService.searchItem(dto.name, 1, baseItems)
|
||||
).find((x) => x);
|
||||
}
|
||||
|
||||
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(),
|
||||
}));
|
||||
|
||||
if (!item) {
|
||||
await interaction.followUp({
|
||||
embeds: [
|
||||
this.discordMessageService.buildMessage({
|
||||
title: 'Jellyfin Search Results',
|
||||
description: description,
|
||||
title: 'No results found',
|
||||
description: `- Check for any misspellings\n- Grant me access to your desired libraries\n- Avoid special characters`,
|
||||
}),
|
||||
],
|
||||
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 valueParts = interaction.values[0].split('_');
|
||||
|
||||
if (valueParts.length !== 2) {
|
||||
this.logger.error(
|
||||
`Failed to extract interaction values from [${valueParts.join(',')}]`,
|
||||
const tracks = await item.toTracks(this.jellyfinSearchService);
|
||||
const reducedDuration = tracks.reduce(
|
||||
(sum, item) => sum + item.duration,
|
||||
0,
|
||||
);
|
||||
const enqueuedCount = this.playbackService
|
||||
.getPlaylistOrDefault()
|
||||
.enqueueTracks(tracks);
|
||||
|
||||
console.log(tracks);
|
||||
|
||||
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 hints = await this.jellyfinSearchService.searchItem(
|
||||
searchQuery,
|
||||
20,
|
||||
TrackRequestDto.getBaseItemKinds(type),
|
||||
);
|
||||
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;
|
||||
|
||||
if (hints.length === 0) {
|
||||
await interaction.respond([]);
|
||||
return;
|
||||
}
|
||||
|
||||
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.respond(
|
||||
hints.map((hint) => ({
|
||||
name: hint.toString(),
|
||||
value: `native-${hint.getId()}`,
|
||||
})),
|
||||
);
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
private enqueueSingleTrack(
|
||||
jellyfinPlayable: BaseJellyfinAudioPlayable,
|
||||
remoteImageResult: RemoteImageResult,
|
||||
) {
|
||||
return this.playbackService
|
||||
.getPlaylistOrDefault()
|
||||
.enqueueTracks([
|
||||
GenericTrack.constructFromJellyfinPlayable(
|
||||
jellyfinPlayable,
|
||||
remoteImageResult,
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
@ -25,21 +25,21 @@ export class PlaylistCommand {
|
||||
async handler(@IA() interaction: CommandInteraction): Promise<void> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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<BaseJellyfinAudioPlayable>;
|
||||
|
||||
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<BaseJellyfinAudioPlayable> {
|
||||
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<BaseJellyfinAudioPlayable> {
|
||||
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<JellyfinMusicAlbum> {
|
||||
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,
|
||||
)}`;
|
||||
};
|
26
src/models/search/AlbumSearchHint.ts
Normal file
26
src/models/search/AlbumSearchHint.ts
Normal file
@ -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<GenericTrack[]> {
|
||||
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);
|
||||
}
|
||||
}
|
30
src/models/search/PlaylistSearchHint.ts
Normal file
30
src/models/search/PlaylistSearchHint.ts
Normal file
@ -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<GenericTrack[]> {
|
||||
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);
|
||||
}
|
||||
}
|
38
src/models/search/SearchHint.ts
Normal file
38
src/models/search/SearchHint.ts
Normal file
@ -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<GenericTrack[]> {
|
||||
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);
|
||||
}
|
||||
}
|
@ -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()
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
@ -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);
|
||||
};
|
Loading…
Reference in New Issue
Block a user