mirror of
https://github.com/MrUnknownDE/sptify2yt.git
synced 2026-04-18 14:23:50 +02:00
first commit
This commit is contained in:
31
.env.example
Normal file
31
.env.example
Normal 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
144
.gitignore
vendored
Normal 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
18
Dockerfile
Normal 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
155
README.md
Normal 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
134
cache/job_1766940808819_at6tdppx0.json
vendored
Normal 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
10
docker-compose.yml
Normal 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
1459
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
package.json
Normal file
31
package.json
Normal 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
488
src/public/app.js
Normal 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
185
src/public/index.html
Normal 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
909
src/public/styles.css
Normal 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
148
src/routes/auth.js
Normal 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
131
src/routes/spotify.js
Normal 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
451
src/routes/youtube.js
Normal 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
71
src/server.js
Normal 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}`);
|
||||
});
|
||||
248
src/services/analysisQueue.js
Normal file
248
src/services/analysisQueue.js
Normal 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
142
src/services/searchCache.js
Normal 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
|
||||
};
|
||||
Reference in New Issue
Block a user