Add playlists playback

This commit is contained in:
Manuel Ruwe 2022-12-18 16:34:06 +01:00
parent 3f2b1a7b5f
commit c218c1273d
4 changed files with 310 additions and 73 deletions

View File

@ -2,9 +2,11 @@ import { Injectable } from '@nestjs/common';
import { JellyfinService } from './jellyfin.service';
import { SearchHint } from '@jellyfin/sdk/lib/generated-client/models';
import { getSearchApi } from '@jellyfin/sdk/lib/utils/api/search-api';
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api/items-api';
import { getPlaylistsApi } from '@jellyfin/sdk/lib/utils/api/playlists-api';
import { getSearchApi } from '@jellyfin/sdk/lib/utils/api/search-api';
import { Logger } from '@nestjs/common/services';
import { JellyfinAudioPlaylist } from '../../models/jellyfinAudioItems';
@Injectable()
export class JellyfinSearchService {
@ -20,16 +22,39 @@ export class JellyfinSearchService {
const searchApi = getSearchApi(api);
const {
data: { SearchHints, TotalRecordCount },
status,
} = await searchApi.get({
searchTerm: searchTerm,
mediaTypes: ['Audio', 'Album'],
mediaTypes: ['Audio', 'MusicAlbum', 'Playlist'],
});
if (status !== 200) {
this.logger.error(`Jellyfin Search failed with status code ${status}`);
return [];
}
this.logger.debug(`Found ${TotalRecordCount} results for '${searchTerm}'`);
return SearchHints;
}
async getPlaylistById(id: string): Promise<JellyfinAudioPlaylist> {
const api = this.jellyfinService.getApi();
const searchApi = getPlaylistsApi(api);
const axiosResponse = await searchApi.getPlaylistItems({
userId: this.jellyfinService.getUserId(),
playlistId: id,
});
if (axiosResponse.status !== 200) {
this.logger.error(`Jellyfin Search failed with status code ${status}`);
return new JellyfinAudioPlaylist();
}
return axiosResponse.data as JellyfinAudioPlaylist;
}
async getById(id: string): Promise<SearchHint> {
const api = this.jellyfinService.getApi();

View File

@ -1,15 +1,13 @@
import { Injectable, Logger } from '@nestjs/common';
import { JellyfinService } from './jellyfin.service';
import { getUniversalAudioApi } from '@jellyfin/sdk/lib/utils/api/universal-audio-api';
@Injectable()
export class JellyfinStreamBuilderService {
private readonly logger = new Logger(JellyfinStreamBuilderService.name);
constructor(private readonly jellyfinService: JellyfinService) {}
async buildStreamUrl(jellyfinItemId: string, bitrate: number) {
buildStreamUrl(jellyfinItemId: string, bitrate: number) {
const api = this.jellyfinService.getApi();
this.logger.debug(

View File

@ -21,12 +21,14 @@ import { TrackRequestDto } from '../models/track-request.dto';
import { DiscordMessageService } from '../clients/discord/discord.message.service';
import { formatDuration, intervalToDuration } from 'date-fns';
import { DiscordVoiceService } from '../clients/discord/discord.voice.service';
import { JellyfinStreamBuilderService } from '../clients/jellyfin/jellyfin.stream.builder.service';
import {
BaseJellyfinAudioPlayable,
searchResultAsJellyfinAudio,
} from '../models/jellyfinAudioItems';
import { PlaybackService } from '../playback/playback.service';
import { Constants } from '../utils/constants';
import { trimStringToFixedLength } from '../utils/stringUtils';
@Command({
name: 'play',
@ -51,8 +53,18 @@ export class PlayItemCommand
executionContext: TransformedCommandExecutionContext<any>,
): Promise<InteractionReplyOptions | string> {
const items = await this.jellyfinSearchService.search(dto.search);
const parsedItems = await Promise.all(
items.map(
async (item) =>
await searchResultAsJellyfinAudio(
this.logger,
this.jellyfinSearchService,
item,
),
),
);
if (items.length < 1) {
if (parsedItems.length < 1) {
return {
embeds: [
this.discordMessageService.buildErrorMessage({
@ -63,15 +75,13 @@ export class PlayItemCommand
};
}
const firstItems = items.slice(0, 10);
const firstItems = parsedItems.slice(0, 10);
const lines: string[] = firstItems.map(
(item) =>
`:white_small_square: ${trimStringToFixedLength(
this.markSearchTermOverlap(item.Name, dto.search),
30,
)} [${item.Artists.join(', ')}] *(${item.Type})*`,
);
const lines: string[] = firstItems.map((item, index) => {
let line = `${index + 1}. `;
line += item.prettyPrint(dto.search);
return line;
});
let description =
'I have found **' +
@ -87,22 +97,11 @@ export class PlayItemCommand
description += '\n\n' + lines.join('\n');
const emojiForType = (type: string) => {
switch (type) {
case 'Audio':
return '🎵';
case 'Playlist':
return '📚';
default:
return undefined;
}
};
const selectOptions: { label: string; value: string; emoji?: string }[] =
firstItems.map((item) => ({
label: `${item.Name} [${item.Artists.join(', ')}]`,
value: item.Id,
emoji: emojiForType(item.Type),
label: item.prettyPrint(dto.search),
value: item.getValueId(),
emoji: item.getEmoji(),
}));
return {
@ -148,10 +147,6 @@ export class PlayItemCommand
return;
}
const item = await this.jellyfinSearchService.getById(
interaction.values[0],
);
const guildMember = interaction.member as GuildMember;
const tryResult =
@ -174,51 +169,73 @@ export class PlayItemCommand
const bitrate = guildMember.voice.channel.bitrate;
const stream = await this.jellyfinStreamBuilder.buildStreamUrl(
item.Id,
const valueParts = interaction.values[0].split('_');
const type = valueParts[0];
const id = valueParts[1];
switch (type) {
case 'track':
const item = await this.jellyfinSearchService.getById(id);
const addedIndex = this.enqueueSingleTrack(
item as BaseJellyfinAudioPlayable,
bitrate,
);
interaction.update({
embeds: [
this.discordMessageService.buildMessage({
title: item.Name,
description: `Your track was added to the position ${addedIndex} in the playlist`,
}),
],
components: [],
});
break;
case 'playlist':
const playlist = await this.jellyfinSearchService.getPlaylistById(id);
playlist.Items.forEach((item) => {
this.enqueueSingleTrack(item as BaseJellyfinAudioPlayable, bitrate);
});
interaction.update({
embeds: [
this.discordMessageService.buildMessage({
title: `Added ${playlist.TotalRecordCount} items from your playlist`,
}),
],
components: [],
});
break;
default:
interaction.update({
embeds: [
this.discordMessageService.buildErrorMessage({
title: 'Unable to process your selection',
description: `Sorry. I don't know the type you selected: \`\`${type}\`\`. Please report this bug to the developers.\n\nDebug Information:\`\`${interaction.values.join(
', ',
)}\`\``,
}),
],
components: [],
});
break;
}
}
private enqueueSingleTrack(
jellyfinPlayable: BaseJellyfinAudioPlayable,
bitrate: number,
) {
const stream = this.jellyfinStreamBuilder.buildStreamUrl(
jellyfinPlayable.Id,
bitrate,
);
const milliseconds = item.RunTimeTicks / 10000;
const milliseconds = jellyfinPlayable.RunTimeTicks / 10000;
const duration = formatDuration(
intervalToDuration({
start: milliseconds,
end: 0,
}),
);
const addedIndex = this.playbackService.eneuqueTrack({
jellyfinId: item.Id,
name: item.Name,
return this.playbackService.eneuqueTrack({
jellyfinId: jellyfinPlayable.Id,
name: jellyfinPlayable.Name,
durationInMilliseconds: milliseconds,
streamUrl: stream,
});
const artists = item.Artists.join(', ');
await interaction.update({
embeds: [
this.discordMessageService.buildMessage({
title: 'Jellyfin Search',
description: `**Duration**: ${duration}\n**Artists**: ${artists}\n\nTrack was added to the queue at position ${addedIndex}`,
}),
],
components: [],
});
}
private markSearchTermOverlap(value: string, searchTerm: string) {
const startIndex = value.indexOf(searchTerm);
const actualValue = value.substring(
startIndex,
startIndex + 1 + searchTerm.length,
);
return `${value.substring(
0,
startIndex,
)}**${actualValue}**${value.substring(
startIndex + 1 + actualValue.length,
)}`;
}
}

View File

@ -0,0 +1,197 @@
import { SearchHint } from '@jellyfin/sdk/lib/generated-client/models';
import { JellyfinStreamBuilderService } from '../clients/jellyfin/jellyfin.stream.builder.service';
import { Track } from '../types/track';
import { trimStringToFixedLength } from '../utils/stringUtils';
import { Logger } from '@nestjs/common';
import { v4 as uuidv4 } from 'uuid';
import { JellyfinSearchService } from '../clients/jellyfin/jellyfin.search.service';
export interface BaseJellyfinAudioPlayable {
/**
* The primary identifier of the item
*/
Id: string;
/**
* The name of the item
*/
Name: string;
/**
* The runtime in ticks. 10'000 ticks equal one second
*/
RunTimeTicks: number;
fromSearchHint(
jellyfinSearchService: JellyfinSearchService,
searchHint: SearchHint,
): Promise<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),
},
];
}
}
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 const searchResultAsJellyfinAudio = async (
logger: Logger,
jellyfinSearchService: JellyfinSearchService,
searchHint: SearchHint,
) => {
switch (searchHint.Type) {
case 'Audio':
return await new JellyfinAudioItem().fromSearchHint(
jellyfinSearchService,
searchHint,
);
case 'Playlist':
return await new JellyfinAudioPlaylist().fromSearchHint(
jellyfinSearchService,
searchHint,
);
default:
logger.error(
`Failed to parse Jellyfin response for item type ${searchHint.Type}`,
);
null;
}
};
export const markSearchTermOverlap = (value: string, searchTerm: string) => {
const startIndex = value.indexOf(searchTerm);
const actualValue = value.substring(
startIndex,
startIndex + 1 + searchTerm.length,
);
return `${value.substring(0, startIndex)}**${actualValue}**${value.substring(
startIndex + 1 + actualValue.length,
)}`;
};