Compare commits

..

1 Commits

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

View File

@ -1,2 +1,5 @@
MUSIC_DIR=music/
NAVIDROME_SCAN_URL=
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

2
backend/.gitignore vendored
View File

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

View File

@ -1,37 +1,14 @@
# ---- Base ----
FROM python:3.11-slim-bookworm
FROM python:3.11-slim
# עדכון מערכת והתקנת FFmpeg (כולל ffprobe)
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*
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
# הגדרות סביבת עבודה בסיסיות
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
# ספריית האפליקציה
WORKDIR /app
# התקנת תלויות
# (משתמש ב־requirements.txt שהבאת)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# קבצי האפליקציה
COPY . .
# תיקיית המוזיקה (הקוד גם יוצר אותה, אבל נגדיר כ־VOLUME לנוחות)
VOLUME ["/app/music"]
RUN pip install --no-cache-dir -r requirements.txt
# פורט האפליקציה
EXPOSE 8000
ENV MUSIC_DIR=/music
# ריצה תחת משתמש לא־רות
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"]
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,18 +1,19 @@
import os
from dotenv import load_dotenv
from pydantic_settings import BaseSettings
from pydantic import Field
from pathlib import Path
load_dotenv()
# בסיס הקבצים יהיה תקיית backend
BASE_DIR = Path(__file__).resolve().parent
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
class Settings(BaseSettings):
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")
NAVIDROME_SCAN_URL = os.getenv("NAVIDROME_SCAN_URL", "")
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
settings = Settings()

View File

@ -1,52 +1,49 @@
import yt_dlp
from pathlib import Path
import subprocess
import os
import glob
import requests
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):
Path(settings.MUSIC_DIR).mkdir(parents=True, exist_ok=True)
# ודא שהתיקייה קיימת
os.makedirs(settings.MUSIC_DIR, 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
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))
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,
command = [
"yt-dlp",
f"ytsearch1:{query}",
"--extract-audio",
"--audio-format", "mp3",
"-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}")
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,41 +1,45 @@
from fastapi import FastAPI, Query, HTTPException
from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from fastapi import APIRouter
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
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 = 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(..., min_length=2)):
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", summary="List downloaded songs")
@app.get("/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)}
return get_song_list()
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to read songs: {str(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__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000, log_level="info", reload=True)
uvicorn.run("main:app", host=settings.host, port=settings.port, reload=settings.reload)

Binary file not shown.

Binary file not shown.

View File

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

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 []

View File

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

1
frontend/.env Normal file
View File

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

View File

@ -1,10 +0,0 @@
#!/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."

View File

@ -1,16 +0,0 @@
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>Tunedrop</title>
<script src="/env.js"></script>
<title>Vite + React</title>
</head>
<body>
<div id="root"></div>

View File

@ -1,16 +0,0 @@
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": "^4.6.0",
"@vitejs/plugin-react-swc": "^3.10.2",
"eslint": "^9.30.1",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@ -1,4 +0,0 @@
// Replaced at container start
window.__ENV = {
VITE_API_URL: "$VITE_API_URL"
};

View File

@ -1,42 +1,35 @@
#root {
max-width: 1280px;
.app-container {
max-width: 600px;
margin: 0 auto;
padding: 2rem;
font-family: sans-serif;
text-align: center;
}
.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);
.search-form {
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 2rem;
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
input {
padding: 0.75rem;
font-size: 1.1rem;
border-radius: 8px;
border: 1px solid #ccc;
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
button {
padding: 0.75rem;
font-size: 1.1rem;
background: #1db954;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
button:hover {
background: #1ed760;
}

View File

@ -1,45 +1,8 @@
import React, { useState, useEffect } from "react";
import DownloadForm from "./components/DownloadForm";
import SongList from "./components/SongList";
import { downloadSong, getDownloadedSongs } from "./services/api";
import Home from "./pages/Home";
import "./App.css";
function App() {
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>
);
return <Home />;
}
export default App;

View File

@ -1,33 +0,0 @@
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

@ -0,0 +1,42 @@
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

@ -1,15 +0,0 @@
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;

100
frontend/src/pages/Home.jsx Normal file
View File

@ -0,0 +1,100 @@
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,19 +1,23 @@
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";
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000";
// 📥 הורדת שיר לפי שם
export async function downloadSong(query) {
const res = await fetch(`${BASE_URL}/download?query=${encodeURIComponent(query)}`);
try {
const res = await fetch(`${API_BASE_URL}/download?query=${encodeURIComponent(query)}`);
if (!res.ok) {
let detail = "Unknown error";
try { detail = (await res.json()).detail || detail; } catch {}
throw new Error(detail);
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;
}
return res.json();
}
export async function getDownloadedSongs() {
const res = await fetch(`${BASE_URL}/songs`);
// 📃 קבלת כל השירים הקיימים
export async function fetchSongs() {
const res = await fetch(`${API_BASE_URL}/songs`);
if (!res.ok) throw new Error("Failed to fetch songs");
return res.json();
return await res.json();
}

View File

@ -1,5 +1,5 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import react from '@vitejs/plugin-react-swc'
// https://vite.dev/config/
export default defineConfig({