first commit

This commit is contained in:
MrUnknownDE
2025-12-28 17:57:20 +01:00
commit 9979697acf
17 changed files with 4755 additions and 0 deletions

31
.env.example Normal file
View File

@@ -0,0 +1,31 @@
# Spotify API Credentials
# Get these from https://developer.spotify.com/dashboard/
SPOTIFY_CLIENT_ID=your_spotify_client_id
SPOTIFY_CLIENT_SECRET=your_spotify_client_secret
# YouTube/Google API Credentials
# Get these from https://console.cloud.google.com/
# Enable "YouTube Data API v3" and create OAuth 2.0 credentials
YOUTUBE_CLIENT_ID=your_youtube_client_id
YOUTUBE_CLIENT_SECRET=your_youtube_client_secret
# Session Secret (generate a random string)
SESSION_SECRET=your_random_session_secret_here
# Server Port
PORT=3000
# Base URL (used for OAuth callbacks)
# IMPORTANT: Spotify requires HTTPS for callbacks!
# For local development, use a tool like local-ssl-proxy or ngrok
BASE_URL=https://localhost:3000
# Cache Settings
# Path where analysis jobs are cached (relative or absolute)
CACHE_PATH=./cache
# Maximum playlist size (tracks) that can be analyzed
MAX_PLAYLIST_SIZE=50
# Rate Limiting
# Delay between YouTube API calls in milliseconds
RATE_LIMIT_DELAY_MS=2000

144
.gitignore vendored Normal file
View File

@@ -0,0 +1,144 @@
# Created by https://www.toptal.com/developers/gitignore/api/node
# Edit at https://www.toptal.com/developers/gitignore?templates=node
### Node ###
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
### Node Patch ###
# Serverless Webpack directories
.webpack/
# Optional stylelint cache
# SvelteKit build / generate output
.svelte-kit
# End of https://www.toptal.com/developers/gitignore/api/node

18
Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM node:22-alpine
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm install --omit=dev
# Copy source code
COPY src/ ./src/
# Expose port
EXPOSE 3000
# Start the application
CMD ["node", "src/server.js"]

155
README.md Normal file
View File

@@ -0,0 +1,155 @@
# Spotify to YouTube Music Migration Tool
A Node.js web application that migrates your Spotify playlists to YouTube Music with a preview and review workflow.
## ✨ Features
- 🔐 **OAuth Login** - Secure authentication for Spotify and YouTube
- 📋 **Playlist Browser** - View and select your Spotify playlists
- 🔍 **Analysis Queue** - Pre-analyze tracks before migration with rate limiting
- 👀 **Review & Edit** - Side-by-side comparison of matches, add manual links for missing tracks
- 🔄 **Real-time Progress** - Live updates via Server-Sent Events
- 💾 **Persistent Cache** - Search results cached to disk, survives restarts
- 📊 **Quota Optimization** - Rate limiting and caching to preserve YouTube API quota
- 🎨 **Modern UI** - Dark theme with glassmorphism design
- 🐳 **Docker Ready** - Easy deployment with Docker Compose
## 📋 Prerequisites
### 1. Spotify API Credentials
1. Go to [Spotify Developer Dashboard](https://developer.spotify.com/dashboard/)
2. Create a new application
3. Add `http://localhost:3000/auth/spotify/callback` to Redirect URIs
4. Copy your **Client ID** and **Client Secret**
### 2. YouTube/Google API Credentials
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Create a new project (or select existing)
3. Enable **YouTube Data API v3**
4. Go to **Credentials****Create Credentials****OAuth 2.0 Client ID**
5. Configure OAuth consent screen (External, add your email as test user)
6. Create OAuth client ID (Web application)
7. Add `http://localhost:3000/auth/youtube/callback` to Authorized redirect URIs
8. Copy your **Client ID** and **Client Secret**
## 🚀 Setup
### Option 1: Local Development
```bash
# Clone and enter directory
cd sptify2yt
# Install dependencies
npm install
# Copy environment template and add your API credentials
cp .env.example .env
nano .env # or use your preferred editor
# Start the server
npm start
```
Open http://localhost:3000 in your browser.
### Option 2: Docker
```bash
# Copy and configure environment
cp .env.example .env
nano .env
# Build and run
docker compose up -d
```
Open http://localhost:3000 in your browser.
## ⚙️ Configuration
All settings are configured via environment variables (`.env` file):
| Variable | Default | Description |
|----------|---------|-------------|
| `SPOTIFY_CLIENT_ID` | - | Spotify OAuth Client ID |
| `SPOTIFY_CLIENT_SECRET` | - | Spotify OAuth Client Secret |
| `YOUTUBE_CLIENT_ID` | - | Google OAuth Client ID |
| `YOUTUBE_CLIENT_SECRET` | - | Google OAuth Client Secret |
| `SESSION_SECRET` | - | Random string for session encryption |
| `PORT` | `3000` | Server port |
| `BASE_URL` | `http://localhost:3000` | Base URL for OAuth callbacks |
| `CACHE_PATH` | `./cache` | Directory for persistent cache |
| `MAX_PLAYLIST_SIZE` | `500` | Maximum tracks per playlist |
| `RATE_LIMIT_DELAY_MS` | `2000` | Delay between YouTube API calls (ms) |
## 📖 Usage
### Workflow
1. **Login** - Connect your Spotify and YouTube accounts
2. **Select Playlist** - Choose a Spotify playlist to migrate
3. **Analyze** - Click "Analyze Playlist" to search for YouTube matches
4. **Review** - View side-by-side comparison:
-**Found** - Track matched on YouTube
-**Not found** - Add a manual YouTube link
5. **Migrate** - Start the migration to create the YouTube playlist
### Caching
The app uses two levels of caching:
1. **Analysis Jobs** - Complete analysis results saved to `./cache/job_*.json`
2. **Search Cache** - Individual YouTube search results saved to `./cache/search_cache.json`
Search results are cached for 30 days. If you analyze the same track again (even in a different playlist), it uses the cached result without making a new API call.
## 📊 YouTube API Quota
YouTube Data API has a daily quota of **10,000 units**. Each search costs ~100 units, allowing approximately **100 searches per day**.
### How this app optimizes quota:
| Feature | Savings |
|---------|---------|
| **Search Cache** | Reuses results for duplicate tracks |
| **Rate Limiting** | 2s delay prevents burst usage |
| **Pre-Analysis** | Only searches once, migration uses cached data |
| **Manual Links** | Skip API calls for unknown tracks |
## 🗂️ Project Structure
```
sptify2yt/
├── src/
│ ├── server.js # Express server with SSE
│ ├── routes/
│ │ ├── auth.js # OAuth routes
│ │ ├── spotify.js # Spotify API routes
│ │ └── youtube.js # YouTube API + migration
│ ├── services/
│ │ ├── analysisQueue.js # Job queue with persistence
│ │ └── searchCache.js # YouTube search cache
│ └── public/
│ ├── index.html # SPA frontend
│ ├── styles.css # Dark theme styles
│ └── app.js # Frontend logic
├── cache/ # Persistent cache (auto-created)
├── .env.example # Environment template
├── Dockerfile
├── docker-compose.yml
└── package.json
```
## ⚠️ Notes
- **YouTube Music Playlists** - YouTube and YouTube Music share playlists, so migrated playlists appear in both
- **Track Matching** - Searches for "Artist - Track Name" in YouTube's Music category
- **Manual Links** - For rare/unavailable tracks, paste any YouTube video URL
- **Cache Cleanup** - Jobs older than 7 days and search results older than 30 days are automatically cleaned up
## 📄 License
MIT

134
cache/job_1766940808819_at6tdppx0.json vendored Normal file
View File

@@ -0,0 +1,134 @@
{
"id": "job_1766940808819_at6tdppx0",
"sessionId": "session_1766940791190_wjvz0ueby",
"playlist": {
"id": "6EmJB6Dtfrk54qBMJzyEHx",
"name": "pfank repeat",
"image": "https://mosaic.scdn.co/640/ab67616d00001e027e44b3c24e2bdef9928ce6c2ab67616d00001e02d43e8f24d477790a78c7b58fab67616d00001e02d5c0a5bc4afd1d9d16cac98eab67616d00001e02e908735737ca3e20be619da1"
},
"tracks": [
{
"spotifyId": "3f9RTvoln0k1FnIS5edKqD",
"name": "EASY PEAZY - Slowed & Reverb",
"artists": [
"ROMANTICA",
"Lestmor"
],
"album": "EASY PEAZY (Slowed & Sped Up)",
"youtubeMatch": null,
"manualVideoId": null,
"status": "not_found"
},
{
"spotifyId": "1yqaea9PLO8rttUlcNTlcB",
"name": "Komarovo (DVRST Phonk Remix)",
"artists": [
"DVRST",
"Igor Sklyar",
"Atomic Heart"
],
"album": "Atomic Heart (Original Game Soundtrack) Vol.1",
"youtubeMatch": null,
"manualVideoId": null,
"status": "not_found"
},
{
"spotifyId": "0C6DzlnpBBZ4T5KFFtNNQI",
"name": "STUCK IN A DAZE - SLOWED",
"artists": [
"SHIIIKARNO"
],
"album": "STUCK IN A DAZE (SLOWED)",
"youtubeMatch": null,
"manualVideoId": null,
"status": "not_found"
},
{
"spotifyId": "7ItZPW34p9g5hEBCRw5GOK",
"name": "Yours",
"artists": [
"EVILDXER"
],
"album": "Yours",
"youtubeMatch": null,
"manualVideoId": null,
"status": "not_found"
},
{
"spotifyId": "25u4PT1CAUcHwe3YQ3JfrH",
"name": "sorry mom, i'm making breakcore xd",
"artists": [
"usedcvnt"
],
"album": "ultraviolet",
"youtubeMatch": null,
"manualVideoId": null,
"status": "not_found"
},
{
"spotifyId": "0WVwOSGn9J8Ccq5wuBDLmJ",
"name": "143 ways to lose urself",
"artists": [
"usedcvnt"
],
"album": "ultraviolet",
"youtubeMatch": null,
"manualVideoId": null,
"status": "not_found"
},
{
"spotifyId": "0MFl9YQsAgxpBtEpXK8UA1",
"name": "wonderfulcore",
"artists": [
"usedcvnt"
],
"album": "wonderfulcore",
"youtubeMatch": null,
"manualVideoId": null,
"status": "not_found"
},
{
"spotifyId": "2p3LCdEMlQgs2ciXS3KIlM",
"name": "wake up Violet",
"artists": [
"usedcvnt"
],
"album": "wonderfulcore",
"youtubeMatch": null,
"manualVideoId": null,
"status": "not_found"
},
{
"spotifyId": "1e6ZXuLRtWnXhwvJftJYuB",
"name": "EASY PEAZY",
"artists": [
"ROMANTICA",
"Lestmor"
],
"album": "EASY PEAZY",
"youtubeMatch": null,
"manualVideoId": null,
"status": "not_found"
},
{
"spotifyId": "5Y4OwloGr9QSxZLZ5DXGte",
"name": "Broken Trust",
"artists": [
"SAY3AM",
"Staarz"
],
"album": "Broken Trust",
"youtubeMatch": null,
"manualVideoId": null,
"status": "not_found"
}
],
"status": "complete",
"progress": {
"current": 10,
"total": 10
},
"createdAt": 1766940808819,
"completedAt": 1766940827781,
"error": null
}

10
docker-compose.yml Normal file
View File

@@ -0,0 +1,10 @@
services:
app:
build: .
ports:
- "${PORT:-3000}:3000"
env_file:
- .env
environment:
- NODE_ENV=production
restart: unless-stopped

1459
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "sptify2yt",
"version": "1.0.0",
"description": "Migrate Spotify playlists to YouTube Music",
"main": "src/server.js",
"type": "module",
"engines": {
"node": ">=22.0.0"
},
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js"
},
"dependencies": {
"axios": "^1.7.9",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express-session": "^1.18.1",
"googleapis": "^144.0.0",
"spotify-web-api-node": "^5.0.2"
},
"keywords": [
"spotify",
"youtube",
"music",
"migration",
"playlist"
],
"author": "",
"license": "MIT"
}

