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 { 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';
|
import { Constants } from '../../utils/constants';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -14,7 +14,6 @@ export class DiscordMessageService {
|
|||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
}): APIEmbed {
|
}): APIEmbed {
|
||||||
const date = formatRFC7231(new Date());
|
|
||||||
return this.buildMessage({
|
return this.buildMessage({
|
||||||
title: title,
|
title: title,
|
||||||
description: description,
|
description: description,
|
||||||
@ -25,7 +24,7 @@ export class DiscordMessageService {
|
|||||||
iconURL: Constants.Design.Icons.ErrorIcon,
|
iconURL: Constants.Design.Icons.ErrorIcon,
|
||||||
})
|
})
|
||||||
.setFooter({
|
.setFooter({
|
||||||
text: `${date} - Report an issue: ${Constants.Links.ReportIssue}`,
|
text: `Report this issue: ${Constants.Links.ReportIssue}`,
|
||||||
})
|
})
|
||||||
.setColor(ErrorJellyfinColor);
|
.setColor(ErrorJellyfinColor);
|
||||||
},
|
},
|
||||||
@ -43,17 +42,12 @@ export class DiscordMessageService {
|
|||||||
authorUrl?: string;
|
authorUrl?: string;
|
||||||
mixin?: (embedBuilder: EmbedBuilder) => EmbedBuilder;
|
mixin?: (embedBuilder: EmbedBuilder) => EmbedBuilder;
|
||||||
}): APIEmbed {
|
}): APIEmbed {
|
||||||
const date = formatRFC7231(new Date());
|
|
||||||
|
|
||||||
let embedBuilder = new EmbedBuilder()
|
let embedBuilder = new EmbedBuilder()
|
||||||
.setColor(DefaultJellyfinColor)
|
.setColor(DefaultJellyfinColor)
|
||||||
.setAuthor({
|
.setAuthor({
|
||||||
name: title,
|
name: title,
|
||||||
iconURL: Constants.Design.Icons.JellyfinLogo,
|
iconURL: Constants.Design.Icons.JellyfinLogo,
|
||||||
url: authorUrl,
|
url: authorUrl,
|
||||||
})
|
|
||||||
.setFooter({
|
|
||||||
text: `${date}`,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (description !== undefined && description.length >= 1) {
|
if (description !== undefined && description.length >= 1) {
|
||||||
|
@ -100,6 +100,7 @@ export class DiscordVoiceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
playResource(resource: AudioResource<unknown>) {
|
playResource(resource: AudioResource<unknown>) {
|
||||||
|
this.logger.debug(`Playing audio resource with volume ${resource.volume}`);
|
||||||
this.createAndReturnOrGetAudioPlayer().play(resource);
|
this.createAndReturnOrGetAudioPlayer().play(resource);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -198,6 +199,12 @@ export class DiscordVoiceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private createAndReturnOrGetAudioPlayer() {
|
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) {
|
if (this.audioPlayer === undefined) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Initialized new instance of AudioPlayer because it has not been defined yet`,
|
`Initialized new instance of AudioPlayer because it has not been defined yet`,
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
BaseItemKind,
|
BaseItemKind,
|
||||||
RemoteImageResult,
|
RemoteImageResult,
|
||||||
SearchHint,
|
SearchHint as JellyfinSearchHint,
|
||||||
} from '@jellyfin/sdk/lib/generated-client/models';
|
} from '@jellyfin/sdk/lib/generated-client/models';
|
||||||
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api/items-api';
|
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api/items-api';
|
||||||
import { getPlaylistsApi } from '@jellyfin/sdk/lib/utils/api/playlists-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 { Injectable } from '@nestjs/common';
|
||||||
import { Logger } from '@nestjs/common/services';
|
import { Logger } from '@nestjs/common/services';
|
||||||
|
|
||||||
import {
|
import { AlbumSearchHint } from '../../models/search/AlbumSearchHint';
|
||||||
JellyfinAudioPlaylist,
|
import { PlaylistSearchHint } from '../../models/search/PlaylistSearchHint';
|
||||||
JellyfinMusicAlbum,
|
import { SearchHint } from '../../models/search/SearchHint';
|
||||||
} from '../../models/jellyfinAudioItems';
|
|
||||||
|
|
||||||
import { JellyfinService } from './jellyfin.service';
|
import { JellyfinService } from './jellyfin.service';
|
||||||
|
|
||||||
@ -24,35 +23,50 @@ export class JellyfinSearchService {
|
|||||||
|
|
||||||
constructor(private readonly jellyfinService: JellyfinService) {}
|
constructor(private readonly jellyfinService: JellyfinService) {}
|
||||||
|
|
||||||
async search(searchTerm: string): Promise<SearchHint[]> {
|
async searchItem(
|
||||||
|
searchTerm: string,
|
||||||
|
limit?: number,
|
||||||
|
includeItemTypes: BaseItemKind[] = [
|
||||||
|
BaseItemKind.Audio,
|
||||||
|
BaseItemKind.MusicAlbum,
|
||||||
|
BaseItemKind.Playlist,
|
||||||
|
],
|
||||||
|
): Promise<SearchHint[]> {
|
||||||
const api = this.jellyfinService.getApi();
|
const api = this.jellyfinService.getApi();
|
||||||
|
|
||||||
this.logger.debug(`Searching for '${searchTerm}'`);
|
|
||||||
|
|
||||||
const searchApi = getSearchApi(api);
|
const searchApi = getSearchApi(api);
|
||||||
const {
|
|
||||||
data: { SearchHints, TotalRecordCount },
|
|
||||||
status,
|
|
||||||
} = await searchApi.get({
|
|
||||||
searchTerm: searchTerm,
|
|
||||||
includeItemTypes: [
|
|
||||||
BaseItemKind.Audio,
|
|
||||||
BaseItemKind.MusicAlbum,
|
|
||||||
BaseItemKind.Playlist,
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (status !== 200) {
|
if (includeItemTypes.length === 0) {
|
||||||
this.logger.error(`Jellyfin Search failed with status code ${status}`);
|
this.logger.warn(
|
||||||
return [];
|
`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<JellyfinAudioPlaylist> {
|
async getPlaylistitems(id: string): Promise<SearchHint[]> {
|
||||||
const api = this.jellyfinService.getApi();
|
const api = this.jellyfinService.getApi();
|
||||||
const searchApi = getPlaylistsApi(api);
|
const searchApi = getPlaylistsApi(api);
|
||||||
|
|
||||||
@ -65,13 +79,15 @@ export class JellyfinSearchService {
|
|||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Jellyfin Search failed with status code ${axiosResponse.status}`,
|
`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 api = this.jellyfinService.getApi();
|
||||||
const searchApi = getSearchApi(api);
|
const searchApi = getSearchApi(api);
|
||||||
const axiosResponse = await searchApi.get({
|
const axiosResponse = await searchApi.get({
|
||||||
@ -85,19 +101,25 @@ export class JellyfinSearchService {
|
|||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Jellyfin Search failed with status code ${axiosResponse.status}`,
|
`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 api = this.jellyfinService.getApi();
|
||||||
|
|
||||||
const searchApi = getItemsApi(api);
|
const searchApi = getItemsApi(api);
|
||||||
const { data } = await searchApi.getItems({
|
const { data } = await searchApi.getItems({
|
||||||
ids: [id],
|
ids: [id],
|
||||||
userId: this.jellyfinService.getUserId(),
|
userId: this.jellyfinService.getUserId(),
|
||||||
|
includeItemTypes: includeItemTypes,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (data.Items.length !== 1) {
|
if (data.Items.length !== 1) {
|
||||||
@ -105,10 +127,10 @@ export class JellyfinSearchService {
|
|||||||
return null;
|
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 api = this.jellyfinService.getApi();
|
||||||
const remoteImageApi = getRemoteImageApi(api);
|
const remoteImageApi = getRemoteImageApi(api);
|
||||||
|
|
||||||
@ -116,7 +138,7 @@ export class JellyfinSearchService {
|
|||||||
const axiosReponse = await remoteImageApi.getRemoteImages({
|
const axiosReponse = await remoteImageApi.getRemoteImages({
|
||||||
itemId: id,
|
itemId: id,
|
||||||
includeAllLanguages: true,
|
includeAllLanguages: true,
|
||||||
limit: 20,
|
limit: limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (axiosReponse.status !== 200) {
|
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;
|
data.getSelection = PlayNowCommand.prototype.getSelection;
|
||||||
const ids = data.getSelection();
|
const ids = data.getSelection();
|
||||||
|
|
||||||
this.logger.debug(
|
// TODO: Implement this again
|
||||||
`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);
|
|
||||||
break;
|
break;
|
||||||
case SessionMessageType[SessionMessageType.Playstate]:
|
case SessionMessageType[SessionMessageType.Playstate]:
|
||||||
const sendPlaystateCommandRequest =
|
const sendPlaystateCommandRequest =
|
||||||
|
@ -7,33 +7,26 @@ import {
|
|||||||
On,
|
On,
|
||||||
} from '@discord-nestjs/core';
|
} 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 { Injectable } from '@nestjs/common';
|
||||||
import { Logger } from '@nestjs/common/services';
|
import { Logger } from '@nestjs/common/services';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CommandInteraction,
|
CommandInteraction,
|
||||||
ComponentType,
|
|
||||||
Events,
|
Events,
|
||||||
GuildMember,
|
GuildMember,
|
||||||
Interaction,
|
Interaction,
|
||||||
InteractionReplyOptions,
|
InteractionReplyOptions,
|
||||||
} from 'discord.js';
|
} from 'discord.js';
|
||||||
|
|
||||||
import {
|
import { SearchType, TrackRequestDto } from '../models/track-request.dto';
|
||||||
BaseJellyfinAudioPlayable,
|
|
||||||
searchResultAsJellyfinAudio,
|
|
||||||
} from '../models/jellyfinAudioItems';
|
|
||||||
import { TrackRequestDto } from '../models/track-request.dto';
|
|
||||||
import { PlaybackService } from '../playback/playback.service';
|
import { PlaybackService } from '../playback/playback.service';
|
||||||
|
import { formatMillisecondsAsHumanReadable } from '../utils/timeUtils';
|
||||||
import { DiscordMessageService } from '../clients/discord/discord.message.service';
|
import { DiscordMessageService } from '../clients/discord/discord.message.service';
|
||||||
import { DiscordVoiceService } from '../clients/discord/discord.voice.service';
|
import { DiscordVoiceService } from '../clients/discord/discord.voice.service';
|
||||||
import { JellyfinSearchService } from '../clients/jellyfin/jellyfin.search.service';
|
import { JellyfinSearchService } from '../clients/jellyfin/jellyfin.search.service';
|
||||||
import { JellyfinStreamBuilderService } from '../clients/jellyfin/jellyfin.stream.builder.service';
|
import { SearchHint } from '../models/search/SearchHint';
|
||||||
import { GenericTrack } from '../models/shared/GenericTrack';
|
|
||||||
import { chooseSuitableRemoteImage } from '../utils/remoteImages/remoteImages';
|
|
||||||
import { trimStringToFixedLength } from '../utils/stringUtils/stringUtils';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@Command({
|
@Command({
|
||||||
@ -48,7 +41,6 @@ export class PlayItemCommand {
|
|||||||
private readonly discordMessageService: DiscordMessageService,
|
private readonly discordMessageService: DiscordMessageService,
|
||||||
private readonly discordVoiceService: DiscordVoiceService,
|
private readonly discordVoiceService: DiscordVoiceService,
|
||||||
private readonly playbackService: PlaybackService,
|
private readonly playbackService: PlaybackService,
|
||||||
private readonly jellyfinStreamBuilder: JellyfinStreamBuilderService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Handler()
|
@Handler()
|
||||||
@ -58,279 +50,118 @@ export class PlayItemCommand {
|
|||||||
): Promise<InteractionReplyOptions | string> {
|
): Promise<InteractionReplyOptions | string> {
|
||||||
await interaction.deferReply();
|
await interaction.deferReply();
|
||||||
|
|
||||||
const items = await this.jellyfinSearchService.search(dto.search);
|
const baseItems = TrackRequestDto.getBaseItemKinds(dto.type);
|
||||||
const parsedItems = await Promise.all(
|
|
||||||
items.map(
|
|
||||||
async (item) =>
|
|
||||||
await searchResultAsJellyfinAudio(
|
|
||||||
this.logger,
|
|
||||||
this.jellyfinSearchService,
|
|
||||||
item,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
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({
|
await interaction.followUp({
|
||||||
embeds: [
|
embeds: [
|
||||||
this.discordMessageService.buildErrorMessage({
|
this.discordMessageService.buildMessage({
|
||||||
title: 'No results for your search query found',
|
title: 'No results 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`,
|
description: `- Check for any misspellings\n- Grant me access to your desired libraries\n- Avoid special characters`,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
return;
|
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;
|
const guildMember = interaction.member as GuildMember;
|
||||||
|
|
||||||
this.logger.debug(
|
|
||||||
`Trying to join the voice channel of ${guildMember.displayName}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const tryResult =
|
const tryResult =
|
||||||
this.discordVoiceService.tryJoinChannelAndEstablishVoiceConnection(
|
this.discordVoiceService.tryJoinChannelAndEstablishVoiceConnection(
|
||||||
guildMember,
|
guildMember,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!tryResult.success) {
|
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;
|
const replyOptions = tryResult.reply as InteractionReplyOptions;
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: replyOptions.embeds,
|
embeds: replyOptions.embeds,
|
||||||
content: undefined,
|
|
||||||
components: [],
|
|
||||||
});
|
});
|
||||||
return;
|
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) {
|
const remoteImage: RemoteImageInfo | undefined = tracks
|
||||||
this.logger.error(
|
.map((x) => x.getRemoteImage())
|
||||||
`Failed to extract interaction values from [${valueParts.join(',')}]`,
|
.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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const type = valueParts[0];
|
const focusedAutoCompleteAction = interaction.options.getFocused(true);
|
||||||
const id = valueParts[1];
|
const typeIndex: number | null = interaction.options.getInteger('type');
|
||||||
|
const type = Object.values(SearchType)[typeIndex] as SearchType;
|
||||||
|
const searchQuery = focusedAutoCompleteAction.value;
|
||||||
|
|
||||||
if (!id) {
|
if (!searchQuery || searchQuery.length < 1) {
|
||||||
this.logger.warn(
|
await interaction.respond([]);
|
||||||
`Failed because ID could not be extracted from interaction`,
|
this.logger.debug(
|
||||||
|
'Did not attempt a search, because the auto-complete option was empty',
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug(
|
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) {
|
const hints = await this.jellyfinSearchService.searchItem(
|
||||||
case 'track':
|
searchQuery,
|
||||||
const item = await this.jellyfinSearchService.getById(id);
|
20,
|
||||||
const remoteImagesOfCurrentAlbum =
|
TrackRequestDto.getBaseItemKinds(type),
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
return embedBuilder.setThumbnail(trackRemoteImage.Url);
|
if (hints.length === 0) {
|
||||||
},
|
await interaction.respond([]);
|
||||||
}),
|
return;
|
||||||
],
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private enqueueSingleTrack(
|
await interaction.respond(
|
||||||
jellyfinPlayable: BaseJellyfinAudioPlayable,
|
hints.map((hint) => ({
|
||||||
remoteImageResult: RemoteImageResult,
|
name: hint.toString(),
|
||||||
) {
|
value: `native-${hint.getId()}`,
|
||||||
return this.playbackService
|
})),
|
||||||
.getPlaylistOrDefault()
|
);
|
||||||
.enqueueTracks([
|
|
||||||
GenericTrack.constructFromJellyfinPlayable(
|
|
||||||
jellyfinPlayable,
|
|
||||||
remoteImageResult,
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -25,21 +25,21 @@ export class PlaylistCommand {
|
|||||||
async handler(@IA() interaction: CommandInteraction): Promise<void> {
|
async handler(@IA() interaction: CommandInteraction): Promise<void> {
|
||||||
const playlist = this.playbackService.getPlaylistOrDefault();
|
const playlist = this.playbackService.getPlaylistOrDefault();
|
||||||
|
|
||||||
if (!playlist || playlist.tracks.length === 0) {
|
if (playlist.isEmpty()) {
|
||||||
await interaction.reply({
|
await interaction.reply({
|
||||||
embeds: [
|
embeds: [
|
||||||
this.discordMessageService.buildMessage({
|
this.discordMessageService.buildMessage({
|
||||||
title: 'Your Playlist',
|
title: 'Your Playlist',
|
||||||
description:
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tracklist = playlist.tracks
|
const tracklist = playlist.tracks
|
||||||
.slice(0, 10)
|
|
||||||
.map((track, index) => {
|
.map((track, index) => {
|
||||||
const isCurrent = track === playlist.getActiveTrack();
|
const isCurrent = track === playlist.getActiveTrack();
|
||||||
|
|
||||||
@ -52,13 +52,12 @@ export class PlaylistCommand {
|
|||||||
|
|
||||||
point += '\n';
|
point += '\n';
|
||||||
point += Constants.Design.InvisibleSpace.repeat(2);
|
point += Constants.Design.InvisibleSpace.repeat(2);
|
||||||
point += 'Duration: ';
|
|
||||||
point += formatMillisecondsAsHumanReadable(track.getDuration());
|
point += formatMillisecondsAsHumanReadable(track.getDuration());
|
||||||
|
|
||||||
return point;
|
return point;
|
||||||
})
|
})
|
||||||
|
.slice(0, 10)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
// const remoteImage = chooseSuitableRemoteImageFromTrack(playlist.getActiveTrack());
|
|
||||||
const remoteImage = undefined;
|
const remoteImage = undefined;
|
||||||
|
|
||||||
await interaction.reply({
|
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];
|
return this.tracks[this.activeTrackIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isEmpty(): boolean {
|
||||||
|
return this.tracks.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
hasActiveTrack(): boolean {
|
hasActiveTrack(): boolean {
|
||||||
return (
|
return (
|
||||||
this.activeTrackIndex !== undefined && !this.isActiveTrackOutOfSync()
|
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';
|
import { JellyfinStreamBuilderService } from '../../clients/jellyfin/jellyfin.stream.builder.service';
|
||||||
|
|
||||||
export class GenericTrack {
|
export class GenericTrack {
|
||||||
@ -45,15 +44,7 @@ export class GenericTrack {
|
|||||||
return streamBuilder.buildStreamUrl(this.id, 96000);
|
return streamBuilder.buildStreamUrl(this.id, 96000);
|
||||||
}
|
}
|
||||||
|
|
||||||
static constructFromJellyfinPlayable(
|
getRemoteImage(): RemoteImageInfo | undefined {
|
||||||
playable: BaseJellyfinAudioPlayable,
|
return this.remoteImages.Images.find((x) => true);
|
||||||
remoteImages: RemoteImageResult | undefined,
|
|
||||||
): GenericTrack {
|
|
||||||
return new GenericTrack(
|
|
||||||
playable.Id,
|
|
||||||
playable.Name,
|
|
||||||
playable.RunTimeTicks / 1000,
|
|
||||||
remoteImages,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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 {
|
export class TrackRequestDto {
|
||||||
@Param({ required: true, description: 'Track name to search' })
|
@Param({
|
||||||
search: string;
|
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 { Injectable, Logger } from '@nestjs/common';
|
||||||
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
|
|
||||||
import { GenericPlaylist } from '../models/shared/GenericPlaylist';
|
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