Compare commits
9 Commits
download-p
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| a6cfad41c7 | |||
| 9cfc07a510 | |||
| 1dd1b96646 | |||
|
|
7f25f9d0b3 | ||
|
|
0e324036bc | ||
|
|
d79161177d | ||
|
|
cc95b2bed1 | ||
| 154035cc1b | |||
| 2fb1b48251 |
@ -1,14 +1,37 @@
|
||||
FROM python:3.11-slim
|
||||
# ---- Base ----
|
||||
FROM python:3.11-slim-bookworm
|
||||
|
||||
RUN apt update && apt install -y curl ffmpeg && \
|
||||
curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp && \
|
||||
chmod a+rx /usr/local/bin/yt-dlp
|
||||
# עדכון מערכת והתקנת FFmpeg (כולל ffprobe)
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# הגדרות סביבת עבודה בסיסיות
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
|
||||
# ספריית האפליקציה
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
|
||||
# התקנת תלויות
|
||||
# (משתמש ב־requirements.txt שהבאת)
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
ENV MUSIC_DIR=/music
|
||||
# קבצי האפליקציה
|
||||
COPY . .
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
# תיקיית המוזיקה (הקוד גם יוצר אותה, אבל נגדיר כ־VOLUME לנוחות)
|
||||
VOLUME ["/app/music"]
|
||||
|
||||
# פורט האפליקציה
|
||||
EXPOSE 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.
@ -1,6 +1,7 @@
|
||||
from fastapi import FastAPI, Query, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi import APIRouter
|
||||
import os
|
||||
|
||||
from downloader import download_song
|
||||
@ -26,6 +27,15 @@ def download(query: str = Query(..., min_length=2)):
|
||||
except Exception as 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__":
|
||||
import uvicorn
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=8000, log_level="info", reload=True)
|
||||
@ -1,5 +1,5 @@
|
||||
fastapi
|
||||
uvicorn
|
||||
pydantic-settings
|
||||
yt-dlp
|
||||
requests
|
||||
pydantic>=2.0
|
||||
pydantic-settings>=2.0
|
||||
|
||||
6
frontend/.dockerignore
Normal file
6
frontend/.dockerignore
Normal file
@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
npm-debug.log
|
||||
.DS_Store
|
||||
10
frontend/10-generate-env.sh
Normal file
10
frontend/10-generate-env.sh
Normal 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
16
frontend/Dockerfile
Normal 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;"]
|
||||
@ -2,12 +2,12 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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" />
|
||||
<title>Vite + React</title>
|
||||
<title>Tunedrop</title>
|
||||
<script src="/env.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
16
frontend/nginx.conf
Normal file
16
frontend/nginx.conf
Normal 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";
|
||||
}
|
||||
}
|
||||
1231
frontend/package-lock.json
generated
1231
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -17,7 +17,7 @@
|
||||
"@eslint/js": "^9.30.1",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react-swc": "^3.10.2",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"eslint": "^9.30.1",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 34 KiB |
4
frontend/public/env.js.template
Normal file
4
frontend/public/env.js.template
Normal file
@ -0,0 +1,4 @@
|
||||
// Replaced at container start
|
||||
window.__ENV = {
|
||||
VITE_API_URL: "$VITE_API_URL"
|
||||
};
|
||||
@ -1,35 +1,42 @@
|
||||
.app-container {
|
||||
max-width: 600px;
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
font-family: sans-serif;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 0.75rem;
|
||||
font-size: 1.1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #ccc;
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.75rem;
|
||||
font-size: 1.1rem;
|
||||
background: #1db954;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #1ed760;
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
@ -1,27 +1,43 @@
|
||||
import { useState } from "react";
|
||||
import "./App.css";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import DownloadForm from "./components/DownloadForm";
|
||||
import SongList from "./components/SongList";
|
||||
import { downloadSong, getDownloadedSongs } from "./services/api";
|
||||
|
||||
function App() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [songs, setSongs] = useState([]);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleSearch = (e) => {
|
||||
e.preventDefault();
|
||||
console.log("Searching for:", query);
|
||||
// later: call backend here
|
||||
const loadSongs = async () => {
|
||||
try {
|
||||
const res = await getDownloadedSongs();
|
||||
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 (
|
||||
<div className="app-container">
|
||||
<h1>Tunedrop 🎵</h1>
|
||||
<form onSubmit={handleSearch} className="search-form">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search for a song..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
<div style={{ maxWidth: "600px", margin: "2rem auto", padding: "1rem" }}>
|
||||
<h2>
|
||||
Tunedrop <span role="img" aria-label="music">🎵</span>
|
||||
</h2>
|
||||
<DownloadForm onDownload={handleDownload} />
|
||||
{error && <p style={{ color: "red" }}>{error}</p>}
|
||||
<SongList songs={songs} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
33
frontend/src/components/DownloadForm.jsx
Normal file
33
frontend/src/components/DownloadForm.jsx
Normal 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;
|
||||
@ -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;
|
||||
15
frontend/src/components/SongList.jsx
Normal file
15
frontend/src/components/SongList.jsx
Normal 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;
|
||||
@ -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;
|
||||
@ -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) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/download?query=${encodeURIComponent(query)}`);
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.detail || "Download failed");
|
||||
}
|
||||
return await res.json();
|
||||
} catch (error) {
|
||||
console.error("❌ Error downloading song:", error);
|
||||
throw error;
|
||||
const res = await fetch(`${BASE_URL}/download?query=${encodeURIComponent(query)}`);
|
||||
if (!res.ok) {
|
||||
let detail = "Unknown error";
|
||||
try { detail = (await res.json()).detail || detail; } catch {}
|
||||
throw new Error(detail);
|
||||
}
|
||||
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();
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user