♻️ Add autocomplete for search (#100)

This commit is contained in:
Manuel 2023-03-05 16:58:03 +01:00 committed by GitHub
parent 08f9a6889e
commit 12065e6c90
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 305 additions and 634 deletions

View File

@ -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) {

View File

@ -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`,

View File

@ -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[]> {
async searchItem(
searchTerm: string,
limit?: number,
includeItemTypes: BaseItemKind[] = [
BaseItemKind.Audio,
BaseItemKind.MusicAlbum,
BaseItemKind.Playlist,
],
): 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: [
BaseItemKind.Audio,
BaseItemKind.MusicAlbum,
BaseItemKind.Playlist,
],
});
if (status !== 200) {
this.logger.error(`Jellyfin Search failed with status code ${status}`);
return [];
if (includeItemTypes.length === 0) {
this.logger.warn(
`Included item types are empty. This may lead to unwanted results`,
);
}
this.logger.debug(`Found ${TotalRecordCount} results for '${searchTerm}'`);
try {
const { data, status } = await searchApi.get({
searchTerm: searchTerm,
includeItemTypes: includeItemTypes,
limit: limit,
});
return SearchHints;
if (status !== 200) {
this.logger.error(
`Jellyfin Search failed with status code ${status}: ${data}`,
);
return [];
}
const { SearchHints } = data;
return SearchHints.map((hint) => this.transformToSearchHint(hint)).filter(
(x) => x !== null,
);
} catch (err) {
this.logger.error(`Failed to search on Jellyfin: ${err}`);
return [];
}
}
async getPlaylistById(id: string): Promise<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;
}
}
}

View File

@ -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 =

View File

@ -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);
if (parsedItems.length === 0) {
let item: SearchHint;
if (dto.name.startsWith('native-')) {
item = await this.jellyfinSearchService.getById(
dto.name.replace('native-', ''),
baseItems,
);
} else {
item = (
await this.jellyfinSearchService.searchItem(dto.name, 1, baseItems)
).find((x) => x);
}
if (!item) {
await interaction.followUp({
embeds: [
this.discordMessageService.buildErrorMessage({
title: 'No results for your search query found',
description: `I was not able to find any matches for your query \`\`${dto.search}\`\`. Please check that I have access to the desired libraries and that your query is not misspelled`,
this.discordMessageService.buildMessage({
title: 'No results found',
description: `- Check for any misspellings\n- Grant me access to your desired libraries\n- Avoid special characters`,
}),
],
});
return;
}
const firstItems = parsedItems.slice(0, 10);
const lines: string[] = firstItems.map((item, index) => {
let line = `${index + 1}. `;
line += item.prettyPrint(dto.search);
return line;
});
let description =
'I have found **' +
items.length +
'** results for your search ``' +
dto.search +
'``.';
if (items.length > 10) {
description +=
'\nSince the results exceed 10 items, I truncated them for better readability.';
}
description += '\n\n' + lines.join('\n');
const selectOptions: { label: string; value: string; emoji?: string }[] =
firstItems.map((item) => ({
label: item.prettyPrint(dto.search).replace(/\*/g, ''),
value: item.getValueId(),
emoji: item.getEmoji(),
}));
await interaction.followUp({
embeds: [
this.discordMessageService.buildMessage({
title: 'Jellyfin Search Results',
description: description,
}),
],
components: [
{
type: ComponentType.ActionRow,
components: [
{
type: ComponentType.StringSelect,
customId: 'searchItemSelect',
options: selectOptions,
},
],
},
],
});
}
@On(Events.InteractionCreate)
async onStringSelect(interaction: Interaction) {
if (!interaction.isStringSelectMenu()) return;
if (interaction.customId !== 'searchItemSelect') {
return;
}
if (interaction.values.length !== 1) {
this.logger.warn(
`Failed to process interaction select with values [${interaction.values.length}]`,
);
return;
}
await interaction.deferUpdate();
await interaction.editReply({
embeds: [
this.discordMessageService.buildMessage({
title: 'Applying your selection to the queue...',
description: `This may take a moment. Please wait`,
}),
],
components: [],
});
const guildMember = interaction.member as GuildMember;
this.logger.debug(
`Trying to join the voice channel of ${guildMember.displayName}`,
);
const tryResult =
this.discordVoiceService.tryJoinChannelAndEstablishVoiceConnection(
guildMember,
);
if (!tryResult.success) {
this.logger.warn(
`Unable to process select result because the member was not in a voice channcel`,
);
const replyOptions = tryResult.reply as InteractionReplyOptions;
await interaction.editReply({
embeds: replyOptions.embeds,
content: undefined,
components: [],
});
return;
}
this.logger.debug('Successfully joined the voice channel');
const tracks = await item.toTracks(this.jellyfinSearchService);
const reducedDuration = tracks.reduce(
(sum, item) => sum + item.duration,
0,
);
const enqueuedCount = this.playbackService
.getPlaylistOrDefault()
.enqueueTracks(tracks);
const valueParts = interaction.values[0].split('_');
console.log(tracks);
if (valueParts.length !== 2) {
this.logger.error(
`Failed to extract interaction values from [${valueParts.join(',')}]`,
);
const remoteImage: RemoteImageInfo | undefined = tracks
.map((x) => x.getRemoteImage())
.find((x) => true);
await interaction.followUp({
embeds: [
this.discordMessageService.buildMessage({
title: `Added ${enqueuedCount} tracks to your playlist (${formatMillisecondsAsHumanReadable(
reducedDuration,
)})`,
mixin(embedBuilder) {
if (!remoteImage) {
return embedBuilder;
}
return embedBuilder.setThumbnail(remoteImage.Url);
},
}),
],
});
}
@On(Events.InteractionCreate)
async onAutocomplete(interaction: Interaction) {
if (!interaction.isAutocomplete()) {
return;
}
const type = valueParts[0];
const id = valueParts[1];
const focusedAutoCompleteAction = interaction.options.getFocused(true);
const typeIndex: number | null = interaction.options.getInteger('type');
const type = Object.values(SearchType)[typeIndex] as SearchType;
const searchQuery = focusedAutoCompleteAction.value;
if (!id) {
this.logger.warn(
`Failed because ID could not be extracted from interaction`,
if (!searchQuery || searchQuery.length < 1) {
await interaction.respond([]);
this.logger.debug(
'Did not attempt a search, because the auto-complete option was empty',
);
return;
}
this.logger.debug(
`Searching for the content using the values [${interaction.values.join(
', ',
)}]`,
`Initiating auto-complete search for query '${searchQuery}' with type '${type}'`,
);
switch (type) {
case 'track':
const item = await this.jellyfinSearchService.getById(id);
const remoteImagesOfCurrentAlbum =
await this.jellyfinSearchService.getRemoteImageById(item.AlbumId);
const trackRemoteImage = chooseSuitableRemoteImage(
remoteImagesOfCurrentAlbum,
);
const addedIndex = this.enqueueSingleTrack(
item as BaseJellyfinAudioPlayable,
remoteImagesOfCurrentAlbum,
);
await interaction.editReply({
embeds: [
this.discordMessageService.buildMessage({
title: item.Name,
description: `Your track was added to the position ${addedIndex} in the playlist`,
mixin(embedBuilder) {
if (trackRemoteImage === undefined) {
return embedBuilder;
}
const hints = await this.jellyfinSearchService.searchItem(
searchQuery,
20,
TrackRequestDto.getBaseItemKinds(type),
);
return embedBuilder.setThumbnail(trackRemoteImage.Url);
},
}),
],
components: [],
});
break;
case 'album':
const album = await this.jellyfinSearchService.getItemsByAlbum(id);
const remoteImages =
await this.jellyfinSearchService.getRemoteImageById(id);
const albumRemoteImage = chooseSuitableRemoteImage(remoteImages);
album.SearchHints.forEach((item) => {
this.enqueueSingleTrack(
item as BaseJellyfinAudioPlayable,
remoteImages,
);
});
await interaction.editReply({
embeds: [
this.discordMessageService.buildMessage({
title: `Added ${album.TotalRecordCount} items from your album`,
description: `${album.SearchHints.map((item) =>
trimStringToFixedLength(item.Name, 20),
).join(', ')}`,
mixin(embedBuilder) {
if (albumRemoteImage === undefined) {
return embedBuilder;
}
return embedBuilder.setThumbnail(albumRemoteImage.Url);
},
}),
],
components: [],
});
break;
case 'playlist':
const playlist = await this.jellyfinSearchService.getPlaylistById(id);
const addedRemoteImages: RemoteImageResult = {};
for (let index = 0; index < playlist.Items.length; index++) {
const item = playlist.Items[index];
const remoteImages =
await this.jellyfinSearchService.getRemoteImageById(id);
addedRemoteImages.Images.concat(remoteImages.Images);
this.enqueueSingleTrack(
item as BaseJellyfinAudioPlayable,
remoteImages,
);
}
const bestPlaylistRemoteImage =
chooseSuitableRemoteImage(addedRemoteImages);
await interaction.editReply({
embeds: [
this.discordMessageService.buildMessage({
title: `Added ${playlist.TotalRecordCount} items from your playlist`,
description: `${playlist.Items.map((item) =>
trimStringToFixedLength(item.Name, 20),
).join(', ')}`,
mixin(embedBuilder) {
if (bestPlaylistRemoteImage === undefined) {
return embedBuilder;
}
return embedBuilder.setThumbnail(bestPlaylistRemoteImage.Url);
},
}),
],
components: [],
});
break;
default:
await interaction.editReply({
embeds: [
this.discordMessageService.buildErrorMessage({
title: 'Unable to process your selection',
description: `Sorry. I don't know the type you selected: \`\`${type}\`\`. Please report this bug to the developers.\n\nDebug Information: \`\`${interaction.values.join(
', ',
)}\`\``,
}),
],
components: [],
});
break;
if (hints.length === 0) {
await interaction.respond([]);
return;
}
}
private enqueueSingleTrack(
jellyfinPlayable: BaseJellyfinAudioPlayable,
remoteImageResult: RemoteImageResult,
) {
return this.playbackService
.getPlaylistOrDefault()
.enqueueTracks([
GenericTrack.constructFromJellyfinPlayable(
jellyfinPlayable,
remoteImageResult,
),
]);
await interaction.respond(
hints.map((hint) => ({
name: hint.toString(),
value: `native-${hint.getId()}`,
})),
);
}
}

View File

@ -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,
});
}

View File

@ -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,
)}`;
};

View 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);
}
}

View 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);
}
}

View 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);
}
}

View File

@ -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()

View File

@ -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);
}
}

View File

@ -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,
];
}
}
}

View File

@ -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';

View File

@ -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');
});
});

View File

@ -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);
};