Compare commits

...

1 Commits

Author SHA1 Message Date
346ae66117 create client 2025-07-30 21:52:43 +03:00
17 changed files with 218 additions and 67 deletions

5
backend/.env Normal file
View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,9 +1,16 @@
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
from pydantic import Field from pydantic import Field
from pathlib import Path
# בסיס הקבצים יהיה תקיית backend
BASE_DIR = Path(__file__).resolve().parent
class Settings(BaseSettings): class Settings(BaseSettings):
MUSIC_DIR: str = Field(default="/music", description="Path where songs are saved") MUSIC_DIR: str = Field(default=str(BASE_DIR / "music"), description="Path where songs are saved")
NAVIDROME_SCAN_URL: str = Field(default="", description="URL to trigger Navidrome rescan") NAVIDROME_SCAN_URL: str = Field(default="", description="URL to trigger Navidrome rescan")
host: str = Field(default="0.0.0.0", description="Host to bind the server")
port: int = Field(default=8000, description="Port to run the server on")
reload: bool = Field(default=True, description="Enable reload for development")
class Config: class Config:
env_file = ".env" env_file = ".env"

View File

@ -1,37 +1,49 @@
import subprocess import subprocess
import os import os
from config import settings import glob
import requests import requests
from config import settings
def download_song(query: str): def download_song(query: str):
output_template = os.path.join(settings.MUSIC_DIR, "%(artist)s/%(album)s/%(title)s.%(ext)s") # ודא שהתיקייה קיימת
os.makedirs(settings.MUSIC_DIR, exist_ok=True)
output_template = os.path.join(settings.MUSIC_DIR, "%(title)s.%(ext)s")
before_files = set(glob.glob(os.path.join(settings.MUSIC_DIR, "**/*.mp3"), recursive=True))
command = [ command = [
"yt-dlp", "yt-dlp",
f"ytsearch1:{query}", f"ytsearch1:{query}",
"--extract-audio", "--extract-audio",
"--audio-format", "mp3", "--audio-format", "mp3",
"--output", output_template, "-o", output_template,
"--no-playlist", "--no-playlist"
"--quiet"
] ]
print("Running command:", " ".join(command)) # Debug
result = subprocess.run(command, capture_output=True, text=True) result = subprocess.run(command, capture_output=True, text=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: if result.returncode != 0:
raise Exception(f"❌ Download failed:\n{result.stderr}") raise Exception(f"❌ Download failed:\n{result.stderr}")
# Optional: trigger Navidrome rescan rescan_status = "skipped"
if settings.NAVIDROME_SCAN_URL: if settings.NAVIDROME_SCAN_URL:
try: try:
res = requests.get(settings.NAVIDROME_SCAN_URL, timeout=5) res = requests.get(settings.NAVIDROME_SCAN_URL, timeout=5)
res.raise_for_status() res.raise_for_status()
rescan_status = "triggered"
except Exception as e: except Exception as e:
print(f"⚠️ Failed to trigger Navidrome rescan: {e}") rescan_status = f"failed: {e}"
return { return {
"status": "success", "status": "success",
"query": query, "query": query,
"log": result.stdout + "\n" + result.stderr "downloaded_files": new_files,
"yt_dlp_stdout": result.stdout,
"yt_dlp_stderr": result.stderr,
"rescan_status": rescan_status
} }

View File

@ -1,18 +1,45 @@
from fastapi import FastAPI, Query, HTTPException from fastapi import FastAPI, Query, HTTPException
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse, FileResponse
from fastapi.staticfiles import StaticFiles
import uvicorn import uvicorn
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(title="Tunedrop") app = FastAPI(
title="Tunedrop",
description="🎵 Search and download songs using yt-dlp. Automatically updates Navidrome library.",
version="1.0.0"
)
# 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(..., description="Song name or YouTube search term")):
try: try:
result = download_song(query) result = download_song(query)
return JSONResponse(content={"status": "success", **result}) if result["downloaded_files"]:
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")
def list_songs():
try:
return get_song_list()
except Exception as e:
raise HTTPException(status_code=500, detail=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="0.0.0.0", port=8000, reload=True) uvicorn.run("main:app", host=settings.host, port=settings.port, reload=settings.reload)

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,38 @@
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 []

1
frontend/.env Normal file
View File

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

View File

@ -1,29 +1,8 @@
import { useState } from "react"; import Home from "./pages/Home";
import "./App.css"; import "./App.css";
function App() { function App() {
const [query, setQuery] = useState(""); return <Home />;
const handleSearch = (e) => {
e.preventDefault();
console.log("Searching for:", query);
// later: call backend here
};
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>
);
} }
export default App; export default App;

View File

@ -1,6 +1,13 @@
import React from 'react'; import React, { useState } from 'react';
function SongCard({ song }) { 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 ( return (
<div className="rounded-lg shadow-md p-4 bg-white dark:bg-gray-800 flex flex-col items-center text-center"> <div className="rounded-lg shadow-md p-4 bg-white dark:bg-gray-800 flex flex-col items-center text-center">
<img <img
@ -8,11 +15,26 @@ function SongCard({ song }) {
alt={song.title} alt={song.title}
className="w-24 h-24 object-cover rounded" 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> <h3 className="mt-2 text-sm font-semibold text-gray-800 dark:text-white">
<p className="text-xs text-gray-500 dark:text-gray-300">{song.artist}</p> {song.title || fileName.replace(".mp3", "")}
<button className="mt-2 px-3 py-1 text-sm bg-blue-500 text-white rounded hover:bg-blue-600"> </h3>
Play <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> </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> </div>
); );
} }

View File

@ -1,45 +1,98 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { downloadSong } from "../services/api"; import { downloadSong, fetchSongs } from "../services/api";
import SongCard from "../components/SongCard";
function Home() { function Home() {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [status, setStatus] = useState(""); const [result, setResult] = useState(null);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [songs, setSongs] = useState([]);
const handleSearch = async () => { const handleSearch = async () => {
if (!query.trim()) return; if (!query.trim()) return;
setLoading(true); setLoading(true);
setStatus(""); setError("");
setResult(null);
try { try {
const result = await downloadSong(query); const res = await downloadSong(query);
setStatus(`✅ Success: Downloaded "${result.query}"`); setResult(res);
} catch (error) { await loadSongs(); // refresh song list
setStatus(`❌ Error: ${error.message}`); } catch (err) {
setError(err.message);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
return ( const loadSongs = async () => {
<div className="p-4 max-w-md mx-auto"> try {
<h1 className="text-2xl font-bold mb-4">🎵 Tunedrop</h1> const data = await fetchSongs();
<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>} 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> </div>
); );
} }

View File

@ -14,3 +14,10 @@ export async function downloadSong(query) {
throw error; throw error;
} }
} }
// 📃 קבלת כל השירים הקיימים
export async function fetchSongs() {
const res = await fetch(`${API_BASE_URL}/songs`);
if (!res.ok) throw new Error("Failed to fetch songs");
return await res.json();
}