Skip to content

Commit 2abfd9c

Browse files
committed
First version!
0 parents  commit 2abfd9c

File tree

14 files changed

+1050
-0
lines changed

14 files changed

+1050
-0
lines changed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
__pycache__
2+
.venv/
3+
dist/
4+
*.egg-info

LICENSE

Lines changed: 674 additions & 0 deletions
Large diffs are not rendered by default.

Makefile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
build:
2+
@python3 -m build
3+
4+
clean:
5+
rm -rf dist *.egg-info

README.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Playlist-sync
2+
3+
`playlist-sync` is a little command line tool to download and sync playlists from Deezer or Spotify to predefined folders. It reads playlists links and target folders from a JSON file.
4+
5+
It uses [`deemix`](https://pypi.org/project/deemix/) under the hood to actually download the playlists.
6+
7+
## What you will need
8+
- Python >= 3.8 with pip (untested on earlier versions of Python)
9+
- A Deeezer account. Since `deemix` downloads songs from Deezer, it uses your Deezer account to access Deezer servers and download music. So even if you only want to download Spotify playlists, you will **need** to have a Deezer account. Note that to download 320kbps MP3 or FLAC, you will need a Deezer Premium account. A free Deezer account only allow to download 128kbps MP3.
10+
- A Spotify account if you want to download playlists from Spotify.
11+
12+
## Installation
13+
Playlist-sync can be installed with `pip` from [PyPI](https://pypi.org/project/playlist-sync/):
14+
```
15+
pip install playlist-sync
16+
```
17+
The pip package adds the `playlist-sync` command to the command line.
18+
19+
## How to setup and use
20+
Playlist-sync relies on two files, `config.json` and `playlists.json`, which must exist in the current working directory. `config.json` contains some general settings (Deezer ARL, Spotify API token, bitrate...), and `playlists.json` contains the links to your playlists as well as the target folders where you want them to be downloaded.
21+
22+
`playlist-sync` can create templates for these two files so you only need to fill them. In your music library folder (where you want your playlists to be downloaded), run:
23+
```
24+
playlist-sync init
25+
```
26+
27+
It will create the 2 json files. Fill them both as explained in the wiki, [here](https://github.com/lilianmallardeau/playlist-sync/wiki/The-config.json-file) and [here](https://github.com/lilianmallardeau/playlist-sync/wiki/The-playlists.json-file).
28+
Once you've filled the `config.json` file with your Deezer ARL (and Spotify API client ID and secret if you want to download Spotify playlists) and the `playlists.json` file with your playlists links, to download them all at once in the desired folders, simply run:
29+
```
30+
playlist-sync sync
31+
```
32+
33+
34+
## How to install and use easily on Windows
35+
1. If you don't have it installed already, download and install [Python](https://www.python.org). During installation, make sure to choose to update the PATH environment variable.
36+
2. Open the command prompt (search for "cmd" in the search bar) and type `pip install playlist-sync`
37+
3. Download the 2 scripts in the [`windows_scripts`](https://github.com/lilianmallardeau/playlist-sync/tree/main/windows_scripts) folder in this repo, and put them in your music library folder
38+
4. Double click on `playlist-sync_init.cmd`. It will create two json files, `config.json` and `playlists.json`, in the same folder.
39+
5. Fill the two json files as described [here](https://github.com/lilianmallardeau/playlist-sync/wiki/The-config.json-file) and [here](https://github.com/lilianmallardeau/playlist-sync/wiki/The-playlists.json-file).
40+
6. To download/update your playlists, simply double click on the `playlist-sync_sync.cmd` file
41+
42+
43+
---
44+
45+
46+
## Todo
47+
- Add support for SoundCloud and YouTube playlists, with [youtube-dl](http://ytdl-org.github.io/youtube-dl/)
48+
- Sync Serato/rekordbox crates with downloaded playlists
49+
- Use ISRC numbers to prevent downloading songs from different playlists twice, and make hardlinks between files instead

playlist_sync/__init__.py

Whitespace-only changes.

playlist_sync/__main__.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
#!/usr/bin/env python3
2+
import os
3+
import sys
4+
import json
5+
import click
6+
7+
from .config import Config
8+
from .deemix_config import DeemixConfig
9+
from .playlist import Playlist
10+
11+
CONFIG_FILE = "config.json"
12+
PLAYLIST_FILE = "playlists.json"
13+
14+
DEFAULT_PLAYLISTS = [
15+
{
16+
"url": "https://deezer.com/playlist/12345678...",
17+
"folder": "PLAYLIST_FOLDER",
18+
"overwrite": False
19+
},
20+
{
21+
"url": "https://open.spotify.com/playlist/abcd1234...",
22+
"folder": "PLAYLIST_FOLDER",
23+
"overwrite": False
24+
}
25+
]
26+
27+
28+
@click.group()
29+
def cli():
30+
pass
31+
32+
@cli.command()
33+
def init():
34+
config = Config(CONFIG_FILE, initialize=True)
35+
if (not config.exists()) or input(f"{config.config_file} already exists. Overwrite? (y/N) ").lower() == 'y':
36+
config.save()
37+
if (not os.path.isfile(PLAYLIST_FILE)) or input(f"{PLAYLIST_FILE} already exists. Overwrite? (y/N) ").lower() == 'y':
38+
with open(PLAYLIST_FILE, "w") as playlist_file:
39+
json.dump(DEFAULT_PLAYLISTS, playlist_file, indent=4)
40+
41+
@cli.command()
42+
def sync():
43+
if not (os.path.isfile(CONFIG_FILE) and os.path.isfile(PLAYLIST_FILE)):
44+
print(f"{CONFIG_FILE} or {PLAYLIST_FILE} doesn't exist. Run `{sys.argv[0]} init` to initialize.")
45+
return
46+
47+
# Reading config
48+
config = Config(CONFIG_FILE)
49+
deemix_config = DeemixConfig("config", arl=config['arl'])
50+
deemix_config.setSpotifyConfig(config['spotifyClientId'], config['spotifyClientSecret'])
51+
deemix_config.setDefaultBitrate(config['defaultBitrate'], fallback=bool(config['fallbackBitrate']))
52+
deemix_config.setOverwrite(bool(config['overwriteFiles']))
53+
deemix_config['saveArtwork'] = config['saveArtwork']
54+
55+
# Reading playlists file and syncing each playlist
56+
for playlist in json.load(open(PLAYLIST_FILE)):
57+
playlist_obj = Playlist(playlist['url'], playlist['folder'], deemix_config)
58+
playlist_obj.sync(overwrite_files=bool(playlist['overwrite']) if 'overwrite' in playlist else False)
59+
60+
deemix_config.clear()
61+
62+
63+
if __name__ == '__main__':
64+
cli()

playlist_sync/config.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import os
2+
from pathlib import Path
3+
import json
4+
5+
DEFAULT_CONFIG = {
6+
"arl": "YOUR_DEEZER_ARL",
7+
"spotifyClientId": "",
8+
"spotifyClientSecret": "",
9+
"defaultBitrate": 320,
10+
"fallbackBitrate": True,
11+
"overwriteFiles": False,
12+
"saveArtwork": True
13+
}
14+
15+
16+
class Config():
17+
def __init__(self, config_file, initialize=False) -> None:
18+
self.config_file = config_file
19+
if initialize:
20+
self._config = DEFAULT_CONFIG
21+
else:
22+
self.load()
23+
24+
def save(self):
25+
json.dump(self._config, open(self.config_file, "w"), indent=4)
26+
27+
def load(self):
28+
self._config = json.load(open(self.config_file))
29+
30+
def exists(self):
31+
return os.path.isfile(self.config_file)
32+
33+
def __getitem__(self, key):
34+
return self._config[key]
35+
36+
def __setitem__(self, key, value):
37+
self._config[key] = value

playlist_sync/deemix_config.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import os
2+
import shutil
3+
from pathlib import Path
4+
import json
5+
6+
DEFAULT_CONFIG = {
7+
"downloadLocation": os.getcwd(),
8+
"tracknameTemplate": "%artist% - %title%",
9+
"albumTracknameTemplate": "%tracknumber% - %title%",
10+
"playlistTracknameTemplate": "%position% - %artist% - %title%",
11+
"createPlaylistFolder": True,
12+
"playlistNameTemplate": "%playlist%",
13+
"createArtistFolder": False,
14+
"artistNameTemplate": "%artist%",
15+
"createAlbumFolder": True,
16+
"albumNameTemplate": "%artist% - %album%",
17+
"createCDFolder": True,
18+
"createStructurePlaylist": False,
19+
"createSingleFolder": False,
20+
"padTracks": True,
21+
"paddingSize": "0",
22+
"illegalCharacterReplacer": "_",
23+
"queueConcurrency": 3,
24+
"maxBitrate": "3",
25+
"feelingLucky": False,
26+
"fallbackBitrate": True,
27+
"fallbackSearch": False,
28+
"fallbackISRC": False,
29+
"logErrors": True,
30+
"logSearched": False,
31+
"overwriteFile": "n",
32+
"createM3U8File": False,
33+
"playlistFilenameTemplate": "playlist",
34+
"syncedLyrics": False,
35+
"embeddedArtworkSize": 800,
36+
"embeddedArtworkPNG": False,
37+
"localArtworkSize": 1400,
38+
"localArtworkFormat": "jpg",
39+
"saveArtwork": True,
40+
"coverImageTemplate": "cover",
41+
"saveArtworkArtist": False,
42+
"artistImageTemplate": "folder",
43+
"jpegImageQuality": 90,
44+
"dateFormat": "Y-M-D",
45+
"albumVariousArtists": True,
46+
"removeAlbumVersion": False,
47+
"removeDuplicateArtists": True,
48+
"featuredToTitle": "0",
49+
"titleCasing": "nothing",
50+
"artistCasing": "nothing",
51+
"executeCommand": "",
52+
"tags": {
53+
"title": True,
54+
"artist": True,
55+
"artists": True,
56+
"album": True,
57+
"cover": True,
58+
"trackNumber": True,
59+
"trackTotal": False,
60+
"discNumber": True,
61+
"discTotal": False,
62+
"albumArtist": True,
63+
"genre": True,
64+
"year": True,
65+
"date": True,
66+
"explicit": False,
67+
"isrc": True,
68+
"length": True,
69+
"barcode": True,
70+
"bpm": True,
71+
"replayGain": False,
72+
"label": True,
73+
"lyrics": False,
74+
"syncedLyrics": False,
75+
"copyright": False,
76+
"composer": False,
77+
"involvedPeople": False,
78+
"source": False,
79+
"rating": False,
80+
"savePlaylistAsCompilation": False,
81+
"useNullSeparator": False,
82+
"saveID3v1": True,
83+
"multiArtistSeparator": "default",
84+
"singleAlbumArtist": False,
85+
"coverDescriptionUTF8": False
86+
}
87+
}
88+
DEFAULT_SPOTIFY_CONFIG = {
89+
"clientId": "",
90+
"clientSecret": "",
91+
"fallbackSearch": False
92+
}
93+
94+
class DeemixConfig():
95+
def __init__(self, config_folder: str = "config", arl: str = None, overwrite: bool = True) -> None:
96+
self.config_folder = Path(config_folder)
97+
if arl:
98+
self.arl = arl
99+
self._config = DEFAULT_CONFIG
100+
self._spotify_config = DEFAULT_SPOTIFY_CONFIG
101+
if not overwrite:
102+
self.load()
103+
104+
def save(self):
105+
os.makedirs(self.config_folder / "spotify", exist_ok=True)
106+
107+
with open(self.config_folder / "config.json", "w") as config_file:
108+
json.dump(self._config, config_file, indent=4)
109+
with open(self.config_folder / "spotify" / "config.json", "w") as config_file:
110+
json.dump(self._spotify_config, config_file, indent=4)
111+
with open(self.config_folder / ".arl", "w") as arl_file:
112+
arl_file.write(self.arl)
113+
114+
def clear(self):
115+
shutil.rmtree(self.config_folder)
116+
117+
def load(self):
118+
# TODO
119+
raise NotImplementedError()
120+
121+
def setSpotifyConfig(self, client_id, secret, fallbackSearch=None):
122+
self._spotify_config["clientId"] = str(client_id)
123+
self._spotify_config["clientSecret"] = str(secret)
124+
if fallbackSearch is not None:
125+
self._spotify_config["fallbackSearch"] = bool(fallbackSearch)
126+
127+
def setDefaultBitrate(self, bitrate, fallback=None):
128+
if bitrate == 128:
129+
self._config['maxBitrate'] = 1
130+
elif bitrate == 320:
131+
self._config['maxBitrate'] = 3
132+
elif bitrate == 'flac':
133+
self._config['maxBitrate'] = 9
134+
else:
135+
raise ValueError(f"`bitrate` must be either 128, 320 or 'flac', got {bitrate}")
136+
137+
if fallback is not None:
138+
self._config['fallbackBitrate'] = bool(fallback)
139+
140+
def setOverwrite(self, overwrite: bool):
141+
self._config['overwriteFile'] = 'y' if overwrite else 'n'
142+
143+
def __getitem__(self, key):
144+
return self._config[key]
145+
146+
def __setitem__(self, key, value):
147+
self._config[key] = value

playlist_sync/playlist.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import os
2+
from pathlib import Path
3+
4+
class Playlist():
5+
def __init__(self, url, folder, deemix_config):
6+
self.url = url
7+
self.folder = Path(folder)
8+
self._deemix_config = deemix_config
9+
10+
self._deemix_config['downloadLocation'] = str(self.folder.absolute())
11+
self._deemix_config['createPlaylistFolder'] = False
12+
13+
def sync(self, overwrite_files=False, suppress_output=False):
14+
self._deemix_config.setOverwrite(overwrite_files)
15+
self._deemix_config.save()
16+
return os.system(f"deemix --portable {self.url}{' > /dev/null' if suppress_output else ''}")

pyproject.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[build-system]
2+
requires = [
3+
"setuptools>=42",
4+
"wheel"
5+
]
6+
build-backend = "setuptools.build_meta"

0 commit comments

Comments
 (0)