initial commit

Hunter
Tuesday, October 28th, 2025 at 12:15 AM

.gitignorenew
host.pynew
index.htmlnew
requirements.txtnew
resources/next.pngnew
resources/pause.pngnew
resources/play.pngnew
resources/prev.pngnew
scan.pynew

.gitignore
 1 +# Python virtual environment
 2 +venv/
 3 +__pycache__/
 4 +*.pyc
 5 +*.pyo
 6 +*.pyd
 7 +
 8 +# Tracks and playlists
 9 +tracks/
10 +tracks.json
11 +
12 +# Personal notes and ideas
13 +ideas.md
14 +
15 +# macOS
16 +.DS_Store
17 +
18 +# IDE
19 +.vscode/
20 +.idea/
21 +*.swp
22 +*.swo
23 +*~
host.py
 1 +#!/usr/bin/env python3
 2 +"""
 3 +Simple HTTP Server for vibe capsule MP3 player
 4 +Starts server and opens browser automatically
 5 +"""
 6 +
 7 +import http.server
 8 +import socketserver
 9 +import webbrowser
10 +import socket
11 +import sys
12 +from pathlib import Path
13 +
14 +DEFAULT_PORT = 8000
15 +
16 +
17 +def get_local_ip():
18 +	"""Get the local IP address for network access"""
19 +	try:
20 +		# Create a socket to determine the local IP
21 +		s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
22 +		# Connect to a public DNS server (doesn't actually send data)
23 +		s.connect(("8.8.8.8", 80))
24 +		local_ip = s.getsockname()[0]
25 +		s.close()
26 +		return local_ip
27 +	except Exception:
28 +		return "Unable to determine"
29 +
30 +
31 +def find_available_port(start_port=DEFAULT_PORT, max_attempts=10):
32 +	"""Find an available port starting from start_port"""
33 +	for port in range(start_port, start_port + max_attempts):
34 +		try:
35 +			with socketserver.TCPServer(("", port), None) as s:
36 +				return port
37 +		except OSError:
38 +			continue
39 +	return None
40 +
41 +
42 +def main():
43 +	# Change to script directory
44 +	script_dir = Path(__file__).parent.absolute()
45 +	os.chdir(script_dir)
46 +
47 +	# Find an available port
48 +	port = find_available_port(DEFAULT_PORT)
49 +
50 +	if port is None:
51 +		print(f"Error: Could not find an available port (tried {DEFAULT_PORT}-{DEFAULT_PORT + 9})")
52 +		sys.exit(1)
53 +
54 +	# Get local IP for network access
55 +	local_ip = get_local_ip()
56 +
57 +	# Create server
58 +	Handler = http.server.SimpleHTTPRequestHandler
59 +
60 +	# Suppress default logging
61 +	class QuietHandler(Handler):
62 +		def log_message(self, format, *args):
63 +			pass
64 +
65 +	try:
66 +		with socketserver.TCPServer(("", port), QuietHandler) as httpd:
67 +			local_url = f"http://localhost:{port}"
68 +			network_url = f"http://{local_ip}:{port}"
69 +
70 +			print("=" * 60)
71 +			print("šŸŽ§ vibe capsule")
72 +			print("=" * 60)
73 +			print(f"\nServer running on port {port}")
74 +			print(f"\nLocal access:   {local_url}")
75 +			print(f"Network access: {network_url}")
76 +			print("\nPress Ctrl+C to stop the server")
77 +			print("=" * 60)
78 +
79 +			# Open browser
80 +			webbrowser.open(local_url)
81 +
82 +			# Serve forever
83 +			httpd.serve_forever()
84 +
85 +	except KeyboardInterrupt:
86 +		print("\n\nShutting down server...")
87 +		sys.exit(0)
88 +	except Exception as e:
89 +		print(f"\nError starting server: {e}")
90 +		sys.exit(1)
91 +
92 +
93 +if __name__ == "__main__":
94 +	import os
95 +	main()
index.html
  1 +<!DOCTYPE html>
  2 +<html lang="en">
  3 +<head>
  4 +	<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>šŸŽ§</text></svg>">
  5 +	<meta charset="UTF-8">
  6 +	<meta name="viewport" content="width=device-width, initial-scale=1.0">
  7 +	<title>vibe capsule</title>
  8 +	<style>
  9 +		:root {
 10 +			--icee: #dddddd;
 11 +			--blueberry: #4c7ae6;
 12 +			--bubblegum: #f421ff;
 13 +			--blush: #bf209c;
 14 +			--asphalt: #080a0c;
 15 +		}
 16 +
 17 +		body {
 18 +			margin: 0;
 19 +			padding: 0;
 20 +			font-family: "MS Sans Serif", Arial, sans-serif;
 21 +			height: 100vh;
 22 +			display: flex;
 23 +			flex-direction: column;
 24 +			font-size: 24px;
 25 +			background-color: var(--asphalt);
 26 +		}
 27 +
 28 +		.window-container {
 29 +			background: transparent;
 30 +			height: 100%;
 31 +			display: flex;
 32 +			flex-direction: column;
 33 +		}
 34 +
 35 +		.player-container {
 36 +			flex-grow: 1;
 37 +			display: flex;
 38 +			flex-direction: column;
 39 +			overflow: hidden;
 40 +			position: relative;
 41 +		}
 42 +
 43 +		.controls {
 44 +			display: flex;
 45 +			justify-content: center;
 46 +			padding-bottom: 10px;
 47 +			height: 52px;
 48 +			background: transparent;
 49 +			position: absolute;
 50 +			bottom: 0;
 51 +			left: 0;
 52 +			right: 0;
 53 +			box-sizing: border-box;
 54 +			z-index: 3;
 55 +		}
 56 +
 57 +		.playlist {
 58 +			flex-grow: 1;
 59 +			overflow-y: auto;
 60 +			padding-bottom: 62px;
 61 +			cursor: initial;
 62 +		}
 63 +
 64 +		.playlist-item {
 65 +			padding: 5px 10px;
 66 +			display: flex;
 67 +			justify-content: space-between;
 68 +			align-items: center;
 69 +			color: var(--icee);
 70 +			min-height: 45px;
 71 +		}
 72 +
 73 +		.playlist-item-content {
 74 +			flex-grow: 1;
 75 +			display: flex;
 76 +			flex-direction: column;
 77 +			gap: 2px;
 78 +		}
 79 +
 80 +		.playlist-item-title {
 81 +			font-size: 20px;
 82 +			line-height: 1.2;
 83 +		}
 84 +
 85 +		.playlist-item-title.current {
 86 +			font-weight: bold;
 87 +			color: var(--blueberry);
 88 +		}
 89 +
 90 +		.playlist-item-artist {
 91 +			font-size: 16px;
 92 +			opacity: 0.8;
 93 +			line-height: 1.2;
 94 +		}
 95 +
 96 +		.playlist-item-artist.current {
 97 +			font-weight: bold;
 98 +			color: var(--blueberry);
 99 +			opacity: 1;
100 +		}
101 +
102 +		.playlist-item:hover {
103 +			background-color: var(--bubblegum);
104 +			cursor: pointer;
105 +			color: var(--asphalt);
106 +		}
107 +
108 +		.playlist-item:hover .playlist-item-title.current,
109 +		.playlist-item:hover .playlist-item-artist.current {
110 +			color: var(--asphalt);
111 +		}
112 +
113 +		.current-song {
114 +			text-align: left;
115 +			padding-left: 10px;
116 +			font-weight: bold;
117 +			color: var(--blueberry);
118 +			min-height: 35px;
119 +			display: flex;
120 +			align-items: center;
121 +		}
122 +
123 +		.current-song span {
124 +			white-space: nowrap;
125 +			overflow: hidden;
126 +			text-overflow: ellipsis;
127 +		}
128 +
129 +		#audioPlayer {
130 +			display: none;
131 +		}
132 +
133 +		.progress-container {
134 +			width: 100%;
135 +			font-size: 24px;
136 +			background-color: var(--icee);
137 +			cursor: pointer;
138 +			margin-top: 3px;
139 +			height: 35px;
140 +		}
141 +
142 +		.progress-container:hover {
143 +			cursor: pointer;
144 +		}
145 +
146 +		.progress-bar {
147 +			width: 0%;
148 +			height: 34px;
149 +			background-color: var(--bubblegum);
150 +			border-bottom: 1px solid var(--blush);
151 +			position: relative;
152 +		}
153 +
154 +		.progress-text {
155 +			text-shadow: 1px 1px 1px var(--asphalt);
156 +		}
157 +
158 +		.progress-text-left {
159 +			position: absolute;
160 +			left: 5px;
161 +			top: 50%;
162 +			transform: translateY(-50%);
163 +			color: var(--icee);
164 +		}
165 +
166 +		.progress-text-center {
167 +			position: absolute;
168 +			left: 50%;
169 +			top: 50%;
170 +			transform: translate(-50%, -50%);
171 +			color: var(--icee);
172 +			opacity: 0;
173 +		}
174 +
175 +		.control-button {
176 +			width: 120px;
177 +			height: 42px;
178 +			background-size: contain;
179 +			background-repeat: no-repeat;
180 +			background-position: center;
181 +			border: none;
182 +			cursor: pointer;
183 +			margin: 0 5px;
184 +			border-radius: 4px;
185 +		}
186 +
187 +		#playPause {
188 +			background-image: url('resources/play.png');
189 +		}
190 +
191 +		#playPause.pause {
192 +			background-image: url('resources/pause.png');
193 +		}
194 +
195 +		#prev {
196 +			background-image: url('resources/prev.png');
197 +		}
198 +
199 +		#next {
200 +			background-image: url('resources/next.png');
201 +		}
202 +	</style>
203 +
204 +</head>
205 +<body>
206 +	<div class="window-container">
207 +		<div class="player-container">
208 +			<div class="current-song" id="currentSong">No song playing</div>
209 +			<div class="progress-container" id="progressContainer">
210 +				<div class="progress-bar" id="progressBar">
211 +					<span class="progress-text progress-text-left" id="progressTextLeft"></span>
212 +					<span class="progress-text progress-text-center" id="progressTextCenter"></span>
213 +				</div>
214 +			</div>
215 +			<div class="playlist" id="playlist"></div>
216 +		</div>
217 +		<div class="controls">
218 +			<button class="control-button" id="prev"></button>
219 +			<button class="control-button" id="playPause"></button>
220 +			<button class="control-button" id="next"></button>
221 +		</div>
222 +	</div>
223 +
224 +	<audio id="audioPlayer"></audio>
225 +
226 +	<script>
227 +		const playPauseBtn = document.getElementById('playPause');
228 +		const prevBtn = document.getElementById('prev');
229 +		const nextBtn = document.getElementById('next');
230 +		const playlist = document.getElementById('playlist');
231 +		const currentSongDisplay = document.getElementById('currentSong');
232 +		const progressBar = document.getElementById('progressBar');
233 +		const progressContainer = document.getElementById('progressContainer');
234 +		const audio = document.getElementById('audioPlayer');
235 +
236 +		let currentSongIndex = 0;
237 +		let isPlaying = false;
238 +		let progressInterval;
239 +		let playerReady = false;
240 +		let songs = [];
241 +
242 +		// Load tracks from tracks.json
243 +		fetch('tracks.json')
244 +			.then(response => {
245 +				if (!response.ok) {
246 +					throw new Error('tracks.json not found');
247 +				}
248 +				return response.json();
249 +			})
250 +			.then(data => {
251 +				songs = data;
252 +				if (songs.length > 0) {
253 +					playerReady = true;
254 +					updateCurrentSongDisplay(`Ready to play: ${songs[0].artist} - ${songs[0].title}`);
255 +					renderPlaylist();
256 +				} else {
257 +					updateCurrentSongDisplay('No tracks found');
258 +				}
259 +			})
260 +			.catch(error => {
261 +				console.error('Error loading tracks:', error);
262 +				updateCurrentSongDisplay('Error: tracks.json not found. Run scan.py first.');
263 +			});
264 +
265 +		// Audio event listeners
266 +		audio.addEventListener('play', () => {
267 +			startProgressBar();
268 +			const song = songs[currentSongIndex];
269 +			updateCurrentSongDisplay(song.looping ?
270 +				`Looping: ${song.artist} - ${song.title}` :
271 +				`Now playing: ${song.artist} - ${song.title}`);
272 +
273 +			// Update media session metadata
274 +			if ('mediaSession' in navigator) {
275 +				navigator.mediaSession.metadata = new MediaMetadata({
276 +					title: song.title,
277 +					artist: song.artist
278 +				});
279 +			}
280 +		});
281 +
282 +		audio.addEventListener('pause', () => {
283 +			stopProgressBar();
284 +			const song = songs[currentSongIndex];
285 +			updateCurrentSongDisplay(`Paused: ${song.artist} - ${song.title}`);
286 +		});
287 +
288 +		audio.addEventListener('ended', () => {
289 +			if (songs[currentSongIndex].looping) {
290 +				audio.currentTime = 0;
291 +				audio.play();
292 +			} else {
293 +				nextSong();
294 +			}
295 +		});
296 +
297 +		audio.addEventListener('error', (e) => {
298 +			console.error('Audio error:', e);
299 +			updateCurrentSongDisplay(`Error loading: ${songs[currentSongIndex].filename}`);
300 +			// Try next song after a brief delay
301 +			setTimeout(() => nextSong(), 1000);
302 +		});
303 +
304 +		audio.addEventListener('loadedmetadata', () => {
305 +			resetProgressBar();
306 +		});
307 +
308 +		function renderPlaylist() {
309 +			playlist.innerHTML = '';
310 +			const currentDisplayText = currentSongDisplay.textContent;
311 +			const isInitialized = currentDisplayText !== 'No song playing';
312 +
313 +			songs.forEach((song, index) => {
314 +				const item = document.createElement('div');
315 +				item.classList.add('playlist-item');
316 +
317 +				const contentDiv = document.createElement('div');
318 +				contentDiv.classList.add('playlist-item-content');
319 +
320 +				const titleDiv = document.createElement('div');
321 +				titleDiv.classList.add('playlist-item-title');
322 +				if (isInitialized && index === currentSongIndex) {
323 +					titleDiv.classList.add('current');
324 +				}
325 +				titleDiv.textContent = song.title;
326 +
327 +				const artistDiv = document.createElement('div');
328 +				artistDiv.classList.add('playlist-item-artist');
329 +				if (isInitialized && index === currentSongIndex) {
330 +					artistDiv.classList.add('current');
331 +				}
332 +				artistDiv.textContent = song.artist;
333 +
334 +				contentDiv.appendChild(titleDiv);
335 +				contentDiv.appendChild(artistDiv);
336 +
337 +				const loopIcon = document.createElement('span');
338 +				loopIcon.textContent = 'šŸ”';
339 +				loopIcon.style.display = (song.looping || false) ? 'inline' : 'none';
340 +
341 +				item.appendChild(contentDiv);
342 +				item.appendChild(loopIcon);
343 +				item.addEventListener('click', () => toggleLooping(index));
344 +				playlist.appendChild(item);
345 +			});
346 +		}
347 +
348 +		function toggleLooping(index) {
349 +			if (!playerReady) return;
350 +			if (index === currentSongIndex) {
351 +				// If it's ready to play but not playing yet, just play it
352 +				if (!isPlaying && currentSongDisplay.textContent.includes('Ready to play')) {
353 +					audio.play();
354 +					isPlaying = true;
355 +					updatePlayPauseButton();
356 +					return;
357 +				}
358 +				// Toggle looping
359 +				songs[index].looping = !(songs[index].looping || false);
360 +				renderPlaylist();
361 +
362 +				const song = songs[index];
363 +				// Update the display text based on current state
364 +				if (isPlaying) {
365 +					updateCurrentSongDisplay(song.looping ?
366 +						`Looping: ${song.artist} - ${song.title}` :
367 +						`Now playing: ${song.artist} - ${song.title}`);
368 +				} else {
369 +					updateCurrentSongDisplay(`Paused: ${song.artist} - ${song.title}`);
370 +				}
371 +			} else {
372 +				playSong(index);
373 +			}
374 +		}
375 +
376 +		function playSong(index) {
377 +			if (!playerReady) return;
378 +			// Clear looping from all songs except the new one if it was already looping
379 +			const wasLooping = songs[index].looping || false;
380 +			songs.forEach(song => song.looping = false);
381 +			if (wasLooping) {
382 +				songs[index].looping = true;
383 +			}
384 +
385 +			currentSongIndex = index;
386 +			const song = songs[currentSongIndex];
387 +			audio.src = `tracks/${song.filename}`;
388 +			audio.play();
389 +			isPlaying = true;
390 +			updatePlayPauseButton();
391 +			renderPlaylist();
392 +		}
393 +
394 +		function updateCurrentSongDisplay(text) {
395 +			currentSongDisplay.innerHTML = `<span>${text}</span>`;
396 +		}
397 +
398 +		function togglePlayPause() {
399 +			if (!playerReady) return;
400 +			if (isPlaying) {
401 +				audio.pause();
402 +				isPlaying = false;
403 +			} else {
404 +				// If no song is loaded, load the first one
405 +				if (!audio.src || audio.src === '') {
406 +					playSong(currentSongIndex);
407 +				} else {
408 +					audio.play();
409 +					isPlaying = true;
410 +				}
411 +			}
412 +			updatePlayPauseButton();
413 +		}
414 +
415 +		function updatePlayPauseButton() {
416 +			playPauseBtn.classList.toggle('pause', isPlaying);
417 +		}
418 +
419 +		function nextSong() {
420 +			if (!playerReady) return;
421 +			currentSongIndex = (currentSongIndex + 1) % songs.length;
422 +			playSong(currentSongIndex);
423 +		}
424 +
425 +		function prevSong() {
426 +			if (!playerReady) return;
427 +			if (audio.currentTime <= 3) {
428 +				currentSongIndex = (currentSongIndex - 1 + songs.length) % songs.length;
429 +				playSong(currentSongIndex);
430 +			} else {
431 +				audio.currentTime = 0;
432 +			}
433 +		}
434 +
435 +		function startProgressBar() {
436 +			stopProgressBar();
437 +			progressInterval = setInterval(updateProgressBar, 100);
438 +		}
439 +
440 +		function stopProgressBar() {
441 +			clearInterval(progressInterval);
442 +		}
443 +
444 +		function resetProgressBar() {
445 +			progressBar.style.width = '0%';
446 +			const progressTextLeft = document.getElementById('progressTextLeft');
447 +			const progressTextCenter = document.getElementById('progressTextCenter');
448 +			progressTextLeft.textContent = '0%';
449 +			progressTextCenter.textContent = '0%';
450 +			progressTextLeft.style.opacity = '1';
451 +			progressTextCenter.style.opacity = '0';
452 +		}
453 +
454 +		function updateProgressBar() {
455 +			if (audio.duration) {
456 +				const currentTime = audio.currentTime;
457 +				const duration = audio.duration;
458 +				const progressPercentage = (currentTime / duration) * 100;
459 +				const displayPercentage = isNaN(progressPercentage) ? 0 : Math.round(progressPercentage);
460 +				const percentageText = `${displayPercentage}%`;
461 +
462 +				progressBar.style.width = `${displayPercentage}%`;
463 +
464 +				const progressTextLeft = document.getElementById('progressTextLeft');
465 +				const progressTextCenter = document.getElementById('progressTextCenter');
466 +
467 +				progressTextLeft.textContent = percentageText;
468 +				progressTextCenter.textContent = percentageText;
469 +
470 +				// Get the width of the text
471 +				const textWidth = progressTextCenter.offsetWidth;
472 +				const barWidth = progressBar.offsetWidth;
473 +
474 +				// Show centered text only when there's room
475 +				if (barWidth >= textWidth + 20) {
476 +					progressTextLeft.style.opacity = '0';
477 +					progressTextCenter.style.opacity = '1';
478 +				} else {
479 +					progressTextLeft.style.opacity = '1';
480 +					progressTextCenter.style.opacity = '0';
481 +				}
482 +			}
483 +		}
484 +
485 +		function seekTo(event) {
486 +			if (!playerReady || !audio.duration) return;
487 +			const rect = progressContainer.getBoundingClientRect();
488 +			const clickPosition = event.clientX - rect.left;
489 +			const clickPercentage = clickPosition / rect.width;
490 +			const duration = audio.duration;
491 +			const seekTime = duration * clickPercentage;
492 +			audio.currentTime = seekTime;
493 +		}
494 +
495 +		playPauseBtn.addEventListener('click', togglePlayPause);
496 +		nextBtn.addEventListener('click', nextSong);
497 +		prevBtn.addEventListener('click', prevSong);
498 +		progressContainer.addEventListener('click', seekTo);
499 +
500 +		// Keyboard controls
501 +		document.addEventListener('keydown', function(event) {
502 +			if (!playerReady) return;
503 +
504 +			// Spacebar: play/pause
505 +			if (event.code === 'Space') {
506 +				event.preventDefault();
507 +				togglePlayPause();
508 +			}
509 +		});
510 +
511 +		// Media key controls
512 +		navigator.mediaSession.metadata = new MediaMetadata({
513 +			title: 'vibe capsule',
514 +			artist: 'MP3 Player'
515 +		});
516 +
517 +		navigator.mediaSession.setActionHandler('play', () => {
518 +			if (playerReady && !isPlaying) {
519 +				togglePlayPause();
520 +			}
521 +		});
522 +
523 +		navigator.mediaSession.setActionHandler('pause', () => {
524 +			if (playerReady && isPlaying) {
525 +				togglePlayPause();
526 +			}
527 +		});
528 +
529 +		navigator.mediaSession.setActionHandler('previoustrack', () => {
530 +			if (playerReady) {
531 +				prevSong();
532 +			}
533 +		});
534 +
535 +		navigator.mediaSession.setActionHandler('nexttrack', () => {
536 +			if (playerReady) {
537 +				nextSong();
538 +			}
539 +		});
540 +	</script>
541 +</body>
542 +</html>
requirements.txt
1 +mutagen==1.47.0
resources/next.png
Binary files differ.
resources/pause.png
Binary files differ.
resources/play.png
Binary files differ.
resources/prev.png
Binary files differ.
scan.py
  1 +#!/usr/bin/env python3
  2 +"""
  3 +MP3 Scanner - Scans /tracks directory and generates tracks.json with metadata
  4 +Automatically manages a virtual environment for dependencies
  5 +"""
  6 +
  7 +import os
  8 +import sys
  9 +import subprocess
 10 +import json
 11 +from pathlib import Path
 12 +
 13 +SCRIPT_DIR = Path(__file__).parent.absolute()
 14 +VENV_DIR = SCRIPT_DIR / "venv"
 15 +TRACKS_DIR = SCRIPT_DIR / "tracks"
 16 +OUTPUT_FILE = SCRIPT_DIR / "tracks.json"
 17 +REQUIREMENTS_FILE = SCRIPT_DIR / "requirements.txt"
 18 +
 19 +
 20 +def setup_venv():
 21 +	"""Create and setup virtual environment if it doesn't exist"""
 22 +	if not VENV_DIR.exists():
 23 +		print("Creating virtual environment...")
 24 +		try:
 25 +			subprocess.check_call([sys.executable, "-m", "venv", str(VENV_DIR)])
 26 +			print("Virtual environment created successfully.")
 27 +		except subprocess.CalledProcessError as e:
 28 +			print(f"Error creating virtual environment: {e}")
 29 +			sys.exit(1)
 30 +
 31 +	# Determine the path to pip in the venv
 32 +	if sys.platform == "win32":
 33 +		pip_path = VENV_DIR / "Scripts" / "pip"
 34 +		python_path = VENV_DIR / "Scripts" / "python"
 35 +	else:
 36 +		pip_path = VENV_DIR / "bin" / "pip"
 37 +		python_path = VENV_DIR / "bin" / "python3"
 38 +
 39 +	# Install requirements if requirements.txt exists
 40 +	if REQUIREMENTS_FILE.exists():
 41 +		print("Installing dependencies from requirements.txt...")
 42 +		try:
 43 +			subprocess.check_call([str(pip_path), "install", "-q", "-r", str(REQUIREMENTS_FILE)])
 44 +			print("Dependencies installed successfully.")
 45 +		except subprocess.CalledProcessError as e:
 46 +			print(f"Error installing dependencies: {e}")
 47 +			sys.exit(1)
 48 +
 49 +	return python_path
 50 +
 51 +
 52 +def run_in_venv():
 53 +	"""Re-run this script in the virtual environment"""
 54 +	python_path = setup_venv()
 55 +
 56 +	# Re-run this script with the venv Python
 57 +	print("Running scanner in virtual environment...\n")
 58 +	subprocess.check_call([str(python_path), __file__, "--in-venv"])
 59 +	sys.exit(0)
 60 +
 61 +
 62 +def scan_tracks():
 63 +	"""Main function to scan MP3 files and generate tracks.json"""
 64 +	# Import mutagen here (only after venv is active)
 65 +	try:
 66 +		from mutagen.mp3 import MP3
 67 +		from mutagen.id3 import ID3
 68 +	except ImportError:
 69 +		print("Error: mutagen library not found. Please check your installation.")
 70 +		sys.exit(1)
 71 +
 72 +	# Check if tracks directory exists, create if it doesn't
 73 +	if not TRACKS_DIR.exists():
 74 +		print(f"Creating {TRACKS_DIR.name} directory...")
 75 +		TRACKS_DIR.mkdir(parents=True, exist_ok=True)
 76 +		print(f"āœ“ {TRACKS_DIR.name} directory created.")
 77 +		print(f"\nPlease add MP3 files to the {TRACKS_DIR.name} directory and run this script again.")
 78 +		sys.exit(0)
 79 +
 80 +	# Check if tracks.json already exists
 81 +	if OUTPUT_FILE.exists():
 82 +		response = input(f"{OUTPUT_FILE.name} already exists. Overwrite? (y/n): ").lower().strip()
 83 +		if response != 'y':
 84 +			print(f"Scan cancelled. {OUTPUT_FILE.name} was not modified.")
 85 +			sys.exit(0)
 86 +
 87 +	# Find all MP3 files
 88 +	mp3_files = list(TRACKS_DIR.glob("*.mp3"))
 89 +
 90 +	if not mp3_files:
 91 +		print(f"No MP3 files found in {TRACKS_DIR}")
 92 +		sys.exit(1)
 93 +
 94 +	print(f"Found {len(mp3_files)} MP3 file(s). Extracting metadata...\n")
 95 +
 96 +	tracks = []
 97 +
 98 +	for mp3_file in sorted(mp3_files):
 99 +		try:
