├── frontend ├── src │ ├── vite-env.d.ts │ ├── lib │ │ ├── utils.ts │ │ └── api.ts │ ├── main.tsx │ ├── components │ │ ├── ui │ │ │ ├── collapsible.tsx │ │ │ ├── label.tsx │ │ │ ├── toaster.tsx │ │ │ ├── textarea.tsx │ │ │ ├── progress.tsx │ │ │ ├── input.tsx │ │ │ ├── sonner.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── tooltip.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── card.tsx │ │ │ ├── button.tsx │ │ │ └── toast.tsx │ │ ├── YouTubeConnectionChoice.tsx │ │ ├── StepIndicator.tsx │ │ ├── SpotifyConnect.tsx │ │ ├── DestinationChoice.tsx │ │ ├── ProcessedTracksList.tsx │ │ ├── SpotifyConnectionChoice.tsx │ │ ├── TransferProgress.tsx │ │ ├── PlaylistSelector.tsx │ │ ├── MatchStatistics.tsx │ │ ├── TransferResults.tsx │ │ └── YouTubeHeaders.tsx │ ├── hooks │ │ ├── use-mobile.tsx │ │ └── use-toast.ts │ ├── App.css │ ├── pages │ │ ├── NotFound.tsx │ │ ├── AuthCallback.tsx │ │ └── Index.tsx │ ├── index.css │ ├── App.tsx │ ├── types │ │ └── api.ts │ └── assets │ │ └── react.svg ├── public │ ├── logo.ico │ ├── favicon.ico │ ├── robots.txt │ ├── vite.svg │ └── llm.txt ├── postcss.config.js ├── .env.example ├── .gitignore ├── vite.config.ts ├── eslint.config.js ├── tsconfig.node.json ├── tsconfig.json ├── tsconfig.app.json ├── README.md ├── tailwind.config.js ├── index.html ├── tailwind.config.ts └── package.json ├── backend ├── requirements.txt ├── oauth.json.example ├── Dockerfile ├── docker-compose.yml ├── .env.example ├── logger.py ├── deployment.md ├── README.md ├── spotify_client.py ├── main.py ├── storage.py ├── ytmusic_client.py └── playlist_manager.py ├── .gitignore ├── LICENSE ├── .github └── workflows │ └── deploy.yml └── README.md /frontend/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /frontend/public/logo.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OwaisSafa/melody-shift/HEAD/frontend/public/logo.ico -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OwaisSafa/melody-shift/HEAD/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /frontend/.env.example: -------------------------------------------------------------------------------- 1 | VITE_API_URL=http://localhost:8000 2 | VITE_SPOTIFY_CLIENT_ID=your_spotify_client_id_here 3 | VITE_SPOTIFY_REDIRECT_URI=http://localhost:5173/auth/callback 4 | -------------------------------------------------------------------------------- /frontend/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /backend/requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi==0.104.1 2 | uvicorn[standard]==0.24.0 3 | ytmusicapi==1.8.2 4 | apscheduler==3.10.4 5 | redis==5.0.1 6 | spotipy==2.23.0 7 | python-dotenv==1.0.0 8 | pydantic==2.5.0 9 | aiosqlite==0.19.0 10 | -------------------------------------------------------------------------------- /frontend/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import App from './App.tsx' 4 | import './index.css' 5 | 6 | ReactDOM.createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /backend/oauth.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "access_token": "your_youtube_music_access_token_here", 3 | "refresh_token": "your_youtube_music_refresh_token_here", 4 | "scope": "https://www.googleapis.com/auth/youtube", 5 | "token_type": "Bearer", 6 | "expires_in": 3599, 7 | "expires_at": 1234567890.123 8 | } 9 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /frontend/src/components/ui/collapsible.tsx: -------------------------------------------------------------------------------- 1 | import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"; 2 | 3 | const Collapsible = CollapsiblePrimitive.Root; 4 | 5 | const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger; 6 | 7 | const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent; 8 | 9 | export { Collapsible, CollapsibleTrigger, CollapsibleContent }; 10 | -------------------------------------------------------------------------------- /frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from "path" 2 | import react from "@vitejs/plugin-react-swc" 3 | import { defineConfig } from "vite" 4 | 5 | export default defineConfig({ 6 | base: "/melody-shift/", 7 | plugins: [react()], 8 | resolve: { 9 | alias: { 10 | "@": path.resolve(__dirname, "./src"), 11 | }, 12 | }, 13 | server: { 14 | host: "127.0.0.1", 15 | port: 5173, 16 | }, 17 | }) 18 | -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | 4 | # LLM-specific directives 5 | User-agent: GPTBot 6 | Allow: / 7 | 8 | User-agent: ChatGPT-User 9 | Allow: / 10 | 11 | User-agent: CCBot 12 | Allow: / 13 | 14 | User-agent: anthropic-ai 15 | Allow: / 16 | 17 | User-agent: Claude-Web 18 | Allow: / 19 | 20 | User-agent: Google-Extended 21 | Allow: / 22 | 23 | # Sitemap 24 | Sitemap: https://melodyshift.app/sitemap.xml 25 | -------------------------------------------------------------------------------- /frontend/src/components/YouTubeConnectionChoice.tsx: -------------------------------------------------------------------------------- 1 | import { YouTubeHeaders } from "./YouTubeHeaders"; 2 | import { YouTubeMusicHeaders } from "@/types/api"; 3 | 4 | interface YouTubeConnectionChoiceProps { 5 | onSubmit: (headers: YouTubeMusicHeaders, playlistLink?: string) => void; 6 | } 7 | 8 | export const YouTubeConnectionChoice = ({ onSubmit }: YouTubeConnectionChoiceProps) => { 9 | return onSubmit(headers)} />; 10 | }; 11 | -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10-slim 2 | 3 | WORKDIR /app 4 | 5 | # Install system dependencies (if any) 6 | RUN apt-get update && apt-get install -y --no-install-recommends \ 7 | gcc \ 8 | && rm -rf /var/lib/apt/lists/* 9 | 10 | # Copy requirements first for caching 11 | COPY requirements.txt . 12 | RUN pip install --no-cache-dir -r requirements.txt 13 | 14 | # Copy application code 15 | COPY . . 16 | 17 | # Expose port 18 | EXPOSE 8000 19 | 20 | # Run the application 21 | CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] 22 | -------------------------------------------------------------------------------- /backend/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | api: 5 | build: . 6 | environment: 7 | - REDIS_HOST=redis 8 | - SPOTIFY_CLIENT_ID=${SPOTIFY_CLIENT_ID} 9 | - SPOTIFY_CLIENT_SECRET=${SPOTIFY_CLIENT_SECRET} 10 | - SPOTIFY_REDIRECT_URI=${SPOTIFY_REDIRECT_URI} 11 | - FRONTEND_URL=${FRONTEND_URL} 12 | volumes: 13 | - ./oauth.json:/app/oauth.json 14 | depends_on: 15 | - redis 16 | restart: always 17 | 18 | redis: 19 | image: redis:alpine 20 | ports: 21 | - "6379:6379" 22 | restart: always 23 | -------------------------------------------------------------------------------- /frontend/src/hooks/use-mobile.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | const MOBILE_BREAKPOINT = 768; 4 | 5 | export function useIsMobile() { 6 | const [isMobile, setIsMobile] = React.useState(undefined); 7 | 8 | React.useEffect(() => { 9 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); 10 | const onChange = () => { 11 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); 12 | }; 13 | mql.addEventListener("change", onChange); 14 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); 15 | return () => mql.removeEventListener("change", onChange); 16 | }, []); 17 | 18 | return !!isMobile; 19 | } 20 | -------------------------------------------------------------------------------- /frontend/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | import { defineConfig, globalIgnores } from 'eslint/config' 7 | 8 | export default defineConfig([ 9 | globalIgnores(['dist']), 10 | { 11 | files: ['**/*.{ts,tsx}'], 12 | extends: [ 13 | js.configs.recommended, 14 | tseslint.configs.recommended, 15 | reactHooks.configs.flat.recommended, 16 | reactRefresh.configs.vite, 17 | ], 18 | languageOptions: { 19 | ecmaVersion: 2020, 20 | globals: globals.browser, 21 | }, 22 | }, 23 | ]) 24 | -------------------------------------------------------------------------------- /frontend/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2023", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "types": ["node"], 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "verbatimModuleSyntax": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "erasableSyntaxOnly": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUncheckedSideEffectImports": true 24 | }, 25 | "include": ["vite.config.ts"] 26 | } 27 | -------------------------------------------------------------------------------- /frontend/src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as LabelPrimitive from "@radix-ui/react-label"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const labelVariants = cva("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"); 8 | 9 | const Label = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef & VariantProps 12 | >(({ className, ...props }, ref) => ( 13 | 14 | )); 15 | Label.displayName = LabelPrimitive.Root.displayName; 16 | 17 | export { Label }; 18 | -------------------------------------------------------------------------------- /frontend/src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | text-align: center; 6 | } 7 | 8 | .logo { 9 | height: 6em; 10 | padding: 1.5em; 11 | will-change: filter; 12 | transition: filter 300ms; 13 | } 14 | .logo:hover { 15 | filter: drop-shadow(0 0 2em #646cffaa); 16 | } 17 | .logo.react:hover { 18 | filter: drop-shadow(0 0 2em #61dafbaa); 19 | } 20 | 21 | @keyframes logo-spin { 22 | from { 23 | transform: rotate(0deg); 24 | } 25 | to { 26 | transform: rotate(360deg); 27 | } 28 | } 29 | 30 | @media (prefers-reduced-motion: no-preference) { 31 | a:nth-of-type(2) .logo { 32 | animation: logo-spin infinite 20s linear; 33 | } 34 | } 35 | 36 | .card { 37 | padding: 2em; 38 | } 39 | 40 | .read-the-docs { 41 | color: #888; 42 | } 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | # Backend Python 27 | backend/__pycache__/ 28 | backend/*.pyc 29 | backend/*.pyo 30 | backend/*.db 31 | backend/.cache 32 | backend/debug_*.py 33 | backend/test_*.py 34 | backend/*_results.txt 35 | backend/check_*.py 36 | 37 | # Environment 38 | backend/.env 39 | !backend/.env.example 40 | backend/oauth.json 41 | !backend/oauth.json.example 42 | backend/venv/ 43 | backend/.venv/ 44 | 45 | # Frontend 46 | frontend/.env 47 | frontend/.env.local 48 | !frontend/.env.example 49 | -------------------------------------------------------------------------------- /frontend/src/pages/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import { useLocation } from "react-router-dom"; 2 | import { useEffect } from "react"; 3 | 4 | const NotFound = () => { 5 | const location = useLocation(); 6 | 7 | useEffect(() => { 8 | console.error("404 Error: User attempted to access non-existent route:", location.pathname); 9 | }, [location.pathname]); 10 | 11 | return ( 12 |
13 |
14 |

