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
33 changed files with 1082 additions and 788 deletions

View File

@ -1,5 +1,2 @@
MUSIC_DIR=./music MUSIC_DIR=music/
NAVIDROME_SCAN_URL=http://navidrome.my-apps.svc.cluster.local:4533/api/rescan NAVIDROME_SCAN_URL=
host=0.0.0.0
port=8000
reload=true

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 && \ # עדכון מערכת והתקנת FFmpeg (כולל ffprobe)
curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp && \ RUN apt-get update \
chmod a+rx /usr/local/bin/yt-dlp && 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 WORKDIR /app
COPY . .
# התקנת תלויות
# (משתמש ב־requirements.txt שהבאת)
COPY requirements.txt .
RUN pip install --no-cache-dir -r 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,19 +1,18 @@
from pydantic_settings import BaseSettings import os
from pydantic import Field from dotenv import load_dotenv
from pathlib import Path
# בסיס הקבצים יהיה תקיית backend load_dotenv()
BASE_DIR = Path(__file__).resolve().parent
class Settings(BaseSettings): class Settings:
MUSIC_DIR: str = Field(default=str(BASE_DIR / "music"), description="Path where songs are saved") # If MUSIC_DIR is not absolute, make it relative to the project root
NAVIDROME_SCAN_URL: str = Field(default="", description="URL to trigger Navidrome rescan") _music_dir = os.getenv("MUSIC_DIR", "music")
host: str = Field(default="0.0.0.0", description="Host to bind the server") if not os.path.isabs(_music_dir):
port: int = Field(default=8000, description="Port to run the server on") # Use the directory where config.py is located as the base
reload: bool = Field(default=True, description="Enable reload for development") 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
class Config: NAVIDROME_SCAN_URL = os.getenv("NAVIDROME_SCAN_URL", "")
env_file = ".env"
env_file_encoding = "utf-8"
settings = Settings() settings = Settings()

View File

@ -1,49 +1,52 @@
import subprocess import yt_dlp
import os from pathlib import Path
import glob
import requests
from config import settings from config import settings
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): def download_song(query: str):
# ודא שהתיקייה קיימת Path(settings.MUSIC_DIR).mkdir(parents=True, exist_ok=True)
os.makedirs(settings.MUSIC_DIR, exist_ok=True)
output_template = os.path.join(settings.MUSIC_DIR, "%(title)s.%(ext)s") if is_youtube_url(query):
before_files = set(glob.glob(os.path.join(settings.MUSIC_DIR, "**/*.mp3"), recursive=True)) 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 = [ ydl_opts = {
"yt-dlp", 'format': 'bestaudio/best',
f"ytsearch1:{query}", 'outtmpl': f"{settings.MUSIC_DIR}/%(title)s.%(ext)s",
"--extract-audio", 'postprocessors': [{
"--audio-format", "mp3", 'key': 'FFmpegExtractAudio',
"-o", output_template, 'preferredcodec': 'mp3',
"--no-playlist" }, {
] 'key': 'FFmpegMetadata',
}],
print("Running command:", " ".join(command)) # Debug 'match_filter': duration_range_filter(2 * 60, 9 * 60), # 2 to 9 minutes
'noplaylist': noplaylist,
result = subprocess.run(command, capture_output=True, text=True) 'quiet': False,
'verbose': True,
after_files = set(glob.glob(os.path.join(settings.MUSIC_DIR, "**/*.mp3"), recursive=True))
new_files = list(after_files - before_files)
if result.returncode != 0:
raise Exception(f"❌ Download failed:\n{result.stderr}")
rescan_status = "skipped"
if settings.NAVIDROME_SCAN_URL:
try:
res = requests.get(settings.NAVIDROME_SCAN_URL, timeout=5)
res.raise_for_status()
rescan_status = "triggered"
except Exception as e:
rescan_status = f"failed: {e}"
return {
"status": "success",
"query": query,
"downloaded_files": new_files,
"yt_dlp_stdout": result.stdout,
"yt_dlp_stderr": result.stderr,
"rescan_status": rescan_status
} }
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,45 +1,41 @@
from fastapi import FastAPI, Query, HTTPException from fastapi import FastAPI, Query, HTTPException
from fastapi.responses import JSONResponse, FileResponse from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.middleware.cors import CORSMiddleware
import uvicorn from fastapi import APIRouter
import os import os
from downloader import download_song from downloader import download_song
from config import settings from config import settings
from utils.song_list import add_song_to_list, get_song_list
app = FastAPI( os.makedirs(settings.MUSIC_DIR, exist_ok=True)
title="Tunedrop", print(f"Using MUSIC_DIR: {settings.MUSIC_DIR}")
description="🎵 Search and download songs using yt-dlp. Automatically updates Navidrome library.",
version="1.0.0" app = FastAPI(title="Tunedrop", version="1.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
) )
# Serve /songs/filename.mp3
app.mount("/songs", StaticFiles(directory=settings.MUSIC_DIR), name="songs")
@app.get("/download") @app.get("/download")
def download(query: str = Query(..., description="Song name or YouTube search term")): def download(query: str = Query(..., min_length=2)):
try: try:
result = download_song(query) result = download_song(query)
if result["downloaded_files"]: return JSONResponse(content={"status": "success", **result})
saved_path = os.path.join(settings.MUSIC_DIR, result["downloaded_files"][0])
add_song_to_list(saved_path, query)
return JSONResponse(content=result)
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") @app.get("/songs", summary="List downloaded songs")
def list_songs(): def list_songs():
try: try:
return get_song_list() files = os.listdir(settings.MUSIC_DIR)
songs = [f for f in files if f.endswith(".mp3")]
return {"songs": sorted(songs)}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=f"Failed to read songs: {str(e)}")
@app.get("/music/{filename}")
def serve_song(filename: str):
file_path = os.path.join(settings.MUSIC_DIR, filename)
if not os.path.isfile(file_path):
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(file_path, media_type="audio/mpeg")
if __name__ == "__main__": if __name__ == "__main__":
uvicorn.run("main:app", host=settings.host, port=settings.port, reload=settings.reload) 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 fastapi
uvicorn uvicorn
pydantic-settings
yt-dlp
requests requests
pydantic>=2.0
pydantic-settings>=2.0

