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 @@
+
+
+
+
![Cover]()
+
+
+
+
+
+
+
+
+
+
+
+