Compare commits

...

19 Commits

Author SHA1 Message Date
a6cfad41c7 Merge pull request 'light-front' (#4) from light-front into master
Reviewed-on: #4
2025-08-08 09:34:18 +00:00
9cfc07a510 Add Dockerfile for back and front, Set the runtime env for front 2025-08-08 12:31:59 +03:00
1dd1b96646 Simple UI work 2025-08-03 18:50:28 +03:00
dvirlabs
7f25f9d0b3 init 2025-08-03 18:31:46 +03:00
dvirlabs
0e324036bc update req.txt 2025-08-03 15:47:00 +03:00
dvirlabs
d79161177d restore 2025-08-03 09:39:36 +03:00
dvirlabs
cc95b2bed1 restore 2025-08-03 09:37:10 +03:00
154035cc1b Merge pull request 'download-playlist' (#3) from download-playlist into master
Reviewed-on: #3
2025-08-03 03:08:16 +00:00
fdd95d73f3 Get more accurate when search artist name 2025-08-03 02:54:48 +00:00
1a0948d52b Update duration and max songs 2025-08-03 02:26:47 +00:00
2fb1b48251 Merge pull request 'download-playlist' (#2) from download-playlist into master
Reviewed-on: #2
2025-08-03 01:46:06 +00:00
c726dbd70b Update .gitignore 2025-08-03 04:44:54 +03:00
994c74b136 Fix relative path 2025-08-03 01:31:38 +00:00
84c738632b Merge pull request 'linux' (#1) from linux into download-playlist
Reviewed-on: #1
2025-08-03 01:15:23 +00:00
872e772292 Work both for single song and playlist 2025-08-03 00:45:14 +00:00
9aae6083a3 Work both for single song and playlist 2025-08-03 00:41:30 +00:00
root
2cdc6c9866 Work on one song 2025-08-03 00:24:46 +00:00
8153b8bce6 init 2025-08-02 22:16:24 +03:00
3438b1587c init 2025-07-31 08:01:51 +03:00
24 changed files with 1082 additions and 637 deletions

2
backend/.env Normal file
View File

@ -0,0 +1,2 @@
MUSIC_DIR=music/
NAVIDROME_SCAN_URL=

2
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
music/
__pycache__/

View File

@ -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"]

View File

@ -1,12 +1,18 @@
from pydantic_settings import BaseSettings
from pydantic import Field
import os
from dotenv import load_dotenv
class Settings(BaseSettings):
MUSIC_DIR: str = Field(default="/music", description="Path where songs are saved")
NAVIDROME_SCAN_URL: str = Field(default="", description="URL to trigger Navidrome rescan")
load_dotenv()
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
class Settings:
# If MUSIC_DIR is not absolute, make it relative to the project root
_music_dir = os.getenv("MUSIC_DIR", "music")
if not os.path.isabs(_music_dir):
# Use the directory where config.py is located as the base
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
MUSIC_DIR = os.path.abspath(os.path.join(BASE_DIR, _music_dir))
else:
MUSIC_DIR = _music_dir
NAVIDROME_SCAN_URL = os.getenv("NAVIDROME_SCAN_URL", "")
settings = Settings()

View File

@ -1,37 +1,52 @@
import subprocess
import os
import yt_dlp
from pathlib import Path
from config import settings
import requests
def is_playlist(query: str) -> bool:
return "playlist" in query or "list=" in query
def is_youtube_url(query: str) -> bool:
return query.startswith("http")
def duration_range_filter(min_seconds, max_seconds):
def _filter(info, *, incomplete):
duration = info.get('duration')
if duration:
if duration < min_seconds:
return f"Skipping: {info.get('title')} is shorter than {min_seconds//60} minutes"
if duration > max_seconds:
return f"Skipping: {info.get('title')} is longer than {max_seconds//60} minutes"
return _filter
def download_song(query: str):
output_template = os.path.join(settings.MUSIC_DIR, "%(artist)s/%(album)s/%(title)s.%(ext)s")
Path(settings.MUSIC_DIR).mkdir(parents=True, exist_ok=True)
if is_youtube_url(query):
yt_query = query
noplaylist = False
elif is_playlist(query):
yt_query = query
noplaylist = False
else:
yt_query = f"ytsearch10:{query}" # Always search for 10 results for any artist/song query
noplaylist = True
command = [
"yt-dlp",
f"ytsearch1:{query}",
"--extract-audio",
"--audio-format", "mp3",
"--output", output_template,
"--no-playlist",
"--quiet"
]
result = subprocess.run(command, capture_output=True, text=True)
if result.returncode != 0:
raise Exception(f"❌ Download failed:\n{result.stderr}")
# Optional: trigger Navidrome rescan
if settings.NAVIDROME_SCAN_URL:
try:
res = requests.get(settings.NAVIDROME_SCAN_URL, timeout=5)
res.raise_for_status()
except Exception as e:
print(f"⚠️ Failed to trigger Navidrome rescan: {e}")
return {
"status": "success",
"query": query,
"log": result.stdout + "\n" + result.stderr
ydl_opts = {
'format': 'bestaudio/best',
'outtmpl': f"{settings.MUSIC_DIR}/%(title)s.%(ext)s",
'postprocessors': [{
'key': 'FFmpegExtractAudio',
'preferredcodec': 'mp3',
}, {
'key': 'FFmpegMetadata',
}],
'match_filter': duration_range_filter(2 * 60, 9 * 60), # 2 to 9 minutes
'noplaylist': noplaylist,
'quiet': False,
'verbose': True,
}
print("yt-dlp options:", ydl_opts)
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
result = ydl.download([yt_query])
return {"downloaded": "Check music folder for downloaded files"}

View File

@ -1,18 +1,41 @@
from fastapi import FastAPI, Query, HTTPException
from fastapi.responses import JSONResponse
import uvicorn
from fastapi.middleware.cors import CORSMiddleware
from fastapi import APIRouter
import os
from downloader import download_song
from config import settings
app = FastAPI(title="Tunedrop")
os.makedirs(settings.MUSIC_DIR, exist_ok=True)
print(f"Using MUSIC_DIR: {settings.MUSIC_DIR}")
app = FastAPI(title="Tunedrop", version="1.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/download")
def download(query: str = Query(..., description="Song name or YouTube search term")):
def download(query: str = Query(..., min_length=2)):
try:
result = download_song(query)
return JSONResponse(content={"status": "success", **result})
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__":
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000, log_level="info", reload=True)

View File

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

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">
<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>

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",
"@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

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 {
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;
}

View File

@ -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>
);
}

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) {
try {
const res = await fetch(`${API_BASE_URL}/download?query=${encodeURIComponent(query)}`);
const res = await fetch(`${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;
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();
}

View File

@ -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({