Merge pull request 'light-front' (#4) from light-front into master

Reviewed-on: #4
This commit is contained in:
dvirlabs 2025-08-08 09:34:18 +00:00
commit a6cfad41c7
25 changed files with 994 additions and 602 deletions

View File

@ -1,29 +1,37 @@
FROM python:3.11-slim # ---- Base ----
FROM python:3.11-slim-bookworm
# Install ffmpeg + curl and download latest yt-dlp binary # עדכון מערכת והתקנת FFmpeg (כולל ffprobe)
RUN apt update && \ RUN apt-get update \
apt install -y curl ffmpeg && \ && apt-get install -y --no-install-recommends ffmpeg \
curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp && \ && rm -rf /var/lib/apt/lists/*
chmod a+rx /usr/local/bin/yt-dlp && \
apt clean && rm -rf /var/lib/apt/lists/*
# Set working directory # הגדרות סביבת עבודה בסיסיות
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
# ספריית האפליקציה
WORKDIR /app WORKDIR /app
# Copy project files # התקנת תלויות
COPY . . # (משתמש ב־requirements.txt שהבאת)
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Create volume path (optional - will be mounted later in K8s) # קבצי האפליקציה
RUN mkdir -p /music COPY . .
# Declare env var for clean override later in Helm # תיקיית המוזיקה (הקוד גם יוצר אותה, אבל נגדיר כ־VOLUME לנוחות)
ENV MUSIC_DIR=/music VOLUME ["/app/music"]
# Expose FastAPI port # פורט האפליקציה
EXPOSE 8000 EXPOSE 8000
# Start app # ריצה תחת משתמש לא־רות
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"]

View File

@ -1,6 +1,7 @@
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
@ -26,6 +27,15 @@ 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)

6
frontend/.dockerignore Normal file
View File

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

View File

@ -0,0 +1,10 @@
#!/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."

16
frontend/Dockerfile Normal file
View File

@ -0,0 +1,16 @@
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>Vite + React</title> <title>Tunedrop</title>
<script src="/env.js"></script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

16
frontend/nginx.conf Normal file
View File

@ -0,0 +1,16 @@
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-swc": "^3.10.2", "@vitejs/plugin-react": "^4.6.0",
"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.

Before

Width:  |  Height:  |  Size: 34 KiB

View File

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

View File

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

View File

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

View File

@ -0,0 +1,33 @@
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

@ -1,20 +0,0 @@
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

@ -0,0 +1,15 @@
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

@ -1,47 +0,0 @@
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,16 +1,19 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000"; const runtimeBase = typeof window !== "undefined" && window.__ENV && window.__ENV.VITE_API_URL;
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) {
try { const res = await fetch(`${BASE_URL}/download?query=${encodeURIComponent(query)}`);
const res = await fetch(`${API_BASE_URL}/download?query=${encodeURIComponent(query)}`);
if (!res.ok) { if (!res.ok) {
const err = await res.json(); let detail = "Unknown error";
throw new Error(err.detail || "Download failed"); try { detail = (await res.json()).detail || detail; } catch {}
} throw new Error(detail);
return await res.json();
} catch (error) {
console.error("❌ Error downloading song:", error);
throw error;
} }
return res.json();
}
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-swc' import react from '@vitejs/plugin-react'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({