488
src/public/app.js Normal file
View File

@@ -0,0 +1,488 @@
// State
let authStatus = { spotify: { connected: false }, youtube: { connected: false } };
let playlists = [];
let selectedPlaylist = null;
let selectedTracks = [];
let currentJob = null;
let eventSource = null;
// Generate unique session ID
const sessionId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
// Elements
const loginView = document.getElementById('loginView');
const playlistView = document.getElementById('playlistView');
const analysisView = document.getElementById('analysisView');
const progressView = document.getElementById('progressView');
const spotifyLoginBtn = document.getElementById('spotifyLoginBtn');
const youtubeLoginBtn = document.getElementById('youtubeLoginBtn');
const spotifyStatus = document.getElementById('spotifyStatus');
const youtubeStatus = document.getElementById('youtubeStatus');
const continueBtn = document.getElementById('continueBtn');
const logoutBtn = document.getElementById('logoutBtn');
const playlistGrid = document.getElementById('playlistGrid');
const backFromAnalysis = document.getElementById('backFromAnalysis');
const analysisPlaylistImage = document.getElementById('analysisPlaylistImage');
const analysisPlaylistName = document.getElementById('analysisPlaylistName');
const analysisPlaylistCount = document.getElementById('analysisPlaylistCount');
const startAnalysisBtn = document.getElementById('startAnalysisBtn');
const analysisProgress = document.getElementById('analysisProgress');
const analysisStatusText = document.getElementById('analysisStatusText');
const analysisProgressBar = document.getElementById('analysisProgressBar');
const analysisCurrentTrack = document.getElementById('analysisCurrentTrack');
const analysisResults = document.getElementById('analysisResults');
const comparisonList = document.getElementById('comparisonList');
const statFound = document.getElementById('statFound');
const statNotFound = document.getElementById('statNotFound');
const startMigrationBtn = document.getElementById('startMigrationBtn');
const progressCurrent = document.getElementById('progressCurrent');
const progressTotal = document.getElementById('progressTotal');
const progressBar = document.getElementById('progressBar');
const currentTrackName = document.getElementById('currentTrackName');
const progressLog = document.getElementById('progressLog');
const migrationComplete = document.getElementById('migrationComplete');
const completeStats = document.getElementById('completeStats');
const playlistLink = document.getElementById('playlistLink');
const newMigrationBtn = document.getElementById('newMigrationBtn');
// Initialize
document.addEventListener('DOMContentLoaded', init);
async function init() {
await checkAuthStatus();
setupEventListeners();
setupSSE();
handleUrlParams();
}
function setupEventListeners() {
spotifyLoginBtn.addEventListener('click', () => window.location.href = '/auth/spotify');
youtubeLoginBtn.addEventListener('click', () => window.location.href = '/auth/youtube');
continueBtn.addEventListener('click', showPlaylistView);
logoutBtn.addEventListener('click', logout);
backFromAnalysis.addEventListener('click', () => showView('playlist'));
startAnalysisBtn.addEventListener('click', startAnalysis);
startMigrationBtn.addEventListener('click', startMigration);
newMigrationBtn.addEventListener('click', () => {
showView('playlist');
loadPlaylists();
});
}
function setupSSE() {
eventSource = new EventSource(`/api/progress/${sessionId}`);
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
handleProgressUpdate(data);
};
eventSource.onerror = () => {
console.log('SSE connection error, reconnecting...');
setTimeout(setupSSE, 5000);
};
}
function handleUrlParams() {
const params = new URLSearchParams(window.location.search);
if (params.get('error')) {
alert('Authentication failed. Please try again.');
}
if (params.toString()) {
window.history.replaceState({}, '', '/');
}
}
// Auth
async function checkAuthStatus() {
try {
const response = await fetch('/auth/status');
authStatus = await response.json();
updateAuthUI();
} catch (err) {
console.error('Failed to check auth status:', err);
}
}
function updateAuthUI() {
const spotifyIndicator = spotifyStatus.querySelector('.status-indicator');
const spotifyText = spotifyStatus.querySelector('.status-text');
if (authStatus.spotify.connected) {
spotifyIndicator.classList.add('connected');
spotifyText.textContent = `Connected as ${authStatus.spotify.user?.name || 'User'}`;
spotifyLoginBtn.textContent = '✓ Connected';
spotifyLoginBtn.disabled = true;
} else {
spotifyIndicator.classList.remove('connected');
spotifyText.textContent = 'Not connected';
spotifyLoginBtn.textContent = 'Login with Spotify';
spotifyLoginBtn.disabled = false;
}
const youtubeIndicator = youtubeStatus.querySelector('.status-indicator');
const youtubeText = youtubeStatus.querySelector('.status-text');
if (authStatus.youtube.connected) {
youtubeIndicator.classList.add('connected');
youtubeText.textContent = `Connected as ${authStatus.youtube.user?.name || 'User'}`;
youtubeLoginBtn.textContent = '✓ Connected';
youtubeLoginBtn.disabled = true;
} else {
youtubeIndicator.classList.remove('connected');
youtubeText.textContent = 'Not connected';
youtubeLoginBtn.textContent = 'Login with YouTube';
youtubeLoginBtn.disabled = false;
}
if (authStatus.spotify.connected && authStatus.youtube.connected) {
continueBtn.style.display = 'inline-flex';
logoutBtn.style.display = 'inline-flex';
} else {
continueBtn.style.display = 'none';
logoutBtn.style.display = 'none';
}
}
async function logout() {
try {
await fetch('/auth/logout', { method: 'POST' });
authStatus = { spotify: { connected: false }, youtube: { connected: false } };
updateAuthUI();
showView('login');
} catch (err) {
console.error('Logout failed:', err);
}
}
// Views
function showView(view) {
loginView.style.display = view === 'login' ? 'block' : 'none';
playlistView.style.display = view === 'playlist' ? 'block' : 'none';
analysisView.style.display = view === 'analysis' ? 'block' : 'none';
progressView.style.display = view === 'progress' ? 'block' : 'none';
}
async function showPlaylistView() {
showView('playlist');
await loadPlaylists();
}
// Playlists
async function loadPlaylists() {
playlistGrid.innerHTML = '<div class="loading-spinner">Loading playlists...</div>';
try {
const response = await fetch('/api/spotify/playlists');
if (!response.ok) throw new Error('Failed to load playlists');
playlists = await response.json();
renderPlaylists();
} catch (err) {
playlistGrid.innerHTML = '<div class="loading-spinner">Failed to load playlists. Please try again.</div>';
console.error(err);
}
}
function renderPlaylists() {
if (playlists.length === 0) {
playlistGrid.innerHTML = '<div class="loading-spinner">No playlists found.</div>';
return;
}
playlistGrid.innerHTML = playlists.map(playlist => `
<div class="playlist-card" data-id="${playlist.id}">
<img src="${playlist.image || 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect fill="%2318181b" width="100" height="100"/><text x="50" y="55" text-anchor="middle" fill="%2352525b" font-size="40">🎵</text></svg>'}" alt="${escapeHtml(playlist.name)}">
<h3>${escapeHtml(playlist.name)}</h3>
<p>${playlist.trackCount} tracks</p>
</div>
`).join('');
document.querySelectorAll('.playlist-card').forEach(card => {
card.addEventListener('click', () => selectPlaylist(card.dataset.id));
});
}
async function selectPlaylist(playlistId) {
selectedPlaylist = playlists.find(p => p.id === playlistId);
if (!selectedPlaylist) return;
showView('analysis');
analysisPlaylistImage.src = selectedPlaylist.image || 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect fill="%2318181b" width="100" height="100"/><text x="50" y="55" text-anchor="middle" fill="%2352525b" font-size="40">🎵</text></svg>';
analysisPlaylistName.textContent = selectedPlaylist.name;
analysisPlaylistCount.textContent = `${selectedPlaylist.trackCount} tracks`;
// Reset analysis UI
analysisProgress.style.display = 'none';
analysisResults.style.display = 'none';
startAnalysisBtn.style.display = 'inline-flex';
startAnalysisBtn.disabled = false;
comparisonList.innerHTML = '';
currentJob = null;
// Load tracks
try {
const response = await fetch(`/api/spotify/playlist/${playlistId}/tracks`);
if (!response.ok) throw new Error('Failed to load tracks');
selectedTracks = await response.json();
} catch (err) {
console.error('Failed to load tracks:', err);
selectedTracks = [];
}
}
// Analysis
async function startAnalysis() {
if (!selectedPlaylist || selectedTracks.length === 0) return;
startAnalysisBtn.disabled = true;
startAnalysisBtn.textContent = 'Analyzing...';
analysisProgress.style.display = 'block';
analysisResults.style.display = 'none';
analysisProgressBar.style.width = '0%';
analysisStatusText.textContent = 'Starting analysis...';
try {
const response = await fetch('/api/youtube/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
playlist: selectedPlaylist,
tracks: selectedTracks,
sessionId
})
});
const result = await response.json();
if (result.error) throw new Error(result.error);
currentJob = { id: result.jobId };
} catch (err) {
console.error('Analysis failed:', err);
analysisStatusText.textContent = 'Analysis failed. Please try again.';
startAnalysisBtn.disabled = false;
startAnalysisBtn.textContent = '🔍 Analyze Playlist';
}
}
function handleProgressUpdate(data) {
switch (data.type) {
// Analysis updates
case 'analysis_progress':
analysisStatusText.textContent = `Searching YouTube... (${data.current}/${data.total})`;
analysisProgressBar.style.width = `${(data.current / data.total) * 100}%`;
analysisCurrentTrack.textContent = `${data.track.artists.join(', ')} - ${data.track.name}`;
break;
case 'analysis_match':
// Update progress
analysisProgressBar.style.width = `${(data.current / data.total) * 100}%`;
break;
case 'analysis_complete':
showAnalysisResults(data.stats);
break;
// Migration updates
case 'status':
currentTrackName.textContent = data.message;
break;
case 'playlist_created':
addLogItem('Playlist created on YouTube', 'success');
break;
case 'processing':
currentTrackName.textContent = `${data.track.artists.join(', ')} - ${data.track.name}`;
progressCurrent.textContent = data.current;
progressBar.style.width = `${(data.current / data.total) * 100}%`;
break;
case 'track_added':
addLogItem(`${data.track}`, 'success');
break;
case 'track_failed':
case 'track_skipped':
addLogItem(`${data.track} - Skipped`, 'not-found');
break;
case 'complete':
showMigrationComplete(data);
break;
case 'error':
currentTrackName.textContent = `Error: ${data.message}`;
break;
}
}
async function showAnalysisResults(stats) {
analysisProgress.style.display = 'none';
startAnalysisBtn.style.display = 'none';
analysisResults.style.display = 'block';
// Fetch full job data
try {
const response = await fetch(`/api/youtube/analysis/${currentJob.id}`);
const job = await response.json();
currentJob = job;
// Update stats
statFound.textContent = stats.found;
statNotFound.textContent = stats.notFound;
// Render comparison table
renderComparisonTable(job.tracks);
} catch (err) {
console.error('Failed to fetch analysis results:', err);
}
}
function renderComparisonTable(tracks) {
comparisonList.innerHTML = tracks.map((track, index) => `
<div class="table-row ${track.status}" data-index="${index}">
<div class="col-spotify">
<div class="track-name">${escapeHtml(track.name)}</div>
<div class="track-artist">${escapeHtml(track.artists.join(', '))}</div>
</div>
<div class="col-status">
${track.status === 'found' ? '<span class="status-badge found">✓ Found</span>' :
track.status === 'manual' ? '<span class="status-badge manual">✓ Manual</span>' :
'<span class="status-badge not-found">✗ Not found</span>'}
</div>
<div class="col-youtube">
${track.status === 'found' && track.youtubeMatch ? `
<div class="youtube-match">
<img src="${track.youtubeMatch.thumbnail || ''}" alt="">
<div class="match-info">
<div class="match-title">${escapeHtml(track.youtubeMatch.title)}</div>
<div class="match-channel">${escapeHtml(track.youtubeMatch.channel)}</div>
</div>
</div>
` : track.status === 'manual' && track.manualVideoId ? `
<div class="youtube-match manual">
<span class="manual-badge">Manual: ${track.manualVideoId}</span>
</div>
` : `
<div class="manual-input">
<input type="text"
placeholder="Paste YouTube URL or Video ID"
class="manual-video-input"
data-index="${index}">
<button class="btn btn-small btn-ghost save-manual-btn" data-index="${index}">Save</button>
</div>
`}
</div>
</div>
`).join('');
// Add event listeners for manual inputs
document.querySelectorAll('.save-manual-btn').forEach(btn => {
btn.addEventListener('click', () => saveManualVideo(parseInt(btn.dataset.index)));
});
document.querySelectorAll('.manual-video-input').forEach(input => {
input.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
saveManualVideo(parseInt(input.dataset.index));
}
});
});
}
async function saveManualVideo(trackIndex) {
const input = document.querySelector(`.manual-video-input[data-index="${trackIndex}"]`);
const videoId = input.value.trim();
if (!videoId) return;
try {
const response = await fetch(`/api/youtube/analysis/${currentJob.id}/track/${trackIndex}/manual`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ videoId })
});
const result = await response.json();
if (result.success) {
// Update local job data
currentJob.tracks[trackIndex].manualVideoId = result.videoId;
currentJob.tracks[trackIndex].status = 'manual';
// Re-render table
renderComparisonTable(currentJob.tracks);
// Update stats
const found = currentJob.tracks.filter(t => t.status === 'found' || t.status === 'manual').length;
const notFound = currentJob.tracks.filter(t => t.status === 'not_found').length;
statFound.textContent = found;
statNotFound.textContent = notFound;
}
} catch (err) {
console.error('Failed to save manual video:', err);
}
}
// Migration
async function startMigration() {
if (!currentJob) return;
showView('progress');
progressCurrent.textContent = '0';
progressTotal.textContent = currentJob.tracks.length;
progressBar.style.width = '0%';
currentTrackName.textContent = 'Starting migration...';
progressLog.innerHTML = '';
migrationComplete.style.display = 'none';
// Show progress elements
document.querySelector('.progress-stats').style.display = 'block';
document.querySelector('.progress-bar-container').style.display = 'block';
document.querySelector('.current-track').style.display = 'flex';
try {
await fetch('/api/youtube/migrate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jobId: currentJob.id,
sessionId
})
});
} catch (err) {
console.error('Migration request failed:', err);
currentTrackName.textContent = 'Migration failed. Please try again.';
}
}
function addLogItem(text, type) {
const item = document.createElement('div');
item.className = `log-item ${type}`;
item.textContent = text;
progressLog.appendChild(item);
progressLog.scrollTop = progressLog.scrollHeight;
}
function showMigrationComplete(data) {
migrationComplete.style.display = 'block';
completeStats.textContent = `${data.successCount} of ${data.total} tracks migrated successfully`;
playlistLink.href = data.playlistUrl;
document.querySelector('.progress-stats').style.display = 'none';
document.querySelector('.progress-bar-container').style.display = 'none';
document.querySelector('.current-track').style.display = 'none';
}
// Helpers
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}

