update
This commit is contained in:
@@ -12,3 +12,13 @@ async def search(query: str, type: str = "track"):
|
|||||||
return JSONResponse(results)
|
return JSONResponse(results)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
@router.get("/artist/{artist_id}/albums")
|
||||||
|
async def get_artist_albums(artist_id: str):
|
||||||
|
wrapper = TidalWrapper()
|
||||||
|
try:
|
||||||
|
albums = wrapper.get_artist_albums(artist_id)
|
||||||
|
return JSONResponse(albums)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ class DownloadManager:
|
|||||||
tidal_obj=tidal,
|
tidal_obj=tidal,
|
||||||
path_base=settings.data.download_base_path,
|
path_base=settings.data.download_base_path,
|
||||||
fn_logger=logger_tidal,
|
fn_logger=logger_tidal,
|
||||||
skip_existing=False,
|
skip_existing=True,
|
||||||
progress_gui=mock_progress,
|
progress_gui=mock_progress,
|
||||||
progress=mock_progress_rich,
|
progress=mock_progress_rich,
|
||||||
event_abort=event_abort,
|
event_abort=event_abort,
|
||||||
|
|||||||
@@ -153,9 +153,67 @@ class TidalWrapper:
|
|||||||
return None
|
return None
|
||||||
return f"https://resources.tidal.com/images/{uuid.replace('-', '/')}/{width}x{height}.jpg"
|
return f"https://resources.tidal.com/images/{uuid.replace('-', '/')}/{width}x{height}.jpg"
|
||||||
|
|
||||||
|
def get_artist_albums(self, artist_id: str, limit: int = 50):
|
||||||
|
"""Fetch all albums for a given artist"""
|
||||||
|
if not self.is_authenticated():
|
||||||
|
raise Exception("Not authenticated")
|
||||||
|
|
||||||
|
artist = self.session.artist(artist_id)
|
||||||
|
albums = artist.get_albums(limit=limit)
|
||||||
|
|
||||||
|
# Deduplicate albums by title, keeping highest quality version
|
||||||
|
albums_dict = {}
|
||||||
|
for album in albums:
|
||||||
|
title = album.name
|
||||||
|
|
||||||
|
# If we haven't seen this album title before, add it
|
||||||
|
if title not in albums_dict:
|
||||||
|
albums_dict[title] = album
|
||||||
|
else:
|
||||||
|
# If we've seen it, keep the one with higher quality
|
||||||
|
existing = albums_dict[title]
|
||||||
|
|
||||||
|
# Prioritize by audio quality (if available)
|
||||||
|
# TIDAL albums with better quality often have higher audio_quality values
|
||||||
|
# Also prefer explicit versions over clean versions (explicit flag)
|
||||||
|
# And prefer newer releases (release_date)
|
||||||
|
|
||||||
|
# Simple heuristic: prefer albums with explicit tag and newer release date
|
||||||
|
keep_new = False
|
||||||
|
if hasattr(album, 'explicit') and hasattr(existing, 'explicit'):
|
||||||
|
if album.explicit and not existing.explicit:
|
||||||
|
keep_new = True
|
||||||
|
|
||||||
|
# If explicit status is same, prefer newer release
|
||||||
|
if not keep_new and hasattr(album, 'release_date') and hasattr(existing, 'release_date'):
|
||||||
|
if album.release_date and existing.release_date:
|
||||||
|
if album.release_date > existing.release_date:
|
||||||
|
keep_new = True
|
||||||
|
|
||||||
|
if keep_new:
|
||||||
|
albums_dict[title] = album
|
||||||
|
|
||||||
|
# Convert deduplicated dict to output format
|
||||||
|
output = []
|
||||||
|
for album in albums_dict.values():
|
||||||
|
output.append({
|
||||||
|
"id": album.id,
|
||||||
|
"title": album.name,
|
||||||
|
"artist": album.artist.name,
|
||||||
|
"tracks": album.num_tracks,
|
||||||
|
"release_date": str(album.release_date) if album.release_date else "Unknown",
|
||||||
|
"cover": self._get_image_url(album.cover),
|
||||||
|
"type": "album"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort by release date (newest first)
|
||||||
|
output.sort(key=lambda x: x['release_date'], reverse=True)
|
||||||
|
|
||||||
|
return output
|
||||||
|
|
||||||
def get_track(self, track_id: str):
|
def get_track(self, track_id: str):
|
||||||
return self.session.track(track_id)
|
return self.session.track(track_id)
|
||||||
|
|
||||||
def get_album(self, album_id: str):
|
def get_album(self, album_id: str):
|
||||||
return self.session.album(album_id)
|
return self.session.album(album_id)
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,106 @@ button:hover {
|
|||||||
color: #ffff00;
|
color: #ffff00;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-cancelled {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal Styles */
|
||||||
|
.modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
margin: 5% auto;
|
||||||
|
padding: 30px;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 900px;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #1db954;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 15px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-item {
|
||||||
|
background: #2d2d2d;
|
||||||
|
border: 2px solid #333;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-item:hover {
|
||||||
|
border-color: #1db954;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-item.selected {
|
||||||
|
border-color: #1db954;
|
||||||
|
background: #2a4a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-item img {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-item-info {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-item-info strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-item-info small {
|
||||||
|
color: #888;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-buttons button {
|
||||||
|
padding: 10px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
margin-right: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.status-failed {
|
.status-failed {
|
||||||
color: #ff0000;
|
color: #ff0000;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Album Selection Modal -->
|
||||||
|
<div id="album-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h3 id="modal-artist-name">Albums</h3>
|
||||||
|
<div id="album-grid" class="album-grid"></div>
|
||||||
|
<div class="modal-buttons">
|
||||||
|
<button onclick="downloadSelectedAlbums()" style="background-color: #1db954;">Download Selected</button>
|
||||||
|
<button onclick="downloadAllAlbums()" style="background-color: #0d7a3a;">Download All</button>
|
||||||
|
<button onclick="closeAlbumModal()" style="background-color: #666;">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="card" style="margin-top: 20px; text-align: center; color: #888;">
|
<div class="card" style="margin-top: 20px; text-align: center; color: #888;">
|
||||||
<small>System IP: <span id="system-ip">Loading...</span></small>
|
<small>System IP: <span id="system-ip">Loading...</span></small>
|
||||||
@@ -124,13 +137,19 @@
|
|||||||
function createResultItem(item) {
|
function createResultItem(item) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'result-item';
|
div.className = 'result-item';
|
||||||
|
|
||||||
|
// For artists, show album selection modal instead of direct download
|
||||||
|
const buttonHtml = item.type === 'artist'
|
||||||
|
? `<button onclick="showArtistAlbums('${item.id}', '${item.title.replace(/'/g, "\\'")}')">View Albums</button>`
|
||||||
|
: `<button onclick="download('${item.type}', '${item.id}')">Download</button>`;
|
||||||
|
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<img src="${item.cover || '/static/placeholder.png'}" alt="Cover">
|
<img src="${item.cover || '/static/placeholder.png'}" alt="Cover">
|
||||||
<div class="result-info">
|
<div class="result-info">
|
||||||
<strong>${item.title}</strong><br>
|
<strong>${item.title}</strong><br>
|
||||||
${item.artist ? item.artist + ' - ' : ''}${item.album || ''}
|
${item.artist ? item.artist + ' - ' : ''}${item.album || ''}
|
||||||
</div>
|
</div>
|
||||||
<button onclick="download('${item.type}', '${item.id}')">Download</button>
|
${buttonHtml}
|
||||||
`;
|
`;
|
||||||
return div;
|
return div;
|
||||||
}
|
}
|
||||||
@@ -189,6 +208,100 @@
|
|||||||
updateQueue();
|
updateQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Album Selection Modal Functions
|
||||||
|
let currentAlbums = [];
|
||||||
|
let selectedAlbumIds = new Set();
|
||||||
|
|
||||||
|
async function showArtistAlbums(artistId, artistName) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/search/artist/${artistId}/albums`);
|
||||||
|
currentAlbums = await response.json();
|
||||||
|
|
||||||
|
document.getElementById('modal-artist-name').textContent = `Albums by ${artistName}`;
|
||||||
|
|
||||||
|
const grid = document.getElementById('album-grid');
|
||||||
|
grid.innerHTML = '';
|
||||||
|
selectedAlbumIds.clear();
|
||||||
|
|
||||||
|
if (currentAlbums.length === 0) {
|
||||||
|
grid.innerHTML = '<p>No albums found.</p>';
|
||||||
|
} else {
|
||||||
|
currentAlbums.forEach(album => {
|
||||||
|
const albumDiv = document.createElement('div');
|
||||||
|
albumDiv.className = 'album-item';
|
||||||
|
albumDiv.dataset.albumId = album.id;
|
||||||
|
albumDiv.onclick = () => toggleAlbumSelection(album.id);
|
||||||
|
|
||||||
|
albumDiv.innerHTML = `
|
||||||
|
<img src="${album.cover || '/static/placeholder.png'}" alt="${album.title}">
|
||||||
|
<div class="album-item-info">
|
||||||
|
<strong>${album.title}</strong>
|
||||||
|
<small>${album.tracks} tracks • ${album.release_date}</small>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
grid.appendChild(albumDiv);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('album-modal').style.display = 'block';
|
||||||
|
} catch (e) {
|
||||||
|
alert('Failed to load albums: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAlbumSelection(albumId) {
|
||||||
|
const albumDiv = document.querySelector(`[data-album-id="${albumId}"]`);
|
||||||
|
|
||||||
|
if (selectedAlbumIds.has(albumId)) {
|
||||||
|
selectedAlbumIds.delete(albumId);
|
||||||
|
albumDiv.classList.remove('selected');
|
||||||
|
} else {
|
||||||
|
selectedAlbumIds.add(albumId);
|
||||||
|
albumDiv.classList.add('selected');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadSelectedAlbums() {
|
||||||
|
if (selectedAlbumIds.size === 0) {
|
||||||
|
alert('Please select at least one album to download.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const albumId of selectedAlbumIds) {
|
||||||
|
await download('album', albumId);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeAlbumModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadAllAlbums() {
|
||||||
|
if (currentAlbums.length === 0) {
|
||||||
|
alert('No albums to download.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const album of currentAlbums) {
|
||||||
|
await download('album', album.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeAlbumModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAlbumModal() {
|
||||||
|
document.getElementById('album-modal').style.display = 'none';
|
||||||
|
selectedAlbumIds.clear();
|
||||||
|
currentAlbums = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal when clicking outside
|
||||||
|
window.onclick = function (event) {
|
||||||
|
const modal = document.getElementById('album-modal');
|
||||||
|
if (event.target === modal) {
|
||||||
|
closeAlbumModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Poll queue
|
// Poll queue
|
||||||
setInterval(updateQueue, 2000);
|
setInterval(updateQueue, 2000);
|
||||||
updateQueue();
|
updateQueue();
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class Settings:
|
|||||||
# multi_thread: bool = False
|
# multi_thread: bool = False
|
||||||
download_delay: bool = True
|
download_delay: bool = True
|
||||||
download_base_path: str = "~/download"
|
download_base_path: str = "~/download"
|
||||||
quality_audio: Quality = Quality.low_320k
|
quality_audio: Quality = Quality.hi_res_lossless
|
||||||
quality_video: QualityVideo = QualityVideo.P480
|
quality_video: QualityVideo = QualityVideo.P480
|
||||||
download_dolby_atmos: bool = False
|
download_dolby_atmos: bool = False
|
||||||
format_album: str = (
|
format_album: str = (
|
||||||
|
|||||||
Reference in New Issue
Block a user