Compare commits

..

No commits in common. "master" and "download-playlist" have entirely different histories.

23 changed files with 582 additions and 989 deletions

View File

@ -1,37 +1,14 @@
# ---- Base ---- FROM python:3.11-slim
FROM python:3.11-slim-bookworm
# עדכון מערכת והתקנת FFmpeg (כולל ffprobe) RUN apt update && apt install -y curl ffmpeg && \
RUN apt-get update \ curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp && \
&& apt-get install -y --no-install-recommends ffmpeg \ chmod a+rx /usr/local/bin/yt-dlp
&& rm -rf /var/lib/apt/lists/*
# הגדרות סביבת עבודה בסיסיות
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
# ספריית האפליקציה
WORKDIR /app WORKDIR /app
# התקנת תלויות
# (משתמש ב־requirements.txt שהבאת)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# קבצי האפליקציה
COPY . . COPY . .
# תיקיית המוזיקה (הקוד גם יוצר אותה, אבל נגדיר כ־VOLUME לנוחות) RUN pip install --no-cache-dir -r requirements.txt
VOLUME ["/app/music"]
# פורט האפליקציה ENV MUSIC_DIR=/music
EXPOSE 8000
# ריצה תחת משתמש לא־רות CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
RUN useradd -ms /bin/bash appuser && chown -R appuser:appuser /app
USER appuser
# הפעלה
# אם תרצה workers: הוסף --workers 2 (או לפי הצורך)
ENTRYPOINT ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers"]

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,7 +1,6 @@
from fastapi import FastAPI, Query, HTTPException from fastapi import FastAPI, Query, HTTPException
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi import APIRouter
import os import os
from downloader import download_song from downloader import download_song
@ -27,15 +26,6 @@ def download(query: str = Query(..., min_length=2)):
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@app.get("/songs", summary="List downloaded songs")
def list_songs():
try:
files = os.listdir(settings.MUSIC_DIR)
songs = [f for f in files if f.endswith(".mp3")]
return {"songs": sorted(songs)}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to read songs: {str(e)}")
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000, log_level="info", reload=True) uvicorn.run("main:app", host="0.0.0.0", port=8000, log_level="info", reload=True)

View File

@ -1,5 +1,5 @@
fastapi fastapi
uvicorn uvicorn
pydantic-settings
yt-dlp
requests requests
pydantic>=2.0
pydantic-settings>=2.0

View File

@ -1,6 +0,0 @@
node_modules
dist
.git
.gitignore
npm-debug.log
.DS_Store

View File

@ -1,10 +0,0 @@
#!/bin/sh
set -eu
TEMPLATE="/etc/env/env.js.template"
TARGET="/usr/share/nginx/html/env.js"
VITE_API_URL="${VITE_API_URL:-http://localhost:8000}"
echo "[entrypoint] Generating runtime env -> $TARGET"
sed "s|\$VITE_API_URL|${VITE_API_URL}|g" "$TEMPLATE" > "$TARGET"
echo "[entrypoint] Done."

View File

@ -1,16 +0,0 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install --legacy-peer-deps
COPY . .
RUN npm run build
FROM nginx:alpine
RUN apk add --no-cache dos2unix gettext
COPY --from=builder /app/dist /usr/share/nginx/html
COPY public/env.js.template /etc/env/env.js.template
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY 10-generate-env.sh /docker-entrypoint.d/10-generate-env.sh
RUN dos2unix /docker-entrypoint.d/10-generate-env.sh && chmod +x /docker-entrypoint.d/10-generate-env.sh
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@ -2,9 +2,9 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tunedrop</title> <title>Vite + React</title>
<script src="/env.js"></script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -1,16 +0,0 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri /index.html;
}
location = /env.js {
alias /usr/share/nginx/html/env.js;
add_header Cache-Control "no-store";
}
}

File diff suppressed because it is too large Load Diff

View File

@ -17,7 +17,7 @@
"@eslint/js": "^9.30.1", "@eslint/js": "^9.30.1",
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0", "@vitejs/plugin-react-swc": "^3.10.2",
"eslint": "^9.30.1", "eslint": "^9.30.1",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "^0.4.20",

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@ -1,4 +0,0 @@
// Replaced at container start
window.__ENV = {
VITE_API_URL: "$VITE_API_URL"
};

View File

@ -1,42 +1,35 @@
#root { .app-container {
max-width: 1280px; max-width: 600px;
margin: 0 auto; margin: 0 auto;
padding: 2rem; padding: 2rem;
font-family: sans-serif;
text-align: center; text-align: center;
} }
.logo { .search-form {
height: 6em; display: flex;
padding: 1.5em; flex-direction: column;
will-change: filter; gap: 1rem;
transition: filter 300ms; margin-top: 2rem;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
} }
@keyframes logo-spin { input {
from { padding: 0.75rem;
transform: rotate(0deg); font-size: 1.1rem;
} border-radius: 8px;
to { border: 1px solid #ccc;
transform: rotate(360deg);
}
} }
@media (prefers-reduced-motion: no-preference) { button {
a:nth-of-type(2) .logo { padding: 0.75rem;
animation: logo-spin infinite 20s linear; font-size: 1.1rem;
} background: #1db954;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
} }
.card { button:hover {
padding: 2em; background: #1ed760;
}
.read-the-docs {
color: #888;
} }

View File

@ -1,43 +1,27 @@
import React, { useState, useEffect } from "react"; import { useState } from "react";
import DownloadForm from "./components/DownloadForm"; import "./App.css";
import SongList from "./components/SongList";
import { downloadSong, getDownloadedSongs } from "./services/api";
function App() { function App() {
const [songs, setSongs] = useState([]); const [query, setQuery] = useState("");
const [error, setError] = useState("");
const loadSongs = async () => { const handleSearch = (e) => {
try { e.preventDefault();
const res = await getDownloadedSongs(); console.log("Searching for:", query);
setSongs(res.songs); // later: call backend here
} catch (err) {
setError("⚠️ Failed to fetch songs");
}
}; };
const handleDownload = async (query) => {
setError("");
try {
await downloadSong(query);
await loadSongs();
} catch (err) {
setError(err.message || "❌ Failed to download");
}
};
useEffect(() => {
loadSongs();
}, []);
return ( return (
<div style={{ maxWidth: "600px", margin: "2rem auto", padding: "1rem" }}> <div className="app-container">
<h2> <h1>Tunedrop 🎵</h1>
Tunedrop <span role="img" aria-label="music">🎵</span> <form onSubmit={handleSearch} className="search-form">
</h2> <input
<DownloadForm onDownload={handleDownload} /> type="text"
{error && <p style={{ color: "red" }}>{error}</p>} placeholder="Search for a song..."
<SongList songs={songs} /> value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<button type="submit">Search</button>
</form>
</div> </div>
); );
} }

View File

@ -1,33 +0,0 @@
import React, { useState } from "react";
function DownloadForm({ onDownload }) {
const [query, setQuery] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
if (!query.trim()) return;
setLoading(true);
await onDownload(query);
setQuery("");
setLoading(false);
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
placeholder="Enter artist name or YouTube link"
value={query}
onChange={(e) => setQuery(e.target.value)}
style={{ width: "100%", padding: "8px", marginBottom: "1rem" }}
/>
<button type="submit" disabled={loading} style={{ width: "100%", padding: "10px" }}>
{loading ? "Downloading..." : "Download"}
</button>
</form>
);
}
export default DownloadForm;

View File

@ -0,0 +1,20 @@
import React from 'react';
function SongCard({ song }) {
return (
<div className="rounded-lg shadow-md p-4 bg-white dark:bg-gray-800 flex flex-col items-center text-center">
<img
src={song.cover || '/default-cover.jpg'}
alt={song.title}
className="w-24 h-24 object-cover rounded"
/>
<h3 className="mt-2 text-sm font-semibold text-gray-800 dark:text-white">{song.title}</h3>
<p className="text-xs text-gray-500 dark:text-gray-300">{song.artist}</p>
<button className="mt-2 px-3 py-1 text-sm bg-blue-500 text-white rounded hover:bg-blue-600">
Play
</button>
</div>
);
}
export default SongCard;

View File

@ -1,15 +0,0 @@
import React from "react";
function SongList({ songs }) {
if (!songs.length) return <p>No songs yet</p>;
return (
<ul style={{ marginTop: "2rem", listStyle: "none", padding: 0 }}>
{songs.map((song, idx) => (
<li key={idx} style={{ marginBottom: "8px" }}>{song}</li>
))}
</ul>
);
}
export default SongList;

View File

@ -0,0 +1,47 @@
import { useState } from "react";
import { downloadSong } from "../services/api";
function Home() {
const [query, setQuery] = useState("");
const [status, setStatus] = useState("");
const [loading, setLoading] = useState(false);
const handleSearch = async () => {
if (!query.trim()) return;
setLoading(true);
setStatus("");
try {
const result = await downloadSong(query);
setStatus(`✅ Success: Downloaded "${result.query}"`);
} catch (error) {
setStatus(`❌ Error: ${error.message}`);
} finally {
setLoading(false);
}
};
return (
<div className="p-4 max-w-md mx-auto">
<h1 className="text-2xl font-bold mb-4">🎵 Tunedrop</h1>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search for a song..."
className="w-full p-2 border rounded mb-2"
/>
<button
onClick={handleSearch}
className="w-full bg-blue-500 text-white py-2 px-4 rounded hover:bg-blue-600"
disabled={loading}
>
{loading ? "Downloading..." : "Search & Download"}
</button>
{status && <p className="mt-4 text-center">{status}</p>}
</div>
);
}
export default Home;

View File

@ -1,19 +1,16 @@
const runtimeBase = typeof window !== "undefined" && window.__ENV && window.__ENV.VITE_API_URL; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000";
const buildTimeBase = import.meta.env?.VITE_API_URL;
const BASE_URL = runtimeBase || buildTimeBase || "http://localhost:8000";
// 📥 הורדת שיר לפי שם
export async function downloadSong(query) { export async function downloadSong(query) {
const res = await fetch(`${BASE_URL}/download?query=${encodeURIComponent(query)}`); try {
const res = await fetch(`${API_BASE_URL}/download?query=${encodeURIComponent(query)}`);
if (!res.ok) { if (!res.ok) {
let detail = "Unknown error"; const err = await res.json();
try { detail = (await res.json()).detail || detail; } catch {} throw new Error(err.detail || "Download failed");
throw new Error(detail);
} }
return res.json(); return await res.json();
} catch (error) {
console.error("❌ Error downloading song:", error);
throw error;
} }
export async function getDownloadedSongs() {
const res = await fetch(`${BASE_URL}/songs`);
if (!res.ok) throw new Error("Failed to fetch songs");
return res.json();
} }

View File

@ -1,5 +1,5 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react-swc'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({