diff --git a/app/core/search.py b/app/core/search.py index 903cca8..b5eb0a3 100644 --- a/app/core/search.py +++ b/app/core/search.py @@ -29,13 +29,26 @@ def _search_yandex_sync(query: str) -> Dict[str, List[Dict[str, Any]]]: res = client.search(query, type_="all") if res.tracks and res.tracks.results: - for t in res.tracks.results[:6]: + for t in res.tracks.results[:8]: if not t: continue artists = ", ".join(a.name for a in t.artists) if t.artists else "Unknown" album_title = t.albums[0].title if t.albums else "Single" year = t.albums[0].year if t.albums and t.albums[0].year else None cover = "https://" + t.cover_uri.replace("%%", "400x400") if t.cover_uri else None + + content_warning = getattr(t, "content_warning", None) + version = getattr(t, "version", None) + title_lower = (t.title or "").lower() + version_lower = (version or "").lower() + + if content_warning == "explicit" or any(w in title_lower or w in version_lower for w in ["explicit", "18+", "uncensored"]): + explicit_status = "explicit" + elif content_warning == "clean" or any(w in title_lower or w in version_lower for w in ["clean", "censored", "радио", "radio edit"]): + explicit_status = "clean" + else: + explicit_status = "neutral" + tracks.append({ "service": "yandex", "service_name": "Яндекс Музыка", @@ -46,7 +59,10 @@ def _search_yandex_sync(query: str) -> Dict[str, List[Dict[str, Any]]]: "year": year, "duration": format_duration(t.duration_ms), "cover": cover, - "url": f"https://music.yandex.ru/track/{t.id}" + "url": f"https://music.yandex.ru/track/{t.id}", + "explicit_status": explicit_status, + "version": version, + "preview_url": None }) if res.albums and res.albums.results: @@ -86,6 +102,15 @@ async def search_apple(query: str) -> Dict[str, List[Dict[str, Any]]]: duration_ms = r.get("trackTimeMillis", 0) release_date = r.get("releaseDate", "") year = int(release_date[:4]) if release_date else None + + track_explicitness = (r.get("trackExplicitness") or "").lower() + if track_explicitness == "explicit": + explicit_status = "explicit" + elif track_explicitness == "cleaned": + explicit_status = "clean" + else: + explicit_status = "neutral" + tracks.append({ "service": "apple", "service_name": "Apple Music", @@ -96,7 +121,10 @@ async def search_apple(query: str) -> Dict[str, List[Dict[str, Any]]]: "year": year, "duration": format_duration(duration_ms), "cover": cover, - "url": r.get("trackViewUrl") + "url": r.get("trackViewUrl"), + "explicit_status": explicit_status, + "version": None, + "preview_url": r.get("previewUrl") }) except Exception as e: print(f"Apple search error: {e}") @@ -122,17 +150,30 @@ def _search_youtube_sync(query: str) -> Dict[str, List[Dict[str, Any]]]: if not entry: continue sec = entry.get("duration", 0) + title_raw = entry.get("title") or "" + title_lower = title_raw.lower() + + if any(w in title_lower for w in ["explicit", "uncensored", "18+"]): + explicit_status = "explicit" + elif any(w in title_lower for w in ["clean", "censored", "radio edit", "радиоверсия"]): + explicit_status = "clean" + else: + explicit_status = "neutral" + tracks.append({ "service": "youtube", "service_name": "YouTube", "id": entry.get("id"), - "title": entry.get("title"), + "title": title_raw, "artist": entry.get("uploader") or entry.get("channel") or "Unknown", "album": "YouTube Audio", "year": None, "duration": f"{sec // 60:02d}:{sec % 60:02d}" if sec else "00:00", "cover": entry.get("thumbnail"), - "url": f"https://www.youtube.com/watch?v={entry.get('id')}" + "url": f"https://www.youtube.com/watch?v={entry.get('id')}", + "explicit_status": explicit_status, + "version": None, + "preview_url": None }) except Exception as e: print(f"YouTube search error: {e}") diff --git a/app/main.py b/app/main.py index 0922b03..7512dc5 100644 --- a/app/main.py +++ b/app/main.py @@ -79,6 +79,40 @@ async def direct_download(url: str = Query(...)): headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"} ) +@app.get("/api/stream") +async def stream_audio(url: str = Query(...)): + """Stream audio track for in-browser playback and seeking with HTTP Range support.""" + provider = sync_manager.get_provider(url) + tracks = await provider.resolve(url) + if not tracks: + raise HTTPException(status_code=404, detail="Track not found") + + track = tracks[0] + dest_path = get_track_dest_path( + settings.MUSIC_DIR, + artist=track.artist, + album=track.album or "Singles", + track_num=track.track_number, + title=track.title, + ext="mp3" + ) + + if not os.path.exists(dest_path): + success = await provider.download_track(track, dest_path) + if not success and track.service == "yandex": + success = await sync_manager.universal_provider.download_track(track, dest_path) + if not success: + raise HTTPException(status_code=500, detail="Failed to fetch track audio") + + return FileResponse( + dest_path, + media_type="audio/mpeg", + headers={ + "Accept-Ranges": "bytes", + "Content-Disposition": "inline" + } + ) + @app.post("/api/sync/yandex-likes") async def sync_yandex_likes(): if not settings.YANDEX_MUSIC_TOKEN: diff --git a/app/static/app.js b/app/static/app.js index ed7a1c9..74f1a96 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -69,23 +69,39 @@ document.addEventListener("DOMContentLoaded", () => { if (tracks.length === 0) { searchTracksList.innerHTML = `

Треков не найдено

`; } else { - searchTracksList.innerHTML = tracks.map(t => ` -
-
- -
- ${escapeHtml(t.title)} - - ${t.service_name} • ${escapeHtml(t.artist)} • ${escapeHtml(t.album || 'Single')} (${t.duration}) - + searchTracksList.innerHTML = tracks.map(t => { + const explicitBadge = t.explicit_status === 'explicit' + ? `🔞 18+ Explicit` + : (t.explicit_status === 'clean' + ? `⚠️ Clean (Цензура)` + : ''); + const versionBadge = t.version ? `${escapeHtml(t.version)}` : ''; + + return ` +
+
+ +
+
+ ${escapeHtml(t.title)} + ${explicitBadge} + ${versionBadge} +
+ + ${t.service_name} • ${escapeHtml(t.artist)} • ${escapeHtml(t.album || 'Single')} (${t.duration}) + +
+
+
+ + + 💾 Скачать
-
- - 💾 Скачать -
-
- `).join(""); + `; + }).join(""); } // Albums @@ -106,6 +122,65 @@ document.addEventListener("DOMContentLoaded", () => { } } + // Audio Preview Player + let currentPlayingUrl = null; + + window.playPreviewTrack = function(url, title, artist, cover, explicitStatus, previewUrl) { + const bar = document.getElementById("audioPlayerBar"); + const audio = document.getElementById("globalAudioPlayer"); + const playerCover = document.getElementById("playerCover"); + const playerTitle = document.getElementById("playerTitle"); + const playerArtist = document.getElementById("playerArtist"); + const playerBadge = document.getElementById("playerExplicitBadge"); + const playerImportBtn = document.getElementById("playerImportBtn"); + const playerDownloadBtn = document.getElementById("playerDownloadBtn"); + + if (currentPlayingUrl === url && !audio.paused) { + audio.pause(); + return; + } + + currentPlayingUrl = url; + playerCover.src = cover || "static/placeholder.png"; + playerTitle.textContent = title; + playerArtist.textContent = artist; + + if (explicitStatus === "explicit") { + playerBadge.className = "badge badge-explicit"; + playerBadge.textContent = "🔞 18+ Explicit (Без цензуры)"; + playerBadge.style.display = "inline-flex"; + } else if (explicitStatus === "clean") { + playerBadge.className = "badge badge-clean"; + playerBadge.textContent = "⚠️ Clean (С цензурой)"; + playerBadge.style.display = "inline-flex"; + } else { + playerBadge.style.display = "none"; + } + + playerImportBtn.onclick = () => handleImport(url); + playerDownloadBtn.onclick = () => { + window.location.href = "api/direct-download?url=" + encodeURIComponent(url); + }; + + bar.style.display = "flex"; + + // Choose source: Apple 30s direct preview or server stream with HTTP Range + const streamSrc = previewUrl || ("api/stream?url=" + encodeURIComponent(url)); + audio.src = streamSrc; + audio.play().catch(e => console.warn("Playback error:", e)); + }; + + const playerCloseBtn = document.getElementById("playerCloseBtn"); + if (playerCloseBtn) { + playerCloseBtn.addEventListener("click", () => { + const audio = document.getElementById("globalAudioPlayer"); + audio.pause(); + audio.src = ""; + currentPlayingUrl = null; + document.getElementById("audioPlayerBar").style.display = "none"; + }); + } + // Global handler for import button window.handleImport = async function(url) { await startDownload(url); diff --git a/app/static/style.css b/app/static/style.css index 910f740..751d4f0 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -420,3 +420,191 @@ input[type="text"]:focus { color: var(--accent-color); text-decoration: none; } + +/* Explicit & Clean Badges */ +.badge-explicit { + background-color: #ef4444; + color: #ffffff; + font-weight: 700; + font-size: 0.72rem; + padding: 0.15rem 0.45rem; + border-radius: 4px; + display: inline-flex; + align-items: center; + gap: 0.2rem; + letter-spacing: 0.02em; +} + +.badge-clean { + background-color: #eab308; + color: #0f172a; + font-weight: 700; + font-size: 0.72rem; + padding: 0.15rem 0.45rem; + border-radius: 4px; + display: inline-flex; + align-items: center; + gap: 0.2rem; +} + +.badge-version { + background-color: #334155; + color: #cbd5e1; + font-size: 0.72rem; + padding: 0.15rem 0.45rem; + border-radius: 4px; +} + +/* Play Preview Button */ +.btn-play-preview { + background-color: #10b981; + color: white; + border: none; + font-size: 0.78rem; + font-weight: 600; + padding: 0.35rem 0.65rem; + border-radius: 6px; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 0.3rem; + transition: all 0.15s ease; +} + +.btn-play-preview:hover { + background-color: #059669; + transform: translateY(-1px); +} + +.btn-play-preview.is-playing { + background-color: #f59e0b; + color: #0f172a; +} + +/* Floating Audio Player Bar */ +.audio-player-bar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background-color: rgba(15, 23, 42, 0.96); + backdrop-filter: blur(12px); + border-top: 1px solid var(--border-color); + box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.6); + padding: 0.75rem 1.5rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1.25rem; + z-index: 1000; + animation: slideUp 0.25s ease-out; +} + +@keyframes slideUp { + from { transform: translateY(100%); } + to { transform: translateY(0); } +} + +.player-track-info { + display: flex; + align-items: center; + gap: 0.75rem; + min-width: 220px; + max-width: 320px; +} + +.player-cover { + width: 48px; + height: 48px; + border-radius: 6px; + object-fit: cover; + background-color: #334155; + flex-shrink: 0; +} + +.player-meta { + display: flex; + flex-direction: column; + gap: 0.15rem; + overflow: hidden; +} + +.player-title-row { + display: flex; + align-items: center; + gap: 0.4rem; + overflow: hidden; +} + +.player-title { + font-size: 0.88rem; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.player-artist { + font-size: 0.75rem; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.player-controls-container { + flex: 1; + max-width: 640px; + display: flex; + align-items: center; +} + +.player-controls-container audio { + width: 100%; + height: 38px; + outline: none; +} + +.player-actions { + display: flex; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; +} + +.btn-player-close { + background: none; + border: none; + color: var(--text-secondary); + font-size: 1.4rem; + line-height: 1; + cursor: pointer; + padding: 0.2rem 0.4rem; + border-radius: 4px; + margin-left: 0.3rem; +} + +.btn-player-close:hover { + color: #fff; + background-color: rgba(255, 255, 255, 0.1); +} + +@media (max-width: 820px) { + .audio-player-bar { + flex-direction: column; + padding: 0.75rem 1rem; + gap: 0.6rem; + } + .player-track-info { + max-width: 100%; + width: 100%; + } + .player-controls-container { + width: 100%; + max-width: 100%; + } + .player-actions { + width: 100%; + justify-content: flex-end; + } +} diff --git a/app/templates/index.html b/app/templates/index.html index 5d199d2..73cfe94 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -107,6 +107,30 @@
+ + +