├── .vscode └── settings.example.json ├── requirements.txt ├── .gitignore ├── .env.example ├── check_pi_cam.py ├── main.py ├── README.md ├── qr_detector_pi.py ├── qr_detector_cv.py └── spotify_controller.py /.vscode/settings.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "[python]": { 3 | "editor.formatOnSave": true, 4 | "editor.defaultFormatter": "charliermarsh.ruff" 5 | } 6 | } -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | numpy==2.2.0 2 | opencv-python==4.10.0.84 3 | picamera2==0.3.24 4 | PyChromecast==14.0.5 5 | python-dotenv==1.0.1 6 | pyzbar==0.1.9 7 | rpi-libcamera==0.1a7 8 | spotipy==2.24.0 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # python 2 | __pycache__ 3 | env 4 | 5 | # spotify 6 | .cache 7 | 8 | # env 9 | .env 10 | .env.local 11 | 12 | # VS Code 13 | .vscode/settings.json 14 | 15 | # images 16 | images/ 17 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Log level 2 | # DEBUG, INFO, WARNING, ERROR, CRITICAL 3 | LOG_LEVEL=DEBUG 4 | 5 | # Debug mode, for showing video window 6 | DEBUG_MODE=True 7 | 8 | # Camera index, usually 0 9 | CAMERA=0 10 | 11 | # Chromecast 12 | CHROMECAST_NAME='' 13 | 14 | # Spotify details: 15 | SPOTIFY_CLIENT_ID='' 16 | SPOTIFY_CLIENT_SECRET='' 17 | SPOTIFY_REDIRECT_URI='http://localhost:8888/callback' 18 | -------------------------------------------------------------------------------- /check_pi_cam.py: -------------------------------------------------------------------------------- 1 | # Run this to check if a Raspberry Pi camera can be accessed with Python 2 | import time 3 | from picamera2 import Picamera2, Preview 4 | from libcamera import controls 5 | 6 | picam = Picamera2() 7 | 8 | # Configure for preview 9 | config = picam.create_preview_configuration() 10 | picam.configure(config) 11 | 12 | # Start the preview 13 | picam.start_preview(Preview.QT) 14 | picam.set_controls({"AfMode": controls.AfModeEnum.Continuous}) 15 | 16 | picam.start() 17 | 18 | time.sleep(10) 19 | 20 | picam.close() 21 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import platform 2 | 3 | # Use macOS or Pi camera lib 4 | if platform.system() == "Darwin": 5 | from qr_detector_cv import QRDetectorCV as QRDetector 6 | else: 7 | from qr_detector_pi import QRDetectorPi as QRDetector 8 | 9 | from spotify_controller import SpotifyController 10 | from dotenv import load_dotenv 11 | import os 12 | import logging 13 | 14 | load_dotenv() 15 | 16 | # Load env vars 17 | env = { 18 | "LOG_LEVEL": os.getenv("LOG_LEVEL", "WARNING").upper(), 19 | "DEBUG_MODE": os.getenv("DEBUG_MODE", "False").lower() == "true", 20 | "CAMERA": int(os.getenv("CAMERA", 0)), 21 | "DEVICE_NAME": os.getenv("DEVICE_NAME"), 22 | "USE_CONNECT": os.getenv("USE_CONNECT", "False").lower() == "true", 23 | "SPOTIFY_CLIENT_ID": os.getenv("SPOTIFY_CLIENT_ID"), 24 | "SPOTIFY_CLIENT_SECRET": os.getenv("SPOTIFY_CLIENT_SECRET"), 25 | "SPOTIFY_REDIRECT_URI": os.getenv("SPOTIFY_REDIRECT_URI"), 26 | } 27 | 28 | logging.basicConfig( 29 | level=getattr(logging, env["LOG_LEVEL"], logging.INFO), 30 | format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", 31 | ) 32 | logger = logging.getLogger(__name__) 33 | 34 | 35 | class Main: 36 | def __init__(self): 37 | logger.info(f"Starting application with configuration: {env}") 38 | 39 | def handle_qr_data(self, qr_data): 40 | """ 41 | Handle decoded QR code data. 42 | 43 | Args: 44 | qr_data (str): The decoded QR code data. 45 | """ 46 | logger.info(f"Action triggered for QR Code: {qr_data}") 47 | try: 48 | self.spotify_controller.play(qr_data) 49 | except Exception as e: 50 | logger.error(f"Failed to play Spotify URL: {e}") 51 | 52 | def run(self): 53 | """ 54 | Instantiate and run. 55 | """ 56 | self.spotify_controller = SpotifyController( 57 | device_name=env["DEVICE_NAME"], 58 | use_connect=env["USE_CONNECT"], 59 | spotify_client_id=env["SPOTIFY_CLIENT_ID"], 60 | spotify_client_secret=env["SPOTIFY_CLIENT_SECRET"], 61 | spotify_redirect_uri=env["SPOTIFY_REDIRECT_URI"], 62 | ) 63 | self.spotify_controller.run() 64 | 65 | qr_detector = QRDetector( 66 | debug_mode=env["DEBUG_MODE"], 67 | camera=env["CAMERA"], 68 | callback=self.handle_qr_data, 69 | ) 70 | qr_detector.run() 71 | 72 | 73 | main = Main() 74 | main.run() 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Spotify QR player 2 | 3 | Use a webcam to read QR codes and play albums/playlists on Spotify 4 | 5 | ## Requirements 6 | 7 | - Python 3.12+ 8 | - zbar 9 | 10 | 11 | ## Setup 12 | 13 | ### Spotify 14 | 15 | Create a new Spotify app: 16 | 17 | [https://developer.spotify.com/dashboard](https://developer.spotify.com/dashboard) 18 | 19 | Make a note of your _Client ID_ and _Client Secret_ 20 | 21 | 22 | ## Installation 23 | 24 | ### macOS 25 | 26 | ```sh 27 | brew install zbar 28 | ``` 29 | 30 | ### Raspberry Pi: 31 | 32 | Running this on a Raspberry Pi may involve installing the following: 33 | 34 | ``` 35 | sudo apt update 36 | sudo apt upgrade 37 | sudo apt install -y python3-picamera2 38 | ``` 39 | 40 | You may also need: 41 | 42 | ``` 43 | sudo apt install -y libcamera-apps libcamera-dev python3-libcamera python3-pyzbar python3-numpy python3-pyqt5 libcap-dev libzbar0 libzbar-dev 44 | ``` 45 | 46 | 47 | ### Python 48 | 49 | Create a virtual env: 50 | 51 | ```sh 52 | python -m venv env 53 | source env/bin/activate 54 | ``` 55 | 56 | On a Raspberry Pi you may need to replace the above with: 57 | 58 | ```sh 59 | python -m venv --system-site-packages env 60 | ``` 61 | 62 | Install dependencies: 63 | 64 | ```sh 65 | pip install -r requirements.txt 66 | ``` 67 | 68 | ### .env 69 | 70 | Duplicate the `.env.example` file, call it `.env` and populate it 71 | 72 | ```sh 73 | cp .env.example .env 74 | ``` 75 | 76 | 77 | ## Running 78 | 79 | ```sh 80 | python main.py 81 | ``` 82 | 83 | 84 | ## URLs 85 | 86 | ### Bill Withers - Just As I Am 87 | 88 | URL: https://open.spotify.com/album/6N8uPmDqbgXD3ztkCCfxoo 89 | 90 | Spotify URL: spotify:album:6N8uPmDqbgXD3ztkCCfxoo 91 | 92 | ![](./images/album-bill-withers.png) 93 | 94 | ### Soul playlist 95 | 96 | URL: https://open.spotify.com/playlist/5lqpiF52jXxDYwUUeANTbI 97 | 98 | Spotify URL: spotify:playlist:5lqpiF52jXxDYwUUeANTbI 99 | 100 | ![](./images/playlist-soul.png) 101 | 102 | --- 103 | 104 | 105 | ## Maintenance and support 106 | 107 | [![No Maintenance Intended](http://unmaintained.tech/badge.svg)](http://unmaintained.tech/) 108 | 109 | --- 110 | 111 | ## License 112 | 113 | This work is free. You can redistribute it and/or modify it under the 114 | terms of the Do What The Fuck You Want To Public License, Version 2, 115 | as published by Sam Hocevar. See http://www.wtfpl.net/ for more details. 116 | 117 | ``` 118 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 119 | Version 2, December 2004 120 | 121 | Copyright (C) 2004 Sam Hocevar 122 | 123 | Everyone is permitted to copy and distribute verbatim or modified 124 | copies of this license document, and changing it is allowed as long 125 | as the name is changed. 126 | 127 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 128 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 129 | 130 | 0. You just DO WHAT THE FUCK YOU WANT TO. 131 | 132 | ``` 133 | -------------------------------------------------------------------------------- /qr_detector_pi.py: -------------------------------------------------------------------------------- 1 | from picamera2 import Picamera2, Preview 2 | from libcamera import controls 3 | from pyzbar.pyzbar import decode 4 | import numpy as np 5 | import time 6 | import logging 7 | 8 | logging.basicConfig(level=logging.INFO) 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class QRDetectorPi: 13 | def __init__(self, debug_mode=False, camera=0, callback=None): 14 | """ 15 | Initialise the QRDetector. 16 | 17 | Args: 18 | debug_mode (bool): Whether to display debug video. 19 | callback (callable): A function to handle detected QR code data. 20 | """ 21 | self.debug_mode = debug_mode 22 | self.camera = camera 23 | self.callback = callback if callback else self.default_callback 24 | 25 | self.last_triggered = None # Last triggered QR code 26 | self.debounce_start_time = None # Start time of the debounce period 27 | self.debounce_duration = 5 # Debounce duration in seconds 28 | 29 | # Initialise the picamera2 30 | self.picam = Picamera2() 31 | self.picam.configure(self.picam.create_preview_configuration()) 32 | logger.info(f"QRDetector initialised with debug_mode={debug_mode}") 33 | 34 | def default_callback(self, qr_data): 35 | """Default callback if no custom callback is provided.""" 36 | logger.info(f"QR Code detected: {qr_data}") 37 | 38 | def detect_qr_codes(self, frame): 39 | """ 40 | Detect QR codes in a video frame. 41 | 42 | Args: 43 | frame (numpy.ndarray): The input video frame. 44 | 45 | Returns: 46 | list: A list of decoded QR codes. 47 | """ 48 | return decode(frame) 49 | 50 | def process_qr_codes(self, qr_codes): 51 | """ 52 | Process detected QR codes and invoke the callback. 53 | 54 | Args: 55 | qr_codes (list): List of detected QR codes. 56 | """ 57 | current_time = time.time() 58 | 59 | if qr_codes: 60 | qr_data = qr_codes[0].data.decode("utf-8") 61 | 62 | if self.last_triggered == qr_data: 63 | # Restart debounce timer for the same QR code 64 | self.debounce_start_time = current_time 65 | return 66 | 67 | if ( 68 | self.debounce_start_time is None 69 | or current_time - self.debounce_start_time >= self.debounce_duration 70 | ): 71 | self.callback(qr_data) 72 | self.last_triggered = qr_data 73 | self.debounce_start_time = current_time 74 | 75 | def run(self): 76 | """ 77 | Start the QR code detection process. 78 | """ 79 | logger.info("Starting QR code detection... Press Ctrl+C to exit.") 80 | self.picam.start_preview(Preview.QT if self.debug_mode else Preview.NULL) 81 | 82 | try: 83 | self.picam.start() 84 | self.picam.set_controls({"AfMode": controls.AfModeEnum.Continuous}) 85 | while True: 86 | # Capture a frame from the camera 87 | frame = self.picam.capture_array() 88 | 89 | # Convert the frame to grayscale 90 | gray_frame = np.mean(frame, axis=2).astype(np.uint8) 91 | 92 | # Detect QR codes 93 | qr_codes = self.detect_qr_codes(gray_frame) 94 | self.process_qr_codes(qr_codes) 95 | 96 | except KeyboardInterrupt: 97 | logger.info("Exit signal received. Stopping...") 98 | 99 | finally: 100 | self.picam.stop() 101 | logger.info("Stopped QR code detection.") 102 | -------------------------------------------------------------------------------- /qr_detector_cv.py: -------------------------------------------------------------------------------- 1 | import cv2 2 | import numpy as np 3 | from pyzbar.pyzbar import decode 4 | import time 5 | import logging 6 | 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class QRDetectorCV: 12 | def __init__(self, debug_mode=False, camera=0, callback=None): 13 | """ 14 | Initialise the QRDetector. 15 | 16 | Args: 17 | debug_mode (bool): Whether to display debug video. 18 | camera (int): The camera index for video capture. 19 | callback (callable): A function to handle detected QR code data. 20 | """ 21 | self.debug_mode = debug_mode 22 | self.camera = camera 23 | self.callback = callback 24 | 25 | self.last_triggered = None # Last triggered QR code 26 | self.debounce_start_time = None # Start time of the debounce period 27 | self.debounce_duration = 5 # Debounce duration in seconds 28 | 29 | logger.debug( 30 | f"QRDetector initialised with debug_mode={debug_mode}, camera={camera}" 31 | ) 32 | 33 | def detect_qr_codes(self, frame): 34 | """ 35 | Detect QR codes in a video frame. 36 | 37 | Args: 38 | frame (numpy.ndarray): The input video frame. 39 | 40 | Returns: 41 | list: A list of decoded QR codes. 42 | """ 43 | gray_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) 44 | return decode(gray_frame) 45 | 46 | def process_qr_codes(self, qr_codes): 47 | """ 48 | Process detected QR codes and invoke the callback. 49 | 50 | Args: 51 | qr_codes (list): List of detected QR codes. 52 | """ 53 | current_time = time.time() 54 | 55 | if qr_codes: 56 | # Use the first detected QR code 57 | qr_data = qr_codes[0].data.decode("utf-8") 58 | 59 | # If in debounce, check if the same QR code is still present 60 | if self.last_triggered == qr_data: 61 | self.debounce_start_time = current_time # Restart debounce 62 | return 63 | 64 | # If not in debounce or debounce has elapsed, trigger the action 65 | if ( 66 | self.debounce_start_time is None 67 | or current_time - self.debounce_start_time >= self.debounce_duration 68 | ): 69 | if self.callback: 70 | self.callback(qr_data) 71 | self.last_triggered = qr_data 72 | self.debounce_start_time = current_time # Start debounce 73 | 74 | def debug(self, frame, qr_codes): 75 | """ 76 | Draw rectangles around detected QR codes, and display in a window. 77 | 78 | Args: 79 | frame (numpy.ndarray): The input video frame. 80 | qr_codes (list): List of detected QR codes. 81 | """ 82 | for qr_code in qr_codes: 83 | points = qr_code.polygon 84 | if len(points) > 4: 85 | hull = cv2.convexHull(np.array(points, dtype=np.float32)) 86 | cv2.polylines(frame, [hull.astype(int)], True, (255, 0, 255), 3) 87 | else: 88 | cv2.polylines( 89 | frame, [np.array(points, dtype=np.int32)], True, (255, 0, 255), 3 90 | ) 91 | 92 | cv2.imshow("Webcam Debug Window", frame) 93 | cv2.waitKey(1) # Keep window responsive 94 | 95 | def run(self): 96 | """ 97 | Start the QR code detection process. 98 | """ 99 | cap = cv2.VideoCapture(self.camera) 100 | logger.info("Starting QR code detection...") 101 | 102 | try: 103 | while True: 104 | ret, frame = cap.read() 105 | if not ret: 106 | logger.error("Failed to capture video frame. Exiting...") 107 | break 108 | 109 | qr_codes = self.detect_qr_codes(frame) 110 | self.process_qr_codes(qr_codes) 111 | 112 | if self.debug_mode: 113 | self.debug(frame, qr_codes) 114 | 115 | finally: 116 | cap.release() 117 | if self.debug_mode: 118 | cv2.destroyAllWindows() 119 | logger.info("Released camera, destroyed windows") 120 | -------------------------------------------------------------------------------- /spotify_controller.py: -------------------------------------------------------------------------------- 1 | import pychromecast 2 | from spotipy import Spotify, SpotifyOAuth 3 | import time 4 | import logging 5 | 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class SpotifyController: 11 | def __init__( 12 | self, 13 | device_name, 14 | use_connect, 15 | spotify_client_id, 16 | spotify_client_secret, 17 | spotify_redirect_uri, 18 | ): 19 | """ 20 | Initialise the SpotifyController. 21 | 22 | Args: 23 | device_name (str): The name of the device to connect to. 24 | use_connect (bool): Use Spotify Connect? (e.g. for Chromecasts) 25 | spotify_client_id (str): The Spotify App client id 26 | spotify_client_secret (str): The Spotify App client secret 27 | spotify_redirect_uri (str): The Spotify App redirect uri 28 | """ 29 | self.device_name = device_name 30 | self.use_connect = use_connect 31 | self.spotify_client_id = spotify_client_id 32 | self.spotify_client_secret = spotify_client_secret 33 | self.spotify_redirect_uri = spotify_redirect_uri 34 | 35 | self.spotify = None 36 | self.device = None 37 | self.zeroconf = None 38 | 39 | def run(self): 40 | """ 41 | Initialise the Spotify client using the spotipy library. 42 | """ 43 | try: 44 | self.spotify = Spotify( 45 | auth_manager=SpotifyOAuth( 46 | client_id=self.spotify_client_id, 47 | client_secret=self.spotify_client_secret, 48 | redirect_uri=self.spotify_redirect_uri, 49 | scope="user-read-playback-state user-modify-playback-state", 50 | open_browser=False, 51 | ) 52 | ) 53 | logger.info("Spotify client initialised successfully.") 54 | except Exception as e: 55 | logger.error(f"Failed to initialise Spotify client: {e}") 56 | raise 57 | 58 | def _discover_chromecast(self): 59 | """ 60 | Discover and connect to the Chromecast device. 61 | 62 | Returns: 63 | Chromecast: The connected Chromecast instance. 64 | """ 65 | logger.info("Discovering Chromecast devices...") 66 | chromecasts, browser = pychromecast.get_listed_chromecasts( 67 | friendly_names=[self.device_name] 68 | ) 69 | if not chromecasts: 70 | logger.error(f"Chromecast named '{self.device_name}' not found.") 71 | raise ValueError(f"Chromecast named '{self.device_name}' not found.") 72 | 73 | cast = list(chromecasts)[0] 74 | logger.info(f"Using Chromecast: {cast.name}") 75 | cast.wait() # Wait for the Chromecast to be ready 76 | self.device = cast 77 | return 78 | 79 | def play(self, spotify_url): 80 | """ 81 | Play a Spotify URL on the device. 82 | 83 | Args: 84 | spotify_url (str): The Spotify track or playlist URL. 85 | """ 86 | if self.use_connect: 87 | if not self.device: 88 | self._discover_chromecast() 89 | 90 | # spotify_app_id = "CC32E753" 91 | # self.device.quit_app() 92 | # self.device.start_app(spotify_app_id) 93 | # self.device.wait() 94 | 95 | # Retry mechanism to get the Spotify device ID 96 | retries = 100 97 | delay = 3 # seconds 98 | 99 | device_id = None 100 | for attempt in range(retries): 101 | devices = self.spotify.devices()["devices"] 102 | device_id = next( 103 | (d["id"] for d in devices if d["name"] == self.device_name), None 104 | ) 105 | if device_id: 106 | break 107 | logger.info( 108 | f"Device '{self.device_name}' not found. Retrying... ({attempt + 1}/{retries})" 109 | ) 110 | time.sleep(delay) 111 | 112 | if not device_id: 113 | logger.error( 114 | f"Spotify device ID for device not found after {retries} retries: {devices}" 115 | ) 116 | raise ValueError("Spotify device ID for device not found.") 117 | 118 | try: 119 | logger.info(f"Playing Spotify URL: {spotify_url}") 120 | self.spotify.start_playback(device_id=device_id, context_uri=spotify_url) 121 | except Exception as e: 122 | logger.error(f"Failed to start playback: {e}") 123 | raise 124 | --------------------------------------------------------------------------------