-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit db7d1f1
Showing
38 changed files
with
1,345 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
__pycache__/ | ||
*.pyc | ||
*.cache |
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" "$@" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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']}") |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
argparse==1.4.0 |
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.