View File

@ -1,38 +0,0 @@
from pathlib import Path
from datetime import datetime
import json
SONG_LIST_PATH = Path("/music/songs.json")
def add_song_to_list(file_path: str, query: str):
file_name = Path(file_path).name
new_song = {
"title": query,
"artist": "Unknown",
"path": file_name,
"cover": "/default-cover.jpg",
"added": datetime.utcnow().isoformat()
}
songs = []
if SONG_LIST_PATH.exists():
with open(SONG_LIST_PATH, "r") as f:
try:
songs = json.load(f)
except json.JSONDecodeError:
songs = []
# Avoid duplicates
if not any(song["path"] == file_name for song in songs):
songs.append(new_song)
with open(SONG_LIST_PATH, "w") as f:
json.dump(songs, f, indent=2)
def get_song_list():
if SONG_LIST_PATH.exists():
with open(SONG_LIST_PATH, "r") as f:
try:
return json.load(f)
except json.JSONDecodeError:
return []
return []

6
frontend/.dockerignore Normal file
View File

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

View File

@ -1 +0,0 @@
VITE_API_BASE_URL=http://localhost:8000

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,8 +1,45 @@
import Home from "./pages/Home"; 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() {
return <Home />; const [songs, setSongs] = useState([]);
const [error, setError] = useState("");
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 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>
);
} }
export default App; export default App;

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,42 +0,0 @@
import React, { useState } from 'react';
function SongCard({ song }) {
const [playing, setPlaying] = useState(false);
if (!song?.path) return null;
const fileName = encodeURIComponent(song.path.split("\\").pop().split("/").pop());
const audioSrc = `http://localhost:8000/songs/${fileName}`;
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 || fileName.replace(".mp3", "")}
</h3>
<p className="text-xs text-gray-500 dark:text-gray-300">
{song.artist || "Unknown"}
</p>
<button
onClick={() => setPlaying(!playing)}
className="mt-2 px-3 py-1 text-sm bg-blue-500 text-white rounded hover:bg-blue-600"
>
{playing ? "Pause" : "Play"}
</button>
{playing && (
<audio controls autoPlay className="mt-2 w-full">
<source src={audioSrc} type="audio/mpeg" />
Your browser does not support the audio element.
</audio>
)}
</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,100 +0,0 @@
import { useState, useEffect } from "react";
import { downloadSong, fetchSongs } from "../services/api";
import SongCard from "../components/SongCard";
function Home() {
const [query, setQuery] = useState("");
const [result, setResult] = useState(null);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [songs, setSongs] = useState([]);
const handleSearch = async () => {
if (!query.trim()) return;
setLoading(true);
setError("");
setResult(null);
try {
const res = await downloadSong(query);
setResult(res);
await loadSongs(); // refresh song list
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
const loadSongs = async () => {
try {
const data = await fetchSongs();
const parsed = data.map((path) => {
const file = path.split("\\").pop().split("/").pop();
const title = decodeURIComponent(file.replace(".mp3", ""));
return {
title,
artist: "Unknown",
path,
cover: "/default-cover.jpg",
};
});
setSongs(parsed);
} catch (err) {
console.error("❌ Failed to load songs:", err);
}
};
useEffect(() => {
loadSongs();
}, []);
return (
<div className="p-4 max-w-xl mx-auto">
<h1 className="text-3xl font-bold mb-4 text-center">🎵 Tunedrop</h1>
<div className="flex mb-4">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search for a song..."
className="flex-1 p-2 border rounded-l"
/>
<button
onClick={handleSearch}
className="bg-green-600 text-white px-4 rounded-r hover:bg-green-700"
disabled={loading}
>
{loading ? "Downloading..." : "Search & Download"}
</button>
</div>
{error && <p className="mt-2 text-red-600 text-sm">{error}</p>}
{result && (
<div className="mt-4 text-center">
<p className="text-green-600"> Success: Downloaded "{result.query}"</p>
<p className="text-xs text-gray-500 mt-1">Navidrome rescan: {result.rescan_status}</p>
</div>
)}
{songs.length > 0 && (
<>
<h2 className="mt-6 text-xl font-semibold text-center">🎶 My Songs</h2>
<div className="grid grid-cols-2 gap-4 mt-4">
{songs.map((song, idx) => (
<SongCard key={idx} song={song} />
))}
</div>
</>
)}
</div>
);
}
export default Home;

View File

@ -1,23 +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() {
export async function fetchSongs() { const res = await fetch(`${BASE_URL}/songs`);
const res = await fetch(`${API_BASE_URL}/songs`);
if (!res.ok) throw new Error("Failed to fetch songs"); if (!res.ok) throw new Error("Failed to fetch songs");
return await res.json(); 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({