This guide helps AI agents understand and work effectively with the DeemixKit codebase.
DeemixKit is a collection of macOS scripts and tools for integrating music services (Spotify, Deezer, Last.fm) with the Deemix desktop application. The project enables easy music downloading and library management through multiple interfaces.
- Platform: macOS only (requires AppleScript, System Events, pbcopy/pbpaste)
- Architecture: Multi-language (Python, Node.js, Bash, AppleScript)
- Purpose: Music service integration and album downloading
- Interface Types: CLI, GUI dialogs, Keyboard Maestro macros
DeemixKit/
├── *.py # Python resolvers (core logic)
├── *.js # Node.js scripts (Spotify integration)
├── *.sh # Bash wrappers for Python scripts
├── *.applescript # GUI dialogs and automation
├── *.md # Documentation files
├── .gitignore # Git ignore rules
├── credentials.json.example # Credentials template
└── macros/ # Keyboard Maestro macro files
These are the core scripts that handle API communication and data retrieval.
Naming Convention: <service>-resolver.py or <functionality>-resolver.py
Standard Pattern:
#!/usr/bin/env python3
"""
Brief Description
Version: X.Y.Z
Author: <name>
Created: <date>
"""
# Imports (standard order)
import sys
import json
import logging
import argparse
import subprocess
from pathlib import Path
from typing import Optional, Dict, Any, List
import requests
from requests.adapters import HTTPAdapter
from requests.packages.urllib3.util.retry import Retry
# Configuration paths (XDG Base Directory)
CONFIG_DIR = Path.home() / ".config" / "<script-name>"
CONFIG_FILE = CONFIG_DIR / "config.json"
CREDENTIALS_FILE = Path.home() / ".config" / "deemixkit" / "credentials.json"
LOG_DIR = Path.home() / ".local" / "log" / "<script-name>"
LOG_FILE = LOG_DIR / "<script-name>.log"
# API endpoints/constants
API_URL = "https://api.example.com/endpoint"
# Functions: setup_logging, load_config, create_session, main functions, etc.
if __name__ == "__main__":
main()Key Functions:
setup_logging(verbose: bool)- Configure logging to file and optionally stderrload_config(config_file: Optional[Path])- Load config from file with defaultscreate_session(config: Dict[str, Any])- Create requests session with retry strategysearch_*functions - API interaction logicparse_input(args: argparse.Namespace)- Handle CLI input (args, stdin, interactive)main()- Entry point with argparse setup
Exit Codes:
0- Success1- Error130- Keyboard interrupt (Ctrl+C)
Lightweight CLI wrappers that call Python resolvers and handle clipboard/AppleScript operations.
Standard Pattern:
#!/bin/bash
# Get arguments
ARTIST="$1"
ALBUM="$2"
# Call Python resolver
python3 "/path/to/resolver.py" --band "$ARTIST" --album "$ALBUM"
# Check exit code
if [ $? -ne 0 ]; then
echo "Error: Failed" >&2
exit 1
fi
# Wait for clipboard
sleep 0.5
# Execute AppleScript to paste into Deemix
osascript "/path/to/paste-to-deemix.applescript"
exit $?Notes:
- Redirect stderr to console with
>&2 - Use
pbcopyfor clipboard operations (macOS built-in) - Call AppleScript utilities for GUI automation
Two types of AppleScript files:
1. GUI Dialog Wrappers (*-to-deemix.applescript)
#!/usr/bin/env osascript
set scriptDir to "/path/to/DeemixKit"
set pythonScript to scriptDir & "/resolver.py"
set pasteScript to scriptDir & "/paste-to-deemix.applescript"
-- Single dialog with both inputs
try
display dialog ¬
"Enter your search:" default answer ¬
"Artist - Album" with title ¬
"Script Name" buttons {"Cancel", "OK"} ¬
default button ¬
"OK" cancel button "Cancel"
set input to text returned of result
set {artist, album} to my splitString(input, " - ")
if artist is "" or album is "" then
display alert "Error" message "Use format: Artist - Album"
return
end if
on error
return
end try
-- Run resolver
try
do shell script "python3 \"" & pythonScript & "\" --band \"" & artist & "\" --album \"" & album & "\""
on error errorMsg
display alert "Error" message "Failed:" & return & errorMsg
return
end try
delay 0.5
-- Run paste into Deemix
try
do shell script "osascript \"" & pasteScript & "\""
display notification "Album added to Deemix" with title "Resolver"
on error errorMsg
display alert "Error" message "Failed to paste:" & return & errorMsg
return
end try
on splitString(theString, delimiter)
set oldDelimiters to AppleScript's text item delimiters
set AppleScript's text item delimiters to delimiter
set theArray to text items of theString
set AppleScript's text item delimiters to oldDelimiters
return theArray
end splitString2. Utility Scripts (paste-to-deemix.applescript)
tell application "System Events"
set isRunning to false
set appList to (get name of every process)
if appList contains "Deemix" then set isRunning to true
end tell
if isRunning is false then
tell application "Deemix" to activate
delay 1
end if
tell application "Deemix" to activate
delay 0.5
tell application "System Events"
keystroke "v" using command down
delay 0.1
key up command
delay 5.0 -- Wait for download to process
keystroke "h" using command down -- Hide app
end tellUsed for Spotify-specific integrations (e.g., currently-playing-to-deemix.js).
Pattern:
import { execSync } from "child_process";
import { readFileSync } from "fs";
import { join } from "path";
import { homedir } from "os";
// Get credentials from unified config
function getCredentials() {
const configPath = join(homedir(), '.config', 'deemixkit', 'credentials.json');
try {
const config = JSON.parse(readFileSync(configPath, 'utf8'));
return config.service?.credential_name;
} catch (err) {
console.error(`Error reading config: ${err.message}`);
process.exit(1);
}
}
// Use execSync for shell commands (osascript, curl)
// Use AppleScript via osascript for Spotify integrationLocation: ~/.config/deemixkit/credentials.json
All scripts read credentials from this centralized file.
Structure:
{
"spotify": {
"client_id": "your_client_id",
"client_secret": "your_client_secret"
},
"deezer": {
"api_key": "your_api_key",
"api_secret": "your_api_secret"
},
"lastfm": {
"api_key": "your_api_key",
"api_secret": "your_api_secret"
},
"youtube": {
"api_key": "your_api_key"
},
"discogs": {
"consumer_key": "your_key",
"consumer_secret": "your_secret",
"access_token": "your_token",
"access_secret": "your_secret"
}
}Reading Credentials:
Python:
from pathlib import Path
import json
creds_path = Path.home() / '.config' / 'deemixkit' / 'credentials.json'
with open(creds_path, 'r') as f:
creds = json.load(f)
client_id = creds.get('spotify', {}).get('client_id')Node.js:
import { readFileSync } from "fs";
import { join } from "path";
import { homedir } from "os";
const configPath = join(homedir(), '.config', 'deemixkit', 'credentials.json');
const config = JSON.parse(readFileSync(configPath, 'utf8'));
const clientId = config.spotify?.client_id;Each Python resolver has its own config at ~/.config/<script-name>/config.json.
Example Structure:
{
"timeout": 10,
"max_retries": 3,
"retry_delay": 1,
"log_level": "INFO",
"cache_results": true,
"cache_file": "~/.config/<script-name>/cache.json",
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
}XDG Base Directory Specification:
- Python scripts:
~/.local/log/<script-name>/<script-name>.log - Log directories auto-created with
Path.mkdir(parents=True, exist_ok=True)
def setup_logging(verbose: bool = False) -> None:
LOG_DIR.mkdir(parents=True, exist_ok=True)
level = logging.DEBUG if verbose else logging.INFO
format_str = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
handlers = [logging.FileHandler(LOG_FILE)]
if verbose:
handlers.append(logging.StreamHandler(sys.stderr))
logging.basicConfig(level=level, format=format_str, handlers=handlers)Usage:
- Always log errors:
logging.error(f"Error message: {e}") - Use verbose flag for stderr output:
--verboseor-v - User messages go to stdout via
print(), not logging
import requests
from requests.adapters import HTTPAdapter
from requests.packages.urllib3.util.retry import Retry
def create_session(config: Dict[str, Any]) -> requests.Session:
session = requests.Session()
retry_strategy = Retry(
total=config.get("max_retries", 3),
backoff_factor=config.get("retry_delay", 1),
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["GET"]
)
adapter = HTTPAdapter(max_retries=retry_strategy)
session.mount("http://", adapter)
session.mount("https://", adapter)
session.headers.update({
'User-Agent': config.get("user_agent", "Your-App/1.0"),
'Accept': 'application/json',
'Accept-Language': 'en-US,en;q=0.9'
})
return sessiontry:
response = session.get(url, timeout=10)
response.raise_for_status()
data = response.json()
except requests.exceptions.Timeout:
logging.error("Request timed out")
return None
except requests.exceptions.RequestException as e:
logging.error(f"Network error: {e}")
if hasattr(e, 'response') and e.response is not None:
logging.error(f"Response status: {e.response.status_code}")
return None
except (KeyError, json.JSONDecodeError) as e:
logging.error(f"Parse error: {e}")
return Nonedef parse_input(args: argparse.Namespace) -> str:
if args.band and args.album:
return f"{args.band} {args.album}"
elif args.query:
return args.query
else:
# Interactive mode
if sys.stdin.isatty():
user_input = input("> ").strip()
return user_input
else:
# Read from stdin (piping)
user_input = sys.stdin.read().strip()
return user_inputimport subprocess
def save_to_clipboard(text: str) -> bool:
try:
import pyperclip
pyperclip.copy(text)
return True
except ImportError:
# macOS fallback
process = subprocess.Popen(
['pbcopy'],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
process.communicate(input=text.encode('utf-8'))
return process.returncode == 0# Direct with arguments
python3 deezer-resolver.py --band "Metallica" --album "Master of Puppets"
# With query
python3 spotify-resolver.py --query "artist:Metallica album:Master of Puppets"
# Piped input
echo "Metallica - Master of Puppets" | python3 deezer-resolver.py
# Interactive mode
python3 deezer-resolver.py
# Verbose logging
python3 deezer-resolver.py --verbose --band "Metallica" --album "Master of Puppets"
# Print URL instead of clipboard
python3 deezer-resolver.py --no-clipboard --band "Metallica" --album "Master of Puppets"# CLI usage
./deezer-to-deemix.sh "Metallica" "Master of Puppets"
./discography-to-deemix.sh "Radiohead" "OK Computer"
./spotify-to-deemix.sh "The Beatles" "Abbey Road"# GUI dialog (opens dialog box)
osascript deezer-to-deemix.applescript
# Or execute directly if shebang is set
./deezer-to-deemix.applescriptnode currently-playing-to-deemix.jsWhen adding a new script to DeemixKit:
- Follow existing patterns - Use the same structure as similar scripts
- Use unified credentials - Read from
~/.config/deemixkit/credentials.jsonfor API keys - Add docstring - Include description, version, author, date
- Create documentation - Create or update
.mddocumentation file - Update example - Add new service to
credentials.json.exampleif needed - Add bash wrapper - Create
*-to-deemix.shfor CLI usage - Add AppleScript wrapper - Create
*.applescriptfor GUI dialogs (if applicable) - Test - Verify with and without credentials present
- Use standard paths - Follow XDG Base Directory for config and logs
- Python resolver script created
- Reads from
~/.config/deemixkit/credentials.json - Logs to
~/.local/log/<script-name>/ - Bash wrapper script created
- AppleScript wrapper created (optional for GUI)
- Documentation
.mdfile created -
credentials.json.exampleupdated - Tested with valid credentials
- Tested with missing credentials (graceful failure)
- Exit codes correct (0=success, 1=error, 130=interrupt)
- Shebang lines correct (
#!/usr/bin/env python3,#!/bin/bash,#!/usr/bin/env osascript)
The .gitignore explicitly protects:
credentials.json.env*_secret.json*_credentials.json*.pem,*.key
Commit credentials.json.example with placeholder values:
{
"service": {
"client_id": "your_client_id_here",
"client_secret": "your_client_secret_here"
}
}Set restrictive permissions on credentials:
chmod 600 ~/.config/deemixkit/credentials.json- Copy to clipboard:
pbcopy(built-in macOS command) - Paste from clipboard:
pbpaste(built-in macOS command) - Clipboard in Python: Use
pypercliplibrary withpbcopyfallback
Activate applications, send keystrokes, check if apps are running:
tell application "Deemix" to activate
tell application "System Events"
keystroke "v" using command down
end tellExecute AppleScript from shell:
osascript -e 'tell application "System Events" to keystroke "v" using command down'
osascript "/path/to/script.applescript"- Python:
#!/usr/bin/env python3 - Bash:
#!/bin/bash - AppleScript:
#!/usr/bin/env osascript
- macOS - All scripts require macOS
- Deemix Desktop - Must be installed for paste functionality
- curl - For API calls (standard on macOS)
- python3 - For Python resolvers
- node - For Node.js scripts (optional)
Install via pip:
pip install requests
pip install pyperclip # Optional, falls back to pbcopyThis project does not have a formal test suite. Testing should be done manually:
- Test with valid credentials
- Test with missing credentials (should fail gracefully)
- Test all input methods (args, stdin, interactive)
- Test clipboard operations
- Test GUI dialogs (AppleScript)
- Verify exit codes
- Check log files are created
python3 <resolver>.py --verbose <args>cat ~/.local/log/<script-name>/<script-name>.logcat ~/.config/deemixkit/credentials.json
cat ~/.config/<script-name>/config.jsonpython3 -m pdb <script>.py <args> # Debugger
python3 <script>.py --no-clipboard --verbose <args> # Verbose + print to stdoutSome scripts contain hardcoded paths like /Users/rd/Scripts/Riley/Audio/DeemixKit. When modifying or running these scripts, be aware they may need to be updated for your environment.
Always check exit codes in Bash wrappers:
python3 script.py --args
if [ $? -ne 0 ]; then
echo "Error" >&2
exit 1
fi- Stdout: Results (URLs, data) - for piping
- Stderr: Status messages, errors - for console display
Example from discography-resolver.py:
print(f"Searching for: {band} - {album}", file=sys.stderr) # Status message
print(url) # URL to stdout for pipingUnlike Spotify, Deezer's basic search API does not require authentication. The deezer-resolver.py can work without API credentials.
Spotify API requires OAuth 2.0 Client Credentials flow:
- Get token from
https://accounts.spotify.com/api/token - Use token in Authorization header
- Token expires and must be refreshed
AppleScript dialogs in macOS have specific behaviors:
- Dialogs appear in front of all windows
- User can cancel via button or close window
- Errors trigger
on errorblock which shouldreturnto exit gracefully
When copying to clipboard then pasting via AppleScript:
python3 resolver.py # Copies to clipboard
sleep 0.5 # Wait for clipboard to be set
osascript paste.applescript # PasteThe delay is necessary because clipboard operations aren't always instantaneous.
The discography resolver outputs URLs to stdout (one per line), while status messages go to stderr. This allows piping:
# Extract only URLs from mixed output
URLS=$(python3 discography-resolver.py --band "Artist" --album "Album" 2>&1)
URLS_ONLY=$(echo "$URLS" | grep '^https://www\.deezer\.com/album/[0-9]')-
CLI method:
./deezer-to-deemix.sh "Metallica" "Master of Puppets"
-
GUI method:
./deezer-to-deemix.applescript # Enter "Metallica - Master of Puppets" in dialog -
Manual method:
python3 deezer-resolver.py --band "Metallica" --album "Master of Puppets" # URL is copied to clipboard # Switch to Deemix and Cmd+V to paste
./discography-to-deemix.sh "Radiohead" "OK Computer"
# All album URLs copied to clipboard
# Paste into Deemix to download allnode currently-playing-to-deemix.js
# Automatically detects playing track, finds album, copies URL, pastes into Deemix| File | Language | Purpose |
|---|---|---|
deezer-resolver.py |
Python | Search Deezer for album, copy URL |
spotify-resolver.py |
Python | Search Spotify for album, copy URL |
discography-resolver.py |
Python | Get artist's full discography from Deezer |
currently-playing-to-deemix.js |
Node.js | Download currently playing Spotify album |
paste-to-deemix.applescript |
AppleScript | Paste clipboard into Deemix app |
| File | Language | Purpose |
|---|---|---|
deezer-to-deemix.sh |
Bash | CLI wrapper for Deezer resolver |
spotify-to-deemix.sh |
Bash | CLI wrapper for Spotify resolver |
discography-to-deemix.sh |
Bash | CLI wrapper for discography resolver |
deezer-to-deemix.applescript |
AppleScript | GUI dialog for Deezer resolver |
spotify-to-deemix.applescript |
AppleScript | GUI dialog for Spotify resolver |
discography-to-deemix.applescript |
AppleScript | GUI dialog for discography resolver |
| File | Purpose |
|---|---|
README.md |
Main project documentation |
CREDENTIALS.md |
Credentials setup guide |
DeemixKit.md |
Project overview |
Deezer Resolver.md |
Deezer resolver documentation |
Spotify Resolver.md |
Spotify resolver documentation |
Deezer to Deemix.md |
Deezer workflow documentation |
Spotify to Deemix.md |
Spotify workflow documentation |
Currently Playing to Deemix.md |
Currently playing workflow documentation |
Discography to Deemix.md |
Discography workflow documentation |
Shell Functions.md |
Shell function examples |
| File | Purpose |
|---|---|
credentials.json.example |
Template for credentials file |
.gitignore |
Files to exclude from git |
Keyboard Maestro macros are available in the macros/ directory. These enable hotkey access to common workflows:
Deemix from Spotify.kmmacros- Download currently playing albumDeemix from Deezer.kmmacros- Download specific Deezer album (via dialog)Band Discography to Deemix.kmmacros- Download full artist catalog (via dialog)
When creating new macros, follow the existing patterns and ensure paths are updated to match the DeemixKit installation location.