185
src/public/index.html Normal file
View File

@@ -0,0 +1,185 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Migrate your Spotify playlists to YouTube Music with ease">
<title>Spotify to YouTube Music | Playlist Migration</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="app">
<!-- Header -->
<header class="header">
<div class="logo">
<span class="logo-spotify">🎵</span>
<span class="logo-arrow"></span>
<span class="logo-youtube">🎬</span>
<h1>Spotify to YouTube Music</h1>
</div>
<button id="logoutBtn" class="btn btn-ghost" style="display: none;">
Logout
</button>
</header>
<!-- Login View -->
<section id="loginView" class="view">
<div class="login-container">
<h2>Connect Your Accounts</h2>
<p class="subtitle">Login to both services to start migrating your playlists</p>
<div class="auth-cards">
<div class="auth-card spotify-card">
<div class="auth-card-header">
<span class="service-icon">🎵</span>
<span class="service-name">Spotify</span>
</div>
<div id="spotifyStatus" class="auth-status">
<span class="status-indicator disconnected"></span>
<span class="status-text">Not connected</span>
</div>
<button id="spotifyLoginBtn" class="btn btn-spotify">
Login with Spotify
</button>
</div>
<div class="auth-card youtube-card">
<div class="auth-card-header">
<span class="service-icon">🎬</span>
<span class="service-name">YouTube Music</span>
</div>
<div id="youtubeStatus" class="auth-status">
<span class="status-indicator disconnected"></span>
<span class="status-text">Not connected</span>
</div>
<button id="youtubeLoginBtn" class="btn btn-youtube">
Login with YouTube
</button>
</div>
</div>
<button id="continueBtn" class="btn btn-primary btn-large" style="display: none;">
Continue to Playlists
</button>
</div>
</section>
<!-- Playlist Selection View -->
<section id="playlistView" class="view" style="display: none;">
<div class="playlist-container">
<div class="view-header">
<h2>Select a Playlist</h2>
<p class="subtitle">Choose a Spotify playlist to migrate to YouTube Music</p>
</div>
<div id="playlistGrid" class="playlist-grid">
<div class="loading-spinner">Loading playlists...</div>
</div>
</div>
</section>
<!-- Analysis View (NEW) -->
<section id="analysisView" class="view" style="display: none;">
<div class="analysis-container">
<button id="backFromAnalysis" class="btn btn-ghost back-btn">
← Back to playlists
</button>
<div class="playlist-header">
<img id="analysisPlaylistImage" class="playlist-cover" src="" alt="Playlist cover">
<div class="playlist-info">
<h2 id="analysisPlaylistName"></h2>
<p id="analysisPlaylistCount" class="track-count"></p>
</div>
<button id="startAnalysisBtn" class="btn btn-primary">
🔍 Analyze Playlist
</button>
</div>
<!-- Analysis Progress -->
<div id="analysisProgress" class="analysis-progress" style="display: none;">
<div class="progress-header">
<span class="pulse"></span>
<span id="analysisStatusText">Analyzing tracks...</span>
</div>
<div class="progress-bar-container">
<div id="analysisProgressBar" class="progress-bar" style="width: 0%"></div>
</div>
<p class="progress-detail"><span id="analysisCurrentTrack"></span></p>
</div>
<!-- Analysis Results -->
<div id="analysisResults" class="analysis-results" style="display: none;">
<div class="results-header">
<h3>Analysis Complete</h3>
<div class="results-stats">
<span class="stat found"><span id="statFound">0</span> found</span>
<span class="stat not-found"><span id="statNotFound">0</span> not found</span>
</div>
</div>
<div class="comparison-table">
<div class="table-header">
<div class="col-spotify">Spotify Track</div>
<div class="col-status">Status</div>
<div class="col-youtube">YouTube Match</div>
</div>
<div id="comparisonList" class="table-body"></div>
</div>
<div class="migration-actions">
<button id="startMigrationBtn" class="btn btn-primary btn-large">
🚀 Start Migration
</button>
</div>
</div>
</div>
</section>
<!-- Progress View -->
<section id="progressView" class="view" style="display: none;">
<div class="progress-container">
<h2>Migration in Progress</h2>
<div class="progress-stats">
<div class="stat">
<span id="progressCurrent" class="stat-value">0</span>
<span class="stat-label">/ <span id="progressTotal">0</span> tracks</span>
</div>
</div>
<div class="progress-bar-container">
<div id="progressBar" class="progress-bar" style="width: 0%"></div>
</div>
<div id="currentTrack" class="current-track">
<span class="pulse"></span>
<span id="currentTrackName">Preparing...</span>
</div>
<div id="progressLog" class="progress-log"></div>
<div id="migrationComplete" class="migration-complete" style="display: none;">
<div class="complete-icon"></div>
<h3>Migration Complete!</h3>
<p id="completeStats"></p>
<a id="playlistLink" href="#" target="_blank" class="btn btn-youtube btn-large">
Open in YouTube Music
</a>
<button id="newMigrationBtn" class="btn btn-ghost">
Migrate another playlist
</button>
</div>
</div>
</section>
</div>
<script src="app.js"></script>
</body>
</html>

