├── requirements.txt
├── .cache
├── .gitingore
├── .github
└── workflows
│ └── actions.yml
├── README.md
└── main.py
/requirements.txt:
--------------------------------------------------------------------------------
1 | spotipy==2.19.0
--------------------------------------------------------------------------------
/.cache:
--------------------------------------------------------------------------------
1 | {"access_token": "BQAS11NDrafiwY1q-qxyXDKXepC5t2tqzaqlQp6Xt5x6POCVfc3Rq53pmx2MWYQCAxH1zUZe8cWFRuvD0PmQlDt1THMzf2CqxT-3s0vtyLp2_II6mLf_vBms5gBKdV5OMyLj2_AXnhGMG4dc41yNpF68_plcpnp4WINCEoZDE8s4R-9Uba3nwgI7Wm7aUMQij7DJSuLcBixOZFCficByEYlcaJupJqFLVl6IUw-XllENJbfJynulfZYszJn0mJzvddKqpw9UUxRpismRaMdK", "token_type": "Bearer", "expires_in": 3600, "scope": "user-library-read playlist-modify-private playlist-modify-public playlist-read-private", "expires_at": 1686516395, "refresh_token": "AQC1zVLSrslXxDUdLwami4_EbBLU4XSsyoVrGMhZFKnUXzwwRHDCaueCI9iwP7NQ6rgLQiiV11_Pw4U6ixEY9kPztlLNJgLIwPeoend3oN2-xIZgV5Y0tYunv8_Wl2cVuGw"}
--------------------------------------------------------------------------------
/.gitingore:
--------------------------------------------------------------------------------
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 | share/python-wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 | MANIFEST
28 |
29 | # PyInstaller
30 | # Usually these files are written by a python script from a template
31 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
32 | *.manifest
33 | *.spec
34 |
35 | # Installer logs
36 | pip-log.txt
37 | pip-delete-this-directory.txt
38 |
39 | # Environments
40 | .env
41 | .venv
42 | env/
43 | venv/
44 | ENV/
45 | env.bak/
46 | venv.bak/
47 |
48 | # Cache files
49 | .cache/
--------------------------------------------------------------------------------
/.github/workflows/actions.yml:
--------------------------------------------------------------------------------
1 | name: run main.py
2 |
3 | on:
4 | schedule:
5 | - cron: '*/15 * * * *' # every 15 minutes - recommended
6 | workflow_dispatch:
7 |
8 | jobs:
9 | build:
10 | timeout-minutes: 4
11 | runs-on: ubuntu-latest
12 | steps:
13 |
14 | - name: checkout repo content
15 | uses: actions/checkout@v2 # checkout the repository content to github runner
16 |
17 | - name: setup python
18 | uses: actions/setup-python@v4
19 | with:
20 | python-version: '3.9' # install the python version needed
21 |
22 | - name: install python packages
23 | run: |
24 | python -m pip install --upgrade pip
25 | pip install -r requirements.txt
26 |
27 | - name: execute py script # run main.py
28 | env:
29 | CLIENT_ID: ${{ secrets.CLIENT_ID }}
30 | CLIENT_SECRET: ${{ secrets.CLIENT_SECRET }}
31 | run: python main.py
32 |
33 | - name: delete .cache file
34 | run: rm -rf .cache
35 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # Spotify Monthly Saves
4 | Add saved songs to a monthly playlist using GitHub Actions, ditching IFTTT.
5 |
6 | ### 🧐 What is this?
7 | The songs you add to your library or give a like to will be included in a monthly playlist (e.g., "Jun '23"), enabling you to revisit and discover the songs you liked 7 months ago during a memorable road trip.
8 |
9 | Like this:
10 |
11 |
12 |
13 |
14 | ### 🗿 Why not [IFTTT](https://ifttt.com/applets/rC5QtGu6-add-saved-songs-to-a-monthly-playlist)?
15 | I have been using that for years, but recently they paywalled it. 🥲
16 | ### ✨ How does it work?
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | ### ⚙️ How do I set it up?
25 |
26 | To set up the repository and configure the necessary steps, follow these instructions:
27 |
28 | 0. Make sure you have a GitHub account. If you don't have one, create an account at [github.com/signup](https://github.com/signup).
29 |
30 | 1. Fork this repository by clicking the "Fork" button at the top right of the repository page. This will create a copy of the repository under your GitHub account.
31 |
32 | 2. Obtain your `client_id` and `client_secret` from the Spotify Developer Dashboard:
33 |
34 | - Visit [developer.spotify.com](https://developer.spotify.com/) and log in with your Spotify account.
35 | - Navigate to your [Dashboard.](https://developer.spotify.com/dashboard)
36 | - Create a new app by clicking the "Create App" button.
37 | - Provide a name and description for your app (you can use any name and description).
38 | - In the Redirect URI field, type `http://localhost:3000` and click "Save".
39 | - Open the settings of your app and copy the `client_id` and `client_secret` to a notepad or any text editor. You will need these in the next steps.
40 |
41 | 3. Before proceeding, you need to run the `main.py` file locally with your `client_id` and `client_secret` to authenticate your secret credentials. This step gives the necessary permissions to the app you created, allowing it to create new playlists and add songs to them. Just follow these steps to proceed:
42 |
43 | - Open fork in VS Code
44 | - ⚠︎ Delete ``.cache`` file (spotify-monthly-saves/.cache)
45 | - Run ``pip install spotipy``. [Not working?](https://pip.pypa.io/en/stable/installation/)
46 | - Run the code by pressing Control + Option + N (Control + Alt + N on Windows)
47 | - A window will pop up asking you to Authorise the Spotify app, click authorise.
48 | - If everything goes well and if you've liked any songs on Spotify this month, you'll see something like `[month name] '23` was created and the song names will pop up in the VS Code terminal!
49 | - Now a new `.cache` file would've been generated in your project directory, open it and copy all of it's content.
50 | - Go to the `.cache` file on your forked repo `(github.com/[your-username]/spotify-monthly-saves)`, click on edit and paste what you copied in the previous step, replacing the original content of the file, then click on 'Commit changes'.
51 | 4. Go to the "Settings" tab of your forked repository on GitHub, and navigate to "Secrets and variables" → "Actions".
52 |
53 | 5. Add both the `client_id` and `client_secret` keys as secrets by clicking on "New repository secret" and entering the respective values.
54 |
55 | 6. Next, enable the workflow under the "Actions" tab by clicking the "I understand my workflows, go ahead and enable them" button. This will allow the automated process to run.
56 |
57 | 7. Additionally, enable the workflow under the sidebar menu called "Run main.py" by clicking the "Enable workflow" button.
58 |
59 | 8. Please note that the song you like on Spotify won't be instantly added to the monthly playlist. The GitHub action runs at an interval of approximately 15 minutes(sometimes even longer), so there might be a slight delay before the song gets added.
60 |
61 | 9. Once the setup is complete, you can continue to like songs on Spotify, and they will be automatically added to a new monthly playlist during the next execution of the GitHub action.
62 |
63 | 10. Profit.
64 |
65 | ### 🧮 Customization
66 |
67 | You have the flexibility to customize the interval at which the GitHub Action runs by modifying the `- cron:` parameter in the `.github/workflows/actions.yml` file. The interval is set using the cron syntax.
68 |
69 | Cron syntax consists of five fields representing different time units: minute, hour, day of the month, month, and day of the week. Each field can contain specific values or special characters to define the schedule.
70 |
71 | To modify the interval, locate the following line in the `.github/workflows/actions.yml` file:
72 |
73 | ```yaml
74 | - cron: '*/15 * * * *'
75 | ```
76 | > this runs every 15 minutes (recommended)
77 |
78 | The `* * * * *` represents the default configuration, which executes the workflow every minute. You can change this to your desired schedule. Refer to [crontab.guru](https://crontab.guru/). It provides a simple and intuitive way to understand and create cron schedules.
79 |
80 | Important: There's a [limit](https://stackoverflow.com/questions/63192132/why-does-my-cron-configured-github-action-not-run-every-2-minutes) on how frequently you can run a cron job on GitHub Actions, The best interval is ≥ 15 minutes, it can't really do much below this amount. But again, how often do you like songs on Spotify? LOL I think it's pretty good considering it's totally free.
81 |
82 |
83 | ### 💰 Is this FREE to use?
84 | 100%. Yes.
85 |
86 |
87 |
--------------------------------------------------------------------------------
/main.py:
--------------------------------------------------------------------------------
1 | import time
2 | import os
3 | from datetime import datetime
4 | from typing import Optional, List
5 | from spotipy import Spotify
6 | import spotipy
7 | from spotipy.oauth2 import SpotifyOAuth
8 |
9 | class Song:
10 | """Stores relevant data for a song retrieved from spotify's API.
11 |
12 | :param song: Dictionary containing playlist data from spotify's API.
13 | """
14 | added_at: datetime
15 | id: str
16 | name: str
17 |
18 | def __init__(self, song: dict) -> None:
19 | self.added_at = datetime.strptime(
20 | song['added_at'], "%Y-%m-%dT%H:%M:%SZ")
21 | self.id = song['track']['id']
22 | self.name = song['track']['name']
23 |
24 | class Playlist:
25 | """Stores relevant data and methods for a playlist retrieved from spotify's API.
26 |
27 | :param sp: Spotipy client object.
28 | :param playlist: Dictionary containing playlist data from Spotipy.
29 | """
30 | songs: Optional[List[Song]]
31 | id: str
32 | name: str
33 | sp: Spotify
34 |
35 | def __init__(self, sp: Spotify, playlist: dict) -> None:
36 | self.sp = sp
37 | self.name = playlist['name']
38 | self.id = playlist['id']
39 | self.songs = None
40 |
41 | def add_song(self, song: Song):
42 | """Adds a song to the playlist."""
43 |
44 | if not self.songs:
45 | if not self.__fetch_songs():
46 | print('error when loading songs in playlist')
47 | return
48 | if not self.__song_in(song):
49 | print(song.name, 'added to', self.name)
50 | self.sp.playlist_add_items(self.id, [song.id])
51 | else:
52 | print(song.name, 'already in', self.name)
53 |
54 | def __song_in(self, song: Song) -> bool:
55 | """Checks if a song is already in the playlist.
56 |
57 | :param song: The song to check for.
58 | :return: True for song in playlist, False otherwise.
59 | """
60 |
61 | return any(x.id == song.id for x in self.songs)
62 |
63 | def __fetch_songs(self) -> bool:
64 | """Retrieves and stores the playlist's songs using spotify's api.
65 |
66 | :return: True for success, False otherwise.
67 | """
68 |
69 | try:
70 | results = self.sp.playlist_items(
71 | playlist_id=self.id, additional_types=('track',))
72 | except Exception as e:
73 | print(repr(e))
74 | return False
75 | if 'items' not in results:
76 | return False
77 | self.songs = [Song(x) for x in results['items']]
78 | return True
79 |
80 |
81 | class MonthlyPlaylists:
82 | """Fetches and checks playlists against newly saved songs then adds new songs to a monthly playlist.
83 |
84 | :param client_id: Client ID for Spotify API.
85 | :param client_secret: Client Secret for Spotify API.
86 | :param redirect_uri: Any valid URI matching the redirect URI in Spotify Developer application (optional).
87 | :param date: Date to detect newly saved songs after (optional).
88 | :param name_format: Strftime format string to name monthly playlists (optional).
89 | :param headless: Allows authenticating Spotify on a headless machine (optional).
90 | """
91 | user_id: str
92 | saved_songs: Optional[List[Song]]
93 | playlists: Optional[List[Playlist]]
94 | last_checked: datetime
95 | name_format: str
96 |
97 | def __init__(self, client_id: str, client_secret: str,
98 | redirect_uri: str = '',
99 | date: datetime = None, name_format: str = '%b \'%y', headless: bool = False) -> None:
100 | self.sp = spotipy.Spotify(
101 | auth_manager=SpotifyOAuth(
102 | client_id=client_id,
103 | client_secret=client_secret,
104 | redirect_uri=redirect_uri,
105 | scope="user-library-read playlist-modify-private playlist-modify-public playlist-read-private",
106 | open_browser=not headless
107 | )
108 | )
109 | self.user_id = self.sp.current_user()['id']
110 | self.saved_songs = None
111 | self.playlists = None
112 | self.last_checked = datetime.today().replace(day=1) if date is None else date
113 | self.name_format = name_format
114 |
115 | def update_monthly_playlists(self):
116 | """Performs every step for maintaining monthly playlists."""
117 |
118 | if not self.__fetch_saved_songs():
119 | print('error when loading saved songs')
120 | new_songs = self.__fetch_new_saved_songs()
121 | if not new_songs:
122 | print('No new songs')
123 | return
124 | if not self.__fetch_playlists():
125 | print('error when loading playlists')
126 | return
127 | if not self.__add_songs_to_playlist(new_songs):
128 | print('error during playlist creation/detection')
129 | return
130 | self.last_checked = new_songs[0].added_at
131 |
132 | def __fetch_saved_songs(self, offset: int = 0) -> bool:
133 | """Fetches and stores currently saved songs using spotify's api.
134 | :param offset: Load songs from offset onwards and append to current saved_songs.
135 | :return: True for success, False otherwise.
136 | """
137 |
138 | try:
139 | results = self.sp.current_user_saved_tracks(
140 | limit=50, offset=offset)
141 | except Exception as e:
142 | print(repr(e))
143 | return False
144 | if 'items' not in results:
145 | return False
146 | songs = [Song(x) for x in results['items']]
147 | if offset > 0:
148 | self.saved_songs.extend(songs)
149 | else:
150 | self.saved_songs = songs
151 | if self.saved_songs[-1].added_at > self.last_checked:
152 | self.__fetch_saved_songs(offset=offset+50)
153 | return True
154 |
155 | def __fetch_playlists(self) -> bool:
156 | """Fetches and stores current playlists using spotify's api.
157 | :return: True for success, False otherwise.
158 | """
159 |
160 | self.playlists = []
161 | offset = 0
162 | limit = 50
163 |
164 | while True:
165 | try:
166 | results = self.sp.current_user_playlists(limit=limit, offset=offset)
167 | except Exception as e:
168 | print(repr(e))
169 | return False
170 | if 'items' not in results:
171 | return False
172 |
173 | playlists_batch = [Playlist(self.sp, x) for x in results['items']]
174 | self.playlists.extend(playlists_batch)
175 |
176 | # If we got fewer items than the limit, we've reached the end
177 | if len(results['items']) < limit:
178 | break
179 |
180 | offset += limit
181 |
182 | return True
183 |
184 | def __fetch_new_saved_songs(self):
185 | """Returns list of songs that were added after the last_date checked."""
186 |
187 | return [song for song in self.saved_songs if song.added_at > self.last_checked]
188 |
189 | def __add_songs_to_playlist(self, songs: List[Song]) -> bool:
190 | """Adds songs to playlist that is named from the current month and year (Jan 22).
191 |
192 | :param songs: List of songs to add.
193 | :return: True for success, False otherwise.
194 | """
195 |
196 | name = songs[0].added_at.strftime(self.name_format)
197 | existing_playlist = self.__find_playlist(name)
198 | if existing_playlist is None:
199 | return False
200 | for song in songs:
201 | # Change playlists if a song is liked from a different month than the previous song
202 | curr_name = song.added_at.strftime(self.name_format)
203 | if existing_playlist.name != curr_name:
204 | existing_playlist = self.__find_playlist(curr_name)
205 | if existing_playlist is None:
206 | return False
207 | existing_playlist.add_song(song)
208 | return True
209 |
210 | def __find_playlist(self, name: str) -> Optional[Playlist]:
211 | """Returns a playlist matching the given name or creates one.
212 |
213 | :param name: The title of the playlist to search for or create.
214 | :return: Playlist if successful, None otherwise.
215 | """
216 |
217 | playlist = next((x for x in self.playlists if x.name == name), None)
218 | # If playlist does not exist attempt to create it
219 | if playlist is None:
220 | try:
221 | data = self.sp.user_playlist_create(
222 | user=self.user_id, name=name)
223 | except Exception as e:
224 | print(repr(e))
225 | return None
226 | if data.get('type') != 'playlist':
227 | return None
228 | playlist = Playlist(sp=self.sp, playlist=data)
229 | print(playlist.name, 'was created')
230 | return playlist
231 |
232 | spotify = MonthlyPlaylists(
233 | client_id= os.environ["CLIENT_ID"],
234 | client_secret= os.environ["CLIENT_SECRET"],
235 | redirect_uri='http://localhost:3000'
236 | )
237 |
238 | # The class updates its date threshold to whichever song it added last.
239 | # Therefore, calling update_monthly_playlists() multiple times will make minimal api calls
240 |
241 | spotify.update_monthly_playlists()
242 |
--------------------------------------------------------------------------------