Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a6cfad41c7 | |||
| 9cfc07a510 | |||
| 1dd1b96646 | |||
|
|
7f25f9d0b3 | ||
|
|
0e324036bc | ||
|
|
d79161177d | ||
|
|
cc95b2bed1 | ||
| 154035cc1b | |||
| fdd95d73f3 | |||
| 1a0948d52b | |||
| 2fb1b48251 | |||
| c726dbd70b | |||
| 994c74b136 | |||
| 84c738632b | |||
| 872e772292 | |||
| 9aae6083a3 | |||
|
|
2cdc6c9866 | ||
| 8153b8bce6 | |||
| 3438b1587c |
@ -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
2
backend/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
music/
|
||||||
|
__pycache__/
|
||||||
@ -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"]
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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()
|
||||||
@ -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"}
|
||||||
@ -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)
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,5 +1,5 @@
|
|||||||
fastapi
|
fastapi
|
||||||
uvicorn
|
uvicorn
|
||||||
|
pydantic-settings
|
||||||
|
yt-dlp
|
||||||
requests
|
requests
|
||||||
pydantic>=2.0
|
|
||||||
pydantic-settings>=2.0
|
|
||||||
|
|||||||
Binary file not shown.
@ -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
6
frontend/.dockerignore
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
npm-debug.log
|
||||||
|
.DS_Store
|
||||||
@ -1 +0,0 @@
|
|||||||
VITE_API_BASE_URL=http://localhost:8000
|
|
||||||
10
frontend/10-generate-env.sh
Normal file
10
frontend/10-generate-env.sh
Normal 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
16
frontend/Dockerfile
Normal 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;"]
|
||||||
@ -2,12 +2,12 @@
|
|||||||
<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>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
16
frontend/nginx.conf
Normal file
16
frontend/nginx.conf
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
1231
frontend/package-lock.json
generated
1231
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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 |
4
frontend/public/env.js.template
Normal file
4
frontend/public/env.js.template
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
// Replaced at container start
|
||||||
|
window.__ENV = {
|
||||||
|
VITE_API_URL: "$VITE_API_URL"
|
||||||
|
};
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
33
frontend/src/components/DownloadForm.jsx
Normal file
33
frontend/src/components/DownloadForm.jsx
Normal 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;
|
||||||
@ -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;
|
|
||||||
15
frontend/src/components/SongList.jsx
Normal file
15
frontend/src/components/SongList.jsx
Normal 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;
|
||||||
@ -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;
|
|
||||||
@ -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) {
|
let detail = "Unknown error";
|
||||||
const err = await res.json();
|
try { detail = (await res.json()).detail || detail; } catch {}
|
||||||
throw new Error(err.detail || "Download failed");
|
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();
|
||||||
}
|
}
|
||||||
@ -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({
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user