909
src/public/styles.css Normal file
View File

@@ -0,0 +1,909 @@
/* CSS Variables */
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #12121a;
--bg-card: rgba(255, 255, 255, 0.03);
--bg-card-hover: rgba(255, 255, 255, 0.06);
--text-primary: #ffffff;
--text-secondary: #a1a1aa;
--text-muted: #52525b;
--spotify-green: #1DB954;
--spotify-green-dark: #169c46;
--youtube-red: #FF0000;
--youtube-red-dark: #cc0000;
--accent-gradient: linear-gradient(135deg, #1DB954 0%, #FF0000 100%);
--glass-border: rgba(255, 255, 255, 0.08);
--glass-bg: rgba(255, 255, 255, 0.02);
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 24px;
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 20px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 40px rgba(0, 0, 0, 0.5);
--transition-fast: 150ms ease;
--transition-normal: 250ms ease;
--transition-slow: 400ms ease;
}
/* Reset & Base */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
scroll-behavior: smooth;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Animated background */
body::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
radial-gradient(ellipse at 20% 20%, rgba(29, 185, 84, 0.08) 0%, transparent 50%),
radial-gradient(ellipse at 80% 80%, rgba(255, 0, 0, 0.06) 0%, transparent 50%);
pointer-events: none;
z-index: 0;
}
.app {
position: relative;
z-index: 1;
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
min-height: 100vh;
}
/* Header */
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24px 0;
border-bottom: 1px solid var(--glass-border);
margin-bottom: 40px;
}
.logo {
display: flex;
align-items: center;
gap: 12px;
}
.logo-spotify,
.logo-youtube {
font-size: 1.8rem;
}
.logo-arrow {
color: var(--text-muted);
font-size: 1.2rem;
}
.logo h1 {
font-size: 1.25rem;
font-weight: 600;
background: var(--accent-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 24px;
font-size: 0.95rem;
font-weight: 500;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-normal);
text-decoration: none;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--accent-gradient);
color: white;
box-shadow: 0 4px 20px rgba(29, 185, 84, 0.3);
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 30px rgba(29, 185, 84, 0.4);
}
.btn-spotify {
background: var(--spotify-green);
color: white;
}
.btn-spotify:hover:not(:disabled) {
background: var(--spotify-green-dark);
transform: translateY(-2px);
}
.btn-youtube {
background: var(--youtube-red);
color: white;
}
.btn-youtube:hover:not(:disabled) {
background: var(--youtube-red-dark);
transform: translateY(-2px);
}
.btn-ghost {
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--glass-border);
}
.btn-ghost:hover:not(:disabled) {
background: var(--bg-card-hover);
color: var(--text-primary);
}
.btn-large {
padding: 16px 32px;
font-size: 1.05rem;
}
/* Views */
.view {
animation: fadeIn var(--transition-slow);
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Login View */
.login-container {
max-width: 800px;
margin: 0 auto;
padding: 60px 0;
text-align: center;
}
.login-container h2 {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 12px;
}
.subtitle {
color: var(--text-secondary);
font-size: 1.1rem;
margin-bottom: 48px;
}
.auth-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 24px;
margin-bottom: 48px;
}
.auth-card {
background: var(--bg-card);
border: 1px solid var(--glass-border);
border-radius: var(--radius-xl);
padding: 32px;
backdrop-filter: blur(20px);
transition: all var(--transition-normal);
}
.auth-card:hover {
background: var(--bg-card-hover);
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
}
.auth-card-header {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 20px;
}
.service-icon {
font-size: 2rem;
}
.service-name {
font-size: 1.5rem;
font-weight: 600;
}
.auth-status {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-bottom: 24px;
padding: 12px;
background: var(--bg-secondary);
border-radius: var(--radius-md);
}
.status-indicator {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--youtube-red);
}
.status-indicator.connected {
background: var(--spotify-green);
box-shadow: 0 0 12px var(--spotify-green);
}
.status-text {
color: var(--text-secondary);
font-size: 0.9rem;
}
.auth-card .btn {
width: 100%;
}
/* Playlist Grid */
.playlist-container,
.review-container,
.progress-container {
padding: 20px 0 60px;
}
.view-header {
text-align: center;
margin-bottom: 40px;
}
.view-header h2 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 8px;
}
.playlist-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 20px;
}
.playlist-card {
background: var(--bg-card);
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
padding: 16px;
cursor: pointer;
transition: all var(--transition-normal);
}
.playlist-card:hover {
background: var(--bg-card-hover);
transform: translateY(-4px);
box-shadow: var(--shadow-md);
border-color: var(--spotify-green);
}
.playlist-card img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: var(--radius-md);
margin-bottom: 12px;
background: var(--bg-secondary);
}
.playlist-card h3 {
font-size: 0.95rem;
font-weight: 600;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.playlist-card p {
font-size: 0.85rem;
color: var(--text-secondary);
}
/* Review View */
.back-btn {
margin-bottom: 24px;
}
.playlist-header {
display: flex;
gap: 24px;
margin-bottom: 40px;
padding: 24px;
background: var(--bg-card);
border: 1px solid var(--glass-border);
border-radius: var(--radius-xl);
}
.playlist-cover {
width: 160px;
height: 160px;
object-fit: cover;
border-radius: var(--radius-lg);
background: var(--bg-secondary);
}
.playlist-info {
display: flex;
flex-direction: column;
justify-content: center;
}
.playlist-info h2 {
font-size: 1.75rem;
font-weight: 700;
margin-bottom: 8px;
}
.track-count {
color: var(--text-secondary);
font-size: 1rem;
}
.tracks-container h3 {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 16px;
color: var(--text-secondary);
}
.track-list {
max-height: 400px;
overflow-y: auto;
background: var(--bg-card);
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
}
.track-item {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 16px;
border-bottom: 1px solid var(--glass-border);
transition: background var(--transition-fast);
}
.track-item:last-child {
border-bottom: none;
}
.track-item:hover {
background: var(--bg-card-hover);
}
.track-number {
width: 32px;
color: var(--text-muted);
font-size: 0.9rem;
text-align: center;
}
.track-info {
flex: 1;
min-width: 0;
}
.track-name {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.track-artist {
font-size: 0.85rem;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.migration-actions {
margin-top: 32px;
text-align: center;
}
/* Progress View */
.progress-container {
max-width: 600px;
margin: 0 auto;
text-align: center;
}
.progress-container h2 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 40px;
}
.progress-stats {
margin-bottom: 24px;
}
.stat-value {
font-size: 3rem;
font-weight: 700;
background: var(--accent-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.stat-label {
font-size: 1.25rem;
color: var(--text-secondary);
}
.progress-bar-container {
height: 8px;
background: var(--bg-secondary);
border-radius: 4px;
overflow: hidden;
margin-bottom: 32px;
}
.progress-bar {
height: 100%;
background: var(--accent-gradient);
border-radius: 4px;
transition: width var(--transition-normal);
}
.current-track {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 16px 24px;
background: var(--bg-card);
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
margin-bottom: 24px;
}
.pulse {
width: 12px;
height: 12px;
background: var(--spotify-green);
border-radius: 50%;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(1.2);
}
}
.progress-log {
max-height: 200px;
overflow-y: auto;
text-align: left;
background: var(--bg-card);
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
padding: 16px;
}
.log-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0;
font-size: 0.9rem;
border-bottom: 1px solid var(--glass-border);
}
.log-item:last-child {
border-bottom: none;
}
.log-item.success {
color: var(--spotify-green);
}
.log-item.error {
color: var(--youtube-red);
}
.log-item.not-found {
color: var(--text-muted);
}
/* Migration Complete */
.migration-complete {
padding: 48px;
background: var(--bg-card);
border: 1px solid var(--glass-border);
border-radius: var(--radius-xl);
animation: fadeIn var(--transition-slow);
}
.complete-icon {
width: 80px;
height: 80px;
background: var(--spotify-green);
color: white;
font-size: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
margin: 0 auto 24px;
box-shadow: 0 0 40px rgba(29, 185, 84, 0.4);
}
.migration-complete h3 {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 8px;
}
.migration-complete p {
color: var(--text-secondary);
margin-bottom: 24px;
}
.migration-complete .btn {
margin: 8px;
}
/* Loading */
.loading-spinner {
display: flex;
align-items: center;
justify-content: center;
padding: 60px;
color: var(--text-secondary);
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: var(--glass-border);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
/* Responsive */
@media (max-width: 768px) {
.app {
padding: 0 16px;
}
.header {
flex-direction: column;
gap: 16px;
text-align: center;
padding: 16px 0;
}
.logo h1 {
font-size: 1.1rem;
}
.login-container h2 {
font-size: 1.75rem;
}
.playlist-header {
flex-direction: column;
align-items: center;
text-align: center;
}
.playlist-cover {
width: 120px;
height: 120px;
}
.comparison-table .table-header,
.comparison-table .table-row {
grid-template-columns: 1fr;
gap: 8px;
}
.col-youtube .manual-input {
flex-direction: column;
}
}
/* Analysis View */
.analysis-container {
padding: 20px 0 60px;
}
.analysis-container .playlist-header {
align-items: center;
}
.analysis-container .playlist-info {
flex: 1;
}
/* Analysis Progress */
.analysis-progress {
background: var(--bg-card);
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
padding: 24px;
margin-bottom: 24px;
}
.progress-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
font-weight: 500;
}
.progress-detail {
margin-top: 12px;
color: var(--text-secondary);
font-size: 0.9rem;
}
/* Analysis Results */
.analysis-results {
animation: fadeIn var(--transition-slow);
}
.results-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.results-header h3 {
font-size: 1.25rem;
font-weight: 600;
}
.results-stats {
display: flex;
gap: 16px;
}
.results-stats .stat {
padding: 6px 14px;
border-radius: var(--radius-md);
font-size: 0.9rem;
font-weight: 500;
}
.results-stats .stat.found {
background: rgba(29, 185, 84, 0.15);
color: var(--spotify-green);
}
.results-stats .stat.not-found {
background: rgba(255, 0, 0, 0.15);
color: var(--youtube-red);
}
/* Comparison Table */
.comparison-table {
background: var(--bg-card);
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
overflow: hidden;
}
.table-header {
display: grid;
grid-template-columns: 1fr 120px 1fr;
gap: 16px;
padding: 16px 20px;
background: var(--bg-secondary);
font-weight: 600;
font-size: 0.85rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.table-body {
max-height: 400px;
overflow-y: auto;
}
.table-row {
display: grid;
grid-template-columns: 1fr 120px 1fr;
gap: 16px;
padding: 14px 20px;
border-bottom: 1px solid var(--glass-border);
align-items: center;
transition: background var(--transition-fast);
}
.table-row:last-child {
border-bottom: none;
}
.table-row:hover {
background: var(--bg-card-hover);
}
.table-row.found {
border-left: 3px solid var(--spotify-green);
}
.table-row.not_found {
border-left: 3px solid var(--youtube-red);
}
.table-row.manual {
border-left: 3px solid #f59e0b;
}
/* Status Badges */
.status-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: var(--radius-sm);
font-size: 0.8rem;
font-weight: 500;
}
.status-badge.found {
background: rgba(29, 185, 84, 0.15);
color: var(--spotify-green);
}
.status-badge.not-found {
background: rgba(255, 0, 0, 0.15);
color: var(--youtube-red);
}
.status-badge.manual {
background: rgba(245, 158, 11, 0.15);
color: #f59e0b;
}
/* YouTube Match */
.youtube-match {
display: flex;
align-items: center;
gap: 12px;
}
.youtube-match img {
width: 48px;
height: 36px;
object-fit: cover;
border-radius: 4px;
background: var(--bg-secondary);
}
.match-info {
min-width: 0;
}
.match-title {
font-size: 0.9rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.match-channel {
font-size: 0.8rem;
color: var(--text-secondary);
}
.manual-badge {
font-size: 0.85rem;
color: #f59e0b;
}
/* Manual Input */
.manual-input {
display: flex;
gap: 8px;
}
.manual-video-input {
flex: 1;
padding: 8px 12px;
background: var(--bg-secondary);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 0.85rem;
font-family: inherit;
outline: none;
transition: border-color var(--transition-fast);
}
.manual-video-input:focus {
border-color: var(--spotify-green);
}
.manual-video-input::placeholder {
color: var(--text-muted);
}
.btn-small {
padding: 8px 12px;
font-size: 0.8rem;
}

148
src/routes/auth.js Normal file
View File

@@ -0,0 +1,148 @@
import { Router } from 'express';
import SpotifyWebApi from 'spotify-web-api-node';
import { google } from 'googleapis';
const router = Router();
// Spotify OAuth configuration
const spotifyApi = new SpotifyWebApi({
clientId: process.env.SPOTIFY_CLIENT_ID,
clientSecret: process.env.SPOTIFY_CLIENT_SECRET,
redirectUri: `${process.env.BASE_URL || 'http://localhost:3000'}/auth/spotify/callback`
});
// YouTube OAuth configuration
const oauth2Client = new google.auth.OAuth2(
process.env.YOUTUBE_CLIENT_ID,
process.env.YOUTUBE_CLIENT_SECRET,
`${process.env.BASE_URL || 'http://localhost:3000'}/auth/youtube/callback`
);
// Spotify scopes needed
const SPOTIFY_SCOPES = [
'user-read-private',
'user-read-email',
'playlist-read-private',
'playlist-read-collaborative'
];
// YouTube scopes needed
const YOUTUBE_SCOPES = [
'https://www.googleapis.com/auth/youtube',
'https://www.googleapis.com/auth/youtube.force-ssl'
];
// Check auth status
router.get('/status', (req, res) => {
res.json({
spotify: {
connected: !!req.session.spotifyTokens,
user: req.session.spotifyUser || null
},
youtube: {
connected: !!req.session.youtubeTokens,
user: req.session.youtubeUser || null
}
});
});
// Spotify OAuth - Initiate
router.get('/spotify', (req, res) => {
const authorizeURL = spotifyApi.createAuthorizeURL(SPOTIFY_SCOPES, 'spotify-auth');
res.redirect(authorizeURL);
});
// Spotify OAuth - Callback
router.get('/spotify/callback', async (req, res) => {
const { code, error } = req.query;
if (error) {
return res.redirect('/?error=spotify_auth_failed');
}
try {
const data = await spotifyApi.authorizationCodeGrant(code);
req.session.spotifyTokens = {
accessToken: data.body.access_token,
refreshToken: data.body.refresh_token,
expiresAt: Date.now() + data.body.expires_in * 1000
};
// Get user info
spotifyApi.setAccessToken(data.body.access_token);
const userInfo = await spotifyApi.getMe();
req.session.spotifyUser = {
id: userInfo.body.id,
name: userInfo.body.display_name,
email: userInfo.body.email,
image: userInfo.body.images?.[0]?.url
};
res.redirect('/?spotify=connected');
} catch (err) {
console.error('Spotify auth error:', err);
res.redirect('/?error=spotify_auth_failed');
}
});
// YouTube OAuth - Initiate
router.get('/youtube', (req, res) => {
const authorizeURL = oauth2Client.generateAuthUrl({
access_type: 'offline',
scope: YOUTUBE_SCOPES,
prompt: 'consent'
});
res.redirect(authorizeURL);
});
// YouTube OAuth - Callback
router.get('/youtube/callback', async (req, res) => {
const { code, error } = req.query;
if (error) {
return res.redirect('/?error=youtube_auth_failed');
}
try {
const { tokens } = await oauth2Client.getToken(code);
oauth2Client.setCredentials(tokens);
req.session.youtubeTokens = {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: tokens.expiry_date
};
// Get user info
const youtube = google.youtube({ version: 'v3', auth: oauth2Client });
const channelResponse = await youtube.channels.list({
part: 'snippet',
mine: true
});
const channel = channelResponse.data.items?.[0];
req.session.youtubeUser = {
id: channel?.id,
name: channel?.snippet?.title,
image: channel?.snippet?.thumbnails?.default?.url
};
res.redirect('/?youtube=connected');
} catch (err) {
console.error('YouTube auth error:', err);
res.redirect('/?error=youtube_auth_failed');
}
});
// Logout
router.post('/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
return res.status(500).json({ error: 'Logout failed' });
}
res.json({ success: true });
});
});
export default router;

