mirror of
https://github.com/informaticker/discord-jellyfin-bot.git
synced 2024-11-23 18:21:55 +01:00
✨ Add playlists playback
This commit is contained in:
parent
3f2b1a7b5f
commit
c218c1273d
@ -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();
|
||||
|
||||
|
@ -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(
|
||||
|
@ -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,
|
||||
);
|
||||
|
||||
const milliseconds = item.RunTimeTicks / 10000;
|
||||
|
||||
const duration = formatDuration(
|
||||
intervalToDuration({
|
||||
start: milliseconds,
|
||||
end: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
const addedIndex = this.playbackService.eneuqueTrack({
|
||||
jellyfinId: item.Id,
|
||||
name: item.Name,
|
||||
durationInMilliseconds: milliseconds,
|
||||
streamUrl: stream,
|
||||
});
|
||||
|
||||
const artists = item.Artists.join(', ');
|
||||
|
||||
await interaction.update({
|
||||
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}`,
|
||||
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 markSearchTermOverlap(value: string, searchTerm: string) {
|
||||
const startIndex = value.indexOf(searchTerm);
|
||||
const actualValue = value.substring(
|
||||
startIndex,
|
||||
startIndex + 1 + searchTerm.length,
|
||||
private enqueueSingleTrack(
|
||||
jellyfinPlayable: BaseJellyfinAudioPlayable,
|
||||
bitrate: number,
|
||||
) {
|
||||
const stream = this.jellyfinStreamBuilder.buildStreamUrl(
|
||||
jellyfinPlayable.Id,
|
||||
bitrate,
|
||||
);
|
||||
return `${value.substring(
|
||||
0,
|
||||
startIndex,
|
||||
)}**${actualValue}**${value.substring(
|
||||
startIndex + 1 + actualValue.length,
|
||||
)}`;
|
||||
|
||||
const milliseconds = jellyfinPlayable.RunTimeTicks / 10000;
|
||||
|
||||
return this.playbackService.eneuqueTrack({
|
||||
jellyfinId: jellyfinPlayable.Id,
|
||||
name: jellyfinPlayable.Name,
|
||||
durationInMilliseconds: milliseconds,
|
||||
streamUrl: stream,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
197
src/models/jellyfinAudioItems.ts
Normal file
197
src/models/jellyfinAudioItems.ts
Normal 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,
|
||||
)}`;
|
||||
};
|
Loading…
Reference in New Issue
Block a user