18 |
19 | {steps.map((step, index) => (
20 |
21 |
22 |
step.id
26 | ? "bg-primary border-primary"
27 | : currentStep === step.id
28 | ? "border-primary bg-primary/20"
29 | : "border-muted bg-card"
30 | )}
31 | >
32 | {currentStep > step.id ? (
33 |
34 | ) : (
35 |
43 | {step.id}
44 |
45 | )}
46 |
47 |
48 |
= step.id ? "text-foreground" : "text-muted-foreground"
52 | )}
53 | >
54 | {step.title}
55 |
56 |
57 |
58 | {index < steps.length - 1 && (
59 |
step.id ? "bg-primary" : "bg-muted"
63 | )}
64 | />
65 | )}
66 |
67 | ))}
68 |
69 |
70 | );
71 | };
72 |
--------------------------------------------------------------------------------
/frontend/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
Melody Shift - Transfer Spotify Playlists to YouTube Music Free
11 |
12 |
14 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
--------------------------------------------------------------------------------
/backend/logger.py:
--------------------------------------------------------------------------------
1 | """Clean logging utility for Melody Shift backend"""
2 | from datetime import datetime
3 |
4 |
5 | class Logger:
6 | """Simple, readable logger without emojis"""
7 |
8 | @staticmethod
9 | def _timestamp():
10 | return datetime.now().strftime("%H:%M:%S")
11 |
12 | @staticmethod
13 | def info(message):
14 | """General information"""
15 | print(f"[{Logger._timestamp()}] INFO: {message}")
16 |
17 | @staticmethod
18 | def success(message):
19 | """Success message"""
20 | print(f"[{Logger._timestamp()}] SUCCESS: {message}")
21 |
22 | @staticmethod
23 | def warning(message):
24 | """Warning message"""
25 | print(f"[{Logger._timestamp()}] WARNING: {message}")
26 |
27 | @staticmethod
28 | def error(message):
29 | """Error message"""
30 | print(f"[{Logger._timestamp()}] ERROR: {message}")
31 |
32 | @staticmethod
33 | def debug(message):
34 | """Debug message"""
35 | print(f"[{Logger._timestamp()}] DEBUG: {message}")
36 |
37 | @staticmethod
38 | def job_start(job_id, playlist_count, track_count):
39 | """Log job start"""
40 | print(f"\n{'='*60}")
41 | print(f"[{Logger._timestamp()}] JOB START: {job_id}")
42 | print(f" Playlists: {playlist_count} | Total Tracks: {track_count}")
43 | print(f"{'='*60}\n")
44 |
45 | @staticmethod
46 | def job_complete(job_id):
47 | """Log job completion"""
48 | print(f"\n{'='*60}")
49 | print(f"[{Logger._timestamp()}] JOB COMPLETE: {job_id}")
50 | print(f"{'='*60}\n")
51 |
52 | @staticmethod
53 | def playlist_start(name, track_count):
54 | """Log playlist processing start"""
55 | print(f"\n{'-'*60}")
56 | print(f"[{Logger._timestamp()}] PLAYLIST: {name} ({track_count} tracks)")
57 | print(f"{'-'*60}")
58 |
59 | @staticmethod
60 | def playlist_complete(name, matched, total):
61 | """Log playlist completion"""
62 | print(f"[{Logger._timestamp()}] COMPLETED: {name} - {matched}/{total} tracks matched")
63 | print(f"{'-'*60}\n")
64 |
65 | @staticmethod
66 | def track_lookup(track_name, success=True):
67 | """Log track lookup result"""
68 | status = "FOUND" if success else "RETRY"
69 | print(f"[{Logger._timestamp()}] {status}: {track_name}")
70 |
71 | @staticmethod
72 | def batch_added(count):
73 | """Log batch addition"""
74 | print(f"[{Logger._timestamp()}] BATCH: Added {count} tracks to playlist")
75 |
--------------------------------------------------------------------------------
/frontend/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | export default {
4 | darkMode: ["class"],
5 | content: ["./pages/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}", "./app/**/*.{ts,tsx}", "./src/**/*.{ts,tsx}"],
6 | prefix: "",
7 | theme: {
8 | container: {
9 | center: true,
10 | padding: "2rem",
11 | screens: {
12 | "2xl": "1400px",
13 | },
14 | },
15 | extend: {
16 | colors: {
17 | border: "hsl(var(--border))",
18 | input: "hsl(var(--input))",
19 | ring: "hsl(var(--ring))",
20 | background: "hsl(var(--background))",
21 | foreground: "hsl(var(--foreground))",
22 | primary: {
23 | DEFAULT: "hsl(var(--primary))",
24 | foreground: "hsl(var(--primary-foreground))",
25 | },
26 | secondary: {
27 | DEFAULT: "hsl(var(--secondary))",
28 | foreground: "hsl(var(--secondary-foreground))",
29 | },
30 | destructive: {
31 | DEFAULT: "hsl(var(--destructive))",
32 | foreground: "hsl(var(--destructive-foreground))",
33 | },
34 | muted: {
35 | DEFAULT: "hsl(var(--muted))",
36 | foreground: "hsl(var(--muted-foreground))",
37 | },
38 | accent: {
39 | DEFAULT: "hsl(var(--accent))",
40 | foreground: "hsl(var(--accent-foreground))",
41 | },
42 | popover: {
43 | DEFAULT: "hsl(var(--popover))",
44 | foreground: "hsl(var(--popover-foreground))",
45 | },
46 | card: {
47 | DEFAULT: "hsl(var(--card))",
48 | foreground: "hsl(var(--card-foreground))",
49 | },
50 | spotify: "hsl(var(--spotify))",
51 | youtube: "hsl(var(--youtube))",
52 | },
53 | borderRadius: {
54 | lg: "var(--radius)",
55 | md: "calc(var(--radius) - 2px)",
56 | sm: "calc(var(--radius) - 4px)",
57 | },
58 | keyframes: {
59 | "accordion-down": {
60 | from: {
61 | height: "0",
62 | },
63 | to: {
64 | height: "var(--radix-accordion-content-height)",
65 | },
66 | },
67 | "accordion-up": {
68 | from: {
69 | height: "var(--radix-accordion-content-height)",
70 | },
71 | to: {
72 | height: "0",
73 | },
74 | },
75 | },
76 | animation: {
77 | "accordion-down": "accordion-down 0.2s ease-out",
78 | "accordion-up": "accordion-up 0.2s ease-out",
79 | },
80 | },
81 | },
82 | plugins: [require("tailwindcss-animate")],
83 | } satisfies Config;
84 |
--------------------------------------------------------------------------------
/frontend/src/components/SpotifyConnect.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import { Card } from "@/components/ui/card";
3 | import { Music2 } from "lucide-react";
4 | import { supabase } from "@/integrations/supabase/client";
5 |
6 | interface SpotifyConnectProps {
7 | onConnect: () => void;
8 | isConnected: boolean;
9 | }
10 |
11 | export const SpotifyConnect = ({ onConnect, isConnected }: SpotifyConnectProps) => {
12 | const handleConnect = async () => {
13 | try {
14 | const { data, error } = await supabase.functions.invoke('spotify-auth-login');
15 | if (error) throw error;
16 |
17 | // Store state and redirect to Spotify
18 | localStorage.setItem('spotify_auth_state', data.state);
19 | window.location.href = data.authUrl;
20 | } catch (error) {
21 | console.error('Failed to initiate Spotify login:', error);
22 | }
23 | };
24 |
25 | if (isConnected) {
26 | return (
27 |
28 |
29 |
30 |
31 |
32 |
33 |
Connected to Spotify
34 |
35 | Your Spotify account is connected and ready
36 |
37 |
38 |
39 | Continue
40 |
41 |
42 |
43 | );
44 | }
45 |
46 | return (
47 |
48 |
49 |
50 |
51 |
52 |
53 |
Connect Your Spotify
54 |
55 | Sign in with your Spotify account to access your playlists and liked songs
56 |
57 |
58 |
63 | Connect Spotify Account
64 |
65 |
66 | We'll request read-only access to your playlists and library
67 |
68 |
69 |
70 | );
71 | };
72 |
--------------------------------------------------------------------------------
/frontend/src/pages/AuthCallback.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from "react";
2 | import { useNavigate, useSearchParams } from "react-router-dom";
3 | import { Music2 } from "lucide-react";
4 | import { saveSpotifyToken } from "@/lib/api";
5 |
6 | const AuthCallback = () => {
7 | const [searchParams] = useSearchParams();
8 | const navigate = useNavigate();
9 |
10 | const processedRef = useRef(false);
11 |
12 | useEffect(() => {
13 | if (processedRef.current) return;
14 | processedRef.current = true;
15 |
16 | const handleCallback = async () => {
17 | const code = searchParams.get("code");
18 | const state = searchParams.get("state");
19 | const storedState = localStorage.getItem("spotify_auth_state");
20 |
21 | if (!code || !state || state !== storedState) {
22 | console.error("Invalid callback");
23 | navigate("/");
24 | return;
25 | }
26 |
27 | try {
28 | // Exchange code for token via backend
29 | // Use root URL for Spotify redirect (no hash), then handle callback on root page
30 | const redirectUri = `${window.location.origin}/melody-shift/`;
31 |
32 | const response = await fetch(`${import.meta.env.VITE_API_URL}/api/auth/spotify/callback`, {
33 | method: 'POST',
34 | headers: {
35 | 'Content-Type': 'application/json',
36 | },
37 | body: JSON.stringify({
38 | code,
39 | redirectUri
40 | }),
41 | });
42 |
43 | if (!response.ok) {
44 | const errorData = await response.json();
45 | throw new Error(errorData.detail || 'Failed to exchange code for token');
46 | }
47 |
48 | const data = await response.json();
49 |
50 | // Store session ID
51 | localStorage.setItem("spotify_session_id", data.sessionId);
52 | localStorage.removeItem("spotify_auth_state");
53 |
54 | // Redirect back to main page
55 | navigate("/?connected=true");
56 | } catch (error) {
57 | console.error("Failed to complete Spotify authentication:", error);
58 | navigate("/");
59 | }
60 | };
61 |
62 | handleCallback();
63 | }, [searchParams, navigate]);
64 |
65 | return (
66 |
67 |
68 |
69 |
70 |
71 |
72 | Connecting to Spotify...
73 |
74 |
Please wait while we complete the authentication
75 |
76 |
77 | );
78 | };
79 |
80 | export default AuthCallback;
81 |
--------------------------------------------------------------------------------
/backend/deployment.md:
--------------------------------------------------------------------------------
1 | # Melody Shift Backend Deployment (Coolify)
2 |
3 | This guide explains how to deploy the Melody Shift backend using **Coolify**.
4 |
5 | ## Prerequisites
6 |
7 | - A running Coolify instance.
8 | - Your `oauth.json` file (generated locally via `setup_ytmusic.py`).
9 |
10 | ## Deployment Steps
11 |
12 | ### 1. Create a New Service
13 |
14 | 1. Go to your Coolify Dashboard.
15 | 2. Click **+ New Resource**.
16 | 3. Select **Docker Compose**.
17 | 4. Paste the contents of `backend/docker-compose.yml`:
18 |
19 | ```yaml
20 | version: '3.8'
21 |
22 | services:
23 | api:
24 | build: .
25 | ports:
26 | - "8000:8000"
27 | environment:
28 | - REDIS_HOST=redis
29 | - SPOTIFY_CLIENT_ID=${SPOTIFY_CLIENT_ID}
30 | - SPOTIFY_CLIENT_SECRET=${SPOTIFY_CLIENT_SECRET}
31 | - SPOTIFY_REDIRECT_URI=${SPOTIFY_REDIRECT_URI}
32 | - FRONTEND_URL=${FRONTEND_URL}
33 | volumes:
34 | - oauth_data:/app/oauth.json
35 | depends_on:
36 | - redis
37 | restart: always
38 |
39 | redis:
40 | image: redis:alpine
41 | ports:
42 | - "6379:6379"
43 | restart: always
44 |
45 | volumes:
46 | oauth_data:
47 | ```
48 |
49 | *(Note: I slightly modified the volume to use a named volume for easier management in Coolify)*
50 |
51 | ### 2. Configure Environment Variables
52 |
53 | In the Coolify service settings, add the following variables:
54 |
55 | - `SPOTIFY_CLIENT_ID`: Your Spotify Client ID
56 | - `SPOTIFY_CLIENT_SECRET`: Your Spotify Client Secret
57 | - `SPOTIFY_REDIRECT_URI`: `https://your-frontend.github.io/melody-shift/auth/callback`
58 | - `FRONTEND_URL`: `https://your-frontend.github.io/melody-shift`
59 |
60 | ### 3. Upload `oauth.json`
61 |
62 | Since `oauth.json` is sensitive and not in git, you need to add it to the container.
63 |
64 | **Option A: Persistent Volume (Recommended)**
65 | 1. In Coolify, go to the **Storage** tab for the `api` service.
66 | 2. You should see the `oauth_data` volume.
67 | 3. You might need to SSH into your server and manually place the file, or use Coolify's file manager if available.
68 | - Path: `/var/lib/docker/volumes/.../_data/oauth.json`
69 |
70 | **Option B: Base64 Env Var (Easier)**
71 | 1. Convert your `oauth.json` to a single line string.
72 | 2. Add a new env var `OAUTH_JSON_CONTENT` with the content.
73 | 3. Modify `main.py` (or a startup script) to write this env var to a file on boot. *(Requires code change)*.
74 |
75 | **Option C: Git Secret (If using private repo)**
76 | 1. Commit `oauth.json` to your private repo (not recommended for public repos).
77 |
78 | ### 4. Deploy
79 |
80 | Click **Deploy**. Coolify will build the Docker image and start the services.
81 |
82 | ### 5. Domain Setup
83 |
84 | 1. In Coolify, go to **Settings** -> **Domains**.
85 | 2. Add your domain (e.g., `https://api.yourdomain.com`).
86 | 3. Coolify handles SSL automatically!
87 |
88 | ## Final Check
89 |
90 | Visit `https://api.yourdomain.com/docs` to verify the API is running.
91 |
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "vite_react_shadcn_ts",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "vite build",
9 | "build:dev": "vite build --mode development",
10 | "lint": "eslint .",
11 | "preview": "vite preview"
12 | },
13 | "dependencies": {
14 | "@hookform/resolvers": "^3.10.0",
15 | "@radix-ui/react-accordion": "^1.2.11",
16 | "@radix-ui/react-alert-dialog": "^1.1.14",
17 | "@radix-ui/react-aspect-ratio": "^1.1.7",
18 | "@radix-ui/react-avatar": "^1.1.10",
19 | "@radix-ui/react-checkbox": "^1.3.2",
20 | "@radix-ui/react-collapsible": "^1.1.11",
21 | "@radix-ui/react-context-menu": "^2.2.15",
22 | "@radix-ui/react-dialog": "^1.1.14",
23 | "@radix-ui/react-dropdown-menu": "^2.1.15",
24 | "@radix-ui/react-hover-card": "^1.1.14",
25 | "@radix-ui/react-label": "^2.1.7",
26 | "@radix-ui/react-menubar": "^1.1.15",
27 | "@radix-ui/react-navigation-menu": "^1.2.13",
28 | "@radix-ui/react-popover": "^1.1.14",
29 | "@radix-ui/react-progress": "^1.1.7",
30 | "@radix-ui/react-radio-group": "^1.3.7",
31 | "@radix-ui/react-scroll-area": "^1.2.9",
32 | "@radix-ui/react-select": "^2.2.5",
33 | "@radix-ui/react-separator": "^1.1.7",
34 | "@radix-ui/react-slider": "^1.3.5",
35 | "@radix-ui/react-slot": "^1.2.3",
36 | "@radix-ui/react-switch": "^1.2.5",
37 | "@radix-ui/react-tabs": "^1.1.12",
38 | "@radix-ui/react-toast": "^1.2.14",
39 | "@radix-ui/react-toggle": "^1.1.9",
40 | "@radix-ui/react-toggle-group": "^1.1.10",
41 | "@radix-ui/react-tooltip": "^1.2.7",
42 | "@supabase/supabase-js": "^2.81.1",
43 | "@tanstack/react-query": "^5.83.0",
44 | "@types/canvas-confetti": "^1.9.0",
45 | "canvas-confetti": "^1.9.4",
46 | "class-variance-authority": "^0.7.1",
47 | "clsx": "^2.1.1",
48 | "cmdk": "^1.1.1",
49 | "date-fns": "^3.6.0",
50 | "embla-carousel-react": "^8.6.0",
51 | "input-otp": "^1.4.2",
52 | "lucide-react": "^0.462.0",
53 | "next-themes": "^0.3.0",
54 | "react": "^18.3.1",
55 | "react-day-picker": "^8.10.1",
56 | "react-dom": "^18.3.1",
57 | "react-hook-form": "^7.61.1",
58 | "react-resizable-panels": "^2.1.9",
59 | "react-router-dom": "^6.30.1",
60 | "recharts": "^2.15.4",
61 | "sonner": "^1.7.4",
62 | "tailwind-merge": "^2.6.0",
63 | "tailwindcss-animate": "^1.0.7",
64 | "vaul": "^0.9.9",
65 | "zod": "^3.25.76"
66 | },
67 | "devDependencies": {
68 | "@eslint/js": "^9.32.0",
69 | "@tailwindcss/typography": "^0.5.16",
70 | "@types/node": "^22.16.5",
71 | "@types/react": "^18.3.23",
72 | "@types/react-dom": "^18.3.7",
73 | "@vitejs/plugin-react-swc": "^3.11.0",
74 | "autoprefixer": "^10.4.21",
75 | "eslint": "^9.32.0",
76 | "eslint-plugin-react-hooks": "^5.2.0",
77 | "eslint-plugin-react-refresh": "^0.4.20",
78 | "globals": "^15.15.0",
79 | "lovable-tagger": "^1.1.11",
80 | "postcss": "^8.5.6",
81 | "tailwindcss": "^3.4.17",
82 | "typescript": "^5.8.3",
83 | "typescript-eslint": "^8.38.0",
84 | "vite": "^5.4.19"
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/backend/README.md:
--------------------------------------------------------------------------------
1 | # Melody Shift - Backend
2 |
3 | The robust Python backend for Melody Shift, handling playlist transfers, background jobs, and data management.
4 |
5 | ## 🛠️ Tech Stack
6 |
7 | - **Framework:** FastAPI
8 | - **Storage:** Redis (Caching, Job Queue, Shared State)
9 | - **Task Scheduling:** APScheduler
10 | - **Music API:** ytmusicapi (YouTube Music), Spotify API
11 | - **Language:** Python 3.x
12 |
13 | ## 🚀 Getting Started
14 |
15 | ### Prerequisites
16 | - Python 3.8+
17 | - Redis (running on localhost:6379)
18 | - Spotify API credentials
19 | - YouTube Music account (optional, for authenticated transfers)
20 |
21 | ### Installation
22 |
23 | 1. Navigate to the backend directory:
24 | ```bash
25 | cd backend
26 | ```
27 |
28 | 2. Install dependencies:
29 | ```bash
30 | pip install -r requirements.txt
31 | ```
32 |
33 | 3. Configure environment variables:
34 |
35 | Copy `.env.example` to `.env` and fill in your Spotify credentials:
36 | ```bash
37 | cp .env.example .env
38 | ```
39 |
40 | Edit `.env`:
41 | ```env
42 | SPOTIFY_CLIENT_ID=your_spotify_client_id
43 | SPOTIFY_CLIENT_SECRET=your_spotify_client_secret
44 | SPOTIFY_REDIRECT_URI=http://localhost:5173/callback
45 | ```
46 |
47 | 4. **(Optional)** Setup YouTube Music Authentication for Public Playlists:
48 |
49 | For server-side public playlist creation, set these environment variables from YouTube Music browser headers:
50 |
51 | **How to get the values:**
52 | 1. Open YouTube Music (music.youtube.com) in your browser
53 | 2. Press F12 → Network tab
54 | 3. Filter by "browse"
55 | 4. Click any request and find "Request Headers"
56 | 5. Copy `cookie` value → Set as `OAUTH_COOKIE` in `.env`
57 | 6. Copy `authorization` value → Set as `OAUTH_AUTHORIZATION` in `.env`
58 |
59 | **Important:**
60 | - The Google account must have a YouTube channel
61 | - When cookies expire, just copy new values from Network tab
62 |
63 | ### Running the Server
64 |
65 | Start the FastAPI server:
66 | ```bash
67 | python main.py
68 | ```
69 | The API will be available at `http://localhost:8000`.
70 |
71 | ## 🎛️ CLI Playlist Manager
72 |
73 | Includes a powerful CLI tool to manage playlists and tokens directly.
74 |
75 | ```bash
76 | # View statistics
77 | python playlist_manager.py stats
78 |
79 | # List all tracked playlists
80 | python playlist_manager.py list
81 |
82 | # Check active OAuth tokens
83 | python playlist_manager.py tokens
84 |
85 | # Delete all playlists (Dangerous)
86 | python playlist_manager.py delete-all-playlists
87 |
88 | # Cleanup entire account (Nuclear)
89 | python playlist_manager.py cleanup-account
90 | ```
91 |
92 | ## ✨ Key Features
93 |
94 | - **Cross-Process Communication:** Shares state with CLI via Redis.
95 | - **Auto-Cleanup:** Automatically deletes guest playlists after 30 minutes.
96 | - **Security:** OAuth tokens expire automatically (30 min TTL).
97 | - **Background Processing:** Handles large transfers asynchronously.
98 | - **Title-Only Matching:** Finds songs even when artist names don't match perfectly.
99 | - **Public/Private Playlists:** Supports both modes (OAuth required for private).
100 |
101 | ## 📁 Important Files
102 |
103 | - `.env` - Configuration with Spotify credentials and YouTube OAuth env vars (gitignored)
104 | - `logger.py` - Clean timestamped logging system
105 | - `playlist_manager.py` - CLI management tool
106 | - `transfer_worker.py` - Core playlist transfer logic
107 |
--------------------------------------------------------------------------------
/backend/spotify_client.py:
--------------------------------------------------------------------------------
1 | """Spotify API client for fetching playlists and tracks"""
2 | import spotipy
3 | from spotipy.oauth2 import SpotifyOAuth
4 | import os
5 | from dotenv import load_dotenv
6 | import storage
7 |
8 | load_dotenv()
9 |
10 | SPOTIFY_CLIENT_ID = os.getenv("SPOTIFY_CLIENT_ID")
11 | SPOTIFY_CLIENT_SECRET = os.getenv("SPOTIFY_CLIENT_SECRET")
12 | SPOTIFY_REDIRECT_URI = os.getenv("SPOTIFY_REDIRECT_URI", "http://localhost:5173/auth/callback")
13 |
14 | SCOPE = "user-library-read playlist-read-private playlist-read-collaborative"
15 |
16 | def get_spotify_client(session_id: str):
17 | """Get authenticated Spotify client for a session"""
18 | token_data = storage.get_auth_token(session_id)
19 |
20 | if not token_data:
21 | raise ValueError(f"No OAuth token found for session {session_id}")
22 |
23 | # Create Spotify client with saved token
24 | sp = spotipy.Spotify(auth=token_data["access_token"])
25 | return sp
26 |
27 | def get_spotify_client_credentials():
28 | """Get Spotify client using Client Credentials flow (for public playlists)"""
29 | from spotipy.oauth2 import SpotifyClientCredentials
30 |
31 | client_credentials_manager = SpotifyClientCredentials(
32 | client_id=SPOTIFY_CLIENT_ID,
33 | client_secret=SPOTIFY_CLIENT_SECRET
34 | )
35 | return spotipy.Spotify(client_credentials_manager=client_credentials_manager)
36 |
37 | def get_user_playlists(session_id: str):
38 | """Fetch all user playlists from Spotify"""
39 | sp = get_spotify_client(session_id)
40 |
41 | playlists = []
42 | offset = 0
43 | limit = 50
44 |
45 | while True:
46 | results = sp.current_user_playlists(limit=limit, offset=offset)
47 | playlists.extend(results['items'])
48 |
49 | if not results['next']:
50 | break
51 | offset += limit
52 |
53 | return playlists
54 |
55 | def get_playlist_tracks(session_id: str, playlist_id: str):
56 | """Fetch all tracks from a Spotify playlist"""
57 | sp = get_spotify_client(session_id)
58 |
59 | tracks = []
60 | offset = 0
61 | limit = 100
62 |
63 | while True:
64 | results = sp.playlist_tracks(playlist_id, limit=limit, offset=offset)
65 | tracks.extend(results['items'])
66 |
67 | if not results['next']:
68 | break
69 | offset += limit
70 |
71 | return tracks
72 |
73 | def get_liked_songs(session_id: str):
74 | """Fetch user's liked songs from Spotify"""
75 | sp = get_spotify_client(session_id)
76 |
77 | tracks = []
78 | offset = 0
79 | limit = 50
80 |
81 | while True:
82 | results = sp.current_user_saved_tracks(limit=limit, offset=offset)
83 | tracks.extend(results['items'])
84 |
85 | if not results['next']:
86 | break
87 | offset += limit
88 |
89 | return tracks
90 |
91 | def get_liked_songs_count(session_id: str):
92 | """Get total count of user's liked songs"""
93 | sp = get_spotify_client(session_id)
94 | results = sp.current_user_saved_tracks(limit=1)
95 | return results['total']
96 |
97 | def get_playlist_details(session_id: str, playlist_id: str):
98 | """Get detailed information about a playlist"""
99 | if session_id == "guest_session":
100 | sp = get_spotify_client_credentials()
101 | else:
102 | sp = get_spotify_client(session_id)
103 | return sp.playlist(playlist_id)
104 |
--------------------------------------------------------------------------------
/frontend/src/assets/react.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/src/lib/api.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * API Client for Python FastAPI Backend
3 | */
4 |
5 | const API_BASE_URL = import.meta.env.VITE_API_URL;
6 |
7 | export interface SpotifyPlaylist {
8 | id: string;
9 | name: string;
10 | description?: string;
11 | images: Array<{ url: string }>;
12 | tracks: { total: number };
13 | owner: { display_name: string };
14 | }
15 |
16 | export interface YouTubeMusicHeaders {
17 | [key: string]: string;
18 | }
19 |
20 | export interface TransferRequest {
21 | sessionId: string;
22 | playlistIds: string[];
23 | includeLiked: boolean;
24 | ytHeaders: YouTubeMusicHeaders;
25 | }
26 |
27 | export interface TransferJob {
28 | job_id: string;
29 | status: 'pending' | 'processing' | 'completed' | 'failed';
30 | progress: {
31 | total: number;
32 | current: number;
33 | currentPlaylist?: string;
34 | };
35 | results?: Array<{
36 | playlistId: string;
37 | playlistName: string;
38 | tracks: number;
39 | matched: number;
40 | failed: number;
41 | ytPlaylistId?: string;
42 | ytPlaylistUrl?: string;
43 | status: string;
44 | exactMatches?: number;
45 | titleMatches?: number;
46 | duplicates?: number;
47 | processedTracks?: Array<{
48 | spotifyName: string;
49 | spotifyArtist: string;
50 | spotifyImage: string;
51 | ytName: string;
52 | ytArtist: string;
53 | ytImage: string;
54 | status: string;
55 | isDuplicate?: boolean;
56 | matchType?: string;
57 | }>;
58 | }>;
59 | error?: string;
60 | }
61 |
62 | /**
63 | * Fetch user's Spotify playlists from Python backend
64 | */
65 | export async function fetchSpotifyPlaylists(sessionId: string): Promise<{ items: SpotifyPlaylist[] }> {
66 | const response = await fetch(`${API_BASE_URL}/spotify-playlists`, {
67 | method: 'POST',
68 | headers: { 'Content-Type': 'application/json' },
69 | body: JSON.stringify({ sessionId }),
70 | });
71 |
72 | if (!response.ok) {
73 | throw new Error(`Failed to fetch playlists: ${response.statusText}`);
74 | }
75 |
76 | return response.json();
77 | }
78 |
79 | /**
80 | * Start a playlist transfer job
81 | */
82 | export async function startTransfer(request: TransferRequest): Promise<{ jobId: string }> {
83 | const response = await fetch(`${API_BASE_URL}/transfer`, {
84 | method: 'POST',
85 | headers: { 'Content-Type': 'application/json' },
86 | body: JSON.stringify(request),
87 | });
88 |
89 | if (!response.ok) {
90 | throw new Error(`Failed to start transfer: ${response.statusText}`);
91 | }
92 |
93 | return response.json();
94 | }
95 |
96 | /**
97 | * Get transfer job status
98 | */
99 | export async function getTransferStatus(jobId: string): Promise
{
100 | const response = await fetch(`${API_BASE_URL}/transfer-status?jobId=${jobId}`);
101 |
102 | if (!response.ok) {
103 | throw new Error(`Failed to fetch job status: ${response.statusText}`);
104 | }
105 |
106 | return response.json();
107 | }
108 |
109 | /**
110 | * Save Spotify OAuth token to backend
111 | */
112 | export async function saveSpotifyToken(
113 | sessionId: string,
114 | accessToken: string,
115 | refreshToken: string | null,
116 | expiresAt: number
117 | ): Promise {
118 | const response = await fetch(`${API_BASE_URL.replace('/api', '')}/api/save-token`, {
119 | method: 'POST',
120 | headers: { 'Content-Type': 'application/json' },
121 | body: JSON.stringify({
122 | sessionId,
123 | accessToken,
124 | refreshToken,
125 | expiresAt,
126 | }),
127 | });
128 |
129 | if (!response.ok) {
130 | throw new Error(`Failed to save token: ${response.statusText}`);
131 | }
132 | if (!response.ok) {
133 | throw new Error(`Failed to save token: ${response.statusText}`);
134 | }
135 | }
136 |
137 | export const api = {
138 | fetchSpotifyPlaylists,
139 | startTransfer,
140 | getTransferStatus,
141 | saveSpotifyToken,
142 | retryFailed: async (jobId: string, failedTracks: any[], ytHeaders: any) => {
143 | const response = await fetch(`${API_BASE_URL}/transfer/retry`, {
144 | method: 'POST',
145 | headers: {
146 | 'Content-Type': 'application/json',
147 | },
148 | body: JSON.stringify({ jobId, failedTracks, ytHeaders }),
149 | });
150 | if (!response.ok) throw new Error('Failed to retry transfer');
151 | return response.json();
152 | }
153 | };
154 |
--------------------------------------------------------------------------------
/frontend/src/hooks/use-toast.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
4 |
5 | const TOAST_LIMIT = 1;
6 | const TOAST_REMOVE_DELAY = 1000000;
7 |
8 | type ToasterToast = ToastProps & {
9 | id: string;
10 | title?: React.ReactNode;
11 | description?: React.ReactNode;
12 | action?: ToastActionElement;
13 | };
14 |
15 | const actionTypes = {
16 | ADD_TOAST: "ADD_TOAST",
17 | UPDATE_TOAST: "UPDATE_TOAST",
18 | DISMISS_TOAST: "DISMISS_TOAST",
19 | REMOVE_TOAST: "REMOVE_TOAST",
20 | } as const;
21 |
22 | let count = 0;
23 |
24 | function genId() {
25 | count = (count + 1) % Number.MAX_SAFE_INTEGER;
26 | return count.toString();
27 | }
28 |
29 | type ActionType = typeof actionTypes;
30 |
31 | type Action =
32 | | {
33 | type: ActionType["ADD_TOAST"];
34 | toast: ToasterToast;
35 | }
36 | | {
37 | type: ActionType["UPDATE_TOAST"];
38 | toast: Partial;
39 | }
40 | | {
41 | type: ActionType["DISMISS_TOAST"];
42 | toastId?: ToasterToast["id"];
43 | }
44 | | {
45 | type: ActionType["REMOVE_TOAST"];
46 | toastId?: ToasterToast["id"];
47 | };
48 |
49 | interface State {
50 | toasts: ToasterToast[];
51 | }
52 |
53 | const toastTimeouts = new Map>();
54 |
55 | const addToRemoveQueue = (toastId: string) => {
56 | if (toastTimeouts.has(toastId)) {
57 | return;
58 | }
59 |
60 | const timeout = setTimeout(() => {
61 | toastTimeouts.delete(toastId);
62 | dispatch({
63 | type: "REMOVE_TOAST",
64 | toastId: toastId,
65 | });
66 | }, TOAST_REMOVE_DELAY);
67 |
68 | toastTimeouts.set(toastId, timeout);
69 | };
70 |
71 | export const reducer = (state: State, action: Action): State => {
72 | switch (action.type) {
73 | case "ADD_TOAST":
74 | return {
75 | ...state,
76 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
77 | };
78 |
79 | case "UPDATE_TOAST":
80 | return {
81 | ...state,
82 | toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
83 | };
84 |
85 | case "DISMISS_TOAST": {
86 | const { toastId } = action;
87 |
88 | // ! Side effects ! - This could be extracted into a dismissToast() action,
89 | // but I'll keep it here for simplicity
90 | if (toastId) {
91 | addToRemoveQueue(toastId);
92 | } else {
93 | state.toasts.forEach((toast) => {
94 | addToRemoveQueue(toast.id);
95 | });
96 | }
97 |
98 | return {
99 | ...state,
100 | toasts: state.toasts.map((t) =>
101 | t.id === toastId || toastId === undefined
102 | ? {
103 | ...t,
104 | open: false,
105 | }
106 | : t,
107 | ),
108 | };
109 | }
110 | case "REMOVE_TOAST":
111 | if (action.toastId === undefined) {
112 | return {
113 | ...state,
114 | toasts: [],
115 | };
116 | }
117 | return {
118 | ...state,
119 | toasts: state.toasts.filter((t) => t.id !== action.toastId),
120 | };
121 | }
122 | };
123 |
124 | const listeners: Array<(state: State) => void> = [];
125 |
126 | let memoryState: State = { toasts: [] };
127 |
128 | function dispatch(action: Action) {
129 | memoryState = reducer(memoryState, action);
130 | listeners.forEach((listener) => {
131 | listener(memoryState);
132 | });
133 | }
134 |
135 | type Toast = Omit;
136 |
137 | function toast({ ...props }: Toast) {
138 | const id = genId();
139 |
140 | const update = (props: ToasterToast) =>
141 | dispatch({
142 | type: "UPDATE_TOAST",
143 | toast: { ...props, id },
144 | });
145 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
146 |
147 | dispatch({
148 | type: "ADD_TOAST",
149 | toast: {
150 | ...props,
151 | id,
152 | open: true,
153 | onOpenChange: (open) => {
154 | if (!open) dismiss();
155 | },
156 | },
157 | });
158 |
159 | return {
160 | id: id,
161 | dismiss,
162 | update,
163 | };
164 | }
165 |
166 | function useToast() {
167 | const [state, setState] = React.useState(memoryState);
168 |
169 | React.useEffect(() => {
170 | listeners.push(setState);
171 | return () => {
172 | const index = listeners.indexOf(setState);
173 | if (index > -1) {
174 | listeners.splice(index, 1);
175 | }
176 | };
177 | }, [state]);
178 |
179 | return {
180 | ...state,
181 | toast,
182 | dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
183 | };
184 | }
185 |
186 | export { useToast, toast };
187 |
--------------------------------------------------------------------------------
/frontend/src/components/DestinationChoice.tsx:
--------------------------------------------------------------------------------
1 | import { User, Globe, Check } from "lucide-react";
2 | import { Card } from "@/components/ui/card";
3 | import { cn } from "@/lib/utils";
4 |
5 | interface DestinationChoiceProps {
6 | onSelect: (mode: "private" | "public") => void;
7 | selectedMode: "private" | "public" | null;
8 | }
9 |
10 | export const DestinationChoice = ({ onSelect, selectedMode }: DestinationChoiceProps) => {
11 | return (
12 |
13 |
onSelect("private")}
19 | >
20 |
21 |
27 | {selectedMode === "private" && }
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 | My Account
36 |
37 | Transfer playlists directly to your own YouTube Music account.
38 |
39 |
40 |
41 | Requires your YouTube Music browser headers to authenticate.
42 |
43 |
44 | 🔒
45 |
46 | 100% Private: Your credentials are used only for this transfer and vanish immediately. We never save or store them.
47 |
48 |
49 |
50 |
51 |
52 |
onSelect("public")}
58 | >
59 |
60 |
66 | {selectedMode === "public" && }
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 | Public Playlist
75 |
76 | Create a public playlist on our server account.
77 |
78 |
79 |
80 | ⚠️ Playlist will be deleted after 30 minutes. Clone it to save!
81 |
82 |
83 | ⚠️ This option may fail due to high traffic. If it fails, please use "My Account".
84 |
85 |
86 |
87 |
88 | );
89 | };
90 |
--------------------------------------------------------------------------------
/frontend/src/components/ProcessedTracksList.tsx:
--------------------------------------------------------------------------------
1 | import { Card } from "@/components/ui/card";
2 | import { ScrollArea } from "@/components/ui/scroll-area";
3 | import { CheckCircle2, XCircle, Music } from "lucide-react";
4 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
5 | import { useState } from "react";
6 | import { ChevronDown } from "lucide-react";
7 |
8 | interface ProcessedTrack {
9 | spotifyName: string;
10 | spotifyArtist: string;
11 | spotifyImage: string;
12 | ytName: string;
13 | ytArtist: string;
14 | ytImage: string;
15 | status: string;
16 | isDuplicate?: boolean;
17 | matchType?: string;
18 | }
19 |
20 | interface ProcessedTracksListProps {
21 | tracks: ProcessedTrack[];
22 | }
23 |
24 | export const ProcessedTracksList = ({ tracks }: ProcessedTracksListProps) => {
25 | const [isOpen, setIsOpen] = useState(false);
26 |
27 | if (!tracks || tracks.length === 0) return null;
28 |
29 | return (
30 |
31 |
32 |
33 |
34 | Processed Tracks ({tracks.length})
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 | {tracks.map((track, index) => (
43 |
44 |
45 | {track.status === 'success' ? (
46 |
47 | ) : (
48 |
49 | )}
50 |
51 | {/* Spotify Info */}
52 |
53 | {track.spotifyImage ? (
54 |
59 | ) : (
60 |
61 |
62 |
63 | )}
64 |
65 |
66 | {track.spotifyName}
67 |
68 |
69 | {track.spotifyArtist}
70 |
71 |
72 |
73 |
74 | {track.status === 'success' && (
75 | <>
76 |
→
77 |
78 | {/* YouTube Info */}
79 |
80 | {track.ytImage ? (
81 |
86 | ) : (
87 |
88 |
89 |
90 | )}
91 |
92 |
93 | {track.ytName}
94 |
95 |
96 | {track.ytArtist}
97 |
98 |
99 |
100 | >
101 | )}
102 |
103 | {track.isDuplicate && (
104 |
105 | Duplicate
106 |
107 | )}
108 |
109 |
110 | ))}
111 |
112 |
113 |
114 |
115 | );
116 | };
117 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/toast.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as ToastPrimitives from "@radix-ui/react-toast";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 | import { X } from "lucide-react";
5 |
6 | import { cn } from "@/lib/utils";
7 |
8 | const ToastProvider = ToastPrimitives.Provider;
9 |
10 | const ToastViewport = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 | ));
23 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
24 |
25 | const toastVariants = cva(
26 | "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
27 | {
28 | variants: {
29 | variant: {
30 | default: "border bg-background text-foreground",
31 | destructive: "destructive group border-destructive bg-destructive text-destructive-foreground",
32 | },
33 | },
34 | defaultVariants: {
35 | variant: "default",
36 | },
37 | },
38 | );
39 |
40 | const Toast = React.forwardRef<
41 | React.ElementRef,
42 | React.ComponentPropsWithoutRef & VariantProps
43 | >(({ className, variant, ...props }, ref) => {
44 | return ;
45 | });
46 | Toast.displayName = ToastPrimitives.Root.displayName;
47 |
48 | const ToastAction = React.forwardRef<
49 | React.ElementRef,
50 | React.ComponentPropsWithoutRef
51 | >(({ className, ...props }, ref) => (
52 |
60 | ));
61 | ToastAction.displayName = ToastPrimitives.Action.displayName;
62 |
63 | const ToastClose = React.forwardRef<
64 | React.ElementRef,
65 | React.ComponentPropsWithoutRef
66 | >(({ className, ...props }, ref) => (
67 |
76 |
77 |
78 | ));
79 | ToastClose.displayName = ToastPrimitives.Close.displayName;
80 |
81 | const ToastTitle = React.forwardRef<
82 | React.ElementRef,
83 | React.ComponentPropsWithoutRef
84 | >(({ className, ...props }, ref) => (
85 |
86 | ));
87 | ToastTitle.displayName = ToastPrimitives.Title.displayName;
88 |
89 | const ToastDescription = React.forwardRef<
90 | React.ElementRef,
91 | React.ComponentPropsWithoutRef
92 | >(({ className, ...props }, ref) => (
93 |
94 | ));
95 | ToastDescription.displayName = ToastPrimitives.Description.displayName;
96 |
97 | type ToastProps = React.ComponentPropsWithoutRef;
98 |
99 | type ToastActionElement = React.ReactElement;
100 |
101 | export {
102 | type ToastProps,
103 | type ToastActionElement,
104 | ToastProvider,
105 | ToastViewport,
106 | Toast,
107 | ToastTitle,
108 | ToastDescription,
109 | ToastClose,
110 | ToastAction,
111 | };
112 |
--------------------------------------------------------------------------------
/frontend/src/components/SpotifyConnectionChoice.tsx:
--------------------------------------------------------------------------------
1 | import { Music2, ArrowRight, Link as LinkIcon } from "lucide-react";
2 | import { Button } from "@/components/ui/button";
3 | import { Card } from "@/components/ui/card";
4 | import { useState } from "react";
5 | import { Input } from "@/components/ui/input";
6 |
7 | interface SpotifyConnectionChoiceProps {
8 | onConnect: (sessionId: string, playlistId?: string) => void;
9 | isConnected?: boolean;
10 | onContinue?: () => void;
11 | onDisconnect?: () => void;
12 | }
13 |
14 | export const SpotifyConnectionChoice = ({
15 | onConnect,
16 | isConnected,
17 | onContinue,
18 | onDisconnect
19 | }: SpotifyConnectionChoiceProps) => {
20 | const [showLinkInput, setShowLinkInput] = useState(false);
21 | const [playlistUrl, setPlaylistUrl] = useState("");
22 |
23 | const handleConnect = () => {
24 | const clientId = import.meta.env.VITE_SPOTIFY_CLIENT_ID;
25 | const redirectUri = import.meta.env.VITE_SPOTIFY_REDIRECT_URI;
26 | const scope = "user-library-read playlist-read-private playlist-read-collaborative";
27 |
28 | // Generate random state
29 | const state = Math.random().toString(36).substring(7);
30 | localStorage.setItem("spotify_auth_state", state);
31 |
32 | const authUrl = `https://accounts.spotify.com/authorize?client_id=${clientId}&response_type=code&redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(scope)}&state=${state}`;
33 |
34 | window.location.href = authUrl;
35 | };
36 |
37 | const handleLinkSubmit = (e: React.FormEvent) => {
38 | e.preventDefault();
39 | // Extract playlist ID from URL
40 | // https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M
41 | const match = playlistUrl.match(/playlist\/([a-zA-Z0-9]+)/);
42 | if (match) {
43 | // For direct link, we still need a session (guest session)
44 | // For now, we'll pass a dummy session and the playlist ID
45 | // The backend will need to handle this case
46 | onConnect("guest_session", match[1]);
47 | }
48 | };
49 |
50 | if (isConnected) {
51 | return (
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
Welcome Back!
60 |
61 | You are currently connected to Spotify.
62 |
63 |
64 |
65 |
66 |
70 | Continue with Spotify
71 |
72 |
73 |
74 |
79 | Switch Account
80 |
81 |
82 |
83 |
84 | );
85 | }
86 |
87 | return (
88 |
89 |
90 |
91 |
92 |
93 |
94 |
Connect Spotify Account
95 |
96 | Access all your playlists and liked songs
97 |
98 |
99 |
100 | Connect Spotify
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
Paste Playlist Link
110 |
111 | Transfer a single public playlist
112 |
113 |
114 |
115 | {showLinkInput ? (
116 |
142 | ) : (
143 | setShowLinkInput(true)}
147 | >
148 | Enter URL
149 |
150 | )}
151 |
152 |
153 | );
154 | };
155 |
--------------------------------------------------------------------------------
/frontend/src/components/TransferProgress.tsx:
--------------------------------------------------------------------------------
1 | import { CheckCircle2, Loader2, XCircle } from "lucide-react";
2 | import { useEffect, useRef, useState } from "react";
3 | import { Card } from "@/components/ui/card";
4 | import { TransferJob } from "@/types/api";
5 | import { cn } from "@/lib/utils";
6 |
7 | interface TransferProgressProps {
8 | jobId: string;
9 | onComplete: () => void;
10 | }
11 |
12 | export const TransferProgress = ({ jobId, onComplete }: TransferProgressProps) => {
13 | const [job, setJob] = useState(null);
14 | const [polling, setPolling] = useState(true);
15 | const [logs, setLogs] = useState([]);
16 | const logEndRef = useRef(null);
17 |
18 | // Simulate logs based on progress updates
19 | useEffect(() => {
20 | if (!job) return;
21 |
22 | if (job.status === "processing" && job.progress.currentPlaylist) {
23 | const newLog = `Processing: ${job.progress.currentPlaylist} (${job.progress.processed || 0}/${job.progress.totalTracks || 0})`;
24 | setLogs(prev => {
25 | // Avoid duplicate consecutive logs
26 | if (prev[prev.length - 1] === newLog) return prev;
27 | return [...prev.slice(-4), newLog]; // Keep last 5 logs
28 | });
29 | } else if (job.status === "completed") {
30 | setLogs(prev => [...prev, "Transfer completed successfully!"]);
31 | } else if (job.status === "failed") {
32 | setLogs(prev => [...prev, "Transfer failed."]);
33 | }
34 | }, [job?.progress.processed, job?.progress.currentPlaylist, job?.status]);
35 |
36 | // Auto-scroll logs
37 | useEffect(() => {
38 | logEndRef.current?.scrollIntoView({ behavior: "smooth" });
39 | }, [logs]);
40 |
41 | useEffect(() => {
42 | if (!polling) return;
43 |
44 | const pollStatus = async () => {
45 | try {
46 | const response = await fetch(
47 | `${import.meta.env.VITE_API_URL}/api/transfer-status?jobId=${jobId}`
48 | );
49 | const data = await response.json();
50 | setJob(data);
51 |
52 | if (data.status === "completed" || data.status === "failed") {
53 | setPolling(false);
54 | // Add a small delay before calling onComplete to let the user see the 100% state
55 | if (data.status === "completed") {
56 | setTimeout(onComplete, 1500);
57 | }
58 | }
59 | } catch (error) {
60 | console.error("Failed to fetch job status:", error);
61 | }
62 | };
63 |
64 | pollStatus();
65 | const interval = setInterval(pollStatus, 1000); // Faster polling for smoother UI
66 |
67 | return () => clearInterval(interval);
68 | }, [jobId, polling, onComplete]);
69 |
70 | if (!job) {
71 | return (
72 |
73 |
77 |
Initializing transfer...
78 |
79 | );
80 | }
81 |
82 | // Calculate global progress
83 | const globalProcessed = job.progress.globalProcessed || 0;
84 | const grandTotal = job.progress.grandTotalTracks || 1; // Avoid div by zero
85 |
86 | const progressPercent = (globalProcessed / grandTotal) * 100;
87 |
88 | const isComplete = job.status === "completed";
89 | const isFailed = job.status === "failed";
90 |
91 | return (
92 |
93 | {/* Main Status Card */}
94 |
95 | {/* Background Glow */}
96 |
97 |
98 |
99 | {/* Circular Progress */}
100 |
101 | {/* Outer Ring (Background) */}
102 |
103 |
112 | {/* Progress Ring */}
113 |
125 |
126 |
127 | {/* Center Icon/Text */}
128 |
129 | {isComplete ? (
130 |
131 | ) : isFailed ? (
132 |
133 | ) : (
134 |
135 |
136 | {globalProcessed}
137 |
138 |
139 | of {grandTotal} songs
140 |
141 |
142 | )}
143 |
144 |
145 |
146 | {/* Status Text */}
147 |
148 |
149 | {isComplete ? "Transfer Complete!" : isFailed ? "Transfer Failed" : "Transferring..."}
150 |
151 |
152 | {!isComplete && !isFailed && (
153 |
154 |
155 | Processing Playlist {(job.progress.current ?? 0) + 1} of {job.progress.total ?? 1}
156 |
157 | )}
158 |
159 | {isComplete && (
160 |
161 | All tracks processed.
162 |
163 | )}
164 |
165 |
166 |
167 |
168 | );
169 | };
170 |
--------------------------------------------------------------------------------
/frontend/src/components/PlaylistSelector.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useMemo } from "react";
2 | import { Button } from "@/components/ui/button";
3 | import { ScrollArea } from "@/components/ui/scroll-area";
4 | import { Heart, Music, User, Search, CheckCircle2, Circle } from "lucide-react";
5 | import { SpotifyPlaylist } from "@/types/api";
6 | import { Input } from "@/components/ui/input";
7 | import { cn } from "@/lib/utils";
8 |
9 | interface PlaylistSelectorProps {
10 | playlists: SpotifyPlaylist[];
11 | onSelect: (playlistIds: string[], includeLiked: boolean) => void;
12 | likedSongsCount?: number;
13 | }
14 |
15 | export const PlaylistSelector = ({ playlists, onSelect, likedSongsCount }: PlaylistSelectorProps) => {
16 | const [selectedPlaylists, setSelectedPlaylists] = useState>(new Set());
17 | const [includeLiked, setIncludeLiked] = useState(false);
18 | const [searchQuery, setSearchQuery] = useState("");
19 |
20 | const filteredPlaylists = useMemo(() => {
21 | return playlists.filter((p) =>
22 | p.name.toLowerCase().includes(searchQuery.toLowerCase())
23 | );
24 | }, [playlists, searchQuery]);
25 |
26 | const togglePlaylist = (id: string) => {
27 | const newSelected = new Set(selectedPlaylists);
28 | if (newSelected.has(id)) {
29 | newSelected.delete(id);
30 | } else {
31 | newSelected.add(id);
32 | }
33 | setSelectedPlaylists(newSelected);
34 | };
35 |
36 | const handleContinue = () => {
37 | if (selectedPlaylists.size === 0 && !includeLiked) {
38 | return; // Need at least one selection
39 | }
40 | onSelect(Array.from(selectedPlaylists), includeLiked);
41 | };
42 |
43 | const totalSelected = selectedPlaylists.size + (includeLiked ? 1 : 0);
44 |
45 | return (
46 |
47 | {/* Header & Search */}
48 |
49 |
50 |
51 |
Select Playlists
52 |
Choose content to transfer
53 |
54 |
55 |
{totalSelected}
56 |
Selected
57 |
58 |
59 |
60 |
61 |
62 | setSearchQuery(e.target.value)}
66 | className="pl-9 bg-secondary/50 border-white/10 focus:border-primary/50 transition-colors h-10"
67 | />
68 |
69 |
70 |
71 |
72 |
73 | {/* Liked Songs Row */}
74 |
setIncludeLiked(!includeLiked)}
76 | className={cn(
77 | "group flex items-center gap-4 p-3 rounded-lg cursor-pointer transition-all duration-200 border",
78 | includeLiked
79 | ? "border-primary/50 bg-primary/10"
80 | : "border-transparent bg-card/40 hover:bg-card/60"
81 | )}
82 | >
83 |
84 |
85 |
86 |
87 |
88 |
Liked Songs
89 |
90 | {likedSongsCount !== undefined ? `${likedSongsCount} tracks` : "Your saved tracks"}
91 |
92 |
93 |
94 |
95 | {includeLiked ? (
96 |
97 | ) : (
98 |
99 | )}
100 |
101 |
102 |
103 | {/* Playlist Rows */}
104 | {filteredPlaylists.map((playlist) => {
105 | const isSelected = selectedPlaylists.has(playlist.id);
106 | return (
107 |
togglePlaylist(playlist.id)}
110 | className={cn(
111 | "group flex items-center gap-4 p-3 rounded-lg cursor-pointer transition-all duration-200 border",
112 | isSelected
113 | ? "border-primary/50 bg-primary/10"
114 | : "border-transparent bg-card/40 hover:bg-card/60"
115 | )}
116 | >
117 |
118 | {playlist.images?.[0] ? (
119 |
124 | ) : (
125 |
126 |
127 |
128 | )}
129 |
130 |
131 |
132 |
133 | {playlist.name}
134 |
135 |
136 | {playlist.tracks.total} tracks
137 |
138 |
139 | {playlist.owner.display_name}
140 |
141 |
142 |
143 |
144 |
145 | {isSelected ? (
146 |
147 | ) : (
148 |
149 | )}
150 |
151 |
152 | );
153 | })}
154 |
155 |
156 |
157 |
158 |
164 | Transfer {totalSelected > 0 ? `${totalSelected} Playlists` : ""}
165 |
166 |
167 |
168 | );
169 | };
170 |
--------------------------------------------------------------------------------
/backend/main.py:
--------------------------------------------------------------------------------
1 | """FastAPI main application for Melody Shift backend"""
2 | from fastapi import FastAPI, BackgroundTasks, HTTPException
3 | from fastapi.middleware.cors import CORSMiddleware
4 | from pydantic import BaseModel
5 | import uuid
6 | import os
7 | from dotenv import load_dotenv
8 |
9 | import storage
10 | from transfer_worker import process_transfer_job
11 | from spotify_client import get_user_playlists
12 |
13 | load_dotenv()
14 |
15 | app = FastAPI(title="Melody Shift API", version="1.0.0")
16 |
17 | # CORS configuration
18 | app.add_middleware(
19 | CORSMiddleware,
20 | allow_origins=["*"],
21 | allow_credentials=True,
22 | allow_methods=["*"],
23 | allow_headers=["*"],
24 | )
25 |
26 | # Pydantic models
27 | class TransferRequest(BaseModel):
28 | sessionId: str
29 | playlistIds: list[str]
30 | includeLiked: bool
31 | grandTotalTracks: int = 0 # New field for global progress
32 | ytHeaders: dict = {} # Not used with OAuth
33 |
34 | class SpotifyPlaylistsRequest(BaseModel):
35 | sessionId: str
36 |
37 | class TokenSaveRequest(BaseModel):
38 | sessionId: str
39 | accessToken: str
40 | refreshToken: str | None = None
41 | expiresAt: int
42 |
43 | class SpotifyCallbackRequest(BaseModel):
44 | code: str
45 | redirectUri: str
46 |
47 | # API Endpoints
48 |
49 | @app.get("/")
50 | async def root():
51 | """Health check"""
52 | return {"status": "ok", "message": "Melody Shift API"}
53 |
54 | @app.post("/api/save-token")
55 | async def save_token(request: TokenSaveRequest):
56 | """Save Spotify OAuth token"""
57 | storage.save_auth_token(
58 | session_id=request.sessionId,
59 | access_token=request.accessToken,
60 | refresh_token=request.refreshToken,
61 | expires_at=request.expiresAt
62 | )
63 | return {"status": "success"}
64 |
65 | @app.post("/api/auth/verify")
66 | async def verify_token(request: SpotifyPlaylistsRequest):
67 | """Verify if a session token is valid"""
68 | token = storage.get_auth_token(request.sessionId)
69 | if not token:
70 | raise HTTPException(status_code=401, detail="Token expired or invalid")
71 | return {"status": "valid"}
72 |
73 | @app.post("/api/auth/spotify/callback")
74 | async def spotify_callback(request: SpotifyCallbackRequest):
75 | """Exchange authorization code for access token"""
76 | import requests
77 | import time
78 |
79 | client_id = os.getenv("SPOTIFY_CLIENT_ID")
80 | client_secret = os.getenv("SPOTIFY_CLIENT_SECRET")
81 |
82 | if not client_id or not client_secret:
83 | raise HTTPException(status_code=500, detail="Spotify credentials not configured")
84 |
85 | response = requests.post(
86 | "https://accounts.spotify.com/api/token",
87 | data={
88 | "grant_type": "authorization_code",
89 | "code": request.code,
90 | "redirect_uri": request.redirectUri,
91 | "client_id": client_id,
92 | "client_secret": client_secret,
93 | },
94 | headers={"Content-Type": "application/x-www-form-urlencoded"}
95 | )
96 |
97 | if response.status_code != 200:
98 | raise HTTPException(status_code=400, detail=f"Failed to exchange token: {response.text}")
99 |
100 | data = response.json()
101 |
102 | # Generate session ID
103 | session_id = f"spotify_{int(time.time())}_{uuid.uuid4().hex[:8]}"
104 |
105 | # Save token using storage
106 | storage.save_auth_token(
107 | session_id=session_id,
108 | access_token=data["access_token"],
109 | refresh_token=data.get("refresh_token"),
110 | expires_at=int(time.time()) + data["expires_in"]
111 | )
112 |
113 | return {
114 | "sessionId": session_id,
115 | "accessToken": data["access_token"],
116 | "expiresIn": data["expires_in"]
117 | }
118 |
119 | @app.post("/api/spotify-playlists")
120 | async def fetch_spotify_playlists(request: SpotifyPlaylistsRequest):
121 | """Get user's Spotify playlists"""
122 | try:
123 | playlists = get_user_playlists(request.sessionId)
124 |
125 | # Get liked songs count
126 | from spotify_client import get_liked_songs_count
127 | liked_count = get_liked_songs_count(request.sessionId)
128 |
129 | return {
130 | "items": playlists,
131 | "likedSongsCount": liked_count
132 | }
133 | except Exception as e:
134 | raise HTTPException(status_code=500, detail=str(e))
135 |
136 | @app.get("/api/spotify-playlist/{playlist_id}")
137 | async def get_single_playlist(playlist_id: str, sessionId: str = "guest_session"):
138 | """Get details for a single Spotify playlist"""
139 | try:
140 | from spotify_client import get_playlist_details
141 | playlist = get_playlist_details(sessionId, playlist_id)
142 | return playlist
143 | except Exception as e:
144 | raise HTTPException(status_code=500, detail=str(e))
145 |
146 | @app.post("/api/transfer")
147 | async def start_transfer(
148 | request: TransferRequest,
149 | background_tasks: BackgroundTasks
150 | ):
151 | """Start a playlist transfer job"""
152 | # Handle guest session for direct link transfer
153 | if request.sessionId == "guest_session" and not request.ytHeaders:
154 | # Public playlist mode - use server credentials
155 | pass
156 |
157 | # Create job
158 | job_id = str(uuid.uuid4())
159 | storage.create_transfer_job(
160 | job_id=job_id,
161 | session_id=request.sessionId,
162 | playlist_ids=request.playlistIds,
163 | include_liked=request.includeLiked,
164 | grand_total_tracks=request.grandTotalTracks
165 | )
166 |
167 | # Start background processing
168 | background_tasks.add_task(
169 | process_transfer_job,
170 | job_id,
171 | request.sessionId,
172 | request.playlistIds,
173 | request.includeLiked,
174 | request.grandTotalTracks,
175 | request.ytHeaders
176 | )
177 |
178 | return {"jobId": job_id}
179 |
180 | class RetryRequest(BaseModel):
181 | jobId: str
182 | failedTracks: list[dict]
183 | ytHeaders: dict = {}
184 |
185 | @app.post("/api/transfer/retry")
186 | async def retry_failed(
187 | request: RetryRequest,
188 | background_tasks: BackgroundTasks
189 | ):
190 | """Retry failed tracks with broader search"""
191 | from transfer_worker import retry_failed_tracks
192 |
193 | # Update job status to processing again
194 | job = storage.get_transfer_job(request.jobId)
195 | if job:
196 | storage.update_job_status(request.jobId, "processing")
197 | storage.update_job_progress(request.jobId, {
198 | "current": 0,
199 | "total": 1,
200 | "currentPlaylist": "Retrying failed tracks..."
201 | })
202 |
203 | background_tasks.add_task(
204 | retry_failed_tracks,
205 | request.jobId,
206 | request.failedTracks,
207 | request.ytHeaders
208 | )
209 |
210 | return {"status": "started"}
211 |
212 | @app.get("/api/transfer-status")
213 | async def get_transfer_status(jobId: str):
214 | """Get transfer job status"""
215 | job = storage.get_transfer_job(jobId)
216 |
217 | if not job:
218 | raise HTTPException(status_code=404, detail="Job not found")
219 |
220 | return {
221 | "job_id": job["id"],
222 | "status": job["status"],
223 | "progress": job.get("progress", {}),
224 | "results": job.get("results", [])
225 | }
226 |
227 | @app.get("/api/routes")
228 | async def get_routes():
229 | """List all registered routes"""
230 | routes = []
231 | for route in app.routes:
232 | routes.append({
233 | "path": route.path,
234 | "methods": list(route.methods)
235 | })
236 | return {"routes": routes}
237 |
238 | @app.on_event("startup")
239 | async def startup_event():
240 | print("Server starting up...")
241 | for route in app.routes:
242 | print(f"Route: {route.path} [{route.methods}]")
243 |
244 | if __name__ == "__main__":
245 | import uvicorn
246 | print("Starting Melody Shift API server...")
247 | print("Server running at: http://localhost:8000")
248 | print("API Docs at: http://localhost:8000/docs")
249 | print("\nPress CTRL+C to stop the server\n")
250 | uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True, log_level="warning")
251 |
--------------------------------------------------------------------------------
/frontend/src/components/MatchStatistics.tsx:
--------------------------------------------------------------------------------
1 | import { Card } from "@/components/ui/card";
2 | import { ScrollArea } from "@/components/ui/scroll-area";
3 | import { Music, CheckCircle2, Copy, XCircle } from "lucide-react";
4 |
5 | import { useState } from "react";
6 | import { cn } from "@/lib/utils";
7 |
8 | import { ProcessedTrack } from "@/types/api";
9 |
10 | interface MatchStatisticsProps {
11 | tracks: ProcessedTrack[];
12 | exactMatches: number;
13 | titleMatches: number;
14 | duplicates: number;
15 | }
16 |
17 | export const MatchStatistics = ({ tracks, exactMatches, titleMatches, duplicates }: MatchStatisticsProps) => {
18 | const [openSection, setOpenSection] = useState(null);
19 |
20 | const exactMatchTracks = tracks.filter(t => t.matchType === 'exact' && t.status === 'success');
21 | const titleMatchTracks = tracks.filter(t => t.matchType === 'title' && t.status === 'success');
22 | const duplicateTracks = tracks.filter(t => t.isDuplicate);
23 | const failedTracks = tracks.filter(t => t.status === 'failed');
24 |
25 | const TrackComparison = ({ track }: { track: ProcessedTrack }) => (
26 |
27 |
28 | {/* Spotify Side */}
29 |
30 | {track.spotifyImage ? (
31 |
36 | ) : (
37 |
38 |
39 |
40 | )}
41 |
42 |
43 | {track.spotifyName}
44 |
45 |
46 | {track.spotifyArtist}
47 |
48 |
Spotify
49 |
50 |
51 |
52 |
→
53 |
54 | {/* YouTube Side */}
55 |
56 | {track.status === 'failed' ? (
57 |
58 |
59 |
60 |
61 |
62 |
Not Found
63 |
Could not match track
64 |
65 |
66 | ) : (
67 | <>
68 | {track.ytImage ? (
69 |
74 | ) : (
75 |
76 |
77 |
78 | )}
79 |
80 |
81 | {track.ytName}
82 |
83 |
84 | {track.ytArtist}
85 |
86 |
YouTube Music
87 |
88 | >
89 | )}
90 |
91 |
92 |
93 | );
94 |
95 | return (
96 |
97 |
98 | {/* Exact Matches */}
99 | setOpenSection(openSection === 'exact' ? null : 'exact')}
101 | className={cn(
102 | "flex flex-col items-center justify-center p-2 rounded-lg border transition-all duration-200",
103 | openSection === 'exact'
104 | ? "bg-spotify/20 border-spotify/50 ring-1 ring-spotify/50"
105 | : "bg-spotify/5 border-spotify/20 hover:bg-spotify/10"
106 | )}
107 | >
108 |
109 | {exactMatches}
110 | Exact
111 |
112 |
113 | {/* Title Matches */}
114 | setOpenSection(openSection === 'title' ? null : 'title')}
116 | className={cn(
117 | "flex flex-col items-center justify-center p-2 rounded-lg border transition-all duration-200",
118 | openSection === 'title'
119 | ? "bg-blue-500/20 border-blue-500/50 ring-1 ring-blue-500/50"
120 | : "bg-blue-500/5 border-blue-500/20 hover:bg-blue-500/10"
121 | )}
122 | >
123 |
124 | {titleMatches}
125 | Title
126 |
127 |
128 | {/* Failed Matches */}
129 | setOpenSection(openSection === 'failed' ? null : 'failed')}
131 | disabled={failedTracks.length === 0}
132 | className={cn(
133 | "flex flex-col items-center justify-center p-2 rounded-lg border transition-all duration-200",
134 | failedTracks.length === 0 && "opacity-50 cursor-not-allowed",
135 | openSection === 'failed'
136 | ? "bg-youtube/20 border-youtube/50 ring-1 ring-youtube/50"
137 | : "bg-youtube/5 border-youtube/20 hover:bg-youtube/10"
138 | )}
139 | >
140 |
141 | {failedTracks.length}
142 | Failed
143 |
144 |
145 | {/* Duplicates */}
146 | setOpenSection(openSection === 'duplicates' ? null : 'duplicates')}
148 | disabled={duplicates === 0}
149 | className={cn(
150 | "flex flex-col items-center justify-center p-2 rounded-lg border transition-all duration-200",
151 | duplicates === 0 && "opacity-50 cursor-not-allowed",
152 | openSection === 'duplicates'
153 | ? "bg-yellow-500/20 border-yellow-500/50 ring-1 ring-yellow-500/50"
154 | : "bg-yellow-500/5 border-yellow-500/20 hover:bg-yellow-500/10"
155 | )}
156 | >
157 |
158 | {duplicates}
159 | Duplicates
160 |
161 |
162 |
163 | {/* Content Area */}
164 | {openSection && (
165 |
166 |
167 |
168 | {openSection === 'exact' && "Exact Matches"}
169 | {openSection === 'title' && "Title Matches"}
170 | {openSection === 'failed' && "Failed / Missing Tracks"}
171 | {openSection === 'duplicates' && "Duplicate Tracks"}
172 |
173 | setOpenSection(null)}
175 | className="text-[10px] text-muted-foreground hover:text-foreground"
176 | >
177 | Close
178 |
179 |
180 |
181 |
182 | {openSection === 'exact' && exactMatchTracks.map((track, i) => )}
183 | {openSection === 'title' && titleMatchTracks.map((track, i) => )}
184 | {openSection === 'failed' && failedTracks.map((track, i) => )}
185 | {openSection === 'duplicates' && duplicateTracks.map((track, i) => )}
186 |
187 |
188 |
189 | )}
190 |
191 | );
192 | };
193 |
--------------------------------------------------------------------------------
/backend/storage.py:
--------------------------------------------------------------------------------
1 | import os
2 | import json
3 | import redis
4 | from datetime import datetime, timedelta
5 | from apscheduler.schedulers.background import BackgroundScheduler
6 | from ytmusic_client import YouTubeMusicClient
7 |
8 | # Initialize Redis client
9 | # Using decode_responses=True to get strings instead of bytes
10 | redis_host = os.getenv("REDIS_HOST", "localhost")
11 | redis_client = redis.Redis(host=redis_host, port=6379, decode_responses=True)
12 |
13 | # Initialize Scheduler
14 | # We use a scheduler for actions that require code execution (deleting from YT Music)
15 | # For simple data expiration (like tokens), we use Redis TTL
16 | scheduler = BackgroundScheduler()
17 |
18 | # ============= Auth Token Functions =============
19 |
20 | def save_auth_token(session_id: str, access_token: str, refresh_token: str = None, expires_at: int = None):
21 | """Save OAuth token to Redis with 30 minute TTL"""
22 | token_data = {
23 | "access_token": access_token,
24 | "refresh_token": refresh_token,
25 | "expires_at": expires_at,
26 | "created_at": datetime.utcnow().isoformat()
27 | }
28 | # Save to Redis with 30 minute (1800 seconds) expiration
29 | # This automatically handles the "auto-deletion of tokens" requirement
30 | redis_client.setex(f"melody:auth:{session_id}", 1800, json.dumps(token_data))
31 | print(f"Saved auth token for session {session_id} (TTL: 30m)")
32 |
33 | def get_auth_token(session_id: str):
34 | """Get OAuth token from Redis"""
35 | data = redis_client.get(f"melody:auth:{session_id}")
36 | if data:
37 | return json.loads(data)
38 | return None
39 |
40 | def delete_auth_token(session_id: str):
41 | """Delete OAuth token from Redis"""
42 | redis_client.delete(f"melody:auth:{session_id}")
43 | print(f"Deleted auth token for session {session_id}")
44 |
45 | def delete_all_auth_tokens():
46 | """Delete all OAuth tokens from Redis"""
47 | keys = redis_client.keys("melody:auth:*")
48 | count = 0
49 | for key in keys:
50 | redis_client.delete(key)
51 | count += 1
52 | print(f"Deleted {count} auth tokens")
53 | return count
54 |
55 | # ============= Transfer Job Functions =============
56 |
57 | def create_transfer_job(job_id: str, session_id: str, playlist_ids: list, include_liked: bool = False, grand_total_tracks: int = 0):
58 | """Create a new transfer job in Redis"""
59 | total_playlists = len(playlist_ids) + (1 if include_liked else 0)
60 | job_data = {
61 | "id": job_id,
62 | "session_id": session_id,
63 | "playlist_ids": playlist_ids,
64 | "include_liked": include_liked,
65 | "grand_total_tracks": grand_total_tracks,
66 | "status": "processing",
67 | "progress": {
68 | "current": 0,
69 | "total": total_playlists,
70 | "currentPlaylist": "Initializing...",
71 | "processed": 0,
72 | "totalTracks": 0,
73 | "globalProcessed": 0,
74 | "grandTotalTracks": grand_total_tracks,
75 | "exactMatches": 0,
76 | "titleMatches": 0,
77 | "duplicates": 0
78 | },
79 | "current_playlist": "",
80 | "total_playlists": total_playlists,
81 | "completed_playlists": 0,
82 | "results": [],
83 | "created_at": datetime.utcnow().isoformat(),
84 | "updated_at": datetime.utcnow().isoformat()
85 | }
86 | redis_client.set(f"melody:job:{job_id}", json.dumps(job_data))
87 | return job_data
88 |
89 | def get_transfer_job(job_id: str):
90 | """Get transfer job details from Redis"""
91 | data = redis_client.get(f"melody:job:{job_id}")
92 | if data:
93 | return json.loads(data)
94 | return None
95 |
96 | def update_job_progress(job_id: str, progress_data: dict):
97 | """Update job progress in Redis"""
98 | # We use a Lua script or optimistic locking for atomic updates if needed,
99 | # but for this simple app, read-modify-write is acceptable or just partial updates.
100 | # To be safe and simple, we'll fetch, update, save.
101 | job_data = get_transfer_job(job_id)
102 | if job_data:
103 | # Update the progress object, not the top-level
104 | job_data["progress"] = progress_data
105 | job_data["updated_at"] = datetime.utcnow().isoformat()
106 | redis_client.set(f"melody:job:{job_id}", json.dumps(job_data))
107 |
108 | def update_job_status(job_id: str, status: str, results: list = None):
109 | """Update job status in Redis"""
110 | job_data = get_transfer_job(job_id)
111 | if job_data:
112 | job_data["status"] = status
113 | if results is not None:
114 | job_data["results"] = results
115 | job_data["updated_at"] = datetime.utcnow().isoformat()
116 | redis_client.set(f"melody:job:{job_id}", json.dumps(job_data))
117 |
118 | # ============= Backend Playlist Functions =============
119 |
120 | def track_backend_playlist(playlist_id: str, job_id: str):
121 | """Track a backend-created playlist for auto-deletion"""
122 | playlist_data = {
123 | "job_id": job_id,
124 | "created_at": datetime.utcnow().isoformat(),
125 | "auto_delete": True # Flag to allow cancellation
126 | }
127 | redis_client.set(f"melody:playlist:{playlist_id}", json.dumps(playlist_data))
128 | print(f"Tracking backend playlist {playlist_id} for auto-deletion")
129 |
130 | def get_backend_playlist(playlist_id: str):
131 | """Get backend playlist details"""
132 | data = redis_client.get(f"melody:playlist:{playlist_id}")
133 | if data:
134 | return json.loads(data)
135 | return None
136 |
137 | def get_all_backend_playlists():
138 | """Get all tracked backend playlists"""
139 | # Scan for keys matching the pattern
140 | keys = redis_client.keys("melody:playlist:*")
141 | playlists = {}
142 | for key in keys:
143 | playlist_id = key.split(":")[-1]
144 | data = redis_client.get(key)
145 | if data:
146 | playlists[playlist_id] = json.loads(data)
147 | return playlists
148 |
149 | def delete_backend_playlist(playlist_id: str):
150 | """Delete playlist from YouTube Music and Redis"""
151 | try:
152 | # 1. Delete from YouTube Music
153 | # We need a client. Since this runs in background, we use headers from oauth.json
154 | # Assuming oauth.json exists and is valid for the backend account
155 | yt = YouTubeMusicClient()
156 | yt.delete_playlist(playlist_id)
157 | print(f"Deleted playlist {playlist_id} from YouTube Music")
158 |
159 | # 2. Remove from Redis
160 | redis_client.delete(f"melody:playlist:{playlist_id}")
161 |
162 | except Exception as e:
163 | print(f"Error deleting playlist {playlist_id}: {e}")
164 | # If it fails (e.g. already deleted), still remove from Redis to clean up
165 | redis_client.delete(f"melody:playlist:{playlist_id}")
166 |
167 | def delete_all_backend_playlists():
168 | """Delete all tracked playlists from YouTube Music and Redis"""
169 | playlists = get_all_backend_playlists()
170 | count = 0
171 | for playlist_id in playlists:
172 | delete_backend_playlist(playlist_id)
173 | count += 1
174 | return count
175 |
176 | def _scheduled_delete_wrapper(playlist_id: str):
177 | """Wrapper to check if deletion is still requested before executing"""
178 | playlist_data = get_backend_playlist(playlist_id)
179 | if playlist_data and playlist_data.get("auto_delete", True):
180 | print(f"Executing scheduled deletion for {playlist_id}")
181 | delete_backend_playlist(playlist_id)
182 | else:
183 | print(f"Skipping deletion for {playlist_id} (cancelled or not found)")
184 |
185 | def schedule_playlist_deletion(playlist_id: str, delay_minutes: int = 30):
186 | """Schedule background deletion of a playlist"""
187 | run_date = datetime.now() + timedelta(minutes=delay_minutes)
188 |
189 | # Update Redis with scheduled time for visibility
190 | playlist_data = get_backend_playlist(playlist_id)
191 | if playlist_data:
192 | playlist_data["scheduled_deletion_time"] = run_date.isoformat()
193 | redis_client.set(f"melody:playlist:{playlist_id}", json.dumps(playlist_data))
194 |
195 | # Add job to scheduler
196 | scheduler.add_job(
197 | _scheduled_delete_wrapper,
198 | 'date',
199 | run_date=run_date,
200 | args=[playlist_id],
201 | id=f"delete_{playlist_id}",
202 | replace_existing=True
203 | )
204 | print(f"Scheduled deletion for {playlist_id} at {run_date}")
205 |
206 | def cancel_playlist_deletion(playlist_id: str):
207 | """Cancel auto-deletion for a playlist"""
208 | # 1. Update Redis flag
209 | playlist_data = get_backend_playlist(playlist_id)
210 | if playlist_data:
211 | playlist_data["auto_delete"] = False
212 | redis_client.set(f"melody:playlist:{playlist_id}", json.dumps(playlist_data))
213 |
214 | # 2. Try to remove from scheduler (if running in same process)
215 | # If running in different process, the flag check in _scheduled_delete_wrapper handles it
216 | try:
217 | scheduler.remove_job(f"delete_{playlist_id}")
218 | print(f"Removed deletion job for {playlist_id}")
219 | except:
220 | pass
221 |
222 | def shutdown():
223 | """Shutdown scheduler"""
224 | if scheduler.running:
225 | scheduler.shutdown()
226 |
--------------------------------------------------------------------------------
/backend/ytmusic_client.py:
--------------------------------------------------------------------------------
1 | """YouTube Music client wrapper using ytmusicapi"""
2 | from ytmusicapi import YTMusic
3 | import time
4 | import os
5 |
6 | class YouTubeMusicClient:
7 | """Wrapper for YouTube Music operations"""
8 |
9 | def __init__(self, headers_json=None):
10 | """Initialize YouTube Music client with Headers or OAuth env vars"""
11 | import json
12 | import tempfile
13 | import time
14 |
15 | if headers_json:
16 | print("Initializing YouTube Music client with PROVIDED HEADERS")
17 | # Clean headers - remove specific headers that cause issues
18 | # Content-Length: Causes timeouts/errors if size doesn't match new request
19 | # Host: Managed by requests
20 | # Accept-Encoding: Managed by requests
21 | keys_to_remove = ['content-length', 'host', 'accept-encoding']
22 | cleaned_headers = {k: v for k, v in headers_json.items() if k.lower() not in keys_to_remove}
23 |
24 | # ytmusicapi expects a file path, so we write headers to a temp file
25 | # Use a unique filename with timestamp to force fresh sessions for each instance
26 | # This prevents session reuse issues when doing multiple transfers
27 | tf = tempfile.NamedTemporaryFile(
28 | mode='w+',
29 | delete=False,
30 | suffix=f'_{int(time.time() * 1000)}.json',
31 | prefix='ytmusic_'
32 | )
33 | json.dump(cleaned_headers, tf)
34 | tf.close()
35 | self.yt = YTMusic(auth=tf.name)
36 | # Store the temp file path so we can clean it up later if needed
37 | self._temp_file = tf.name
38 |
39 | # Check for OAuth env vars (Server Public Account)
40 | elif os.getenv("OAUTH_COOKIE") and os.getenv("OAUTH_AUTHORIZATION"):
41 | print("Initializing YouTube Music client with OAUTH env vars")
42 | # Build oauth.json from env vars with sensible defaults
43 | oauth_data = {
44 | "user-agent": os.getenv("OAUTH_USER_AGENT"),
45 | "accept": os.getenv("OAUTH_ACCEPT"),
46 | "accept-language": os.getenv("OAUTH_ACCEPT_LANGUAGE"),
47 | "content-type": os.getenv("OAUTH_CONTENT_TYPE"),
48 | "referer": os.getenv("OAUTH_REFERER"),
49 | "x-goog-visitor-id": os.getenv("OAUTH_X_GOOG_VISITOR_ID"),
50 | "x-youtube-bootstrap-logged-in": os.getenv("OAUTH_X_YOUTUBE_BOOTSTRAP_LOGGED_IN"),
51 | "x-youtube-client-name": os.getenv("OAUTH_X_YOUTUBE_CLIENT_NAME"),
52 | "x-youtube-client-version": os.getenv("OAUTH_X_YOUTUBE_CLIENT_VERSION"),
53 | "x-goog-authuser": os.getenv("OAUTH_X_GOOG_AUTHUSER"),
54 | "x-origin": os.getenv("OAUTH_X_ORIGIN"),
55 | "origin": os.getenv("OAUTH_ORIGIN"),
56 | "sec-fetch-dest": os.getenv("OAUTH_SEC_FETCH_DEST"),
57 | "sec-fetch-mode": os.getenv("OAUTH_SEC_FETCH_MODE"),
58 | "sec-fetch-site": os.getenv("OAUTH_SEC_FETCH_SITE"),
59 | "cookie": os.getenv("OAUTH_COOKIE"),
60 | "authorization": os.getenv("OAUTH_AUTHORIZATION")
61 | }
62 |
63 | # Write to temp file for ytmusicapi
64 | tf = tempfile.NamedTemporaryFile(
65 | mode='w+',
66 | delete=False,
67 | suffix='.json',
68 | prefix='ytmusic_oauth_'
69 | )
70 | json.dump(oauth_data, tf)
71 | tf.close()
72 | self.yt = YTMusic(auth=tf.name)
73 | self._temp_file = tf.name
74 | else:
75 | raise ValueError(
76 | "No valid authentication found. Either:\n"
77 | "1. Provide headers_json parameter for private playlists, OR\n"
78 | "2. Set OAUTH_COOKIE and OAUTH_AUTHORIZATION env vars for public playlists"
79 | )
80 |
81 | def create_playlist(self, title: str, description: str = "", privacy_status: str = "PRIVATE"):
82 | """Create a new YouTube Music playlist with retry logic"""
83 | exception_sleep = 5
84 | for attempt in range(10):
85 | try:
86 | playlist_id = self.yt.create_playlist(
87 | title=title,
88 | description=description,
89 | privacy_status=privacy_status
90 | )
91 | time.sleep(1) # Prevent rate limiting
92 | return playlist_id
93 | except Exception as e:
94 | print(f"ERROR: (Retry {attempt+1}/10 create_playlist: {title}) {e} in {exception_sleep}s")
95 | time.sleep(exception_sleep)
96 | exception_sleep *= 2
97 |
98 | raise Exception(f"Failed to create playlist '{title}' after 10 retries")
99 |
100 | def search_song(self, query: str, track_name: str = None, artist_name: str = None, album_name: str = None):
101 | """Search for a song on YouTube Music
102 |
103 | Uses the proven algorithm from spotify_to_ytmusic:
104 | 1. Search for album by artist
105 | 2. Look for exact track match in album
106 | 3. Fall back to song search
107 | """
108 | # Try album search first for better accuracy
109 | if artist_name and album_name:
110 | albums = self.yt.search(query=f"{album_name} by {artist_name}", filter="albums")
111 | for album in albums[:3]:
112 | try:
113 | album_tracks = self.yt.get_album(album["browseId"])["tracks"]
114 | for track in album_tracks:
115 | if track["title"] == track_name:
116 | return [track]
117 | except Exception as e:
118 | pass
119 | except Exception:
120 | # Album lookup failed, continue to next album or song search
121 | pass
122 |
123 | # Fall back to song search with better query handling
124 | # Clean up query for better results
125 | clean_query = query.replace(" & ", " ").replace("&", "and")
126 |
127 | songs = self.yt.search(query=clean_query, filter="songs", limit=10)
128 | if songs:
129 | # Return top 10 results for better scoring, filtering out those without videoId
130 | return [s for s in songs[:10] if s.get("videoId")]
131 |
132 | # If no songs found, try without filter (searches everything)
133 | if not songs and track_name:
134 | all_results = self.yt.search(query=clean_query, limit=10)
135 | # Filter for songs/videos manually AND ensure videoId exists
136 | songs = [r for r in all_results if r.get("resultType") in ["song", "video"] and r.get("videoId")]
137 | if songs:
138 | return songs[:10]
139 |
140 | return []
141 |
142 | def search_video(self, query: str):
143 | """Search for a video on YouTube Music (fallback for songs)"""
144 | videos = self.yt.search(query=query, filter="videos")
145 | if videos:
146 | # Filter out videos without videoId
147 | return [v for v in videos[:5] if v.get("videoId")]
148 | return []
149 |
150 | def add_tracks_batch(self, playlist_id: str, video_ids: list, duplicates: bool = False):
151 | """Add multiple tracks to a playlist with retry logic"""
152 | if not video_ids:
153 | return
154 |
155 | exception_sleep = 5
156 | for attempt in range(10):
157 | try:
158 | self.yt.add_playlist_items(
159 | playlistId=playlist_id,
160 | videoIds=video_ids,
161 | duplicates=duplicates
162 | )
163 | return
164 | except Exception as e:
165 | print(f"ERROR: (Retry {attempt+1}/10 add_tracks_batch) {e} in {exception_sleep}s")
166 | time.sleep(exception_sleep)
167 | exception_sleep *= 2
168 |
169 | raise Exception(f"Failed to add tracks to playlist after 10 retries")
170 |
171 | def rate_song(self, video_id: str, rating: str = "LIKE"):
172 | """Like a song on YouTube Music with retry logic"""
173 | exception_sleep = 5
174 | for attempt in range(10):
175 | try:
176 | self.yt.rate_song(video_id, rating)
177 | return
178 | except Exception as e:
179 | print(f"ERROR: (Retry {attempt+1}/10 rate_song) {e} in {exception_sleep}s")
180 | time.sleep(exception_sleep)
181 | exception_sleep *= 2
182 |
183 | raise Exception(f"Failed to like song after 10 retries")
184 |
185 | def delete_playlist(self, playlist_id: str):
186 | """Delete a playlist from YouTube Music with retry logic"""
187 | exception_sleep = 5
188 | for attempt in range(10):
189 | try:
190 | self.yt.delete_playlist(playlist_id)
191 | return
192 | except Exception as e:
193 | print(f"ERROR: (Retry {attempt+1}/10 delete_playlist: {playlist_id}) {e} in {exception_sleep}s")
194 | time.sleep(exception_sleep)
195 | exception_sleep *= 2
196 |
197 | def get_library_playlists(self, limit: int = None):
198 | """Get all playlists from the user's library"""
199 | try:
200 | return self.yt.get_library_playlists(limit=limit)
201 | except Exception as e:
202 | print(f"ERROR: Failed to get library playlists: {e}")
203 | return []
204 |
--------------------------------------------------------------------------------
/frontend/src/components/TransferResults.tsx:
--------------------------------------------------------------------------------
1 | // Force refresh
2 | import { useEffect } from "react";
3 | import { Card } from "@/components/ui/card";
4 | import { Button } from "@/components/ui/button";
5 | import { CheckCircle2, Music2, ExternalLink, AlertCircle, RefreshCcw } from "lucide-react";
6 | import { TransferResult } from "@/types/api";
7 | import { MatchStatistics } from "./MatchStatistics";
8 | import confetti from "canvas-confetti";
9 | import { cn } from "@/lib/utils";
10 |
11 | interface TransferResultsProps {
12 | results: TransferResult[];
13 | onReset: () => void;
14 | }
15 |
16 | export const TransferResults = ({ results, onReset }: TransferResultsProps) => {
17 | const totalTracks = results.reduce((sum, r) => sum + r.tracks, 0);
18 | const totalMatched = results.reduce((sum, r) => sum + r.matched, 0);
19 | const totalFailed = results.reduce((sum, r) => sum + r.failed, 0);
20 | const successRate = totalTracks > 0 ? Math.round((totalMatched / totalTracks) * 100) : 0;
21 |
22 | useEffect(() => {
23 | // Trigger confetti on mount
24 | const duration = 3 * 1000;
25 | const animationEnd = Date.now() + duration;
26 | const defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 0 };
27 |
28 | const randomInRange = (min: number, max: number) => {
29 | return Math.random() * (max - min) + min;
30 | };
31 |
32 | const interval: any = setInterval(() => {
33 | const timeLeft = animationEnd - Date.now();
34 |
35 | if (timeLeft <= 0) {
36 | return clearInterval(interval);
37 | }
38 |
39 | const particleCount = 50 * (timeLeft / duration);
40 | confetti({
41 | ...defaults,
42 | particleCount,
43 | origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 },
44 | });
45 | confetti({
46 | ...defaults,
47 | particleCount,
48 | origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 },
49 | });
50 | }, 250);
51 |
52 | return () => clearInterval(interval);
53 | }, []);
54 |
55 | return (
56 |
57 | {/* Header Section */}
58 |
59 |
60 |
61 |
62 |
63 |
64 | Transfer Complete!
65 |
66 |
67 | Successfully processed {totalTracks} tracks across {results.length} playlists with a {successRate}% success rate .
68 |
69 |
70 |
71 |
72 | {/* Summary Stats Grid */}
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
Total Tracks
81 |
{totalTracks}
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
Matched
93 |
{totalMatched}
94 |
95 |
96 |
97 |
98 |
99 |
100 |
103 |
104 |
Failed
105 |
{totalFailed}
106 |
107 |
108 |
109 |
110 |
111 | {/* Detailed Results (Ticket Style) */}
112 |
113 |
Playlist Results
114 |
115 | {results.map((result, index) => {
116 | const playlistSuccessRate = result.tracks > 0
117 | ? Math.round((result.matched / result.tracks) * 100)
118 | : 0;
119 |
120 | return (
121 |
125 | {/* Left Border Accent */}
126 |
50 ? "bg-yellow-500" : "bg-youtube"
129 | )} />
130 |
131 |
132 | {/* Icon */}
133 |
134 |
135 |
136 |
137 | {/* Info */}
138 |
139 |
140 | {result.playlistName || `Playlist ${index + 1}`}
141 |
142 |
143 |
147 | {playlistSuccessRate}% Success
148 |
149 | •
150 | {result.matched} / {result.tracks} tracks
151 |
152 |
153 |
154 | {/* Actions */}
155 |
175 |
176 |
177 | {/* Error Details (if any) */}
178 | {result.failed > 0 && (
179 |
180 |
181 |
182 |
{result.failed} tracks could not be matched
183 |
184 |
185 | )}
186 |
187 | {/* Detailed Statistics */}
188 | {result.processedTracks && result.processedTracks.length > 0 && (
189 |
190 |
196 |
197 | )}
198 |
199 | );
200 | })}
201 |
202 |
203 |
204 | {/* Footer Actions */}
205 |
206 |
212 |
213 | Transfer More Playlists
214 |
215 |
216 |
217 | );
218 | };
219 |
--------------------------------------------------------------------------------
/frontend/src/components/YouTubeHeaders.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { Button } from "@/components/ui/button";
3 | import { Card } from "@/components/ui/card";
4 | import { Textarea } from "@/components/ui/textarea";
5 | import { FileText, CheckCircle2, Info, ChevronDown, ChevronUp, PlayCircle, AlertCircle } from "lucide-react";
6 | import { YouTubeMusicHeaders } from "@/types/api";
7 | import { cn } from "@/lib/utils";
8 |
9 | interface YouTubeHeadersProps {
10 | onSubmit: (headers: YouTubeMusicHeaders) => void;
11 | headers?: YouTubeMusicHeaders | null;
12 | onBack?: () => void;
13 | }
14 |
15 | export const YouTubeHeaders = ({ onSubmit, headers, onBack }: YouTubeHeadersProps) => {
16 | const [headersText, setHeadersText] = useState("");
17 | const [error, setError] = useState("");
18 | const [verifying, setVerifying] = useState(false);
19 | const [showInstructions, setShowInstructions] = useState(true);
20 | const [showExample, setShowExample] = useState(false);
21 |
22 | const parseHeaders = (text: string): YouTubeMusicHeaders => {
23 | try {
24 | // Try parsing as JSON first
25 | return JSON.parse(text);
26 | } catch {
27 | // Parse as plain text headers
28 | const lines = text.split("\n");
29 | const parsed: Record
= {};
30 |
31 | lines.forEach(line => {
32 | const [key, ...valueParts] = line.split(":");
33 | if (key && valueParts.length > 0) {
34 | parsed[key.trim().toLowerCase()] = valueParts.join(":").trim();
35 | }
36 | });
37 |
38 | return parsed;
39 | }
40 | };
41 |
42 | const handleSubmit = async () => {
43 | try {
44 | const parsed = parseHeaders(headersText);
45 |
46 | if (!parsed.authorization && !parsed.cookie) {
47 | setError("Headers must include at least authorization or cookie");
48 | return;
49 | }
50 |
51 | setError("");
52 | setVerifying(true);
53 |
54 | // Simulate verification delay
55 | await new Promise(resolve => setTimeout(resolve, 1000));
56 |
57 | setVerifying(false);
58 | onSubmit(parsed);
59 | } catch (err) {
60 | setVerifying(false);
61 | setError("Invalid headers format. Please check your headers.");
62 | }
63 | };
64 |
65 |
66 |
67 | if (headers) {
68 | return (
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 | YouTube Music Connected
77 |
78 |
79 | Your authentication headers have been verified and saved.
80 |
81 |
82 |
onSubmit(headers)}
84 | className="w-full max-w-xs bg-white text-black hover:bg-zinc-200 transition-all duration-300 font-medium"
85 | >
86 | Continue
87 |
88 |
89 |
90 | );
91 | }
92 |
93 | return (
94 |
95 | {/* Header Section */}
96 |
97 |
98 |
99 |
102 |
103 |
104 | Connect YouTube Music
105 |
106 |
107 | To create playlists on your account, we need your authentication headers.
108 | This allows us to interact with YouTube Music on your behalf.
109 |
110 |
111 |
112 |
113 |
114 |
115 | {/* Instructions Accordion */}
116 |
117 |
setShowInstructions(!showInstructions)}
119 | className="w-full flex items-center justify-between p-4 hover:bg-zinc-800/50 transition-colors"
120 | >
121 |
122 |
123 | How to get your headers
124 |
125 | {showInstructions ? (
126 |
127 | ) : (
128 |
129 | )}
130 |
131 |
132 |
136 |
137 |
138 |
139 |
140 |
141 | 1
142 | Open Developer Tools
143 |
144 |
Go to music.youtube.com and press F12 or Ctrl+Shift+I
145 |
146 |
147 |
148 |
149 | 2
150 | Find Request
151 |
152 |
In Network tab, filter by "browse". Find a request (usually POST) with status 200.
153 |
154 |
155 |
156 | Don't see any requests? Try going to your Library or playing a song to force a new request.
157 |
158 |
159 |
160 |
161 |
162 |
163 | 3
164 | Copy Headers
165 |
166 |
167 |
168 | Firefox
169 | Right click request → Copy → Copy Request Headers
170 |
171 |
172 | Chrome / Edge
173 | Headers tab → Request Headers → Copy all
174 |
175 |
176 |
177 |
178 |
179 |
180 |
181 |
182 |
183 |
184 |
185 | {/* Example Accordion */}
186 |
187 |
setShowExample(!showExample)}
189 | className="w-full flex items-center justify-between p-4 hover:bg-zinc-800/50 transition-colors"
190 | >
191 |
192 |
193 | View example input
194 |
195 | {showExample ? (
196 |
197 | ) : (
198 |
199 | )}
200 |
201 |
202 |
206 |
207 |
208 |
209 |
210 |
POST /youtubei/v1/browse?prettyPrint=false HTTP/3
211 |
Host: music.youtube.com
212 |
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (REQUIRED)
213 |
Accept: */*
214 |
Accept-Language: en-US,en;q=0.9
215 |
Accept-Encoding: gzip, deflate, br
216 |
Content-Type: application/json (REQUIRED)
217 |
Content-Length: 1234
218 |
Referer: https://music.youtube.com/
219 |
X-Goog-Visitor-Id: Cgt4eXp4eXp4eXp4...
220 |
X-Youtube-Bootstrap-Logged-In: true
221 |
X-Youtube-Client-Name: 67
222 |
X-Youtube-Client-Version: 1.20241120.01.00
223 |
X-Goog-AuthUser: 0 (REQUIRED)
224 |
X-Origin: https://music.youtube.com (REQUIRED)
225 |
Origin: https://music.youtube.com
226 |
Sec-Fetch-Dest: empty
227 |
Sec-Fetch-Mode: same-origin
228 |
Sec-Fetch-Site: same-origin
229 |
Authorization: SAPISIDHASH 1234567890_abcdef1234567890abcdef1234567890abcdef12 (REQUIRED)
230 |
Connection: keep-alive
231 |
Alt-Used: music.youtube.com
232 |
Cookie: VISITOR_INFO1_LIVE=abcdefghijk; PREF=tz=America.New_York; YSC=xyz123abc; ... (REQUIRED)
233 |
Priority: u=1
234 |
TE: trailers
235 |
236 |
237 |
238 |
239 |
240 |
241 |
242 | {/* Input Area */}
243 |
273 |
274 |
275 | );
276 | };
277 |
278 |
--------------------------------------------------------------------------------
/backend/playlist_manager.py:
--------------------------------------------------------------------------------
1 | """Playlist Manager - Track and manage backend playlists with auto-deletion (Redis)
2 | Usage: python playlist_manager.py [command]
3 |
4 | Commands:
5 | stats - Show playlist statistics
6 | list - List all tracked playlists
7 | delete - Delete a specific playlist
8 | cancel - Cancel scheduled deletion for a playlist
9 | cleanup - Manually trigger cleanup of old playlists
10 | """
11 |
12 | import sys
13 | from dotenv import load_dotenv
14 | import os
15 |
16 | # Load environment variables from .env file
17 | load_dotenv()
18 |
19 | from datetime import datetime
20 | from storage import (
21 | get_all_backend_playlists,
22 | delete_backend_playlist,
23 | cancel_playlist_deletion,
24 | delete_all_backend_playlists,
25 | delete_all_auth_tokens
26 | )
27 |
28 | def show_stats():
29 | """Display playlist statistics"""
30 | print("\n" + "="*50)
31 | print("📊 PLAYLIST STATISTICS (Redis Storage)")
32 | print("="*50)
33 |
34 | playlists = get_all_backend_playlists()
35 | total = len(playlists)
36 | print(f"\n📝 Total Tracked Playlists: {total}")
37 |
38 | if total == 0:
39 | print(" No playlists currently tracked for deletion.")
40 | return
41 |
42 | # Count active deletions based on auto_delete flag
43 | active_deletions = sum(1 for p in playlists.values() if p.get("auto_delete", True))
44 |
45 | print(f"⏰ Active Scheduled Deletions: {active_deletions}")
46 | print(f"⏸️ Cancelled/Pending: {total - active_deletions}")
47 |
48 | # Show age distribution
49 | now = datetime.utcnow()
50 | new_count = 0 # < 10 min
51 | medium_count = 0 # 10-20 min
52 | old_count = 0 # > 20 min
53 |
54 | for info in playlists.values():
55 | created = datetime.fromisoformat(info['created_at'])
56 | age_minutes = (now - created).total_seconds() / 60
57 |
58 | if age_minutes < 10:
59 | new_count += 1
60 | elif age_minutes < 20:
61 | medium_count += 1
62 | else:
63 | old_count += 1
64 |
65 | print("\n📈 Age Distribution:")
66 | print(f" 🟢 New (< 10 min): {new_count}")
67 | print(f" 🟡 Medium (10-20 min): {medium_count}")
68 | print(f" 🔴 Old (> 20 min): {old_count}")
69 | print()
70 |
71 | def list_playlists():
72 | """List all tracked playlists with details"""
73 | print("\n" + "="*50)
74 | print("📋 TRACKED PLAYLISTS")
75 | print("="*50)
76 |
77 | playlists = get_all_backend_playlists()
78 |
79 | if not playlists:
80 | print("\nNo playlists currently tracked.")
81 | return
82 |
83 | now = datetime.utcnow()
84 |
85 | for i, (playlist_id, info) in enumerate(playlists.items(), 1):
86 | print(f"\n[{i}] Playlist ID: {playlist_id}")
87 | print(f" Job ID: {info.get('job_id', 'N/A')}")
88 |
89 | created = datetime.fromisoformat(info['created_at'])
90 | age = now - created
91 | age_str = f"{int(age.total_seconds() / 60)} min"
92 | print(f" Created: {created.strftime('%Y-%m-%d %H:%M:%S')} ({age_str} ago)")
93 |
94 | if info.get("auto_delete", True):
95 | scheduled_time_str = info.get("scheduled_deletion_time")
96 | if scheduled_time_str:
97 | scheduled_time = datetime.fromisoformat(scheduled_time_str)
98 | time_until = scheduled_time - datetime.now()
99 | minutes_left = int(time_until.total_seconds() / 60)
100 | if minutes_left < 0:
101 | print(f" ⏰ Scheduled: {scheduled_time.strftime('%H:%M:%S')} (Overdue by {abs(minutes_left)} min)")
102 | else:
103 | print(f" ⏰ Scheduled: {scheduled_time.strftime('%H:%M:%S')} ({minutes_left} min left)")
104 | else:
105 | print(f" ⏰ Scheduled: (Time unknown)")
106 | else:
107 | print(f" ⏸️ Deletion Cancelled")
108 |
109 | print(f" 🔗 URL: https://music.youtube.com/playlist?list={playlist_id}")
110 |
111 | print()
112 |
113 | def delete_playlist_manual():
114 | """Manually delete a specific playlist"""
115 | playlists = get_all_backend_playlists()
116 | if not playlists:
117 | print("\n❌ No playlists to delete.")
118 | return
119 |
120 | list_playlists()
121 |
122 | try:
123 | choice = input("\nEnter playlist number to delete (or 'q' to quit): ").strip()
124 | if choice.lower() == 'q':
125 | return
126 |
127 | idx = int(choice) - 1
128 | playlist_ids = list(playlists.keys())
129 |
130 | if idx < 0 or idx >= len(playlist_ids):
131 | print("❌ Invalid playlist number.")
132 | return
133 |
134 | playlist_id = playlist_ids[idx]
135 |
136 | confirm = input(f"\n⚠️ Delete playlist {playlist_id}? (y/n): ").strip().lower()
137 | if confirm not in ['y', 'yes']:
138 | print("❌ Deletion cancelled.")
139 | return
140 |
141 | print(f"\n🗑️ Deleting playlist {playlist_id}...")
142 | delete_backend_playlist(playlist_id)
143 | print("✅ Playlist deleted successfully!")
144 |
145 | except (ValueError, IndexError) as e:
146 | print(f"❌ Error: {e}")
147 |
148 | def cancel_deletion():
149 | """Cancel scheduled deletion for a specific playlist"""
150 | playlists = get_all_backend_playlists()
151 | if not playlists:
152 | print("\n❌ No playlists tracked.")
153 | return
154 |
155 | list_playlists()
156 |
157 | try:
158 | choice = input("\nEnter playlist number to cancel deletion (or 'q' to quit): ").strip()
159 | if choice.lower() == 'q':
160 | return
161 |
162 | idx = int(choice) - 1
163 | playlist_ids = list(playlists.keys())
164 |
165 | if idx < 0 or idx >= len(playlist_ids):
166 | print("❌ Invalid playlist number.")
167 | return
168 |
169 | playlist_id = playlist_ids[idx]
170 |
171 | cancel_playlist_deletion(playlist_id)
172 | print(f"✅ Cancelled scheduled deletion for {playlist_id}")
173 | print(" (Playlist will remain in YouTube Music)")
174 |
175 | except (ValueError, IndexError) as e:
176 | print(f"❌ Error: {e}")
177 |
178 | def cleanup_old():
179 | """Manually trigger cleanup of playlists older than 25 minutes"""
180 | playlists = get_all_backend_playlists()
181 | if not playlists:
182 | print("\n❌ No playlists to cleanup.")
183 | return
184 |
185 | now = datetime.utcnow()
186 | old_playlists = []
187 |
188 | for playlist_id, info in playlists.items():
189 | created = datetime.fromisoformat(info['created_at'])
190 | age_minutes = (now - created).total_seconds() / 60
191 |
192 | if age_minutes > 25:
193 | old_playlists.append((playlist_id, age_minutes))
194 |
195 | if not old_playlists:
196 | print("\n✅ No playlists older than 25 minutes found.")
197 | return
198 |
199 | print(f"\n🧹 Found {len(old_playlists)} old playlist(s):")
200 | for playlist_id, age in old_playlists:
201 | print(f" - {playlist_id} ({int(age)} min old)")
202 |
203 | confirm = input(f"\n⚠️ Delete these {len(old_playlists)} playlist(s)? (y/n): ").strip().lower()
204 | if confirm not in ['y', 'yes']:
205 | print("❌ Cleanup cancelled.")
206 | return
207 |
208 | print("\n🗑️ Deleting old playlists...")
209 | for playlist_id, _ in old_playlists:
210 | try:
211 | delete_backend_playlist(playlist_id)
212 | print(f" ✅ Deleted {playlist_id}")
213 | except Exception as e:
214 | print(f" ❌ Failed to delete {playlist_id}: {e}")
215 |
216 | print("\n✅ Cleanup complete!")
217 |
218 | def check_tokens():
219 | """List all active OAuth tokens and their TTL"""
220 | # Need to import redis_client here or at top.
221 | # Since storage.py doesn't export it directly in the import list above,
222 | # we should update imports or access it via storage.redis_client if available.
223 | # Checking imports: from storage import ...
224 | # storage.py has redis_client initialized.
225 | # Let's update imports in a separate step if needed, but for now assuming we can access it.
226 | # Wait, I need to import redis_client from storage.
227 | from storage import redis_client
228 |
229 | print("\n" + "="*50)
230 | print("🔑 ACTIVE OAUTH TOKENS (Redis TTL)")
231 | print("="*50)
232 |
233 | token_keys = redis_client.keys("melody:auth:*")
234 |
235 | if not token_keys:
236 | print("\nNo active OAuth tokens found.")
237 | return
238 |
239 | print(f"\nFound {len(token_keys)} active tokens:\n")
240 |
241 | for key in token_keys:
242 | if isinstance(key, bytes):
243 | key_str = key.decode('utf-8')
244 | else:
245 | key_str = str(key)
246 |
247 | ttl = redis_client.ttl(key)
248 | session_id = key_str.replace("melody:auth:", "")
249 |
250 | if ttl == -2:
251 | status = "🔴 Expired"
252 | elif ttl == -1:
253 | status = "⚠️ No Expiry"
254 | else:
255 | minutes = ttl // 60
256 | seconds = ttl % 60
257 | status = f"🟢 Expires in {minutes}m {seconds}s"
258 |
259 | print(f"Session: {session_id}")
260 | print(f"Status: {status}")
261 | print("-" * 40)
262 | print()
263 |
264 | def delete_all_playlists_command():
265 | """Delete ALL tracked playlists"""
266 | playlists = get_all_backend_playlists()
267 | if not playlists:
268 | print("\n❌ No playlists to delete.")
269 | return
270 |
271 | count = len(playlists)
272 | count = len(playlists)
273 | print(f"\n⚠️ WARNING: This will delete ALL {count} tracked playlists from YouTube Music!")
274 | confirm = input("Are you sure you want to delete all tracked playlists? (y/n): ").strip().lower()
275 |
276 | if confirm not in ['y', 'yes']:
277 | print("❌ Operation cancelled.")
278 | return
279 |
280 | print("\n🗑️ Deleting all playlists...")
281 | deleted_count = delete_all_backend_playlists()
282 | print(f"✅ Deleted {deleted_count} playlists.")
283 |
284 | def delete_all_tokens_command():
285 | """Delete ALL OAuth tokens"""
286 | # Check active tokens first (using check_tokens logic but simpler)
287 | from storage import redis_client
288 | keys = redis_client.keys("melody:auth:*")
289 |
290 | if not keys:
291 | print("\n❌ No active tokens to delete.")
292 | return
293 |
294 | count = len(keys)
295 | count = len(keys)
296 | print(f"\n⚠️ WARNING: This will log out ALL {count} active sessions!")
297 | confirm = input("Are you sure you want to log out all active sessions? (y/n): ").strip().lower()
298 |
299 | if confirm not in ['y', 'yes']:
300 | print("❌ Operation cancelled.")
301 | return
302 |
303 | print("\n🗑️ Deleting all tokens...")
304 | deleted_count = delete_all_auth_tokens()
305 | print(f"✅ Deleted {deleted_count} tokens.")
306 |
307 | def cleanup_account_command():
308 | """Delete ALL playlists from the YouTube Music account (Dangerous!)"""
309 | from ytmusic_client import YouTubeMusicClient
310 |
311 | print("\n" + "!"*50)
312 | print("☢️ DANGER: ACCOUNT CLEANUP ☢️")
313 | print("!"*50)
314 | print("This command will fetch ALL playlists from your YouTube Music account")
315 | print("and allow you to delete them. This includes playlists NOT created by Melody Shift.")
316 |
317 | confirm_fetch = input("\nFetch all playlists? (yes/no): ").strip().lower()
318 | if confirm_fetch not in ['y', 'yes']:
319 | return
320 |
321 | print("\n📥 Fetching playlists from YouTube Music...")
322 | try:
323 | yt = YouTubeMusicClient()
324 | playlists = yt.get_library_playlists(limit=None)
325 | except Exception as e:
326 | print(f"❌ Failed to fetch playlists: {e}")
327 | return
328 |
329 | if not playlists:
330 | print("✅ No playlists found on this account.")
331 | return
332 |
333 | print(f"\nFound {len(playlists)} playlists:")
334 | for i, p in enumerate(playlists, 1):
335 | title = p.get('title', 'Unknown')
336 | count = p.get('trackCount', p.get('count', '?'))
337 | pid = p.get('playlistId', 'Unknown')
338 | print(f"{i}. {title} ({count} tracks) - ID: {pid}")
339 |
340 | print(f"\n⚠️ WARNING: You are about to delete {len(playlists)} playlists.")
341 | print("This action is IRREVERSIBLE.")
342 |
343 | confirm = input(f"\nAre you sure you want to delete ALL {len(playlists)} playlists? (y/n): ").strip().lower()
344 |
345 | if confirm not in ['y', 'yes']:
346 | print("❌ Operation cancelled.")
347 | return
348 |
349 | print("\n🗑️ Deleting ALL playlists...")
350 | deleted_count = 0
351 | for p in playlists:
352 | pid = p.get('playlistId')
353 | title = p.get('title', 'Unknown')
354 |
355 | if not pid:
356 | print(f" ⚠️ Skipping playlist '{title}' (No ID found)")
357 | continue
358 |
359 | try:
360 | yt.delete_playlist(pid)
361 | print(f" ✅ Deleted '{title}'")
362 | deleted_count += 1
363 | except Exception as e:
364 | print(f" ❌ Failed to delete '{title}': {e}")
365 |
366 | print(f"\n✅ Cleanup complete. Deleted {deleted_count} playlists.")
367 |
368 | def show_help():
369 | """Display help information"""
370 | print(__doc__)
371 | print(" tokens - Show active OAuth tokens and their expiry")
372 | print(" delete-all-playlists - Delete ALL tracked playlists")
373 | print(" delete-all-tokens - Delete ALL active OAuth tokens")
374 | print(" cleanup-account - Delete ALL playlists from YouTube Music account (Dangerous)")
375 |
376 | def main():
377 | if len(sys.argv) < 2:
378 | show_help()
379 | sys.exit(1)
380 |
381 | command = sys.argv[1].lower()
382 |
383 | commands = {
384 | 'stats': show_stats,
385 | 'list': list_playlists,
386 | 'delete': delete_playlist_manual,
387 | 'cancel': cancel_deletion,
388 | 'cleanup': cleanup_old,
389 | 'tokens': check_tokens,
390 | 'delete-all-playlists': delete_all_playlists_command,
391 | 'delete-all-tokens': delete_all_tokens_command,
392 | 'cleanup-account': cleanup_account_command,
393 | 'help': show_help,
394 | }
395 |
396 | if command not in commands:
397 | print(f"❌ Unknown command: {command}")
398 | show_help()
399 | sys.exit(1)
400 |
401 | commands[command]()
402 |
403 | if __name__ == "__main__":
404 | main()
405 |
--------------------------------------------------------------------------------
/frontend/src/pages/Index.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from "react";
2 | import { StepIndicator } from "@/components/StepIndicator";
3 | import { SpotifyConnectionChoice } from "@/components/SpotifyConnectionChoice";
4 | import { YouTubeConnectionChoice } from "@/components/YouTubeConnectionChoice";
5 | import { DestinationChoice } from "@/components/DestinationChoice";
6 | import { PlaylistSelector } from "@/components/PlaylistSelector";
7 | import { TransferProgress } from "@/components/TransferProgress";
8 | import { TransferResults } from "@/components/TransferResults";
9 | import { Music2, Github } from "lucide-react";
10 | import { SpotifyPlaylist, YouTubeMusicHeaders } from "@/types/api";
11 |
12 | const STEPS = [
13 | { id: 1, title: "Connect", description: "Spotify or Link" },
14 | { id: 2, title: "Destination", description: "Choose target" },
15 | { id: 3, title: "Select", description: "Choose playlists" },
16 | { id: 4, title: "Transfer", description: "Process tracks" },
17 | { id: 5, title: "Results", description: "View results" },
18 | ];
19 |
20 | // Mock data for demonstration
21 | const MOCK_PLAYLISTS: SpotifyPlaylist[] = [
22 | {
23 | id: "1",
24 | name: "Chill Vibes",
25 | description: "Relaxing music for focus",
26 | images: [{ url: "https://images.unsplash.com/photo-1470225620780-dba8ba36b745?w=300&h=300&fit=crop" }],
27 | tracks: { total: 42 },
28 | owner: { display_name: "John Doe" },
29 | },
30 | ];
31 |
32 | const Index = () => {
33 | const [currentStep, setCurrentStep] = useState(1);
34 | const [spotifyConnected, setSpotifyConnected] = useState(false);
35 | const [ytHeaders, setYtHeaders] = useState(() => {
36 | const stored = localStorage.getItem("yt_headers");
37 | return stored ? JSON.parse(stored) : null;
38 | });
39 | const [playlists, setPlaylists] = useState([]);
40 | const [likedSongsCount, setLikedSongsCount] = useState(0);
41 | const [transferJobId, setTransferJobId] = useState(null);
42 | const [transferResults, setTransferResults] = useState(null);
43 | const [selectedPlaylistId, setSelectedPlaylistId] = useState(null);
44 | const [destinationMode, setDestinationMode] = useState<"private" | "public" | null>(null);
45 |
46 | // Check for Spotify connection status and restore session
47 | useEffect(() => {
48 | const params = new URLSearchParams(window.location.search);
49 | const code = params.get("code");
50 | const state = params.get("state");
51 | const connected = params.get("connected");
52 | const sessionId = localStorage.getItem("spotify_session_id");
53 |
54 | // Handle OAuth callback
55 | if (code && state) {
56 | const storedState = localStorage.getItem("spotify_auth_state");
57 | if (state === storedState) {
58 | // Exchange code for token
59 | const redirectUri = import.meta.env.VITE_SPOTIFY_REDIRECT_URI;
60 | fetch(`${import.meta.env.VITE_API_URL}/api/auth/spotify/callback`, {
61 | method: 'POST',
62 | headers: { 'Content-Type': 'application/json' },
63 | body: JSON.stringify({ code, redirectUri }),
64 | })
65 | .then(res => res.json())
66 | .then(data => {
67 | localStorage.setItem("spotify_session_id", data.sessionId);
68 | localStorage.removeItem("spotify_auth_state");
69 | setSpotifyConnected(true);
70 | // Clean URL
71 | window.history.replaceState({}, "", window.location.pathname);
72 | })
73 | .catch(err => console.error("Auth failed:", err));
74 | }
75 | return;
76 | }
77 |
78 | if (connected === "true" && sessionId) {
79 | setSpotifyConnected(true);
80 | window.history.replaceState({}, "", "/");
81 | } else if (sessionId) {
82 | setSpotifyConnected(true);
83 | }
84 | }, []);
85 |
86 | // Verify auth token validity periodically
87 | useEffect(() => {
88 | if (!spotifyConnected) return;
89 |
90 | const checkAuthStatus = async () => {
91 | const sessionId = localStorage.getItem("spotify_session_id");
92 | if (!sessionId || sessionId === "guest_session") return;
93 |
94 | try {
95 | const response = await fetch(`${import.meta.env.VITE_API_URL}/api/auth/verify`, {
96 | method: "POST",
97 | headers: { "Content-Type": "application/json" },
98 | body: JSON.stringify({ sessionId }),
99 | });
100 |
101 | if (response.status === 401) {
102 | // Token expired
103 | console.log("Session expired, signing out...");
104 | localStorage.removeItem("spotify_session_id");
105 | setSpotifyConnected(false);
106 | handleReset();
107 | }
108 | } catch (error) {
109 | console.error("Auth check failed:", error);
110 | }
111 | };
112 |
113 | // Check immediately
114 | checkAuthStatus();
115 |
116 | // Check every 60 seconds
117 | const interval = setInterval(checkAuthStatus, 60000);
118 |
119 | // Check on window focus
120 | const handleFocus = () => checkAuthStatus();
121 | window.addEventListener("focus", handleFocus);
122 |
123 | return () => {
124 | clearInterval(interval);
125 | window.removeEventListener("focus", handleFocus);
126 | };
127 | }, [spotifyConnected]);
128 |
129 | // Fetch playlists when Spotify is connected and we are at step 3 (Selection)
130 | useEffect(() => {
131 | const fetchPlaylists = async () => {
132 | if (spotifyConnected && currentStep === 3 && !selectedPlaylistId) {
133 | const sessionId = localStorage.getItem("spotify_session_id");
134 | if (!sessionId) return;
135 |
136 | try {
137 | const response = await fetch(
138 | `${import.meta.env.VITE_API_URL}/api/spotify-playlists`,
139 | {
140 | method: "POST",
141 | headers: { "Content-Type": "application/json" },
142 | body: JSON.stringify({ sessionId }),
143 | }
144 | );
145 |
146 | const data = await response.json();
147 |
148 | if (!response.ok) {
149 | throw new Error(data.error || "Failed to fetch playlists");
150 | }
151 |
152 | setPlaylists(data.items || []);
153 | if (data.likedSongsCount !== undefined) {
154 | setLikedSongsCount(data.likedSongsCount);
155 | }
156 | } catch (error) {
157 | console.error("Failed to fetch playlists:", error);
158 | setPlaylists(MOCK_PLAYLISTS);
159 | }
160 | }
161 | };
162 |
163 | fetchPlaylists();
164 | }, [spotifyConnected, currentStep, selectedPlaylistId]);
165 |
166 | const handleSpotifyConnect = (sessionId: string, playlistId?: string) => {
167 | if (sessionId !== "guest_session") {
168 | localStorage.setItem("spotify_session_id", sessionId);
169 | setSpotifyConnected(true);
170 | } else {
171 | // Guest session (Link paste)
172 | setSpotifyConnected(false);
173 | if (playlistId) {
174 | setSelectedPlaylistId(playlistId);
175 | // Fetch playlist details to get track count
176 | fetch(`${import.meta.env.VITE_API_URL}/api/spotify-playlist/${playlistId}`)
177 | .then(res => res.json())
178 | .then(data => {
179 | setPlaylists([data]);
180 | })
181 | .catch(err => console.error("Failed to fetch playlist details:", err));
182 | }
183 | }
184 | setCurrentStep(2); // Move to Destination Choice
185 | };
186 |
187 | const handleDestinationSelect = (mode: "private" | "public") => {
188 | setDestinationMode(mode);
189 |
190 | if (mode === "public") {
191 | // If public, we skip headers and go to selection (or transfer if direct link)
192 | setYtHeaders({}); // Empty headers for public mode
193 |
194 | if (selectedPlaylistId) {
195 | // Direct link + Public -> Start Transfer immediately
196 | handlePlaylistsSelect([selectedPlaylistId], false, {});
197 | } else {
198 | // Logged in + Public -> Go to playlist selection
199 | setCurrentStep(3);
200 | }
201 | } else {
202 | // If private, we need to show headers input
203 | // We'll use a sub-step or just render the headers component
204 | }
205 | };
206 |
207 | const handleHeadersSubmit = (headers: YouTubeMusicHeaders) => {
208 | setYtHeaders(headers);
209 | localStorage.setItem("yt_headers", JSON.stringify(headers));
210 |
211 | if (selectedPlaylistId) {
212 | // Direct link + Private Headers -> Start Transfer
213 | handlePlaylistsSelect([selectedPlaylistId], false, headers);
214 | } else {
215 | // Logged in + Private Headers -> Go to playlist selection
216 | setCurrentStep(3);
217 | }
218 | };
219 |
220 | const handlePlaylistsSelect = async (playlistIds: string[], includeLiked: boolean, explicitHeaders?: YouTubeMusicHeaders) => {
221 | const sessionId = localStorage.getItem("spotify_session_id") || "guest_session";
222 |
223 | // Calculate grand total tracks
224 | let grandTotal = 0;
225 | playlistIds.forEach(id => {
226 | const playlist = playlists.find(p => p.id === id);
227 | if (playlist) {
228 | grandTotal += playlist.tracks.total;
229 | }
230 | });
231 |
232 | if (includeLiked) {
233 | grandTotal += likedSongsCount;
234 | }
235 |
236 | try {
237 | const response = await fetch(
238 | `${import.meta.env.VITE_API_URL}/api/transfer`,
239 | {
240 | method: "POST",
241 | headers: { "Content-Type": "application/json" },
242 | body: JSON.stringify({
243 | sessionId,
244 | playlistIds,
245 | includeLiked,
246 | grandTotalTracks: grandTotal,
247 | ytHeaders: explicitHeaders || ytHeaders || {}, // Pass explicit if provided, else state, else empty
248 | }),
249 | }
250 | );
251 |
252 | const data = await response.json();
253 | setTransferJobId(data.jobId);
254 | setCurrentStep(4);
255 | } catch (error) {
256 | console.error("Failed to start transfer:", error);
257 | }
258 | };
259 |
260 | const handleTransferComplete = async () => {
261 | if (!transferJobId) return;
262 |
263 | try {
264 | const response = await fetch(
265 | `${import.meta.env.VITE_API_URL}/api/transfer-status?jobId=${transferJobId}`
266 | );
267 | const data = await response.json();
268 | setTransferResults(data.results || []);
269 | setCurrentStep(5);
270 | } catch (error) {
271 | console.error("Failed to fetch results:", error);
272 | }
273 | };
274 |
275 | const handleReset = () => {
276 | setTransferJobId(null);
277 | setTransferResults(null);
278 | setSelectedPlaylistId(null);
279 | setDestinationMode(null);
280 | // Don't clear ytHeaders - keep them for subsequent transfers
281 | // setYtHeaders(null);
282 | setCurrentStep(1);
283 | // Don't clear spotify session if logged in, unless user explicitly signs out
284 | };
285 |
286 | return (
287 |
288 | {/* Header */}
289 |
290 | {/* Animated background gradient */}
291 |
292 |
293 |
294 |
295 |
296 |
297 |
298 |
299 |
300 |
301 | Melody Shift
302 |
303 |
304 | Transfer playlists from Spotify to YouTube Music
305 |
306 |
307 |
308 | {spotifyConnected && (
309 |
{
311 | localStorage.removeItem("spotify_session_id");
312 | setSpotifyConnected(false);
313 | handleReset();
314 | }}
315 | className="text-sm text-muted-foreground hover:text-foreground transition-colors"
316 | >
317 | Sign Out
318 |
319 | )}
320 |
321 |
322 |
323 |
324 | {/* Main Content */}
325 |
326 |
327 |
328 |
329 | {/* Step 1: Connect */}
330 | {currentStep === 1 && (
331 |
setCurrentStep(2)}
335 | onDisconnect={() => {
336 | localStorage.removeItem("spotify_session_id");
337 | setSpotifyConnected(false);
338 | setPlaylists([]);
339 | }}
340 | />
341 | )}
342 |
343 | {/* Step 2: Destination */}
344 | {currentStep === 2 && !destinationMode && (
345 |
349 | )}
350 |
351 | {/* Step 2 (Continued): Headers Input (if Private mode selected) */}
352 | {currentStep === 2 && destinationMode === "private" && (
353 |
354 |
355 |
Authentication Required
356 | setDestinationMode(null)}
358 | className="text-sm text-muted-foreground hover:text-primary"
359 | >
360 | Change Destination
361 |
362 |
363 |
364 |
365 | )}
366 |
367 | {/* Step 3: Select Playlists */}
368 | {currentStep === 3 && (
369 |
374 | )}
375 |
376 | {/* Step 4: Transfer */}
377 | {currentStep === 4 && transferJobId && (
378 |
382 | )}
383 |
384 | {/* Step 5: Results */}
385 | {currentStep === 5 && transferResults && (
386 |
390 | )}
391 |
392 |
393 |
394 | {/* Footer */}
395 |
412 |
413 | );
414 | };
415 |
416 | export default Index;
417 |
--------------------------------------------------------------------------------