131
src/routes/spotify.js Normal file
View File

@@ -0,0 +1,131 @@
import { Router } from 'express';
import SpotifyWebApi from 'spotify-web-api-node';
const router = Router();
// Helper to get authenticated Spotify API instance
function getSpotifyApi(session) {
if (!session.spotifyTokens) {
throw new Error('Not authenticated with Spotify');
}
const spotifyApi = new SpotifyWebApi({
clientId: process.env.SPOTIFY_CLIENT_ID,
clientSecret: process.env.SPOTIFY_CLIENT_SECRET
});
spotifyApi.setAccessToken(session.spotifyTokens.accessToken);
spotifyApi.setRefreshToken(session.spotifyTokens.refreshToken);
return spotifyApi;
}
// Middleware to check Spotify auth
function requireSpotifyAuth(req, res, next) {
if (!req.session.spotifyTokens) {
return res.status(401).json({ error: 'Not authenticated with Spotify' });
}
next();
}
// Get user's playlists
router.get('/playlists', requireSpotifyAuth, async (req, res) => {
try {
const spotifyApi = getSpotifyApi(req.session);
let allPlaylists = [];
let offset = 0;
const limit = 50;
let hasMore = true;
while (hasMore) {
const response = await spotifyApi.getUserPlaylists({ limit, offset });
allPlaylists = allPlaylists.concat(response.body.items);
if (response.body.next) {
offset += limit;
} else {
hasMore = false;
}
}
const playlists = allPlaylists.map(playlist => ({
id: playlist.id,
name: playlist.name,
description: playlist.description,
trackCount: playlist.tracks.total,
image: playlist.images?.[0]?.url,
owner: playlist.owner.display_name
}));
res.json(playlists);
} catch (err) {
console.error('Error fetching playlists:', err);
res.status(500).json({ error: 'Failed to fetch playlists' });
}
});
// Get tracks from a playlist
router.get('/playlist/:id/tracks', requireSpotifyAuth, async (req, res) => {
try {
const spotifyApi = getSpotifyApi(req.session);
const playlistId = req.params.id;
let allTracks = [];
let offset = 0;
const limit = 100;
let hasMore = true;
while (hasMore) {
const response = await spotifyApi.getPlaylistTracks(playlistId, {
limit,
offset,
fields: 'items(track(id,name,artists,album,duration_ms)),next'
});
allTracks = allTracks.concat(response.body.items);
if (response.body.next) {
offset += limit;
} else {
hasMore = false;
}
}
const tracks = allTracks
.filter(item => item.track) // Filter out null tracks
.map(item => ({
id: item.track.id,
name: item.track.name,
artists: item.track.artists.map(a => a.name),
album: item.track.album?.name,
duration: item.track.duration_ms
}));
res.json(tracks);
} catch (err) {
console.error('Error fetching tracks:', err);
res.status(500).json({ error: 'Failed to fetch tracks' });
}
});
// Get playlist info
router.get('/playlist/:id', requireSpotifyAuth, async (req, res) => {
try {
const spotifyApi = getSpotifyApi(req.session);
const response = await spotifyApi.getPlaylist(req.params.id);
res.json({
id: response.body.id,
name: response.body.name,
description: response.body.description,
trackCount: response.body.tracks.total,
image: response.body.images?.[0]?.url
});
} catch (err) {
console.error('Error fetching playlist:', err);
res.status(500).json({ error: 'Failed to fetch playlist' });
}
});
export default router;

