import { spawn } from 'child_process';
import logger from '../utils/logger.js';
import { platform } from 'os';
interface AppleMusicResponse {
success: boolean;
output: string;
}
export class AppleMusicService {
constructor() {
if (platform() !== 'darwin') {
logger.warn('AppleMusicService is only supported on macOS');
}
}
private sanitizeInput(input: string): string {
return input.replace(/"/g, '""');
}
private async exec(script: string): Promise<string> {
return new Promise((resolve, reject) => {
logger.debug('Executing AppleScript:', { script });
const process = spawn('osascript', ['-e', script]);
let stdout = '';
let stderr = '';
process.stdout.on('data', data => {
stdout += data.toString();
});
process.stderr.on('data', data => {
stderr += data.toString();
});
process.on('close', code => {
logger.debug('AppleScript execution completed:', {
code,
stdout,
stderr,
});
if (code === 0) {
resolve(stdout.trim());
} else {
const error = stderr.trim() || `Process exited with code ${code}`;
logger.error('AppleScript execution failed:', { error, code });
reject(new Error(error));
}
});
process.on('error', error => {
logger.error('Failed to spawn osascript process:', {
error: error.message,
});
reject(new Error(`Failed to execute AppleScript: ${error.message}`));
});
});
}
async setVolume(level: number): Promise<AppleMusicResponse> {
try {
if (level < 0 || level > 100) {
throw new Error('Volume level must be between 0 and 100');
}
const script = `set volume ${level}`;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const output = await this.exec(script);
logger.info('Volume set successfully:', { level });
return { success: true, output: `Volume set to ${level}` };
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
logger.error('Failed to set volume:', { error: errorMessage, level });
return {
success: false,
output: `Failed to set volume: ${errorMessage}`,
};
}
}
async nextTrack(): Promise<AppleMusicResponse> {
try {
const script = 'tell application "Music" to next track';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const output = await this.exec(script);
logger.info('Next track command executed successfully');
return { success: true, output: 'Skipped to next track' };
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
logger.error('Failed to skip to next track:', { error: errorMessage });
return {
success: false,
output: `Failed to skip to next track: ${errorMessage}`,
};
}
}
async pause(): Promise<AppleMusicResponse> {
try {
const script = 'tell application "Music" to pause';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const output = await this.exec(script);
logger.info('Music paused successfully');
return { success: true, output: 'Music paused' };
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
logger.error('Failed to pause music:', { error: errorMessage });
return {
success: false,
output: `Failed to pause music: ${errorMessage}`,
};
}
}
async play(): Promise<AppleMusicResponse> {
try {
const script = 'tell application "Music" to play';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const output = await this.exec(script);
logger.info('Music playback started successfully');
return { success: true, output: 'Music playback started' };
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
logger.error('Failed to start music playback:', { error: errorMessage });
return {
success: false,
output: `Failed to start music playback: ${errorMessage}`,
};
}
}
async getCurrentTrack(): Promise<AppleMusicResponse> {
try {
const script = 'tell application "Music" to get name of current track';
const output = await this.exec(script);
logger.info('Current track retrieved successfully:', { track: output });
return { success: true, output: output || 'No track currently playing' };
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
logger.error('Failed to get current track:', { error: errorMessage });
return {
success: false,
output: `Failed to get current track: ${errorMessage}`,
};
}
}
async searchAlbum(album: string): Promise<AppleMusicResponse> {
try {
const sanitizedAlbum = this.sanitizeInput(album);
const script = `tell application "Music"
set trackList to every track of playlist "Library" whose album contains "${sanitizedAlbum}"
repeat with t in trackList
log (name of t) & " - " & (artist of t) & " (" & (album of t) & ")"
end repeat
end tell`;
const output = await this.exec(script);
logger.info('Album search completed successfully:', {
album,
resultCount: output.split('\n').length,
});
return {
success: true,
output: output || `No tracks found for album: ${album}`,
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
logger.error('Failed to search album:', { error: errorMessage, album });
return {
success: false,
output: `Failed to search album: ${errorMessage}`,
};
}
}
async searchAndPlay(song: string): Promise<AppleMusicResponse> {
try {
const sanitizedSong = this.sanitizeInput(song);
const script = `tell application "Music"
set searchResults to (search playlist "Library" for "${sanitizedSong}")
if (count of searchResults) > 0 then
play (item 1 of searchResults)
return "Playing: " & (name of item 1 of searchResults)
else
return "No results found"
end if
end tell`;
const output = await this.exec(script);
logger.info('Search and play completed successfully:', {
song,
result: output,
});
return { success: true, output };
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
logger.error('Failed to search and play song:', {
error: errorMessage,
song,
});
return {
success: false,
output: `Failed to search and play song: ${errorMessage}`,
};
}
}
async searchSong(song: string): Promise<AppleMusicResponse> {
try {
const sanitizedSong = this.sanitizeInput(song);
const script = `tell application "Music"
set searchResults to (search playlist "Library" for "${sanitizedSong}")
repeat with t in searchResults
log (name of t) & " - " & (artist of t)
end repeat
end tell`;
const output = await this.exec(script);
logger.info('Song search completed successfully:', {
song,
resultCount: output.split('\n').length,
});
return { success: true, output: output || `No songs found for: ${song}` };
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
logger.error('Failed to search song:', { error: errorMessage, song });
return {
success: false,
output: `Failed to search song: ${errorMessage}`,
};
}
}
async searchArtist(artist: string): Promise<AppleMusicResponse> {
try {
const sanitizedArtist = this.sanitizeInput(artist);
const script = `tell application "Music"
set trackList to every track of playlist "Library" whose artist contains "${sanitizedArtist}"
repeat with t in trackList
log (name of t) & " - " & (artist of t)
end repeat
end tell`;
const output = await this.exec(script);
logger.info('Artist search completed successfully:', {
artist,
resultCount: output.split('\n').length,
});
return {
success: true,
output: output || `No tracks found for artist: ${artist}`,
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
logger.error('Failed to search artist:', { error: errorMessage, artist });
return {
success: false,
output: `Failed to search artist: ${errorMessage}`,
};
}
}
}