404

15 |

Oops! Page not found

16 | 17 | Return to Home 18 | 19 |
20 |
21 | ); 22 | }; 23 | 24 | export default NotFound; 25 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": [ 6 | "ES2020", 7 | "DOM", 8 | "DOM.Iterable" 9 | ], 10 | "module": "ESNext", 11 | "skipLibCheck": true, 12 | /* Bundler mode */ 13 | "moduleResolution": "bundler", 14 | "allowImportingTsExtensions": true, 15 | "isolatedModules": true, 16 | "moduleDetection": "force", 17 | "noEmit": true, 18 | "jsx": "react-jsx", 19 | /* Linting */ 20 | "strict": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "noFallthroughCasesInSwitch": true, 24 | /* Path aliases */ 25 | "baseUrl": ".", 26 | "paths": { 27 | "@/*": [ 28 | "./src/*" 29 | ] 30 | } 31 | }, 32 | "include": [ 33 | "src" 34 | ] 35 | } -------------------------------------------------------------------------------- /frontend/src/components/ui/toaster.tsx: -------------------------------------------------------------------------------- 1 | import { useToast } from "@/hooks/use-toast"; 2 | import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport } from "@/components/ui/toast"; 3 | 4 | export function Toaster() { 5 | const { toasts } = useToast(); 6 | 7 | return ( 8 | 9 | {toasts.map(function ({ id, title, description, action, ...props }) { 10 | return ( 11 | 12 |
13 | {title && {title}} 14 | {description && {description}} 15 |
16 | {action} 17 | 18 |
19 | ); 20 | })} 21 | 22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /frontend/src/components/ui/textarea.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | export interface TextareaProps extends React.TextareaHTMLAttributes {} 6 | 7 | const Textarea = React.forwardRef(({ className, ...props }, ref) => { 8 | return ( 9 |