451
src/routes/youtube.js Normal file
View File

@@ -0,0 +1,451 @@
import { Router } from 'express';
import { google } from 'googleapis';
import { sendProgress } from '../server.js';
import * as queue from '../services/analysisQueue.js';
import * as searchCache from '../services/searchCache.js';
const router = Router();
// Helper to get authenticated YouTube API instance
function getYouTubeApi(session) {
if (!session.youtubeTokens) {
throw new Error('Not authenticated with YouTube');
}
const oauth2Client = new google.auth.OAuth2(
process.env.YOUTUBE_CLIENT_ID,
process.env.YOUTUBE_CLIENT_SECRET
);
oauth2Client.setCredentials({
access_token: session.youtubeTokens.accessToken,
refresh_token: session.youtubeTokens.refreshToken
});
return google.youtube({ version: 'v3', auth: oauth2Client });
}
// Middleware to check YouTube auth
function requireYouTubeAuth(req, res, next) {
if (!req.session.youtubeTokens) {
return res.status(401).json({ error: 'Not authenticated with YouTube' });
}
next();
}
// Search for a video on YouTube (with caching)
async function searchVideo(youtube, artists, trackName) {
// Check cache first
const cached = searchCache.getCachedSearch(artists, trackName);
if (cached !== null) {
console.log(`📦 Cache hit: ${artists.join(', ')} - ${trackName}`);
return cached;
}
// Not in cache, search YouTube
const query = `${artists.join(', ')} - ${trackName}`;
try {
const response = await youtube.search.list({
part: 'snippet',
q: query,
type: 'video',
videoCategoryId: '10', // Music category
maxResults: 1
});
let result = null;
if (response.data.items && response.data.items.length > 0) {
const video = response.data.items[0];
result = {
id: video.id.videoId,
title: video.snippet.title,
channel: video.snippet.channelTitle,
thumbnail: video.snippet.thumbnails?.default?.url
};
}
// Cache the result (even if null/not found)
searchCache.cacheSearch(artists, trackName, result);
console.log(`🔍 API search: ${artists.join(', ')} - ${trackName}${result ? 'found' : 'not found'}`);
return result;
} catch (err) {
console.error('Search error:', err.message);
return null;
}
}
// Create a playlist
async function createPlaylist(youtube, title, description) {
const response = await youtube.playlists.insert({
part: 'snippet,status',
requestBody: {
snippet: {
title,
description: description || `Migrated from Spotify`
},
status: {
privacyStatus: 'private'
}
}
});
return response.data;
}
// Add video to playlist
async function addToPlaylist(youtube, playlistId, videoId) {
await youtube.playlistItems.insert({
part: 'snippet',
requestBody: {
snippet: {
playlistId,
resourceId: {
kind: 'youtube#video',
videoId
}
}
}
});
}
// ============ ANALYSIS ENDPOINTS ============
// Start analysis job
router.post('/analyze', requireYouTubeAuth, async (req, res) => {
const { playlist, tracks, sessionId } = req.body;
if (!tracks || !Array.isArray(tracks) || tracks.length === 0) {
return res.status(400).json({ error: 'No tracks provided' });
}
if (!sessionId) {
return res.status(400).json({ error: 'Session ID required' });
}
// Check playlist size limit
const maxSize = queue.getMaxPlaylistSize();
if (tracks.length > maxSize) {
return res.status(400).json({
error: `Playlist too large. Maximum ${maxSize} tracks allowed.`,
maxSize,
trackCount: tracks.length
});
}
// Create job
try {
const job = queue.createJob(sessionId, playlist, tracks);
// Start analysis in background
analyzePlaylist(job.id, req.session).catch(err => {
console.error('Analysis error:', err);
queue.updateJobStatus(job.id, 'error', err.message);
});
res.json({
jobId: job.id,
status: job.status,
trackCount: tracks.length
});
} catch (err) {
res.status(400).json({ error: err.message });
}
});
// Background analysis function
async function analyzePlaylist(jobId, session) {
const job = queue.getJob(jobId);
if (!job) return;
queue.updateJobStatus(jobId, 'analyzing');
const youtube = getYouTubeApi(session);
const delay = queue.getRateLimitDelay();
for (let i = 0; i < job.tracks.length; i++) {
const track = job.tracks[i];
// Update status to searching
job.tracks[i].status = 'searching';
// Send progress update
sendProgress(job.sessionId, {
type: 'analysis_progress',
jobId,
current: i + 1,
total: job.tracks.length,
track: {
name: track.name,
artists: track.artists
},
status: 'searching'
});
// Search YouTube (with caching)
const match = await searchVideo(youtube, track.artists, track.name);
if (match) {
queue.updateTrackMatch(jobId, i, match, 'found');
sendProgress(job.sessionId, {
type: 'analysis_match',
jobId,
current: i + 1,
total: job.tracks.length,
track: {
name: track.name,
artists: track.artists
},
match,
status: 'found'
});
} else {
queue.updateTrackMatch(jobId, i, null, 'not_found');
sendProgress(job.sessionId, {
type: 'analysis_match',
jobId,
current: i + 1,
total: job.tracks.length,
track: {
name: track.name,
artists: track.artists
},
match: null,
status: 'not_found'
});
}
// Rate limiting delay (except for last item)
if (i < job.tracks.length - 1) {
await new Promise(resolve => setTimeout(resolve, delay));
}
}
queue.updateJobStatus(jobId, 'complete');
// Send completion
const completedJob = queue.getJob(jobId);
const stats = {
found: completedJob.tracks.filter(t => t.status === 'found').length,
notFound: completedJob.tracks.filter(t => t.status === 'not_found').length,
total: completedJob.tracks.length
};
sendProgress(job.sessionId, {
type: 'analysis_complete',
jobId,
stats
});
}
// Get analysis job status
router.get('/analysis/:jobId', requireYouTubeAuth, (req, res) => {
const job = queue.getJob(req.params.jobId);
if (!job) {
return res.status(404).json({ error: 'Job not found' });
}
res.json(job);
});
// Update manual video ID for a track
router.post('/analysis/:jobId/track/:trackIndex/manual', requireYouTubeAuth, (req, res) => {
const { jobId, trackIndex } = req.params;
const { videoId } = req.body;
if (!videoId) {
return res.status(400).json({ error: 'Video ID required' });
}
// Extract video ID from URL if full URL provided
let extractedVideoId = videoId;
const urlMatch = videoId.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\s]+)/);
if (urlMatch) {
extractedVideoId = urlMatch[1];
}
const success = queue.setManualVideoId(jobId, parseInt(trackIndex), extractedVideoId);
if (!success) {
return res.status(404).json({ error: 'Job or track not found' });
}
res.json({ success: true, videoId: extractedVideoId });
});
// ============ MIGRATION ENDPOINT ============
// Migrate with pre-analyzed data
router.post('/migrate', requireYouTubeAuth, async (req, res) => {
const { jobId, sessionId } = req.body;
const job = queue.getJob(jobId);
if (!job) {
return res.status(404).json({ error: 'Analysis job not found' });
}
if (job.status !== 'complete') {
return res.status(400).json({ error: 'Analysis not complete' });
}
try {
const youtube = getYouTubeApi(req.session);
// Create the playlist
sendProgress(sessionId, {
type: 'status',
message: 'Creating YouTube playlist...'
});
const playlist = await createPlaylist(youtube, job.playlist.name, `Migrated from Spotify`);
sendProgress(sessionId, {
type: 'playlist_created',
playlistId: playlist.id,
playlistUrl: `https://music.youtube.com/playlist?list=${playlist.id}`
});
// Track progress
let successCount = 0;
let skipCount = 0;
const results = [];
// Process tracks using cached matches
for (let i = 0; i < job.tracks.length; i++) {
const track = job.tracks[i];
// Get video ID (manual override or matched)
const videoId = track.manualVideoId || track.youtubeMatch?.id;
sendProgress(sessionId, {
type: 'processing',
current: i + 1,
total: job.tracks.length,
track: {
name: track.name,
artists: track.artists
}
});
if (videoId) {
try {
await addToPlaylist(youtube, playlist.id, videoId);
successCount++;
results.push({
track: track.name,
status: 'success',
videoId
});
sendProgress(sessionId, {
type: 'track_added',
current: i + 1,
total: job.tracks.length,
track: track.name,
success: true
});
} catch (err) {
results.push({
track: track.name,
status: 'error',
error: err.message
});
sendProgress(sessionId, {
type: 'track_failed',
current: i + 1,
total: job.tracks.length,
track: track.name,
error: err.message,
success: false
});
}
} else {
skipCount++;
results.push({
track: track.name,
status: 'skipped',
reason: 'No video ID'
});
sendProgress(sessionId, {
type: 'track_skipped',
current: i + 1,
total: job.tracks.length,
track: track.name
});
}
// Small delay to avoid rate limiting on playlist adds
await new Promise(resolve => setTimeout(resolve, 300));
}
// Send completion
sendProgress(sessionId, {
type: 'complete',
playlistId: playlist.id,
playlistUrl: `https://music.youtube.com/playlist?list=${playlist.id}`,
successCount,
skipCount,
total: job.tracks.length
});
res.json({
success: true,
playlistId: playlist.id,
playlistUrl: `https://music.youtube.com/playlist?list=${playlist.id}`,
successCount,
skipCount,
results
});
} catch (err) {
console.error('Migration error:', err);
sendProgress(sessionId, {
type: 'error',
message: err.message || 'Migration failed'
});
res.status(500).json({ error: 'Migration failed', details: err.message });
}
});
// Get user's YouTube playlists (for reference)
router.get('/playlists', requireYouTubeAuth, async (req, res) => {
try {
const youtube = getYouTubeApi(req.session);
const response = await youtube.playlists.list({
part: 'snippet,contentDetails',
mine: true,
maxResults: 50
});
const playlists = response.data.items.map(playlist => ({
id: playlist.id,
name: playlist.snippet.title,
description: playlist.snippet.description,
trackCount: playlist.contentDetails.itemCount,
image: playlist.snippet.thumbnails?.default?.url
}));
res.json(playlists);
} catch (err) {
console.error('Error fetching YouTube playlists:', err);
res.status(500).json({ error: 'Failed to fetch playlists' });
}
});
// Get configuration limits and cache stats
router.get('/config', (req, res) => {
res.json({
maxPlaylistSize: queue.getMaxPlaylistSize(),
rateLimitDelayMs: queue.getRateLimitDelay(),
searchCache: searchCache.getSearchCacheStats()
});
});
export default router;

