# Cloudflare Worker Audio Playlist System

I'll create a system with a build step and a Cloudflare worker that serves an audio playlist based on server time.

## ```package.json```

```json
{
  "name": "audio-playlist-worker",
  "version": "1.0.0",
  "scripts": {
    "build": "node build.js && wrangler deploy",
    "dev": "node build.js && wrangler dev",
    "prebuild": "node build.js"
  },
  "devDependencies": {
    "@cloudflare/workers-types": "^4.20231218.0",
    "wrangler": "^3.22.1",
    "typescript": "^5.3.3"
  },
  "dependencies": {
    "music-metadata": "^8.1.4"
  }
}
```

## ```build.js```

```javascript
import fs from 'fs/promises';
import path from 'path';
import { parseFile } from 'music-metadata';

const AUDIO_DIR = './audio';
const OUTPUT_FILE = './src/manifest.json';

async function getAudioDuration(filePath) {
  try {
    const metadata = await parseFile(filePath);
    return Math.ceil(metadata.format.duration * 1000); // milliseconds, rounded up
  } catch (error) {
    console.error(`Error reading ${filePath}:`, error);
    return 0;
  }
}

function parseFileName(fileName) {
  const match = fileName.match(/HHGTTG\.Phase(\d+)\.Fit(\d+)\.mp3$/i);
  if (match) {
    return {
      phase: parseInt(match[1], 10),
      fit: parseInt(match[2], 10)
    };
  }
  return null;
}

async function buildManifest() {
  console.log('Building manifest...');
  
  try {
    const files = await fs.readdir(AUDIO_DIR);
    const audioFiles = files.filter(f => 
      f.match(/\.(mp3|mp4|m4a|wav|ogg|webm)$/i)
    );

    const manifest = {
      files: [],
      totalDuration: 0,
      generatedAt: new Date().toISOString()
    };

    // Process files in order
    audioFiles.sort((a, b) => {
      const parseA = parseFileName(a);
      const parseB = parseFileName(b);
      
      if (parseA && parseB) {
        if (parseA.phase !== parseB.phase) {
          return parseA.phase - parseB.phase;
        }
        return parseA.fit - parseB.fit;
      }
      return a.localeCompare(b);
    });

    let cumulativeTime = 0;

    for (const file of audioFiles) {
      const filePath = path.join(AUDIO_DIR, file);
      const duration = await getAudioDuration(filePath);
      const parsed = parseFileName(file);

      manifest.files.push({
        filename: file,
        startTime: cumulativeTime,
        endTime: cumulativeTime + duration,
        duration: duration,
        phase: parsed?.phase || null,
        fit: parsed?.fit || null
      });

      cumulativeTime += duration;
      console.log(`Processed: ${file} (${duration}ms)`);
    }

    manifest.totalDuration = cumulativeTime;

    await fs.mkdir('./src', { recursive: true });
    await fs.writeFile(OUTPUT_FILE, JSON.stringify(manifest, null, 2));
    
    console.log(`✓ Manifest built successfully!`);
    console.log(`  Total files: ${manifest.files.length}`);
    console.log(`  Total duration: ${Math.floor(manifest.totalDuration / 1000 / 60)} minutes`);
  } catch (error) {
    console.error('Error building manifest:', error);
    process.exit(1);
  }
}

buildManifest();
```

## ```src/index.ts```

```typescript
import manifest from './manifest.json';

interface Env {
  // Add any environment variables here
}

interface AudioFile {
  filename: string;
  startTime: number;
  endTime: number;
  duration: number;
  phase: number | null;
  fit: number | null;
}

interface Manifest {
  files: AudioFile[];
  totalDuration: number;
  generatedAt: string;
}

const MANIFEST: Manifest = manifest as Manifest;
const PASSWORD = '42';

function getCurrentPlaybackPosition(totalDuration: number): number {
  const now = Date.now();
  const dayStart = new Date(now).setHours(0, 0, 0, 0);
  const timeSinceDayStart = now - dayStart;
  return timeSinceDayStart % totalDuration;
}

function findCurrentFile(position: number): { file: AudioFile; offset: number } | null {
  const file = MANIFEST.files.find(f => position >= f.startTime && position < f.endTime);
  if (file) {
    return {
      file,
      offset: position - file.startTime
    };
  }
  return null;
}

function generateMarkdown(): string {
  let md = `# Audio Playlist\n\n`;
  md += `Generated: ${MANIFEST.generatedAt}\n\n`;
  md += `Total Duration: ${Math.floor(MANIFEST.totalDuration / 1000 / 60)} minutes\n\n`;
  md += `## Files\n\n`;
  
  let currentPhase: number | null = null;
  
  for (const file of MANIFEST.files) {
    if (file.phase !== null && file.phase !== currentPhase) {
      currentPhase = file.phase;
      md += `\n### Phase ${file.phase}\n\n`;
    }
    
    const minutes = Math.floor(file.duration / 1000 / 60);
    const seconds = Math.floor((file.duration / 1000) % 60);
    md += `- **${file.filename}** (${minutes}:${seconds.toString().padStart(2, '0')})\n`;
  }
  
  return md;
}

