Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
ciembor committed Dec 23, 2023
0 parents commit db7d1f1
Show file tree
Hide file tree
Showing 38 changed files with 1,345 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
__pycache__/
*.pyc
*.cache
595 changes: 595 additions & 0 deletions LICENSE.md

Large diffs are not rendered by default.

77 changes: 77 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# spotify-cmd v0.1.0

`spotify-cmd` is a Spotify client that allows controlling the playback of albums and playlists from a user's library (based on names or Spotify URIs) and individual tracks (based solely on Spotify URIs). The application is intended for use with [spotifyd](https://github.com/Spotifyd/spotifyd), but it works with any Spotify-enabled device.

## Installation

Ensure you have Python 3 installed. Then, install the project dependencies using `pip3`:

```bash
pip3 install -r ./src/spotify-cmd-daemon/requirements.txt
pip3 install -r ./src/spotify-cmd/requirements.txt
```

## Configuration

The application configuration should be located in `~/.config/spotify-cmd/config.ini`. Below is a detailed guide on each configuration option:

```ini
[SPOTIFY]
client_id = your_client_id # Required. Your Spotify application's client ID.
client_secret = your_client_secret # Required. Your Spotify application's client secret.
device_name = your_device_name # Required. The name of your Spotify playback device.
redirect_uri = http://localhost:8888/callback

[SPOTIFY_CMD_DAEMON]
socket_path = /tmp/spotify-cmd-daemon.sock
socket_buffer_size = 1024
```

## Commands

`spotify-cmd` offers the following commands:

* `play`, `pause`, `next`, `previous`: Controls playback.
* `set shuffle <on|off>`: Toggles shuffle mode.
* `set volume <0-100>`: Sets the volume level.
* `get playlists`: Lists the user's playlists.
* `get albums`: Lists the user's albums.
* `play playlist <name>`, `play album <name>`, `play uri <spotify_uri>`: Plays a specific playlist, album, or resource by Spotify URI.

## Output Format

Select output format using the `--format` flag:

`--format text`: Plain text output (default).
`--format json`: Output in JSON format.
`--format verbose`: Verbose text information.

## Usage Examples

``` bash
./bin/spotify-cmd get albums
./bin/spotify-cmd get playlists
./bin/spotify-cmd play album "Listening Tree"
./bin/spotify-cmd play playlist "Discover Weekly"
./bin/spotify-cmd play uri spotify:album:5zKTfU3vyuZfLgtYRfJyza
```

## For Developers

Developers can create interfaces for `spotify-cmd-daemon` using `/tmp/spotify-cmd-daemon.sock`. Socket handling and data format details are in the `./src/common` directory (there is no documentation).

## Planned Features and Upcoming Development

* Displaying tracks from specific albums or playlists.
* MPRIS D-Bus interface support to reduce server queries.
* `spotify-cmd-ui`: A ncurses-based UI for track display and control, working with the same daemon.
* Search functionality.
* Support for other resource types like artists.

## License

This project is licensed under the terms of the GNU General Public License. Detailed information can be found in the [LICENSE.md](LICENSE.md) file.

## Author

Project created by Maciej Ciemborowicz.
3 changes: 3 additions & 0 deletions bin/spotify-cmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/usr/bin/bash

python3 "$(dirname "$0")/../src/spotify_cmd_client/main.py" "$@"
3 changes: 3 additions & 0 deletions bin/spotify-cmd-daemon
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/usr/bin/bash

python3 "$(dirname "$0")/../src/spotify_cmd_daemon/main.py" "$@"
Empty file added src/__init__.py
Empty file.
3 changes: 3 additions & 0 deletions src/common/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from . import config
from . import socket_message
from . import socket_data_handler
38 changes: 38 additions & 0 deletions src/common/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import os
import configparser

class Config:
_instance = None

@staticmethod
def get_instance():
if Config._instance is None:
Config()
return Config._instance

def __init__(self):
if Config._instance is not None:
raise Exception("This class is a singleton!")
else:
Config._instance = self
self.load_config()

def load_config(self):
config_dir = os.path.expanduser('~/.config/spotify-cmd')
config_file = os.path.join(config_dir, 'config.ini')

if not os.path.exists(config_file):
raise Exception(f"Config file not found: {config_file}")

config = configparser.ConfigParser()
config.read(config_file)