71
src/server.js Normal file
View File

@@ -0,0 +1,71 @@
import 'dotenv/config';
import express from 'express';
import session from 'express-session';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import authRoutes from './routes/auth.js';
import spotifyRoutes from './routes/spotify.js';
import youtubeRoutes from './routes/youtube.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware
app.use(express.json());
app.use(express.static(join(__dirname, 'public')));
// Session configuration
app.use(session({
secret: process.env.SESSION_SECRET || 'dev-secret-change-in-production',
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production',
maxAge: 24 * 60 * 60 * 1000 // 24 hours
}
}));
// Store SSE clients for progress updates
const clients = new Map();
// SSE endpoint for migration progress
app.get('/api/progress/:sessionId', (req, res) => {
const sessionId = req.params.sessionId;
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
clients.set(sessionId, res);
req.on('close', () => {
clients.delete(sessionId);
});
});
// Helper to send progress updates
export function sendProgress(sessionId, data) {
const client = clients.get(sessionId);
if (client) {
client.write(`data: ${JSON.stringify(data)}\n\n`);
}
}
// Routes
app.use('/auth', authRoutes);
app.use('/api/spotify', spotifyRoutes);
app.use('/api/youtube', youtubeRoutes);
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
app.listen(PORT, () => {
console.log(`🎵 Spotify to YouTube Music Migration Tool`);
console.log(`🚀 Server running at http://localhost:${PORT}`);
});

View File

