├── img ├── pavucontrol_playback_tab.jpeg ├── pavucontrol_recording_tab.jpeg ├── pavucontrol_configuration_tab.jpeg ├── pavucontrol_input_devices_tab.jpeg └── pavucontrol_output_devices_tab.jpeg ├── LICENSE ├── .gitignore ├── README.md └── spotrec.py /img/pavucontrol_playback_tab.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bleuzen/SpotRec/HEAD/img/pavucontrol_playback_tab.jpeg -------------------------------------------------------------------------------- /img/pavucontrol_recording_tab.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bleuzen/SpotRec/HEAD/img/pavucontrol_recording_tab.jpeg -------------------------------------------------------------------------------- /img/pavucontrol_configuration_tab.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bleuzen/SpotRec/HEAD/img/pavucontrol_configuration_tab.jpeg -------------------------------------------------------------------------------- /img/pavucontrol_input_devices_tab.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bleuzen/SpotRec/HEAD/img/pavucontrol_input_devices_tab.jpeg -------------------------------------------------------------------------------- /img/pavucontrol_output_devices_tab.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bleuzen/SpotRec/HEAD/img/pavucontrol_output_devices_tab.jpeg -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Bleuzen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # Build files 107 | *.bin 108 | *.build 109 | 110 | # Binary 111 | spotrec 112 | 113 | # Dist files 114 | *.dist 115 | *.tar.gz 116 | 117 | # Default recording directory 118 | Audio 119 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SpotRec 2 | 3 | Python script to record the audio of the Spotify desktop client using FFmpeg 4 | and PulseAudio 5 | 6 | AUR: https://aur.archlinux.org/packages/spotrec/ 7 | 8 | 9 | 10 | ## Usage 11 | 12 | If you use the AUR package, 13 | you can simply run: 14 | 15 | ``` 16 | spotrec 17 | ``` 18 | 19 | If you have a GNU/Linux distribution with a different package manager system, 20 | run: 21 | 22 | ``` 23 | python3 spotrec.py 24 | ``` 25 | 26 | 27 | 28 | ### Example 29 | 30 | First of all run spotify. 31 | 32 | Then you can run the python script which will record the music: 33 | 34 | ``` 35 | ./spotrec.py -o ./my_song_dir --skip-intro 36 | ``` 37 | 38 | Check the pulseaudio configuration: 39 | 40 | ``` 41 | pavucontrol 42 | ``` 43 | 44 | Pay attention to the red circles, everything else is muted and with volume set 45 | to 0% 46 | 47 | ![playback tab](https://github.com/Bleuzen/SpotRec/raw/master/img/pavucontrol_playback_tab.jpeg) 48 | 49 | Note: actually "Lavf..." will appear after you start playing a song 50 | 51 | ![recording tab](https://github.com/Bleuzen/SpotRec/raw/master/img/pavucontrol_recording_tab.jpeg) 52 | 53 | ![output devices tab](https://github.com/Bleuzen/SpotRec/raw/master/img/pavucontrol_output_devices_tab.jpeg) 54 | 55 | ![input devices tab](https://github.com/Bleuzen/SpotRec/raw/master/img/pavucontrol_input_devices_tab.jpeg) 56 | 57 | ![configuration tab](https://github.com/Bleuzen/SpotRec/raw/master/img/pavucontrol_configuration_tab.jpeg) 58 | 59 | Finally start playing whatever you want 60 | 61 | 62 | ## Hints 63 | 64 | - Disable volume normalization in the Spotify Client 65 | 66 | - Do not change the volume during recording 67 | 68 | - Use Audacity for post processing 69 | 70 | * because SpotRec records a little longer at the end to ensure that nothing is missing of the song. But sometimes this also includes the beginning of the next song. So you should use Audacity to cut the audio to what you want. From Audacity you can also export it to the format you like (ogg/mp3/...). 71 | 72 | 73 | ## Troubleshooting 74 | 75 | Start the script with the debug flag: 76 | 77 | ``` 78 | ./spotrec.py --debug 79 | ``` 80 | 81 | If one of the following scenarios happens: 82 | 83 | * you do not see something like the ffmpeg output, which should appear right 84 | few seconds after the song start 85 | 86 | ``` 87 | # what you should see when ffmpeg is recording ... 88 | size=56400kB time=00:00:04.15 bitrate= 130.7kbits/s speed=1x 89 | ``` 90 | 91 | * you do not see any "Lavf..." in the pavucontrol 92 | [recording tab](https://github.com/Bleuzen/SpotRec/raw/master/img/pavucontrol_recording_tab.jpeg) 93 | * you get a stacktrace ending with: 94 | 95 | ``` 96 | ValueError: invalid literal for int() with base 10: 'nput' 97 | ``` 98 | 99 | I would suggest you to: 100 | 101 | * quickly press the "next song button" and then the "previous song button" in 102 | the spotify client 103 | * stop everything and start over, after some tries it usually works :) 104 | 105 | 106 | **Note: sometimes spotify detects when the user does not interact with the 107 | application for a long time (more or less an hour) and starts looping over a 108 | song, to avoid this scenario I would suggest to keep interacting with the 109 | spotify client.** 110 | -------------------------------------------------------------------------------- /spotrec.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | # License: https://raw.githubusercontent.com/Bleuzen/SpotRec/master/LICENSE 4 | 5 | import dbus 6 | from dbus.exceptions import DBusException 7 | import dbus.mainloop.glib 8 | from gi.repository import GLib 9 | from pathlib import Path 10 | 11 | from threading import Thread 12 | import subprocess 13 | import time 14 | import sys 15 | import shutil 16 | import re 17 | import os 18 | import argparse 19 | import traceback 20 | import logging 21 | import shlex 22 | import requests 23 | 24 | # Deps: 25 | # 'python' 26 | # 'python-dbus' 27 | # 'ffmpeg' 28 | # 'gawk': awk in command to get sink input id of spotify 29 | # 'pulseaudio': sink control stuff 30 | # 'bash': shell commands 31 | # 'requests': get album art 32 | 33 | # TODO: 34 | # - set fixed latency on pipewire (currently only done by ffmpeg while it is recording ("fragment_size" parameter), but should ideally be set before recording) 35 | 36 | app_name = "SpotRec" 37 | app_version = "0.15.1" 38 | 39 | # Settings with Defaults 40 | _debug_logging = False 41 | _skip_intro = False 42 | _mute_pa_recording_sink = False 43 | _output_directory = f"{Path.home()}/{app_name}" 44 | _filename_pattern = "{trackNumber} - {artist} - {title}" 45 | _underscored_filenames = False 46 | _use_internal_track_counter = False 47 | _add_cover_art = False 48 | 49 | # Hard-coded settings 50 | _pa_recording_sink_name = "spotrec" 51 | _pa_max_volume = "65536" 52 | _recording_time_before_song = 0.25 53 | _recording_time_after_song = 1.25 54 | _playback_time_before_seeking_to_beginning = 5.0 55 | _shell_executable = "/bin/bash" # Default: "/bin/sh" 56 | _shell_encoding = "utf-8" 57 | _ffmpeg_executable = "ffmpeg" # Example: "/usr/bin/ffmpeg" 58 | 59 | # Variables that change during runtime 60 | is_script_paused = False 61 | is_first_playing = True 62 | pa_spotify_sink_input_id = -1 63 | internal_track_counter = 1 64 | is_shutting_down = False 65 | 66 | 67 | def main(): 68 | handle_command_line() 69 | 70 | if not _skip_intro: 71 | print(app_name + " v" + app_version) 72 | print("You should not pause, seek or change volume during recording!") 73 | print("Existing files will be overridden!") 74 | print("Use --help as argument to see all options.") 75 | print() 76 | print("Disclaimer:") 77 | print('This software is for "educational" purposes only. No responsibility is held or accepted for misuse.') 78 | print() 79 | print("Output directory:") 80 | print(_output_directory) 81 | print() 82 | 83 | init_log() 84 | 85 | # Create the output directory 86 | Path(_output_directory).mkdir( 87 | parents=True, exist_ok=True) 88 | 89 | # Init Spotify DBus listener 90 | global _spotify 91 | _spotify = Spotify() 92 | 93 | # Load PulseAudio sink 94 | PulseAudio.load_sink() 95 | 96 | _spotify.init_pa_stuff_if_needed() 97 | 98 | # Keep the main thread alive (to be able to handle KeyboardInterrupt) 99 | while True: 100 | time.sleep(1) 101 | 102 | 103 | def doExit(): 104 | log.info(f"[{app_name}] Shutting down ...") 105 | 106 | global is_shutting_down 107 | is_shutting_down = True 108 | 109 | # Stop Spotify DBus listener 110 | _spotify.quit_glib_loop() 111 | 112 | # Kill all FFmpeg subprocesses 113 | FFmpeg.killAll() 114 | 115 | # Unload PulseAudio sink 116 | PulseAudio.unload_sink() 117 | 118 | log.info(f"[{app_name}] Bye") 119 | 120 | # Have to use os exit here, because otherwise GLib would print a strange error message 121 | os._exit(0) 122 | # sys.exit(0) 123 | 124 | 125 | def handle_command_line(): 126 | global _debug_logging 127 | global _skip_intro 128 | global _mute_pa_recording_sink 129 | global _output_directory 130 | global _filename_pattern 131 | global _underscored_filenames 132 | global _use_internal_track_counter 133 | global _add_cover_art 134 | 135 | parser = argparse.ArgumentParser( 136 | description=app_name + " v" + app_version, formatter_class=argparse.RawTextHelpFormatter) 137 | parser.add_argument("-d", "--debug", help="Print a little more", 138 | action="store_true", default=_debug_logging) 139 | parser.add_argument("-s", "--skip-intro", help="Skip the intro message", 140 | action="store_true", default=_skip_intro) 141 | parser.add_argument("-m", "--mute-recording", help="Mute Spotify on your main output device while recording", 142 | action="store_true", default=_mute_pa_recording_sink) 143 | parser.add_argument("-o", "--output-directory", help="Where to save the recordings\n" 144 | "Default: " + _output_directory, default=_output_directory) 145 | parser.add_argument("-p", "--filename-pattern", help="A pattern for the file names of the recordings\n" 146 | "Available: {artist}, {album}, {trackNumber}, {title}\n" 147 | "Default: \"" + _filename_pattern + "\"\n" 148 | "May contain slashes to create sub directories\n" 149 | "Example: \"{artist}/{album}/{trackNumber} {title}\"", default=_filename_pattern) 150 | parser.add_argument("-u", "--underscored-filenames", help="Force the file names to have underscores instead of whitespaces", 151 | action="store_true", default=_underscored_filenames) 152 | parser.add_argument("-c", "--internal-track-counter", help="Replace Spotify's trackNumber with own counter. Useable for preserving a playlist file order", 153 | action="store_true", default=_use_internal_track_counter) 154 | parser.add_argument("-a", "--add-cover-art", help="Embed the cover art from Spotify into the file", 155 | action="store_true", default=_add_cover_art) 156 | 157 | args = parser.parse_args() 158 | 159 | _debug_logging = args.debug 160 | 161 | _skip_intro = args.skip_intro 162 | 163 | _mute_pa_recording_sink = args.mute_recording 164 | 165 | _filename_pattern = args.filename_pattern 166 | 167 | _output_directory = args.output_directory 168 | 169 | _underscored_filenames = args.underscored_filenames 170 | 171 | _use_internal_track_counter = args.internal_track_counter 172 | 173 | _add_cover_art = args.add_cover_art 174 | 175 | 176 | def init_log(): 177 | global log 178 | log = logging.getLogger() 179 | 180 | if _debug_logging: 181 | FORMAT = '%(asctime)-15s - %(levelname)s - %(message)s' 182 | log.setLevel(logging.DEBUG) 183 | else: 184 | FORMAT = '%(message)s' 185 | log.setLevel(logging.INFO) 186 | 187 | logging.basicConfig(format=FORMAT) 188 | 189 | log.debug("Logger initialized") 190 | 191 | 192 | class Spotify: 193 | dbus_dest = "org.mpris.MediaPlayer2.spotify" 194 | dbus_path = "/org/mpris/MediaPlayer2" 195 | mpris_player_string = "org.mpris.MediaPlayer2.Player" 196 | 197 | def __init__(self): 198 | self.glibloop = None 199 | 200 | dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) 201 | 202 | try: 203 | # Connect to Spotify client dbus interface 204 | bus = dbus.SessionBus() 205 | player = bus.get_object(self.dbus_dest, self.dbus_path) 206 | self.iface = dbus.Interface( 207 | player, "org.freedesktop.DBus.Properties") 208 | # Pull the metadata of the current track from Spotify 209 | self.pull_metadata() 210 | # Update own metadata vars for current track 211 | self.update_metadata() 212 | except DBusException: 213 | log.error( 214 | f"Error: Could not connect to the Spotify Client. It has to be running first before starting {app_name}.") 215 | sys.exit(1) 216 | pass 217 | 218 | self.track = self.get_track() 219 | self.trackid = self.metadata.get(dbus.String(u'mpris:trackid')) 220 | self.playbackstatus = self.iface.Get( 221 | self.mpris_player_string, "PlaybackStatus") 222 | 223 | self.iface.connect_to_signal( 224 | "PropertiesChanged", self.on_playing_uri_changed) 225 | 226 | class DBusListenerThread(Thread): 227 | def __init__(self, parent, *args): 228 | Thread.__init__(self) 229 | self.parent = parent 230 | 231 | def run(self): 232 | # Run the GLib event loop to process DBus signals as they arrive 233 | self.parent.glibloop = GLib.MainLoop() 234 | self.parent.glibloop.run() 235 | 236 | # run() blocks this thread. This gets printed after it's dead. 237 | log.info(f"[{app_name}] GLib Loop thread killed") 238 | 239 | dbuslistener = DBusListenerThread(self) 240 | dbuslistener.start() 241 | 242 | log.info(f"[{app_name}] Spotify DBus listener started") 243 | 244 | log.info(f"[{app_name}] Current song: {self.track}") 245 | log.info(f"[{app_name}] Current state: " + self.playbackstatus) 246 | 247 | # TODO: this is a dirty solution (uses cmdline instead of python for now) 248 | def send_dbus_cmd(self, cmd): 249 | Shell.run('dbus-send --print-reply --dest=' + self.dbus_dest + 250 | ' ' + self.dbus_path + ' ' + self.mpris_player_string + '.' + cmd) 251 | 252 | def quit_glib_loop(self): 253 | if self.glibloop is not None: 254 | self.glibloop.quit() 255 | 256 | log.info(f"[{app_name}] Spotify DBus listener stopped") 257 | 258 | def get_metadata_for_ffmpeg(self): 259 | return { 260 | "artist": self.metadata_artist, 261 | "album": self.metadata_album, 262 | "track": self.metadata_trackNumber.lstrip("0"), 263 | "title": self.metadata_title, 264 | "cover_url": self.metadata_artUrl, 265 | } 266 | 267 | def get_track(self): 268 | if _underscored_filenames: 269 | filename_pattern = re.sub(" - ", "__", _filename_pattern) 270 | else: 271 | filename_pattern = _filename_pattern 272 | 273 | ret = str(filename_pattern.format( 274 | artist=self.metadata_artist.replace("/", "_"), 275 | album=self.metadata_album.replace("/", "_"), 276 | trackNumber=self.metadata_trackNumber, 277 | title=self.metadata_title.replace("/", "_") 278 | )) 279 | 280 | if _underscored_filenames: 281 | ret = ret.replace(".", "").lower() 282 | ret = re.sub(r"[\s\-\[\]()']+", "_", ret) 283 | ret = re.sub("__+", "__", ret) 284 | 285 | return ret 286 | 287 | def is_playing(self): 288 | return self.playbackstatus == "Playing" 289 | 290 | def start_record(self): 291 | # Start new recording in new Thread 292 | class RecordThread(Thread): 293 | def __init__(self, parent, *args): 294 | Thread.__init__(self) 295 | self.parent = parent 296 | 297 | def run(self): 298 | global is_script_paused 299 | global _output_directory 300 | 301 | # Save current trackid to check later if it is still the same song playing (to avoid a bug when user skipped a song) 302 | self.trackid_when_thread_started = self.parent.trackid 303 | 304 | # Stop the recording before 305 | # Use copy() to not change the list during this method runs 306 | self.parent.stop_old_recording(FFmpeg.instances.copy()) 307 | 308 | # This is currently the only way to seek to the beginning (let it Play for some seconds, Pause and send Previous) 309 | time.sleep(_playback_time_before_seeking_to_beginning) 310 | 311 | # Check if still the same song is still playing, return if not 312 | if self.trackid_when_thread_started != self.parent.trackid: 313 | return 314 | 315 | # Spotify pauses when the playlist ended. Don't start a recording / return in this case. 316 | if not self.parent.is_playing(): 317 | log.info( 318 | f"[{app_name}] Spotify is paused. Maybe the current album or playlist has ended.") 319 | 320 | # Exit after playlist recorded 321 | if not is_script_paused: 322 | doExit() 323 | 324 | return 325 | 326 | # Do not record ads 327 | if self.parent.trackid.startswith("spotify:ad:"): 328 | log.debug(f"[{app_name}] Skipping ad") 329 | return 330 | 331 | log.info(f"[{app_name}] Starting recording") 332 | 333 | # Set is_script_paused to not trigger wrong Pause event in playbackstatus_changed() 334 | is_script_paused = True 335 | # Pause until out dir is created 336 | self.parent.send_dbus_cmd("Pause") 337 | 338 | # Create output folder if necessary 339 | # If filename_pattern specifies subfolder(s) the track name is only the basename while the dirname is the subfolder path 340 | self.out_dir = os.path.join( 341 | _output_directory, os.path.dirname(self.parent.track)) 342 | Path(self.out_dir).mkdir( 343 | parents=True, exist_ok=True) 344 | 345 | # Go to beginning of the song 346 | is_script_paused = False 347 | self.parent.send_dbus_cmd("Previous") 348 | 349 | # Start FFmpeg recording 350 | ff = FFmpeg() 351 | ff.record(self.out_dir, 352 | self.parent.track, self.parent.get_metadata_for_ffmpeg()) 353 | 354 | # Give FFmpeg some time to start up before starting the song 355 | time.sleep(_recording_time_before_song) 356 | 357 | # Play the track 358 | self.parent.send_dbus_cmd("Play") 359 | 360 | record_thread = RecordThread(self) 361 | record_thread.start() 362 | 363 | def stop_old_recording(self, instances): 364 | # Stop the oldest FFmpeg instance (from recording of song before) (if one is running) 365 | if len(instances) > 0: 366 | class OverheadRecordingStopThread(Thread): 367 | def run(self): 368 | # Record a little longer to not miss something 369 | time.sleep(_recording_time_after_song) 370 | 371 | # Stop the recording 372 | instances[0].stop_blocking() 373 | 374 | overhead_recording_stop_thread = OverheadRecordingStopThread() 375 | overhead_recording_stop_thread.start() 376 | 377 | # This gets called whenever Spotify sends the playingUriChanged signal 378 | def on_playing_uri_changed(self, Player, three, four): 379 | # Pull updated metadata from Spotify 380 | self.pull_metadata() 381 | 382 | # Update track & trackid 383 | new_trackid = self.metadata.get(dbus.String(u'mpris:trackid')) 384 | if self.trackid != new_trackid: 385 | # Update internal track metadata vars 386 | self.update_metadata() 387 | # Update trackid 388 | self.trackid = new_trackid 389 | # Update track name 390 | self.track = self.get_track() 391 | # Trigger event method 392 | self.playing_song_changed() 393 | # Update track counter 394 | if _use_internal_track_counter: 395 | global internal_track_counter 396 | internal_track_counter += 1 397 | 398 | # Update playback status 399 | new_playbackstatus = self.iface.Get(Player, "PlaybackStatus") 400 | if self.playbackstatus != new_playbackstatus: 401 | self.playbackstatus = new_playbackstatus 402 | self.playbackstatus_changed() 403 | 404 | def playing_song_changed(self): 405 | log.info("[Spotify] Song changed: " + self.track) 406 | 407 | self.start_record() 408 | 409 | def playbackstatus_changed(self): 410 | log.info("[Spotify] State changed: " + self.playbackstatus) 411 | 412 | self.init_pa_stuff_if_needed() 413 | 414 | def pull_metadata(self): 415 | self.metadata = self.iface.Get(self.mpris_player_string, "Metadata") 416 | 417 | def update_metadata(self): 418 | self.metadata_artist = ", ".join( 419 | self.metadata.get(dbus.String(u'xesam:artist'))) 420 | self.metadata_album = self.metadata.get(dbus.String(u'xesam:album')) 421 | self.metadata_title = self.metadata.get(dbus.String(u'xesam:title')) 422 | self.metadata_trackNumber = str(self.metadata.get( 423 | dbus.String(u'xesam:trackNumber'))).zfill(2) 424 | # https://github.com/patrickziegler/SpotifyRecorder/blob/4c1cc0a5449d0ca8bfb409ef98f4c7a21c73fe0f/spotify_recorder/track.py#L88 425 | # https://community.spotify.com/t5/Desktop-Linux/MPRIS-cover-art-url-file-not-found/m-p/4929877/highlight/true#M19504 426 | self.metadata_artUrl = str(self.metadata.get(dbus.String(u'mpris:artUrl'))).replace( 427 | "https://open.spotify.com/image/", 428 | "https://i.scdn.co/image/" 429 | ) 430 | 431 | if _use_internal_track_counter: 432 | global internal_track_counter 433 | self.metadata_trackNumber = str(internal_track_counter).zfill(3) 434 | 435 | def init_pa_stuff_if_needed(self): 436 | if self.is_playing(): 437 | global is_first_playing 438 | if is_first_playing: 439 | is_first_playing = False 440 | log.debug(f"[{app_name}] Initializing PulseAudio stuff") 441 | 442 | PulseAudio.init_spotify_sink_input_id() 443 | PulseAudio.set_sink_volumes_to_100() 444 | 445 | PulseAudio.move_spotify_to_own_sink() 446 | 447 | 448 | class FFmpeg: 449 | instances = [] 450 | 451 | def record(self, out_dir: str, file: str, metadata_for_file={}): 452 | self.out_dir = out_dir 453 | 454 | self.pulse_input = _pa_recording_sink_name + ".monitor" 455 | 456 | # Use a dot as filename prefix to hide the file until the recording was successful 457 | self.tmp_file_prefix = "." 458 | self.filename = self.tmp_file_prefix + \ 459 | os.path.basename(file) + ".flac" 460 | 461 | # save this to self because metadata_params is discarded after this function 462 | self.cover_url = metadata_for_file.pop('cover_url') 463 | # build metadata param 464 | metadata_params = '' 465 | for key, value in metadata_for_file.items(): 466 | metadata_params += ' -metadata ' + key + '=' + shlex.quote(value) 467 | 468 | # FFmpeg Options: 469 | # "-hide_banner": short the debug log a little 470 | # "-y": overwrite existing files 471 | # "-ac 2": always use 2 audio channels (stereo) (same as Spotify) 472 | # "-ar 44100": always use 44.1k samplerate (same as Spotify) 473 | # "-fragment_size 8820": set recording latency to 50 ms (0.05*44100*2*2) (very high values can cause ffmpeg to not stop fast enough, so post-processing fails) 474 | # "-acodec flac": use the flac lossless audio codec, so we don't lose quality while recording 475 | self.process = Shell.Popen(_ffmpeg_executable + ' -hide_banner -y ' 476 | '-f pulse ' + 477 | '-ac 2 -ar 44100 -fragment_size 8820 ' + 478 | '-i ' + self.pulse_input + metadata_params + ' ' 479 | '-acodec flac' + 480 | ' ' + shlex.quote(os.path.join(self.out_dir, self.filename))) 481 | 482 | self.pid = str(self.process.pid) 483 | 484 | self.instances.append(self) 485 | 486 | log.info(f"[FFmpeg] [{self.pid}] Recording started") 487 | 488 | # The blocking version of this method waits until the process is dead 489 | def stop_blocking(self): 490 | # Remove from instances list (and terminate) 491 | if self in self.instances: 492 | self.instances.remove(self) 493 | 494 | # Send CTRL_C 495 | self.process.terminate() 496 | 497 | log.info(f"[FFmpeg] [{self.pid}] terminated") 498 | 499 | # Sometimes this is not enough and ffmpeg survives, so we have to kill it after some time 500 | time.sleep(1) 501 | 502 | if self.process.poll() == None: 503 | # None means it has no return code (yet), with other words: it is still running 504 | 505 | self.process.kill() 506 | 507 | log.info(f"[FFmpeg] [{self.pid}] killed") 508 | else: 509 | global is_shutting_down 510 | if not is_shutting_down: # Do not post-process unfinished recordings 511 | tmp_file = os.path.join( 512 | self.out_dir, self.filename) 513 | new_file = os.path.join(self.out_dir, 514 | self.filename[len(self.tmp_file_prefix):]) 515 | if os.path.exists(tmp_file): 516 | shutil.move(tmp_file, new_file) 517 | log.debug( 518 | f"[FFmpeg] [{self.pid}] Successfully renamed {self.filename}") 519 | global _add_cover_art 520 | if _add_cover_art: 521 | class AddCoverArtThread(Thread): 522 | def __init__(self, parent, fullfilepath): 523 | Thread.__init__(self) 524 | self.parent = parent 525 | self.fullfilepath = fullfilepath 526 | 527 | def run(self): 528 | self.parent.add_cover_art( 529 | self.fullfilepath) 530 | 531 | add_cover_art_thread = AddCoverArtThread( 532 | self, new_file) 533 | add_cover_art_thread.start() 534 | else: 535 | log.warning( 536 | f"[FFmpeg] [{self.pid}] Failed renaming {self.filename}") 537 | 538 | # Remove process from memory (and don't left a ffmpeg 'zombie' process) 539 | self.process = None 540 | 541 | # Kill the process in the background 542 | def stop(self): 543 | class KillThread(Thread): 544 | def __init__(self, parent, *args): 545 | Thread.__init__(self) 546 | self.parent = parent 547 | 548 | def run(self): 549 | self.parent.stop_blocking() 550 | 551 | kill_thread = KillThread(self) 552 | kill_thread.start() 553 | 554 | # add cover art to temp _withArtwork file 555 | # and then move it to replace the original file 556 | def add_cover_art(self, fullfilepath): 557 | if self.cover_url is None: 558 | log.debug(f'[FFmpeg] No cover art found for {fullfilepath}') 559 | return 560 | # save the image locally -> could use a temp file here 561 | # but might add option to keep image later 562 | cover_file = fullfilepath.rsplit( 563 | '.flac', 1)[0] # remove the extension 564 | log.debug(f'Saving cover art to {cover_file} + image_ext') 565 | temp_file = cover_file + '_withArtwork.' + 'flac' 566 | if self.cover_url.startswith('file://'): 567 | log.debug(f'[FFmpeg] Cover art is local for {fullfilepath}') 568 | path = self.cover_url[len('file://'):] 569 | _, ext = os.path.splitext(path) 570 | cover_file += ext 571 | shutil.copy2(path, cover_file) 572 | else: 573 | log.debug(f'[FFmpeg] Cover art is on server for {fullfilepath}') 574 | answer = requests.get(self.cover_url) 575 | if not answer.ok: 576 | log.debug( 577 | f'[FFmpeg] Cover art not loaded from server for {fullfilepath}') 578 | return 579 | cover_file += "." + answer.headers["Content-Type"].rsplit("/")[-1] 580 | with open(cover_file, "wb") as fd: 581 | fd.write(answer.content) 582 | # add it to a temporary file 583 | log.debug(f'[FFmpeg] Merging cover art into {fullfilepath}') 584 | # no need for separate thread / logging here because quick 585 | returncode = Shell.run(_ffmpeg_executable + ' ' + 586 | '-y -i {} -i {} -map 0:a -map 1 '.format( 587 | shlex.quote(fullfilepath), shlex.quote(cover_file)) + 588 | '-codec copy -id3v2_version 3 ' + 589 | '-metadata:s:v title="Album cover" ' + 590 | '-metadata:s:v comment="Cover (front)" ' + 591 | '-disposition:v attached_pic ' + 592 | shlex.quote(temp_file)).returncode 593 | if returncode != 0: 594 | log.warning(f"[FFmpeg] Failed adding artwork to {fullfilepath}") 595 | return 596 | # overwrite the actual file by the temp file 597 | log.debug( 598 | f'[FFmpeg] Added cover art for {fullfilepath} in temp file, moving it') 599 | shutil.move(temp_file, fullfilepath) 600 | os.remove(cover_file) # now delete the cover art 601 | 602 | @staticmethod 603 | def killAll(): 604 | log.info("[FFmpeg] Killing all instances") 605 | 606 | # Run as long as list ist not empty 607 | while FFmpeg.instances: 608 | FFmpeg.instances[0].stop_blocking() 609 | 610 | log.info("[FFmpeg] All instances killed") 611 | 612 | 613 | class Shell: 614 | @staticmethod 615 | def run(cmd): 616 | # 'run()' waits until the process is done 617 | log.debug(f"[Shell] run: {cmd}") 618 | if _debug_logging: 619 | return subprocess.run(cmd.encode(_shell_encoding), stdin=None, shell=True, executable=_shell_executable, encoding=_shell_encoding) 620 | else: 621 | with open("/dev/null", "w") as devnull: 622 | return subprocess.run(cmd.encode(_shell_encoding), stdin=None, stdout=devnull, stderr=devnull, shell=True, executable=_shell_executable, encoding=_shell_encoding) 623 | 624 | @staticmethod 625 | def Popen(cmd): 626 | # 'Popen()' continues running in the background 627 | log.debug(f"[Shell] Popen: {cmd}") 628 | if _debug_logging: 629 | return subprocess.Popen(cmd.encode(_shell_encoding), stdin=None, shell=True, executable=_shell_executable, encoding=_shell_encoding) 630 | else: 631 | with open("/dev/null", "w") as devnull: 632 | return subprocess.Popen(cmd.encode(_shell_encoding), stdin=None, stdout=devnull, stderr=devnull, shell=True, executable=_shell_executable, encoding=_shell_encoding) 633 | 634 | @staticmethod 635 | def check_output(cmd): 636 | log.debug(f"[Shell] check_output: {cmd}") 637 | out = subprocess.check_output(cmd.encode( 638 | _shell_encoding), shell=True, executable=_shell_executable, encoding=_shell_encoding) 639 | # when not using 'encoding=' -> out.decode() 640 | # but since it is set, decode() ist not needed anymore 641 | # out = out.decode() 642 | return out.rstrip('\n') 643 | 644 | 645 | class PulseAudio: 646 | sink_id = "" 647 | 648 | @staticmethod 649 | def load_sink(): 650 | log.info(f"[{app_name}] Creating pulse sink") 651 | 652 | if _mute_pa_recording_sink: 653 | PulseAudio.sink_id = Shell.check_output('pactl load-module module-null-sink sink_name="' + _pa_recording_sink_name + 654 | '" sink_properties=device.description="' + _pa_recording_sink_name + '" rate=44100 channels=2') 655 | else: 656 | PulseAudio.sink_id = Shell.check_output('pactl load-module module-remap-sink sink_name="' + _pa_recording_sink_name + 657 | '" sink_properties=device.description="' + _pa_recording_sink_name + '" rate=44100 channels=2 remix=no') 658 | # To use another master sink where to play: 659 | # pactl load-module module-remap-sink sink_name=spotrec sink_properties=device.description="spotrec" master=MASTER_SINK_NAME channels=2 remix=no 660 | 661 | @staticmethod 662 | def unload_sink(): 663 | log.info(f"[{app_name}] Unloading pulse sink") 664 | Shell.run('pactl unload-module ' + PulseAudio.sink_id) 665 | 666 | @staticmethod 667 | def init_spotify_sink_input_id(): 668 | global pa_spotify_sink_input_id 669 | 670 | if pa_spotify_sink_input_id > -1: 671 | return 672 | 673 | application_name = "spotify" 674 | cmdout = Shell.check_output( 675 | "pactl list sink-inputs | awk '{print tolower($0)};' | awk '/ #/ {print $0} /application.name = \"" + application_name + "\"/ {print $3};'") 676 | index = -1 677 | last = "" 678 | 679 | for line in cmdout.split('\n'): 680 | if line == '"' + application_name + '"': 681 | index = last.split(" #", 1)[1] 682 | break 683 | last = line 684 | 685 | # Alternative command: 686 | # for i in $(LC_ALL=C pactl list | grep -E '(^Sink Input)|(media.name = \"Spotify\"$)' | cut -d \# -f2 | grep -v Spotify); do echo "$i"; done 687 | 688 | pa_spotify_sink_input_id = int(index) 689 | 690 | @staticmethod 691 | def move_spotify_to_own_sink(): 692 | class MoveSpotifyToSinktThread(Thread): 693 | def run(self): 694 | if pa_spotify_sink_input_id > -1: 695 | exit_code = Shell.run("pactl move-sink-input " + str( 696 | pa_spotify_sink_input_id) + " " + _pa_recording_sink_name).returncode 697 | 698 | if exit_code == 0: 699 | log.info(f"[{app_name}] Moved Spotify to own sink") 700 | else: 701 | log.warning( 702 | f"[{app_name}] Failed to move Spotify to own sink") 703 | 704 | move_spotify_to_sink_thread = MoveSpotifyToSinktThread() 705 | move_spotify_to_sink_thread.start() 706 | 707 | @staticmethod 708 | def set_sink_volumes_to_100(): 709 | log.debug(f"[{app_name}] Set sink volumes to 100%") 710 | 711 | # Set Spotify volume to 100% 712 | Shell.Popen("pactl set-sink-input-volume " + 713 | str(pa_spotify_sink_input_id) + " " + _pa_max_volume) 714 | 715 | # Set recording sink volume to 100% 716 | Shell.Popen("pactl set-sink-volume " + 717 | _pa_recording_sink_name + " " + _pa_max_volume) 718 | 719 | 720 | if __name__ == "__main__": 721 | # Handle exit (not print error when pressing Ctrl^C) 722 | try: 723 | main() 724 | except KeyboardInterrupt: 725 | doExit() 726 | except Exception: 727 | traceback.print_exc(file=sys.stdout) 728 | sys.exit(1) 729 | sys.exit(0) 730 | --------------------------------------------------------------------------------