diff --git a/config/tuneshine.json.example b/config/tuneshine.json.example new file mode 100644 index 00000000..99da0e49 --- /dev/null +++ b/config/tuneshine.json.example @@ -0,0 +1,10 @@ +[ + { + "enable": true, + "id": "MyTuneshine", + "name": "MyCoolTuneshine", + "data": { + "host": "http://tuneshine-4064.local" + } + } +] diff --git a/src/backend/common/infrastructure/config/client/clients.ts b/src/backend/common/infrastructure/config/client/clients.ts index cda106cd..7755d8e8 100644 --- a/src/backend/common/infrastructure/config/client/clients.ts +++ b/src/backend/common/infrastructure/config/client/clients.ts @@ -7,6 +7,7 @@ import {tealClientAIOConfigSchema, tealClientConfigSchema} from "./tealfm.ts"; import {rockSkyClientAIOConfigSchema, rockSkyClientConfigSchema} from "./rocksky.ts"; import {librefmClientAIOConfigSchema, librefmClientConfigSchema} from "./librefm.ts"; import {discordClientAIOConfigSchema, discordClientConfigSchema} from "./discord.ts"; +import {tuneshineClientAIOConfigSchema, tuneshineClientConfigSchema} from './tuneshine.ts'; export const clientConfigSchema = z.union([ malojaClientConfigSchema, @@ -17,6 +18,7 @@ export const clientConfigSchema = z.union([ tealClientConfigSchema, rockSkyClientConfigSchema, discordClientConfigSchema, + tuneshineClientConfigSchema ]); export type ClientConfig = z.infer; @@ -30,6 +32,7 @@ export const clientAIOConfigSchema = z.union([ tealClientAIOConfigSchema, rockSkyClientAIOConfigSchema, discordClientAIOConfigSchema, + tuneshineClientAIOConfigSchema ]); export type ClientAIOConfig = z.infer; \ No newline at end of file diff --git a/src/backend/common/infrastructure/config/client/clientsMap.ts b/src/backend/common/infrastructure/config/client/clientsMap.ts index 2d64ef2f..5430e588 100644 --- a/src/backend/common/infrastructure/config/client/clientsMap.ts +++ b/src/backend/common/infrastructure/config/client/clientsMap.ts @@ -6,6 +6,7 @@ import type { TealClientAIOConfig, TealClientConfig} from "./tealfm.ts"; import type { RockSkyClientAIOConfig, RockSkyClientConfig} from "./rocksky.ts"; import type { LibrefmClientAIOConfig, LibrefmClientConfig} from "./librefm.ts"; import type { DiscordClientAIOConfig, DiscordClientConfig} from "./discord.ts"; +import type { TuneshineClientAIOConfig, TuneshineClientConfig} from "./tuneshine.ts"; import type { ClientAIOConfig } from "./clients.ts"; import type { CommonClientConfig, EnvClientSchema } from "./index.ts"; import type {ZodType, ZodObject} from 'zod'; @@ -22,6 +23,7 @@ export interface ClientTypeConfigMap extends Record>], rocksky: [RockSkyClientConfig,RockSkyClientAIOConfig, Partial>], discord: [DiscordClientConfig,DiscordClientAIOConfig, Partial>] + tuneshine: [TuneshineClientConfig,TuneshineClientAIOConfig, Partial>] } export const clientConfigSchemaMapAsync: { [K in keyof ClientTypeConfigMap]:() => Promise<[ZodType,ZodType,EnvClientSchema]> } = { @@ -56,6 +58,10 @@ export const clientConfigSchemaMapAsync: { [K in keyof ClientTypeConfigMap]:() = discord: async() => { const {discordClientConfigSchema, discordClientAIOConfigSchema, envSchemas} = (await import('./discord.ts')); return [discordClientConfigSchema,discordClientAIOConfigSchema,envSchemas] + }, + tuneshine: async() => { + const {tuneshineClientConfigSchema, tuneshineClientAIOConfigSchema, envSchemas} = (await import('./tuneshine.ts')); + return [tuneshineClientConfigSchema,tuneshineClientAIOConfigSchema,envSchemas] } } diff --git a/src/backend/common/infrastructure/config/client/tuneshine.ts b/src/backend/common/infrastructure/config/client/tuneshine.ts index bea37745..3f5358ac 100644 --- a/src/backend/common/infrastructure/config/client/tuneshine.ts +++ b/src/backend/common/infrastructure/config/client/tuneshine.ts @@ -12,8 +12,8 @@ export const tuneshineDataSchema = z.object({ * @examples ["192.168.1.100"] * */ host: httpUrl.meta({ - description: "IP or hostname for the Tuneshine device", - examples: ["192.168.1.100"] + description: "IP or http(s)://hostname for the Tuneshine device", + examples: ["http://192.168.1.100"] }), }); export type TuneshineData = z.infer; @@ -34,7 +34,6 @@ export const envSchemas: EnvClientSchema; - constructor(name: string, config: TuneshineClientConfig, options: AbstractApiOptions & {cache?: Cacheable}) { + host!: URLData; + + //private lastArtUrl?: string; + + constructor(name: string, config: Pick, options: AbstractApiOptions & { cache?: Cacheable }) { super('Tuneshine', name, config, options); this.cache = options.cache ?? getRoot().items.cache().cacheApi; } @@ -35,35 +40,83 @@ export class TuneshineApiClient extends AbstractApiClient { throw new SimpleError('host must be defined'); } const u = normalizeWebAddress(host); + this.host = u; this.logger.verbose(`Config Host: '${host ?? '(None Given)'}' => Normalized: '${u.url}'`); - this.api = createClient({baseUrl: u.url.toString()}) + this.api = createClient({ baseUrl: u.url.toString() }) + } + + public async checkConnection() { + try { + await this.api.GET('/health'); + //await isPortReachableConnect(this.host.port, { host: this.host.url.hostname }); + } catch (e) { + new UpstreamError('Unable to reach Tuneshine /health endpoint', { cause: e }) + } } async clearNowPlaying() { - const {data, error} = await this.api.DELETE("/image"); + try { + const res = await this.api.DELETE("/image"); + } catch (e) { + throw new UpstreamError(`Clearing Now Playing failed with error`, {cause: e}); + } } async sendNowPlaying(play: PlayObject) { let artUrl: string | undefined = undefined; - if(play.meta?.art?.track !== undefined) { + if (play.meta?.art?.track !== undefined) { artUrl = play.meta?.art?.track; - } else if(play.meta?.art?.album !== undefined) { + } else if (play.meta?.art?.album !== undefined) { artUrl = play.meta?.art?.album; - } else if(play.meta?.art?.artist !== undefined) { + } else if (play.meta?.art?.artist !== undefined) { artUrl = play.meta?.art?.artist; } - if(artUrl === undefined) { + if (artUrl === undefined) { throw new SimpleError('No art found on Play'); } - //const postImage = this.api.path('/image').method('post').create(); const metadata = playToPostImageMetadata(play); - const {data, error} = await this.api.POST("/image", { - body: { - image: (await this.convertToWebp({url: artUrl})).toString(), - metadata: JSON.stringify(metadata) + // TODO optimize to only update metadata if we can determine current artwork is same as last artwork + // for now we will just always send new art + // + // if(this.lastArtUrl !== undefined && artUrl === this.lastArtUrl) { + // // there's a chance there is local artwork that is the same as the previous track + // // try to see if we should just update metadata + // } + + //const formData = new FormData(); + let imageContents: string; + try { + imageContents = (await this.convertToWebp({ url: artUrl })).toString(); + //formData.append('image', (await this.convertToWebp({ url: artUrl })).toString()); + } catch (e) { + throw new Error('Failed to process art image before sending Now Playing data', {cause: e}); + } + + //formData.append('metadata', JSON.stringify(metadata)); + + try { + const res = await this.api.POST("/image", { + bodySerializer(body) { + const fd = new FormData(); + for (const name in body) { + // @ts-expect-error not sure how to fix this but its fine + fd.append(name, body[name] as string); + } + return fd; + }, + body: { + image: imageContents, + metadata: JSON.stringify(metadata) + }, + }); + if(res.error !== undefined) { + throw new UpstreamError(`Sending Now Playing data failed with error (${res.response.status}) ${res.error?.message}`) } - }); + } catch (e) { + throw new UpstreamError('Failed to POST to /image with Now Playing data', { cause: e }); + } + } public async convertToWebp(opts: { url?: string, data?: SharpInput }) { @@ -71,22 +124,32 @@ export class TuneshineApiClient extends AbstractApiClient { if (opts.data !== undefined) { input = opts.data; } else if (opts.url !== undefined) { - input = await ky.get(opts.url).arrayBuffer(); + try { + input = await ky.get(opts.url).arrayBuffer(); + } catch (e) { + throw new UpstreamError(`Failed to get resource from art URL ${opts.url}`, { cause: e }); + } } else { throw new SimpleError('Must pass either url or data'); } - return sharp(input) - .resize(64, 64, { withoutEnlargement: true }) - .webp({ nearLossless: true }) - .toBuffer() + try { + return sharp(input) + .resize(64, 64, { withoutEnlargement: true }) + .webp({ nearLossless: true }) + .toBuffer() + } catch (e) { + throw new SimpleError(`Failed to convert ${opts.url !== undefined ? `resource from URL ${opts.url}` : 'image data'} to webp`, { cause: e }); + } } } -const playToPostImageMetadata = (play: PlayObject): components['schemas']['ImageUrlRequest'] => ({ - timeoutMs: nowPlayingExpirationDuration({play, position: play.meta.trackProgressPosition }).asMilliseconds(), - trackName: play.data.track, - artistName: play.data.artists === undefined ? undefined : play.data.artists.map(x => x.name).join(' / '), - albumName: play.data.album, - serviceName: play.meta.musicService ?? play.meta.source, - itemId: play.meta.trackId ?? play.uid +export const playToPostImageMetadata = (play: PlayObject): components['schemas']['ImageUrlRequest'] => ({ + timeoutMs: nowPlayingExpirationDuration({ play, position: play.meta.trackProgressPosition }).asMilliseconds(), + trackName: play.data.track, + artistName: play.data.artists === undefined ? undefined : play.data.artists.map(x => x.name).join(' / '), + albumName: play.data.album, + serviceName: play.meta.musicService ?? play.meta.source, + itemId: play.meta.trackId ?? play.uid, + imageUrl: play.meta?.art?.track ?? play.meta?.art?.album ?? play.meta?.art?.artist, + contentType: 'track' }); \ No newline at end of file diff --git a/src/backend/scrobblers/AbstractScrobbleClient.ts b/src/backend/scrobblers/AbstractScrobbleClient.ts index 6b0b997c..7826319f 100644 --- a/src/backend/scrobblers/AbstractScrobbleClient.ts +++ b/src/backend/scrobblers/AbstractScrobbleClient.ts @@ -13,7 +13,6 @@ import { QUEUE_STATUS_COMPLETED, SOURCE_SOT, QUEUE_STATUS_FAILED, - DEAD_LETTER_RETRIES_DEFAULT, PARSED_FROM } from "../../core/Atomic.ts"; import { buildTrackString, truncateStringToLength } from "../../core/StringUtils.ts"; @@ -62,7 +61,7 @@ import { serializeError} from 'serialize-error'; import { DEFAULT_NEW_PADDING, groupPlaysToTimeRanges } from "../utils/ListenFetchUtils.ts"; import { spawn, isAbortError, delay, waitForEvent } from 'abort-controller-x'; import { type QueryPlaysOpts, type WithPlayRelation } from "../common/database/drizzle/repositories/PlayRepository.ts"; -import type {PlaySelectWithQueueStates, PlayWith } from "../common/database/drizzle/drizzleTypes.ts"; +import type {PlayWith } from "../common/database/drizzle/drizzleTypes.ts"; import { asPlay } from "../../core/PlayMarshalUtils.ts"; import { GenericRepository } from "../common/database/drizzle/repositories/BaseRepository.ts"; import assert from "node:assert"; diff --git a/src/backend/scrobblers/ScrobbleClients.ts b/src/backend/scrobblers/ScrobbleClients.ts index 20836cbc..90934b1e 100644 --- a/src/backend/scrobblers/ScrobbleClients.ts +++ b/src/backend/scrobblers/ScrobbleClients.ts @@ -359,6 +359,11 @@ export default class ScrobbleClients { await this.instantiateClients('tealfm', strongConfigs, clientDefaults, TealScrobbler, (config, options) => [config.name ?? config.id, { ...config, options }, this.internalConfig, this.emitter, this.logger]); } break; + case 'tuneshine': { + const TuneshineScrobbler = (await import('./TuneshineScrobbler.ts')).default; + await this.instantiateClients('tuneshine', strongConfigs, clientDefaults, TuneshineScrobbler, + (config, options) => [config.name ?? config.id, { ...config, options }, this.internalConfig, this.emitter, this.logger]); + } break; } } diff --git a/src/backend/scrobblers/TuneshineScrobbler.ts b/src/backend/scrobblers/TuneshineScrobbler.ts new file mode 100644 index 00000000..c73a8a0f --- /dev/null +++ b/src/backend/scrobblers/TuneshineScrobbler.ts @@ -0,0 +1,73 @@ +import { childLogger, type Logger } from "@foxxmd/logging"; +import type EventEmitter from "events"; +import {type PlayMatchResult, type PlayObject, type SourcePlayerObj} from "../../core/Atomic.ts"; +import type {FormatPlayObjectOptions} from "../common/infrastructure/Atomic.ts"; +import AbstractScrobbleClient, { nowPlayingUpdateByPlayDuration, shouldClearNPStatus } from "./AbstractScrobbleClient.ts"; +import { playToPostImageMetadata, TuneshineApiClient } from "../common/vendor/tuneshine/TuneshineApiClient.ts"; +import type { TuneshineClientConfig } from "../common/infrastructure/config/client/tuneshine.ts"; +import dayjs from "dayjs"; + +export default class TuneshineScrobbler extends AbstractScrobbleClient { + + override nowPlayingIsRealtime: boolean = true; + override supportsNowPlaying: boolean = true; + + api: TuneshineApiClient; + + declare config: TuneshineClientConfig + + constructor(name: any, config: TuneshineClientConfig, options = {}, emitter: EventEmitter, logger: Logger) { + super('tuneshine', name, config, emitter, logger); + this.api = new TuneshineApiClient(name, this.config, { logger: childLogger(this.logger, 'API') }); + this.nowPlayingMaxThreshold = nowPlayingUpdateByPlayDuration; + this.nowPlayingMinThreshold = (_: any) => 5; + } + + getScrobblesForTimeRange = async (_: any) => []; + + formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => obj; + + protected async doBuildInitData(): Promise { + await this.api.buildData(); + return true; + } + + protected async doCheckConnection(): Promise { + + try { + await this.api.checkConnection(); + return true; + } catch (e) { + throw e; + } + + } + // tuneshine does not handle scrobbles, only Now Playing + // so don't bother queueing any scrobbles as we don't want to cache them + // or give the user the impression they are used (in UI as a number of queued scrobbles) + queuePlay = async (data: Parameters[0]) => [] + alreadyScrobbled = async (playObj: PlayObject, log = false): Promise<[boolean, PlayMatchResult]> => ([false, {match: false, breakdowns: [], score: 0, createdAt: dayjs().toISOString()}]) + doScrobble = async (playObj: PlayObject) => ({ play: playObj, payload: {}, createdAt: dayjs().toISOString() }) + + public playToClientPayload(playObject: PlayObject): any { + return playToPostImageMetadata(playObject) + } + + doPlayingNow = async (data: SourcePlayerObj) => { + try { + if(shouldClearNPStatus(data) || data.play === undefined) { + await this.api.clearNowPlaying(); + } else { + const play = { + ...data.play, + meta: { + ...(data.play.meta ?? {}), + trackProgressPosition: data.position ?? data.play.meta?.trackProgressPosition + }} + await this.api.sendNowPlaying(play); + } + } catch (e) { + throw e; + } + } +} \ No newline at end of file diff --git a/src/backend/tests/config/config.test.ts b/src/backend/tests/config/config.test.ts index 7fe4d458..bd6293ce 100644 --- a/src/backend/tests/config/config.test.ts +++ b/src/backend/tests/config/config.test.ts @@ -212,7 +212,7 @@ describe('Sample Configs', function () { }); it(`Sample ${componentType}.json parses and validates in ScrobbleClients`, async function () { - this.timeout(5000); + this.timeout(500000); const emitter = new WildcardEmitter(); await copyFile(samplePath(componentType), `${componentType}.json`); diff --git a/src/backend/tests/tuneshine/icon.png b/src/backend/tests/tuneshine/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..29f4f9b0799ee6cf0050ac718495f50f8365adf3 GIT binary patch literal 13768 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

!v9E-TZ|8WXXgj%_?t|Gs``z-z2m?WSu^* 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 literal 0 HcmV?d00001 diff --git a/src/backend/tests/tuneshine/tuneshine.test.ts b/src/backend/tests/tuneshine/tuneshine.test.ts new file mode 100644 index 00000000..083ac2fc --- /dev/null +++ b/src/backend/tests/tuneshine/tuneshine.test.ts @@ -0,0 +1,58 @@ +import * as dotenv from 'dotenv'; +import { loggerTest } from "@foxxmd/logging"; +import chai, { expect } from 'chai'; +import asPromised from 'chai-as-promised'; +import { before, describe, it } from 'mocha'; +import { initMemoryCache } from "../../common/Cache.ts"; +import { Cacheable } from "cacheable"; +import { getPathFromCWD } from '../../common/index.ts'; +import path from 'path'; +import { withRequestInterception } from '../utils/networking.ts'; +import { http, HttpResponse } from "msw"; +import { generatePlay } from '../../../core/tests/utils/PlayTestUtils.ts'; +import { faker } from '@faker-js/faker'; +import { TuneshineApiClient } from '../../common/vendor/tuneshine/TuneshineApiClient.ts'; +import fs from 'node:fs/promises'; + +chai.use(asPromised); + +const envPath = path.join(getPathFromCWD(), '.env'); +dotenv.config({ path: envPath }); + +const memorycache = () => new Cacheable({ primary: initMemoryCache({ ttl: '1ms' }) }); + +describe('Tuneshine API', function () { + + describe('Basic Operations', function () { + + it('responds', async function () { + + this.timeout(350000); + + const client = new TuneshineApiClient('test', {data: {host: 'https://test.local'}}, {cache: memorycache(), logger: loggerTest}); + await client.buildData(); + + const play = generatePlay(undefined, {art: {album: 'https://test.image/01.png'}}); + + await withRequestInterception( + [ + http.post('https://test.local/image', (req) => HttpResponse.json({status: "ok"}, { status: 200 })), + http.get('https://test.image/01.png', async() => { + const imagePath = path.resolve(import.meta.dirname, './icon.png'); + const fileContents = await fs.readFile(imagePath); + return HttpResponse.arrayBuffer(fileContents.buffer, { + headers: { + 'content-type': 'image/png', + }, + }); + }) + ], async function() { + await client.sendNowPlaying(play); + const f = 1; + } + )(); + }); + + }); + +}); \ No newline at end of file diff --git a/src/core/Atomic.ts b/src/core/Atomic.ts index 903fe26a..4dc9cb42 100644 --- a/src/core/Atomic.ts +++ b/src/core/Atomic.ts @@ -772,7 +772,8 @@ export type ClientType = 'koito' | 'tealfm' | 'rocksky' | - 'discord'; + 'discord' | + 'tuneshine'; export const clientTypes: ClientType[] = [ 'maloja', 'lastfm', @@ -781,7 +782,8 @@ export const clientTypes: ClientType[] = [ 'koito', 'tealfm', 'rocksky', - 'discord' + 'discord', + 'tuneshine' ]; export type ReportedPlayerStatus = 'playing' | 'stopped' | 'paused' | 'unknown'; export const REPORTED_PLAYER_STATUSES = {