diff --git a/backend/.env b/backend/.env new file mode 100644 index 0000000..349f563 --- /dev/null +++ b/backend/.env @@ -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 diff --git a/backend/__pycache__/config.cpython-313.pyc b/backend/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000..d2aa0d2 Binary files /dev/null and b/backend/__pycache__/config.cpython-313.pyc differ diff --git a/backend/__pycache__/downloader.cpython-313.pyc b/backend/__pycache__/downloader.cpython-313.pyc new file mode 100644 index 0000000..854caec Binary files /dev/null and b/backend/__pycache__/downloader.cpython-313.pyc differ diff --git a/backend/__pycache__/main.cpython-313.pyc b/backend/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..bbb9996 Binary files /dev/null and b/backend/__pycache__/main.cpython-313.pyc differ diff --git a/backend/config.py b/backend/config.py index 15700b7..6f36fd9 100644 --- a/backend/config.py +++ b/backend/config.py @@ -1,9 +1,16 @@ from pydantic_settings import BaseSettings from pydantic import Field +from pathlib import Path + +# בסיס הקבצים יהיה תקיית backend +BASE_DIR = Path(__file__).resolve().parent 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") + 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: env_file = ".env" diff --git a/backend/downloader.py b/backend/downloader.py index fc0c89e..38d2d2a 100644 --- a/backend/downloader.py +++ b/backend/downloader.py @@ -1,37 +1,49 @@ import subprocess import os -from config import settings +import glob import requests +from config import settings 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 = [ "yt-dlp", f"ytsearch1:{query}", "--extract-audio", "--audio-format", "mp3", - "--output", output_template, - "--no-playlist", - "--quiet" + "-o", output_template, + "--no-playlist" ] + print("Running command:", " ".join(command)) # Debug + 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: raise Exception(f"❌ Download failed:\n{result.stderr}") - # Optional: trigger Navidrome rescan + 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: - print(f"⚠️ Failed to trigger Navidrome rescan: {e}") + rescan_status = f"failed: {e}" return { "status": "success", "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 } diff --git a/backend/main.py b/backend/main.py index f23a499..3d8541d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,18 +1,45 @@ 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 os from downloader import download_song 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") def download(query: str = Query(..., description="Song name or YouTube search term")): try: 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: 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__": - 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) diff --git a/backend/music/Alan Walker - Faded.mp3 b/backend/music/Alan Walker - Faded.mp3 new file mode 100644 index 0000000..b893181 Binary files /dev/null and b/backend/music/Alan Walker - Faded.mp3 differ diff --git a/backend/music/Eminem - Fall (Official Music Video).mp3 b/backend/music/Eminem - Fall (Official Music Video).mp3 new file mode 100644 index 0000000..7d74e6b Binary files /dev/null and b/backend/music/Eminem - Fall (Official Music Video).mp3 differ diff --git a/backend/music/Eminem - Mockingbird [Official Music Video].mp3 b/backend/music/Eminem - Mockingbird [Official Music Video].mp3 new file mode 100644 index 0000000..7e675a8 Binary files /dev/null and b/backend/music/Eminem - Mockingbird [Official Music Video].mp3 differ diff --git a/backend/utils/__pycache__/song_list.cpython-313.pyc b/backend/utils/__pycache__/song_list.cpython-313.pyc new file mode 100644 index 0000000..660d320 Binary files /dev/null and b/backend/utils/__pycache__/song_list.cpython-313.pyc differ diff --git a/backend/utils/song_list.py b/backend/utils/song_list.py new file mode 100644 index 0000000..4cd898b --- /dev/null +++ b/backend/utils/song_list.py @@ -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 [] diff --git a/frontend/.env b/frontend/.env new file mode 100644 index 0000000..c2058b5 --- /dev/null +++ b/frontend/.env @@ -0,0 +1 @@ +VITE_API_BASE_URL=http://localhost:8000 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 6f5966b..64e9d01 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,29 +1,8 @@ -import { useState } from "react"; +import Home from "./pages/Home"; import "./App.css"; function App() { - const [query, setQuery] = useState(""); - - const handleSearch = (e) => { - e.preventDefault(); - console.log("Searching for:", query); - // later: call backend here - }; - - return ( -
-

Tunedrop 🎵

-
- setQuery(e.target.value)} - /> - -
-
- ); + return ; } export default App; diff --git a/frontend/src/components/SongCard.jsx b/frontend/src/components/SongCard.jsx index b975884..29793df 100644 --- a/frontend/src/components/SongCard.jsx +++ b/frontend/src/components/SongCard.jsx @@ -1,6 +1,13 @@ -import React from 'react'; +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 (
{song.title} -

{song.title}

-

{song.artist}

- + + {playing && ( + + )}
); } diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx index 827c194..e7c2567 100644 --- a/frontend/src/pages/Home.jsx +++ b/frontend/src/pages/Home.jsx @@ -1,45 +1,98 @@ -import { useState } from "react"; -import { downloadSong } from "../services/api"; +import { useState, useEffect } from "react"; +import { downloadSong, fetchSongs } from "../services/api"; +import SongCard from "../components/SongCard"; function Home() { const [query, setQuery] = useState(""); - const [status, setStatus] = 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); - setStatus(""); + setError(""); + setResult(null); + try { - const result = await downloadSong(query); - setStatus(`✅ Success: Downloaded "${result.query}"`); - } catch (error) { - setStatus(`❌ Error: ${error.message}`); + const res = await downloadSong(query); + setResult(res); + await loadSongs(); // refresh song list + } catch (err) { + setError(err.message); } finally { setLoading(false); } }; - return ( -
-

🎵 Tunedrop

- setQuery(e.target.value)} - placeholder="Search for a song..." - className="w-full p-2 border rounded mb-2" - /> - + const loadSongs = async () => { + try { + const data = await fetchSongs(); - {status &&

{status}

} + 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 ( +
+

🎵 Tunedrop

+ +
+ setQuery(e.target.value)} + placeholder="Search for a song..." + className="flex-1 p-2 border rounded-l" + /> + +
+ + {error &&

{error}

} + + {result && ( +
+

✅ Success: Downloaded "{result.query}"

+

Navidrome rescan: {result.rescan_status}

+
+ )} + + {songs.length > 0 && ( + <> +

🎶 My Songs

+
+ {songs.map((song, idx) => ( + + ))} +
+ + )}
); } diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 11c914e..76f647a 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -14,3 +14,10 @@ export async function downloadSong(query) { 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(); +}