Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 346ae66117 |
5
backend/.env
Normal file
5
backend/.env
Normal 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
|
||||||
BIN
backend/__pycache__/config.cpython-313.pyc
Normal file
BIN
backend/__pycache__/config.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/downloader.cpython-313.pyc
Normal file
BIN
backend/__pycache__/downloader.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/main.cpython-313.pyc
Normal file
BIN
backend/__pycache__/main.cpython-313.pyc
Normal file
Binary file not shown.
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
BIN
backend/music/Alan Walker - Faded.mp3
Normal file
BIN
backend/music/Alan Walker - Faded.mp3
Normal file
Binary file not shown.
BIN
backend/music/Eminem - Fall (Official Music Video).mp3
Normal file
BIN
backend/music/Eminem - Fall (Official Music Video).mp3
Normal file
Binary file not shown.
BIN
backend/music/Eminem - Mockingbird [Official Music Video].mp3
Normal file
BIN
backend/music/Eminem - Mockingbird [Official Music Video].mp3
Normal file
Binary file not shown.
BIN
backend/utils/__pycache__/song_list.cpython-313.pyc
Normal file
BIN
backend/utils/__pycache__/song_list.cpython-313.pyc
Normal file
Binary file not shown.
38
backend/utils/song_list.py
Normal file
38
backend/utils/song_list.py
Normal 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
1
frontend/.env
Normal file
@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE_URL=http://localhost:8000
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="p-4 max-w-md mx-auto">
|
<div className="p-4 max-w-xl mx-auto">
|
||||||
<h1 className="text-2xl font-bold mb-4">🎵 Tunedrop</h1>
|
<h1 className="text-3xl font-bold mb-4 text-center">🎵 Tunedrop</h1>
|
||||||
|
|
||||||
|
<div className="flex mb-4">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="Search for a song..."
|
placeholder="Search for a song..."
|
||||||
className="w-full p-2 border rounded mb-2"
|
className="flex-1 p-2 border rounded-l"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleSearch}
|
onClick={handleSearch}
|
||||||
className="w-full bg-blue-500 text-white py-2 px-4 rounded hover:bg-blue-600"
|
className="bg-green-600 text-white px-4 rounded-r hover:bg-green-700"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{loading ? "Downloading..." : "Search & Download"}
|
{loading ? "Downloading..." : "Search & Download"}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{status && <p className="mt-4 text-center">{status}</p>}
|
{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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user