self.spotify_client_id = config.get('SPOTIFY', 'client_id', fallback=None)
self.spotify_client_secret = config.get('SPOTIFY', 'client_secret', fallback=None)
self.device_name = config.get('SPOTIFY', 'device_name', fallback=None)
self.spotify_redirect_uri = config.get('SPOTIFY', 'redirect_uri', fallback='http://localhost:8888/callback')
self.socket_path = config.get('SPOTIFY_CMD_DAEMON', 'socket_path', fallback='/tmp/spotify-cmd-daemon.sock')
self.socket_buffer_size = config.getint('SPOTIFY_CMD_DAEMON', 'socket_buffer_size', fallback=1024)

if not self.spotify_client_id or not self.spotify_client_secret:
raise Exception("Spotify client_id and client_secret must be set in config.ini")
33 changes: 33 additions & 0 deletions src/common/socket_data_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from config import Config

class SocketDataHandler:
config = Config.get_instance()
SOCKET_BUFFER_SIZE = config.socket_buffer_size
END_OF_MESSAGE = b"<END_OF_MESSAGE>"

@staticmethod
def send_data(connection, message):
if message:
response = message.encode()
for i in range(0, len(response), SocketDataHandler.SOCKET_BUFFER_SIZE):
connection.sendall(response[i:i+SocketDataHandler.SOCKET_BUFFER_SIZE])
connection.sendall(SocketDataHandler.END_OF_MESSAGE)

@staticmethod
def receive_data(connection):
data = b""
end_of_message_received = False

while True:
packet = connection.recv(SocketDataHandler.SOCKET_BUFFER_SIZE)
data += packet

if SocketDataHandler.END_OF_MESSAGE in data:
end_of_message_received = True
break

if end_of_message_received:
data = data.replace(SocketDataHandler.END_OF_MESSAGE, b"")
return data.decode()
else:
return None
28 changes: 28 additions & 0 deletions src/common/socket_message.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import json
import uuid
from datetime import datetime

class SocketMessage:
def __init__(self, message_type, payload, app_id='server'):
self.message_type = message_type
self.message_id = str(uuid.uuid4())
self.timestamp = datetime.now().isoformat()
self.payload = payload
self.app_id = app_id

def to_json(self):
return json.dumps({
"message_type": self.message_type,
"message_id": self.message_id,
"timestamp": self.timestamp,
"payload": self.payload,
"app_id": self.app_id,
})

@staticmethod
def from_json(json_str):
data = json.loads(json_str)
msg = SocketMessage(data["message_type"], data["payload"], data["app_id"])
msg.message_id = data["message_id"]
msg.timestamp = data["timestamp"]
return msg
Empty file.
80 changes: 80 additions & 0 deletions src/spotify_cmd_client/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import os
import sys
current_dir = os.path.dirname(os.path.abspath(__file__))
base_path = os.path.join(current_dir, "..", 'common')
sys.path.insert(0, base_path)

import socket
import json
from socket_message import SocketMessage
from socket_data_handler import SocketDataHandler
from config import Config

class Client:
def __init__(self):
config = Config.get_instance()
self.server_address = config.socket_path
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)

def connect(self):
try:
self.sock.connect(self.server_address)
except socket.error as msg:
print(f"Cannot connect to daemon: {msg}")
return False
return True

def send_command(self, payload):
try:
socket_message = SocketMessage("request", payload)
SocketDataHandler.send_data(self.sock, socket_message.to_json())
except socket.error as msg:
print(f"Error sending command to daemon: {msg}")

def handle_response(self, format):
try:
response_json = SocketDataHandler.receive_data(self.sock)
if response_json:
response_dict = json.loads(response_json)
if (format == 'verbose'):
formatted_response = json.dumps(response_dict, indent=2)
print(formatted_response)
if (format == 'json'):
formatted_response = json.dumps(response_dict['payload'], indent=2)
print(formatted_response)
else:
self.__print_output(response_dict['payload'])
except socket.error as msg:
print(f"Error receiving response from daemon: {msg}")

def close(self):
self.sock.close()

def __print_output(self, data):
if 'error' in data:
print(f"Error: {data['error']}")
elif 'notification' in data:
print(data['notification'])
elif 'albums' in data:
self.__print_collection('albums', data['albums'])
elif 'playlists' in data:
self.__print_collection('playlists', data['playlists'])

def __print_collection(self, type, items):
print("──────────────────────────────────────────")
for item in items:
if (type=='albums'):
self.__print_album(item)
elif (type=='playlists'):
self.__print_playlist(item)
print("──────────────────────────────────────────")

def __print_album(self, album_data):
print(f"Artist: {album_data['artists']}")
print(f"Album: {album_data['name']}")
print(f"URI: spotify:album:{album_data['spotify_id']}")