function generateHTML(authenticated: boolean, serverTime: number): string {
  if (!authenticated) {
    return `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Audio Playlist - Authentication Required</title>
  <style>
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
      max-width: 400px;
      margin: 100px auto;
      padding: 20px;
      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
      min-height: 100vh;
    }
    .card {
      background: white;
      padding: 30px;
      border-radius: 10px;
      box-shadow: 0 10px 40px rgba(0,0,0,0.2);
    }
    h1 {
      margin: 0 0 20px 0;
      color: #333;
      font-size: 24px;
    }
    input {
      width: 100%;
      padding: 12px;
      border: 2px solid #ddd;
      border-radius: 5px;
      font-size: 16px;
      box-sizing: border-box;
      margin-bottom: 15px;
    }
    input:focus {
      outline: none;
      border-color: #667eea;
    }
    button {
      width: 100%;
      padding: 12px;
      background: #667eea;
      color: white;
      border: none;
      border-radius: 5px;
      font-size: 16px;
      cursor: pointer;
      font-weight: 600;
    }
    button:hover {
      background: #5568d3;
    }
  </style>
</head>
<body>
  <div class="card">
    <h1>🔒 Authentication Required</h1>
    <form method="POST">
      <input type="password" name="password" placeholder="Enter password" required autofocus>
      <button type="submit">Access Playlist</button>
    </form>
  </div>
</body>
</html>`;
  }

  const currentPosition = getCurrentPlaybackPosition(MANIFEST.totalDuration);
  const currentPlayback = findCurrentFile(currentPosition);

  return `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Audio Playlist Player</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
      background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
      color: white;
      min-height: 100vh;
      padding: 20px;
    }
    .container {
      max-width: 800px;
      margin: 0 auto;
    }
    .player {
      background: rgba(255, 255, 255, 0.1);
      backdrop-filter: blur(10px);
      border-radius: 20px;
      padding: 30px;
      margin-bottom: 30px;
    }
    h1 {
      font-size: 32px;
      margin-bottom: 10px;
    }
    .now-playing {
      font-size: 18px;
      opacity: 0.9;
      margin-bottom: 20px;
    }
    audio {
      width: 100%;
      margin-top: 20px;
    }
    .playlist {
      background: rgba(255, 255, 255, 0.1);
      backdrop-filter: blur(10px);
      border-radius: 20px;
      padding: 20px;
    }
    .playlist h2 {
      margin-bottom: 15px;
    }
    .file {
      padding: 10px;
      margin-bottom: 5px;
      border-radius: 5px;
      background: rgba(255, 255, 255, 0.05);
    }
    .file.active {
      background: rgba(255, 255, 255, 0.2);
      font-weight: 600;
    }
    .status {
      margin-top: 20px;
      padding: 15px;
      background: rgba(0, 0, 0, 0.2);
      border-radius: 10px;
      font-family: monospace;
      font-size: 14px;
    }
  </style>
</head>
<body>
  <div class="container">
    <div class="player">
      <h1>🎵 Continuous Playlist Player</h1>
      <div class="now-playing" id="now-playing">Loading...</div>
      <audio id="audio" controls autoplay></audio>
      <div class="status" id="status"></div>
    </div>
    
    <div class="playlist">
      <h2>Playlist</h2>
      <div id="playlist-items"></div>
    </div>
  </div>

  <script>
    const manifest = ${JSON.stringify(MANIFEST)};
    const audio = document.getElementById('audio');
    const nowPlaying = document.getElementById('now-playing');
    const status = document.getElementById('status');
    const playlistItems = document.getElementById('playlist-items');
    
    let currentIndex = ${currentPlayback ? MANIFEST.files.indexOf(currentPlayback.file) : 0};
    let startOffset = ${currentPlayback ? currentPlayback.offset / 1000 : 0};
    
    // Render playlist
    manifest.files.forEach((file, index) => {
      const div = document.createElement('div');
      div.className = 'file';
      div.id = 'file-' + index;
      const duration = Math.floor(file.duration / 1000);
      const mins = Math.floor(duration / 60);
      const secs = duration % 60;
      div.textContent = file.filename + ' (' + mins + ':' + secs.toString().padStart(2, '0') + ')';
      playlistItems.appendChild(div);
    });
    
    function updateUI(index) {
      document.querySelectorAll('.file').forEach(f => f.classList.remove('active'));
      document.getElementById('file-' + index)?.classList.add('active');
      
      const file = manifest.files[index];
      nowPlaying.textContent = 'Now Playing: ' + file.filename;
      if (file.phase && file.fit) {
        nowPlaying.textContent += ' (Phase ' + file.phase + ', Fit ' + file.fit + ')';
      }
    }
    
    function playFile(index, seekTo = 0) {
      if (index >= manifest.files.length) {
        index = 0; // Loop back to start
      }
      
      currentIndex = index;
      const file = manifest.files[index];
      audio.src = '/audio/' + file.filename;
      
      audio.addEventListener('loadedmetadata', function onLoaded() {
        if (seekTo > 0 && seekTo < audio.duration) {
          audio.currentTime = seekTo;
        }
        audio.play();
        audio.removeEventListener('loadedmetadata', onLoaded);
      });
      
      updateUI(index);
      status.textContent = 'Playing file ' + (index + 1) + '/' + manifest.files.length;
    }
    
    audio.addEventListener('ended', () => {
      playFile(currentIndex + 1);
    });
    
    // Start playback
    playFile(currentIndex, startOffset);
  </script>
</body>
</html>`;
}

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const url = new URL(request.url);
    
    // Serve audio files
    if (url.pathname.startsWith('/audio/')) {
      return new Response('Audio file serving should be handled by Workers KV or R2', {
        status: 404
      });
    }
    
    // Handle root path
    if (url.pathname === '/') {
      const acceptHeader = request.headers.get('Accept') || '';
      
      // JSON response
      if (acceptHeader.includes('application/json')) {
        return new Response(JSON.stringify(MANIFEST, null, 2), {
          headers: { 'Content-Type': 'application/json' }
        });
      }
      
      // Markdown response
      if (acceptHeader.includes('text/markdown')) {
        return new Response(generateMarkdown(), {
          headers: { 'Content-Type': 'text/markdown' }
        });
      }
      
      // HTML response (default)
      let authenticated = false;
      
      if (request.method === 'POST') {
        const formData = await request.formData();
        const password = formData.get('password');
        authenticated = password === PASSWORD;
        
        if (!authenticated) {
          return new Response(generateHTML(false, Date.now()), {
            status: 401,
            headers: { 'Content-Type': 'text/html' }
          });
        }
      }
      
      return new Response(generateHTML(authenticated, Date.now()), {
        headers: { 'Content-Type': 'text/html' }
      });
    }
    
    return new Response('Not Found', { status: 404 });
  }
};
```

## ```wrangler.toml```

```toml
name = "audio-playlist-worker"
main = "src/index.ts"
compatibility_date = "2024-01-01"