100 +			audio = MP3(mp3_file)
101 +
102 +			# Try to get ID3 tags
103 +			title = None
104 +			artist = None
105 +
106 +			if audio.tags:
107 +				# Try different title tags
108 +				if 'TIT2' in audio.tags:  # Title
109 +					title = str(audio.tags['TIT2'])
110 +
111 +				# Try different artist tags
112 +				if 'TPE1' in audio.tags:  # Artist
113 +					artist = str(audio.tags['TPE1'])
114 +
115 +			# Fallback to filename for title if not found
116 +			if not title:
117 +				title = mp3_file.stem  # filename without extension
118 +
119 +			# Fallback to "Unknown Artist" if not found
120 +			if not artist:
121 +				artist = "Unknown Artist"
122 +
123 +			track_info = {
124 +				"title": title,
125 +				"artist": artist,
126 +				"filename": mp3_file.name
127 +			}
128 +
129 +			tracks.append(track_info)
130 +			print(f"āœ“ {track_info['artist']} - {track_info['title']}")
131 +
132 +		except Exception as e:
133 +			print(f"āœ— Error reading {mp3_file.name}: {e}")
134 +			continue
135 +
136 +	if not tracks:
137 +		print("\nNo valid MP3 files could be processed.")
138 +		sys.exit(1)
139 +
140 +	# Write to tracks.json
141 +	try:
142 +		with open(OUTPUT_FILE, 'w', encoding='utf-8') as f:
143 +			json.dump(tracks, f, indent="\t", ensure_ascii=False)
144 +
145 +		print(f"\nāœ“ Successfully generated {OUTPUT_FILE.name} with {len(tracks)} track(s).")
146 +
147 +	except Exception as e:
148 +		print(f"\nError writing {OUTPUT_FILE.name}: {e}")
149 +		sys.exit(1)
150 +
151 +
152 +def main():
153 +	"""Main entry point"""
154 +	# Check if we're already running in venv
155 +	if "--in-venv" not in sys.argv:
156 +		run_in_venv()
157 +	else:
158 +		scan_tracks()
159 +
160 +
161 +if __name__ == "__main__":
162 +	main()