Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/FoxxMD/multi-scrobbler. Scrobble plays from multiple sources to multiple clients docs.multi-scrobbler.app
deezer docker jellyfin koito lastfm listenbrainz maloja mopidy mpris music music-assistant plex scrobble self-hosted spotify subsonic tautulli youtube-music
Something went wrong. Try again.
9.6 kB · 270 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271import { parseRegexSingle, parseToRegex } from "@foxxmd/regex-buddy-core";import type { EventEmitter } from "events";import * as VLC from "vlc-client"import type {VlcMeta, VlcStatus} from "vlc-client/dist/Types.js";import type {ComponentAuthType, PlayObject, PlayObjectMinimal} from "../../core/Atomic.ts";import { type FormatPlayObjectOptions, type InternalConfig, type PlayerStateData,} from "../common/infrastructure/Atomic.ts";import { COMPONENT_AUTH_TYPE, SINGLE_USER_PLATFORM_ID } from '../../core/Atomic.ts';import { REPORTED_PLAYER_STATUSES } from '../../core/Atomic.ts';import type {ReportedPlayerStatus} from '../../core/Atomic.ts';import type {VlcAudioMeta, VLCSourceConfig, PlayerState} from "../common/infrastructure/config/source/vlc.ts";import { isPortReachable } from "../utils/NetworkUtils.ts";import { firstNonEmptyStr } from "../utils/StringUtils.ts";import type {RecentlyPlayedOptions} from "./AbstractSource.ts";import { MemoryPositionalSource } from "./MemoryPositionalSource.ts";import { isDebugMode } from "../utils.ts";import { baseFormatPlayObj } from "../utils/PlayTransformUtils.ts";import { artistNamesToCredits } from "../../core/StringUtils.ts";import { AuthError } from "../common/errors/MSErrors.ts";
const CLIENT_PLAYER_STATE: Record<PlayerState, ReportedPlayerStatus> = { 'playing': REPORTED_PLAYER_STATUSES.playing, 'paused': REPORTED_PLAYER_STATUSES.paused, 'stopped': REPORTED_PLAYER_STATUSES.stopped,}
export class VLCSource extends MemoryPositionalSource { declare config: VLCSourceConfig; override authType: ComponentAuthType = COMPONENT_AUTH_TYPE.unattended;
host?: string port?: number client!: VLC.Client; deviceId: string vlcVersion?: string; filenamePatterns: RegExp[] = [];
constructor(name: any, config: VLCSourceConfig, internal: InternalConfig, emitter: EventEmitter) { const { data, } = config; const { interval = 5, // reduced polling interval because its likely we are on the same network ...rest } = data || {}; super('vlc', name, {...config, data: {...rest, interval}}, internal, emitter);
this.requiresAuth = true; this.canPoll = true; }
static parseConnectionUrl(valRaw: string): [string, string] { if(valRaw.trim() === '') { throw new Error(`'url' cannot be an empty string`); }
const [host, port] = valRaw.trim().split(':'); return [host, port ?? '8080']; }
protected async doBuildInitData(): Promise<true | string | undefined> { const { data: { url, password, } = {}, options: { filenamePatterns = [] } = {} } = this.config;
const [host, port] = VLCSource.parseConnectionUrl(url ?? 'localhost:8080'); this.logger.verbose(`Config URL: '${url ?? '(None Given)'}' => Normalized: '${host}:${port}'`); this.host = host; this.port = Number.parseInt(port); this.client = new VLC.Client({ ip: host, port: this.port, password: password });
let fp = filenamePatterns; if(typeof filenamePatterns === 'string') { fp = [filenamePatterns]; } if(fp.length > 0) { for(const p of fp) { const reg = parseToRegex(p); if(reg === undefined) { throw new Error(`filenamePattern could not be parsed as a valid Regex => ${p}`); } this.filenamePatterns.push(reg); } }
return true; }
protected async doCheckConnection(): Promise<true | string | undefined> { if(this.host !== undefined) { try { await isPortReachable(this.port, {host: this.host}); return `${this.host}:${this.port} is reachable.`; } catch (e) { throw e; } } return null; }
doAuthentication = async () => { try { const status = await this.client.status(); this.logger.info(`Connected successfully, found VLC ${status.version}`); this.vlcVersion = status.version; return true; } catch (e) { let friendlyError: string | undefined; throw new AuthError(`Could not connect to VLC server${friendlyError !== undefined ? ` (Hint: ${friendlyError})` : ''}`, {cause: e}); } }
formatPlayObj(obj: VlcAudioMeta, options: FormatPlayObjectOptions = {}): PlayObject {
let vlcState: VlcStatus; const { vlcStatus, } = options;
if(vlcStatus !== undefined) { vlcState = vlcStatus as VlcStatus; }
const { filename, title, album: albumVal, ALBUMARTIST, Writer, StreamArtist, StreamTitle, artist } = obj;
let artists: string[] = []; let albumArtists: string[] = []; const validArtist = firstNonEmptyStr([artist, StreamArtist, ALBUMARTIST, Writer]); if(artist !== undefined) { artists.push(validArtist); } const aa = firstNonEmptyStr([ALBUMARTIST]); if(aa !== undefined) { albumArtists.push(aa); } if(artists.length === 0 && albumArtists.length !== 0) { // switch these, tags are probably improper artists = albumArtists; albumArtists = []; }
let album = albumVal;
let trackName = firstNonEmptyStr([title, StreamTitle]); if (trackName === undefined && album === undefined && artists.length === 0 && albumArtists.length === 0 && this.filenamePatterns.length > 0) { const { options: { logFilenamePatterns = false } = {} } = this.config;
let anyMatched = false; for (const reg of this.filenamePatterns) { const result = parseRegexSingle(reg, filename); const matchedPatternDebug: Record<string, string> = {}; if (result !== undefined) { anyMatched = true; if (result.named.title !== undefined) { trackName = result.named.title; matchedPatternDebug.title = trackName; } if (result.named.artist !== undefined) { artists.push(result.named.artist); matchedPatternDebug.artist = result.named.artist; } if (result.named.album !== undefined) { album = result.named.album; matchedPatternDebug.album = album; }
if (logFilenamePatterns) { if (Object.keys(matchedPatternDebug).length > 0) { matchedPatternDebug.filenamePattern = reg.source; this.logger.debug(matchedPatternDebug, `No metadata found for file '${filename}' but it was matched by filenamePattern`); } else { this.logger.debug(`filenamePattern matched but no named groups? Should have groups named any of: title artist album | filenamePattern => ${reg.source}`); } } } } if (!anyMatched && logFilenamePatterns) { this.logger.debug(`no filenamePatterns matched for filename => ${filename}`); } }
if(trackName === undefined) { trackName = filename; }
const { /** time position within the current track */ time, length, /** percent as decimal within current track*/ position, state } = vlcState || {};
const play: PlayObjectMinimal = { data: { artists: artistNamesToCredits(artists), albumArtists: artistNamesToCredits(albumArtists), album, track: trackName, duration: length }, meta: { trackProgressPosition: time, source: 'vlc', mediaPlayerName: 'VLC', mediaPlayerVersion: this.vlcVersion } } return baseFormatPlayObj({...obj, vlcState}, play); }
getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => {
let state: VlcStatus; let meta: VlcMeta; try { state = await this.client.status(); meta = await this.client.meta(); } catch (e) { this.connectionOK = false; this.authed = false; throw e; }
let play: PlayObject | undefined; if(meta !== undefined) { play = this.formatPlayObj(meta, {vlcStatus: state}); if(this.config?.options?.dumpVlcMetadata || isDebugMode()) { this.logger.debug(meta, 'VLC Metadata'); } }
const playerState: PlayerStateData = { platformId: SINGLE_USER_PLATFORM_ID, status: CLIENT_PLAYER_STATE[state.state], play, position: state.time }
return await this.processRecentPlays([playerState]); }
}