[build]
command = "node build.js"

# Uncomment and configure if using R2 for audio storage
# [[r2_buckets]]
# binding = "AUDIO_BUCKET"
# bucket_name = "audio-files"
```

## ```tsconfig.json```

```json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "lib": ["ES2022"],
    "types": ["@cloudflare/workers-types"],
    "resolveJsonModule": true,
    "moduleResolution": "node",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules"]
}
```

## ```README.md```

```markdown
# Cloudflare Worker Audio Playlist System

A time-synchronized continuous audio playlist player using Cloudflare Workers.

## Features

- **Build-time manifest generation**: Analyzes audio files and generates a precise timeline
- **Time-synchronized playback**: Plays files based on server time, synced across all clients
- **Multiple content types**: Serves JSON, Markdown, or HTML based on Accept header
- **Password protection**: Simple authentication for HTML player
- **Continuous loop**: Automatically loops through the entire playlist

## Setup

1. Install dependencies:
   ```bash
   npm install
   ```

2. Create an `audio` folder and add your audio files:
   ```
   audio/
   ├── HHGTTG.Phase1.Fit01.mp3
   ├── HHGTTG.Phase1.Fit02.mp3
   └── ...
   ```

3. Build and deploy:
   ```bash
   npm run build
   ```

## Usage

- **JSON API**: `curl -H "Accept: application/json" https://your-worker.dev`
- **Markdown**: `curl -H "Accept: text/markdown" https://your-worker.dev`
- **HTML Player**: Visit `https://your-worker.dev` in browser (password: `42`)

## How it works

1. Build script scans audio files and creates manifest with precise timings
2. Worker calculates current position based on server time
3. Player starts from correct file and offset, then continues sequentially
4. Playlist loops indefinitely

## Note

This example doesn't include actual audio file serving. For production, upload files to Cloudflare R2 and serve them from the worker.
```

This system creates a continuous, time-synchronized audio playlist where all users hear the same content at the same time, based on server time!