def __print_playlist(self, playlist_data):
print(f"Owner: {playlist_data['owner']}")
print(f"Name: {playlist_data['name']}")
print(f"URI: spotify:playlist:{playlist_data['spotify_id']}")
67 changes: 67 additions & 0 deletions src/spotify_cmd_client/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import socket
import argparse
import json
from client import Client

def main():
# Parse arguments
parser = argparse.ArgumentParser(description='Client for controlling Spotify playback through a spotify-cmd-daemon.')
subparsers = parser.add_subparsers(dest='command', required=True)

# Basic commands
subparsers.add_parser('play', help='start playback')
subparsers.add_parser('pause', help='pause playback')
subparsers.add_parser('next', help='play next track')
subparsers.add_parser('previous', help='play previous track')

# Set commands
set_parser = subparsers.add_parser('set', help='player settings')
set_subparsers = set_parser.add_subparsers(dest='setting')

# Shuffle
shuffle_parser = set_subparsers.add_parser('shuffle', help='toggle shuffle')
shuffle_parser.add_argument('state', choices=['on', 'off'], help='shuffle state')

# Volume
volume_parser = set_subparsers.add_parser('volume', help='set volume')
volume_parser.add_argument('level', type=int, choices=range(0, 101), help='volume level from 0 to 100')

# Get commands
get_parser = subparsers.add_parser('get', help='get information')
get_subparsers = get_parser.add_subparsers(dest='get_type')

get_subparsers.add_parser('playlists', help='get playlists')
get_subparsers.add_parser('albums', help='get albums')

# Play specific item
play_parser = subparsers.add_parser('play', help='play specific item')
play_subparsers = play_parser.add_subparsers(dest='play_type')

play_playlist_parser = play_subparsers.add_parser('playlist', help='play a specific playlist')
play_playlist_parser.add_argument('name', type=str, help='playlist name')

play_album_parser = play_subparsers.add_parser('album', help='play a specific album')
play_album_parser.add_argument('name', type=str, help='album name')

play_album_parser = play_subparsers.add_parser('uri', help='play resource by spotify URI')
play_album_parser.add_argument('uri', type=str, help='spotify uri')

parser.add_argument('--format', choices=['json', 'text', 'verbose'], default='text', help='output format')

args = parser.parse_args()

payload = {
'command': args.command,
'setting': getattr(args, 'setting', None),
'type': getattr(args, 'get_type', None) or getattr(args, 'play_type', None),
'value': getattr(args, 'state', None) or getattr(args, 'level', None) or getattr(args, 'name', None) or getattr(args, 'uri', None)
}

client = Client()
if client.connect():
client.send_command(payload)
client.handle_response(getattr(args, 'format'))
client.close()

if __name__ == "__main__":
main()
1 change: 1 addition & 0 deletions src/spotify_cmd_client/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
argparse==1.4.0
Empty file.
39 changes: 39 additions & 0 deletions src/spotify_cmd_daemon/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import signal
import sys
import argparse
import os
import daemon
from spotify_controller import SpotifyController
from socket_server import SocketServer

def signal_handler(sig, frame, server):
server.stop_server()
if os.path.exists(lock_file):
os.remove(lock_file)
sys.exit(0)

def run_server(server):
with daemon.DaemonContext():
server.start_server()

parser = argparse.ArgumentParser()
parser.add_argument("-f", "--foreground", action="store_true", help="Run in foreground mode (not as a daemon)")
args = parser.parse_args()

spotify = SpotifyController()
server = SocketServer(spotify)

lock_file = "/tmp/myapp.lock"
if os.path.exists(lock_file):
print("Application already running.")
sys.exit(1)
else:
with open(lock_file, 'w'): pass

signal.signal(signal.SIGINT, lambda s, f: signal_handler(s, f, server))
signal.signal(signal.SIGTERM, lambda s, f: signal_handler(s, f, server))

if args.foreground:
server.start_server()
else:
run_server(server)
9 changes: 9 additions & 0 deletions src/spotify_cmd_daemon/operations/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from .get_library_albums import get_library_albums
from .get_library_playlists import get_library_playlists
from .next_track import next_track
from .pause_playback import pause_playback
from .previous_track import previous_track
from .resume_playback import resume_playback
from .set_shuffle import set_shuffle
from .set_volume import set_volume
from .start_playback import start_playback
Loading

0 comments on commit db7d1f1

Please sign in to comment.