Music Streaming (Spotify)
Iterator for playlist traversal with pluggable playback strategies (shuffle, repeat, sequential). Observer pushes now-playing updates to all connected UI components without the player knowing who is listening.
Key Abstractions
Facade coordinating user actions, playlist management, and playback
Immutable metadata (title, artist, album, duration) plus a reference to the audio source
Ordered collection of songs with an owner, add/remove, and iterator creation
Listener with a subscription tier, owned playlists, and listening history
Playback controller managing current song, play/pause/skip state, and observer notifications
Defines how the next song is picked: sequential, shuffle, or repeat
Suggests songs based on listening history and what the user has not heard yet
Class Diagram
The Key Insight
A music player looks simple on the surface. Play a song, then play the next one. But the moment you add shuffle, repeat, and repeat-one modes, the traversal logic gets tangled fast if it lives inside the player. The player ends up tracking which mode is active, maintaining shuffle history so "previous" works, and handling edge cases for each mode in a growing switch statement.
Pull that traversal out into an iterator. The player just calls next(). It does not know or care whether the next song comes from a sequential scan, a shuffled list, or a looping single track. Changing playback mode means swapping the iterator. The play loop stays untouched.
Combine this with the observer pattern for now-playing updates and you get clean separation. The player controls state. Iterators control order. Observers control what happens when the song changes.
Requirements
Functional
- Users can create, modify, and delete playlists
- Songs can be played, paused, and skipped
- Support sequential, shuffle, and repeat playback modes, switchable at runtime
- Notify UI components when the current song changes or playback state changes
- Search songs by title or artist
- Recommend songs the user has not listened to yet
Non-Functional
- Playback mode changes should not require modifying the Player class
- Adding a new playback mode means adding a new Strategy and Iterator class, nothing else
- Observer notifications should not block playback control
- Song metadata lookups should be O(1) by ID
Design Decisions
Why Iterator for playlist traversal?
The naive approach is a switch statement inside the player: "if sequential, increment index; if shuffle, pick random; if repeat, wrap around." Three modes, three branches. Now add repeat-one. Four branches. Add a priority queue mode for smart playlists and you have five. Each branch carries its own edge cases around boundary conditions. The iterator pattern gives each mode its own class with clear, testable behavior. The player calls hasNext() and next(). New modes are new classes. Zero changes to the player.
Why Strategy wrapping the Iterator?
Strategy creates the appropriate iterator for a given song list. This separates the decision of "which traversal mode" from the traversal mechanics. The player holds a strategy reference, and when you switch modes, you swap the strategy and reload. The player never touches iterator construction logic directly.
Why Observer for now-playing updates?
Multiple components need to react when the song changes: the main UI, the lock screen widget, the social activity feed, the scrobbling service. Without observer, the player needs explicit references to all of them. Adding a new consumer means modifying the player. With observer, consumers register themselves and the player broadcasts to whoever is listening. Zero coupling between the player and its consumers.
Why is Playlist a first-class entity?
A playlist is more than a List<Song>. It has an owner, a name, and eventually sharing permissions and collaborative editing. Treating it as a class lets you add features without restructuring. It also encapsulates the song list so external code cannot corrupt ordering or bypass validation rules.
Interview Follow-ups
- "How would you handle offline playback?" Add a
DownloadManagerthat caches audio files locally. Songs get alocalPathfield alongsideaudioRef. The player checks local cache first before attempting to stream. - "How would you build shuffle so 'previous' works?" The
ShuffleIteratormaintains a history stack. Callingprevious()pops from the stack. Callingnext()pushes to it. This gives deterministic back-and-forth navigation through the shuffled order. - "How would you add a queue (play next / play later)?" Introduce a
PlaybackQueuethat sits between the player and the playlist iterator. Manually queued songs take priority over iterator output. When the queue empties, fall back to the iterator. - "How would you implement collaborative playlists?" Add an
EditPermissionenum (OWNER, EDITOR, VIEWER) and a permissions map on Playlist. Editors can add and remove songs. Use observer to notify collaborators of changes in real time.
Code Implementation
1 from enum import Enum
2 from abc import ABC, abstractmethod
3 from dataclasses import dataclass, field
4 import random
5 from typing import Optional
6
7
8 class SubscriptionTier(Enum):
9 FREE = "free"
10 PREMIUM = "premium"
11
12
13 @dataclass(frozen=True)
14 class Song:
15 """Immutable. Shared across playlists without defensive copying."""
16 id: str
17 title: str
18 artist: str
19 album: str
20 duration_secs: int
21 audio_ref: str
22
23 def __str__(self) -> str:
24 return f"{self.title} - {self.artist} ({self.duration_secs}s)"
25
26
27 # --- Iterator hierarchy ---
28
29 class PlaylistIterator(ABC):
30 @abstractmethod
31 def has_next(self) -> bool: ...
32
33 @abstractmethod
34 def next(self) -> Song: ...
35
36 @abstractmethod
37 def current(self) -> Optional[Song]: ...
38
39
40 class SequentialIterator(PlaylistIterator):
41 def __init__(self, songs: list[Song]):
42 self._songs = list(songs)
43 self._index = -1
44
45 def has_next(self) -> bool:
46 return self._index + 1 < len(self._songs)
47
48 def next(self) -> Song:
49 if not self.has_next():
50 raise StopIteration("No more songs")
51 self._index += 1
52 return self._songs[self._index]
53
54 def current(self) -> Optional[Song]:
55 if 0 <= self._index < len(self._songs):
56 return self._songs[self._index]
57 return None
58
59
60 class ShuffleIterator(PlaylistIterator):
61 def __init__(self, songs: list[Song]):
62 self._songs = list(songs)
63 random.shuffle(self._songs)
64 self._index = -1
65
66 def has_next(self) -> bool:
67 return self._index + 1 < len(self._songs)
68
69 def next(self) -> Song:
70 if not self.has_next():
71 raise StopIteration("No more songs")
72 self._index += 1
73 return self._songs[self._index]
74
75 def current(self) -> Optional[Song]:
76 if 0 <= self._index < len(self._songs):
77 return self._songs[self._index]
78 return None
79
80
81 class RepeatIterator(PlaylistIterator):
82 def __init__(self, songs: list[Song]):
83 self._songs = list(songs)
84 self._index = -1
85
86 def has_next(self) -> bool:
87 return len(self._songs) > 0
88
89 def next(self) -> Song:
90 if not self._songs:
91 raise StopIteration("Empty playlist")
92 self._index = (self._index + 1) % len(self._songs)
93 return self._songs[self._index]
94
95 def current(self) -> Optional[Song]:
96 if self._songs and self._index >= 0:
97 return self._songs[self._index % len(self._songs)]
98 return None
99
100
101 # --- Strategy hierarchy ---
102
103 class PlaybackStrategy(ABC):
104 @abstractmethod
105 def create_iterator(self, songs: list[Song]) -> PlaylistIterator: ...
106
107
108 class SequentialStrategy(PlaybackStrategy):
109 def create_iterator(self, songs: list[Song]) -> PlaylistIterator:
110 return SequentialIterator(songs)
111
112
113 class ShuffleStrategy(PlaybackStrategy):
114 def create_iterator(self, songs: list[Song]) -> PlaylistIterator:
115 return ShuffleIterator(songs)
116
117
118 class RepeatStrategy(PlaybackStrategy):
119 def create_iterator(self, songs: list[Song]) -> PlaylistIterator:
120 return RepeatIterator(songs)
121
122
123 # --- Observer hierarchy ---
124
125 class PlayerObserver(ABC):
126 @abstractmethod
127 def on_song_changed(self, song: Song) -> None: ...
128
129 @abstractmethod
130 def on_playback_state_changed(self, is_playing: bool) -> None: ...
131
132
133 class NowPlayingDisplay(PlayerObserver):
134 def __init__(self, name: str):
135 self._name = name
136
137 def on_song_changed(self, song: Song) -> None:
138 print(f" [{self._name}] Now playing: {song}")
139
140 def on_playback_state_changed(self, is_playing: bool) -> None:
141 status = "Playing" if is_playing else "Paused"
142 print(f" [{self._name}] Status: {status}")
143
144
145 # --- Core domain ---
146
147 class Playlist:
148 def __init__(self, playlist_id: str, name: str, owner_id: str):
149 self.id = playlist_id
150 self.name = name
151 self.owner_id = owner_id
152 self._songs: list[Song] = []
153
154 def add_song(self, song: Song) -> None:
155 self._songs.append(song)
156
157 def remove_song(self, song_id: str) -> None:
158 self._songs = [s for s in self._songs if s.id != song_id]
159
160 def get_songs(self) -> list[Song]:
161 return list(self._songs)
162
163 def iterator(self, strategy: PlaybackStrategy) -> PlaylistIterator:
164 return strategy.create_iterator(self._songs)
165
166 def __len__(self) -> int:
167 return len(self._songs)
168
169 def __str__(self) -> str:
170 return f"Playlist('{self.name}', {len(self._songs)} songs)"
171
172
173 class Player:
174 def __init__(self):
175 self._current_song: Optional[Song] = None
176 self._is_playing: bool = False
177 self._iterator: Optional[PlaylistIterator] = None
178 self._strategy: PlaybackStrategy = SequentialStrategy()
179 self._observers: list[PlayerObserver] = []
180
181 def add_observer(self, observer: PlayerObserver) -> None:
182 self._observers.append(observer)
183
184 def remove_observer(self, observer: PlayerObserver) -> None:
185 self._observers.remove(observer)
186
187 def _notify_song_changed(self) -> None:
188 if self._current_song:
189 for obs in self._observers:
190 obs.on_song_changed(self._current_song)
191
192 def _notify_state_changed(self) -> None:
193 for obs in self._observers:
194 obs.on_playback_state_changed(self._is_playing)
195
196 def set_strategy(self, strategy: PlaybackStrategy) -> None:
197 self._strategy = strategy
198
199 def load_playlist(self, playlist: Playlist) -> None:
200 self._iterator = playlist.iterator(self._strategy)
201 self._current_song = None
202 self._is_playing = False
203
204 def play(self) -> None:
205 if self._current_song is None and self._iterator and self._iterator.has_next():
206 self._current_song = self._iterator.next()
207 self._notify_song_changed()
208 self._is_playing = True
209 self._notify_state_changed()
210
211 def pause(self) -> None:
212 self._is_playing = False
213 self._notify_state_changed()
214
215 def skip(self) -> Optional[Song]:
216 if self._iterator and self._iterator.has_next():
217 self._current_song = self._iterator.next()
218 self._is_playing = True
219 self._notify_song_changed()
220 return self._current_song
221 self._is_playing = False
222 self._notify_state_changed()
223 return None
224
225 @property
226 def current_song(self) -> Optional[Song]:
227 return self._current_song
228
229 @property
230 def is_playing(self) -> bool:
231 return self._is_playing
232
233
234 @dataclass
235 class User:
236 id: str
237 name: str
238 subscription: SubscriptionTier
239 playlists: list[Playlist] = field(default_factory=list)
240 history: list[Song] = field(default_factory=list)
241
242 def create_playlist(self, playlist_id: str, name: str) -> Playlist:
243 pl = Playlist(playlist_id, name, self.id)
244 self.playlists.append(pl)
245 return pl
246
247 def record_listen(self, song: Song) -> None:
248 self.history.append(song)
249
250
251 class RecommendationEngine:
252 def recommend(self, user: User, all_songs: list[Song], limit: int = 3) -> list[Song]:
253 listened_ids = {s.id for s in user.history}
254 unheard = [s for s in all_songs if s.id not in listened_ids]
255 return unheard[:limit]
256
257
258 class MusicService:
259 """Facade. All client interactions go through here."""
260
261 def __init__(self):
262 self._users: dict[str, User] = {}
263 self._songs: dict[str, Song] = {}
264 self._playlists: dict[str, Playlist] = {}
265 self._player = Player()
266 self._recommender = RecommendationEngine()
267
268 @property
269 def player(self) -> Player:
270 return self._player
271
272 def add_song(self, song: Song) -> None:
273 self._songs[song.id] = song
274
275 def register_user(self, user: User) -> None:
276 self._users[user.id] = user
277
278 def create_playlist(self, user_id: str, playlist_id: str, name: str) -> Playlist:
279 user = self._users[user_id]
280 pl = user.create_playlist(playlist_id, name)
281 self._playlists[playlist_id] = pl
282 return pl
283
284 def play_playlist(self, playlist_id: str, strategy: PlaybackStrategy) -> None:
285 playlist = self._playlists[playlist_id]
286 self._player.set_strategy(strategy)
287 self._player.load_playlist(playlist)
288 self._player.play()
289
290 def search(self, query: str) -> list[Song]:
291 q = query.lower()
292 return [
293 s for s in self._songs.values()
294 if q in s.title.lower() or q in s.artist.lower()
295 ]
296
297 def get_recommendations(self, user_id: str, limit: int = 3) -> list[Song]:
298 user = self._users[user_id]
299 return self._recommender.recommend(user, list(self._songs.values()), limit)
300
301
302 if __name__ == "__main__":
303 service = MusicService()
304
305 # Build a small catalog
306 songs = [
307 Song("s1", "Bohemian Rhapsody", "Queen", "A Night at the Opera", 354, "audio://s1"),
308 Song("s2", "Hotel California", "Eagles", "Hotel California", 391, "audio://s2"),
309 Song("s3", "Stairway to Heaven", "Led Zeppelin", "Led Zeppelin IV", 482, "audio://s3"),
310 Song("s4", "Imagine", "John Lennon", "Imagine", 183, "audio://s4"),
311 Song("s5", "Smells Like Teen Spirit", "Nirvana", "Nevermind", 301, "audio://s5"),
312 ]
313 for song in songs:
314 service.add_song(song)
315
316 # Register user and create playlist
317 user = User("u1", "Alice", SubscriptionTier.PREMIUM)
318 service.register_user(user)
319
320 playlist = service.create_playlist("u1", "pl1", "Classic Rock Hits")
321 for song in songs[:4]:
322 playlist.add_song(song)
323 print(f"Created: {playlist}")
324 print()
325
326 # Attach UI observer
327 display = NowPlayingDisplay("Desktop UI")
328 service.player.add_observer(display)
329
330 # Sequential playback
331 print("=== Sequential Playback ===")
332 service.play_playlist("pl1", SequentialStrategy())
333 service.player.skip()
334 service.player.skip()
335 service.player.pause()
336 print()
337
338 # Switch to shuffle
339 print("=== Shuffle Playback ===")
340 service.play_playlist("pl1", ShuffleStrategy())
341 service.player.skip()
342 print()
343
344 # Search
345 print("=== Search for 'queen' ===")
346 results = service.search("queen")
347 for r in results:
348 print(f" Found: {r}")
349 print()
350
351 # Recommendations after listening to some songs
352 user.record_listen(songs[0])
353 user.record_listen(songs[1])
354 print("=== Recommendations (after listening to 2 songs) ===")
355 recs = service.get_recommendations("u1", limit=3)
356 for r in recs:
357 print(f" Recommended: {r}")
358
359 # Repeat mode: never runs out of songs
360 print()
361 print("=== Repeat Playback (loops forever) ===")
362 service.play_playlist("pl1", RepeatStrategy())
363 service.player.skip()
364 service.player.skip()
365 service.player.skip()
366 service.player.skip()
367 print(f"\nPlayer still going: {service.player.is_playing}")
368 print("All assertions passed.")Common Mistakes
- ✗Baking shuffle logic directly into the Player. Adding repeat-one later means rewriting the play loop.
- ✗Storing full audio data inside the Song object. Songs should reference audio, not contain it.
- ✗Skipping an iterator and using raw index math for playlist traversal. Shuffle and repeat edge cases break immediately.
- ✗Making Playlist a plain list with no encapsulation. You lose the ability to enforce ordering rules or notify on changes.
Key Points
- ✓Iterator pattern decouples playlist traversal from the playlist data structure. The player calls next() and does not care about order logic.
- ✓Strategy pattern makes playback modes (shuffle, repeat, sequential) swappable at runtime without touching the Player class
- ✓Observer pattern pushes now-playing updates to UI, social feeds, and scrobbling services simultaneously
- ✓Playlist is a first-class entity with its own identity and encapsulation, not just a bare list of song IDs
- ✓Song is immutable. It holds a reference to the audio, not the bytes. Safe to share across playlists without copying.