@@ -0,0 +1,248 @@
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, unlinkSync } from 'fs';
import { join, resolve } from 'path';
// Configuration from environment
const CACHE_PATH = resolve(process.env.CACHE_PATH || './cache');
const MAX_PLAYLIST_SIZE = parseInt(process.env.MAX_PLAYLIST_SIZE || '500', 10);
const RATE_LIMIT_DELAY_MS = parseInt(process.env.RATE_LIMIT_DELAY_MS || '2000', 10);
// Ensure cache directory exists
if (!existsSync(CACHE_PATH)) {
mkdirSync(CACHE_PATH, { recursive: true });
}
// In-memory job cache (loaded from disk on demand)
const jobs = new Map();
/**
* Get cache file path for a job
*/
function getJobFilePath(jobId) {
return join(CACHE_PATH, `${jobId}.json`);
}
/**
* Save job to disk
*/
function saveJobToDisk(job) {
try {
const filePath = getJobFilePath(job.id);
writeFileSync(filePath, JSON.stringify(job, null, 2), 'utf-8');
} catch (err) {
console.error('Failed to save job to disk:', err.message);
}
}
/**
* Load job from disk
*/
function loadJobFromDisk(jobId) {
try {
const filePath = getJobFilePath(jobId);
if (existsSync(filePath)) {
const data = readFileSync(filePath, 'utf-8');
return JSON.parse(data);
}
} catch (err) {
console.error('Failed to load job from disk:', err.message);
}
return null;
}
/**
* Load all jobs from disk on startup
*/
function loadAllJobsFromDisk() {
try {
if (!existsSync(CACHE_PATH)) return;
const files = readdirSync(CACHE_PATH).filter(f => f.endsWith('.json'));
for (const file of files) {
const jobId = file.replace('.json', '');
const job = loadJobFromDisk(jobId);
if (job) {
jobs.set(jobId, job);
}
}
console.log(`📁 Loaded ${files.length} cached jobs from ${CACHE_PATH}`);
} catch (err) {
console.error('Failed to load jobs from disk:', err.message);
}
}
// Load existing jobs on module init
loadAllJobsFromDisk();
/**
* Get max playlist size limit
*/
export function getMaxPlaylistSize() {
return MAX_PLAYLIST_SIZE;
}
/**
* Get rate limit delay
*/
export function getRateLimitDelay() {
return RATE_LIMIT_DELAY_MS;
}
/**
* Create a new analysis job
*/
export function createJob(sessionId, playlist, tracks) {
// Check playlist size limit
if (tracks.length > MAX_PLAYLIST_SIZE) {
throw new Error(`Playlist exceeds maximum size of ${MAX_PLAYLIST_SIZE} tracks`);
}
const jobId = `job_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const job = {
id: jobId,
sessionId,
playlist: {
id: playlist.id,
name: playlist.name,
image: playlist.image
},
tracks: tracks.map(track => ({
spotifyId: track.id,
name: track.name,
artists: track.artists,
album: track.album,
youtubeMatch: null,
manualVideoId: null,
status: 'pending' // pending, searching, found, not_found, manual
})),
status: 'pending', // pending, analyzing, complete, error
progress: {
current: 0,
total: tracks.length
},
createdAt: Date.now(),
completedAt: null,
error: null
};
jobs.set(jobId, job);
saveJobToDisk(job);
return job;
}
/**
* Get a job by ID (from memory or disk)
*/
export function getJob(jobId) {
// Try memory first
if (jobs.has(jobId)) {
return jobs.get(jobId);
}
// Try loading from disk
const job = loadJobFromDisk(jobId);
if (job) {
jobs.set(jobId, job);
}
return job;
}
/**
* Get all jobs for a session
*/
export function getJobsBySession(sessionId) {
const sessionJobs = [];
for (const job of jobs.values()) {
if (job.sessionId === sessionId) {
sessionJobs.push(job);
}
}
return sessionJobs.sort((a, b) => b.createdAt - a.createdAt);
}
/**
* Update job status
*/
export function updateJobStatus(jobId, status, error = null) {
const job = jobs.get(jobId);
if (job) {
job.status = status;
if (error) job.error = error;
if (status === 'complete' || status === 'error') {
job.completedAt = Date.now();
}
saveJobToDisk(job);
}
return job;
}
/**
* Update track match result
*/
export function updateTrackMatch(jobId, trackIndex, youtubeMatch, status) {
const job = jobs.get(jobId);
if (job && job.tracks[trackIndex]) {
job.tracks[trackIndex].youtubeMatch = youtubeMatch;
job.tracks[trackIndex].status = status;
job.progress.current = trackIndex + 1;
// Save to disk periodically (every 10 tracks or on completion)
if ((trackIndex + 1) % 10 === 0 || trackIndex === job.tracks.length - 1) {
saveJobToDisk(job);
}
}
return job;
}
/**
* Set manual video ID for a track
*/
export function setManualVideoId(jobId, trackIndex, videoId) {
const job = jobs.get(jobId);
if (job && job.tracks[trackIndex]) {
job.tracks[trackIndex].manualVideoId = videoId;
job.tracks[trackIndex].status = 'manual';
saveJobToDisk(job);
return true;
}
return false;
}
/**
* Clean up old jobs (older than 7 days)
*/
export function cleanupOldJobs() {
const now = Date.now();
const maxAge = 7 * 24 * 60 * 60 * 1000; // 7 days
for (const [jobId, job] of jobs.entries()) {
if (now - job.createdAt > maxAge) {
jobs.delete(jobId);
// Delete from disk too
try {
const filePath = getJobFilePath(jobId);
if (existsSync(filePath)) {
unlinkSync(filePath);
}
} catch (err) {
console.error('Failed to delete old job file:', err.message);
}
}
}
}
// Run cleanup every hour
setInterval(cleanupOldJobs, 60 * 60 * 1000);
export default {
createJob,
getJob,
getJobsBySession,
updateJobStatus,
updateTrackMatch,
setManualVideoId,
getRateLimitDelay,
getMaxPlaylistSize,
cleanupOldJobs
};

142
src/services/searchCache.js Normal file
View File

@@ -0,0 +1,142 @@
import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
import { join, resolve } from 'path';
// Configuration from environment
const CACHE_PATH = resolve(process.env.CACHE_PATH || './cache');
const SEARCH_CACHE_FILE = join(CACHE_PATH, 'search_cache.json');
// Ensure cache directory exists
if (!existsSync(CACHE_PATH)) {
mkdirSync(CACHE_PATH, { recursive: true });
}
// In-memory search cache
let searchCache = new Map();
/**
* Load search cache from disk
*/
function loadSearchCache() {
try {
if (existsSync(SEARCH_CACHE_FILE)) {
const data = readFileSync(SEARCH_CACHE_FILE, 'utf-8');
const parsed = JSON.parse(data);
searchCache = new Map(Object.entries(parsed));
console.log(`🔍 Loaded ${searchCache.size} cached search results`);
}
} catch (err) {
console.error('Failed to load search cache:', err.message);
searchCache = new Map();
}
}
/**
* Save search cache to disk
*/
function saveSearchCache() {
try {
const obj = Object.fromEntries(searchCache);
writeFileSync(SEARCH_CACHE_FILE, JSON.stringify(obj, null, 2), 'utf-8');
} catch (err) {
console.error('Failed to save search cache:', err.message);
}
}
/**
* Generate cache key from artist and track name
*/
function generateCacheKey(artists, trackName) {
// Normalize: lowercase, trim, sort artists
const normalizedArtists = artists.map(a => a.toLowerCase().trim()).sort().join('|');
const normalizedTrack = trackName.toLowerCase().trim();
return `${normalizedArtists}::${normalizedTrack}`;
}
/**
* Get cached search result
*/
export function getCachedSearch(artists, trackName) {
const key = generateCacheKey(artists, trackName);
const cached = searchCache.get(key);
if (cached) {
// Check if cache is still valid (30 days)
const maxAge = 30 * 24 * 60 * 60 * 1000;
if (Date.now() - cached.cachedAt < maxAge) {
return cached.result;
}
// Expired, remove from cache
searchCache.delete(key);
}
return null;
}
/**
* Cache a search result
*/
export function cacheSearch(artists, trackName, result) {
const key = generateCacheKey(artists, trackName);
searchCache.set(key, {
result,
cachedAt: Date.now()
});
// Save to disk periodically (every 10 new entries)
if (searchCache.size % 10 === 0) {
saveSearchCache();
}
}
/**
* Force save cache to disk
*/
export function flushSearchCache() {
saveSearchCache();
}
/**
* Get cache statistics
*/
export function getSearchCacheStats() {
return {
entries: searchCache.size,
path: SEARCH_CACHE_FILE
};
}
/**
* Clear expired entries from cache
*/
export function cleanupSearchCache() {
const maxAge = 30 * 24 * 60 * 60 * 1000; // 30 days
const now = Date.now();
let removed = 0;
for (const [key, value] of searchCache.entries()) {
if (now - value.cachedAt > maxAge) {
searchCache.delete(key);
removed++;
}
}
if (removed > 0) {
saveSearchCache();
console.log(`🧹 Cleaned up ${removed} expired search cache entries`);
}
}
// Load cache on module init
loadSearchCache();
// Cleanup expired entries on startup and daily
cleanupSearchCache();
setInterval(cleanupSearchCache, 24 * 60 * 60 * 1000);
export default {
getCachedSearch,
cacheSearch,
flushSearchCache,
getSearchCacheStats,
cleanupSearchCache
};