initial commit
Hunter
Tuesday, October 28th, 2025 at 12:15 AM
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 +*~
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()
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>
1 +mutagen==1.47.0
Binary files differ.
Binary files differ.
Binary files differ.
Binary files differ.
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()