Merge branch 'main' of github.com:monochrome-music/monochrome

This commit is contained in:
Samidy 2026-04-05 19:19:54 +03:00
commit b4a7f116f9
7 changed files with 168 additions and 84 deletions

View file

@ -37,10 +37,6 @@ jobs:
with: with:
python-version: '3.x' python-version: '3.x'
- name: Install webp
if: steps.backoff.outputs.skip == 'false'
run: sudo apt-get update && sudo apt-get install -y webp
- name: Archive current editors picks - name: Archive current editors picks
if: steps.backoff.outputs.skip == 'false' if: steps.backoff.outputs.skip == 'false'
run: | run: |
@ -68,14 +64,19 @@ jobs:
with open("public/editors-picks.json") as f: with open("public/editors-picks.json") as f:
current = json.load(f) current = json.load(f)
# Replace cover URLs with original UUIDs # Replace cover URLs with original UUIDs (handles both legacy and intermediate formats)
url_pattern = re.compile(r'^https://monochrome\.tf/editors-picks-images/([a-f0-9-]+)\.webp$') url_pattern1 = re.compile(r'^https://monochrome\.tf/editors-picks-images/([a-f0-9-]+)\.webp$')
url_pattern2 = re.compile(r'^https://wsrv\.nl/\?url=https://resources\.tidal\.com/images/([a-f0-9/]+)/320x320\.jpg&w=250&h=250&output=webp$')
for item in current: for item in current:
for field in ['cover', 'picture', 'image']: for field in ['cover', 'picture', 'image']:
if field in item and item[field]: if field in item and item[field]:
match = url_pattern.match(item[field]) m1 = url_pattern1.match(item[field])
if match: if m1:
item[field] = match.group(1) item[field] = m1.group(1)
continue
m2 = url_pattern2.match(item[field])
if m2:
item[field] = m2.group(1).replace("/", "-")
with open(archive_path, "w") as f: with open(archive_path, "w") as f:
json.dump(current, f, indent=4) json.dump(current, f, indent=4)
@ -103,7 +104,7 @@ jobs:
run: | run: |
git config user.name "github-actions[bot]" git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com" git config user.email "github-actions[bot]@users.noreply.github.com"
git add public/editors-picks.json public/editors-picks-old/ public/editors-picks-images/ git add public/editors-picks.json public/editors-picks-old/
git diff --staged --quiet && echo "No changes to commit." && exit 0 git diff --staged --quiet && echo "No changes to commit." && exit 0
git commit -m "chore: update editors picks" git commit -m "chore: update editors picks"
git push git push

22
fix-gen.py Normal file
View file

@ -0,0 +1,22 @@
import re
with open("gen-editors-picks.py", "r") as f: content = f.read()
content = re.sub(r"IMAGES_DIR = \"public/editors-picks-images\"\n+", "", content)
# Remove clear_images_dir definition
content = re.sub(r"def clear_images_dir\(\):[\s\S]*?os\.makedirs[^\n]*\n+", "", content)
content = re.sub(r"clear_images_dir\(\)\n+", "", content)
# Remove the import line for subprocess, shutil if present
content = re.sub(r"import subprocess\n", "", content)
content = re.sub(r"import shutil\n", "", content)
# Replace download_and_process_cover
new_func = """def download_and_process_cover(cover_uuid):
url = f"https://resources.tidal.com/images/{uuid_to_path_segments(cover_uuid)}/320x320.jpg"
return f"https://wsrv.nl/?url={url}&w=250&h=250&output=webp"
"""
content = re.sub(r"def download_and_process_cover\(cover_uuid\):[\s\S]*?(?=def process_cover)", new_func + "\n\n", content)
with open("gen-editors-picks.py", "w") as f: f.write(content)

View file

@ -8,12 +8,9 @@ import sys
import hashlib import hashlib
import time import time
import os import os
import subprocess
import shutil
import tempfile import tempfile
INPUT_FILE = "editors-picks-input.txt" INPUT_FILE = "editors-picks-input.txt"
IMAGES_DIR = "public/editors-picks-images"
COUNTRY = "US" COUNTRY = "US"
# Tidal internal token replace when expired # Tidal internal token replace when expired
@ -90,59 +87,6 @@ def fetch_podcast(feed_id):
# ── Image processing ─────────────────────────────────────────────────────────── # ── Image processing ───────────────────────────────────────────────────────────
def clear_images_dir():
if os.path.exists(IMAGES_DIR):
shutil.rmtree(IMAGES_DIR)
os.makedirs(IMAGES_DIR, exist_ok=True)
def is_uuid_cover(cover_value):
if not cover_value:
return False
return bool(re.match(r'^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$', cover_value))
def uuid_to_path_segments(uuid):
return uuid.replace('-', '/')
def download_and_process_cover(cover_uuid):
url = f"https://resources.tidal.com/images/{uuid_to_path_segments(cover_uuid)}/320x320.jpg"
with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as tmp:
tmp_path = tmp.name
try:
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req) as resp:
with open(tmp_path, 'wb') as f:
shutil.copyfileobj(resp, f)
output_path = os.path.join(IMAGES_DIR, f"{cover_uuid}.webp")
subprocess.run(
['cwebp', '-q', '50', tmp_path, '-o', output_path],
check=True,
capture_output=True
)
return f"https://monochrome.tf/editors-picks-images/{cover_uuid}.webp"
except Exception as e:
print(f"Error processing cover {cover_uuid}: {e}", file=sys.stderr)
return None
finally:
if os.path.exists(tmp_path):
os.remove(tmp_path)
def process_cover(cover_value):
if not cover_value:
return cover_value
if is_uuid_cover(cover_value):
return download_and_process_cover(cover_value)
return cover_value
# ── Transformers ────────────────────────────────────────────────────────────── # ── Transformers ──────────────────────────────────────────────────────────────
def transform_album(d): def transform_album(d):
@ -155,7 +99,7 @@ def transform_album(d):
"name": d.get("artist", {}).get("name"), "name": d.get("artist", {}).get("name"),
}, },
"releaseDate": d.get("releaseDate"), "releaseDate": d.get("releaseDate"),
"cover": process_cover(d.get("cover")), "cover": d.get("cover"),
"explicit": d.get("explicit"), "explicit": d.get("explicit"),
"audioQuality": d.get("audioQuality"), "audioQuality": d.get("audioQuality"),
"mediaMetadata": d.get("mediaMetadata"), "mediaMetadata": d.get("mediaMetadata"),
@ -167,7 +111,7 @@ def transform_artist(d):
"type": "artist", "type": "artist",
"id": d.get("id"), "id": d.get("id"),
"name": d.get("name"), "name": d.get("name"),
"picture": process_cover(d.get("picture")), "picture": d.get("picture"),
} }
@ -184,7 +128,7 @@ def transform_track(d):
"album": { "album": {
"id": album.get("id"), "id": album.get("id"),
"title": album.get("title"), "title": album.get("title"),
"cover": process_cover(album.get("cover")), "cover": album.get("cover"),
}, },
"duration": d.get("duration"), "duration": d.get("duration"),
"explicit": d.get("explicit"), "explicit": d.get("explicit"),
@ -200,7 +144,7 @@ def transform_playlist(d):
"type": "playlist", "type": "playlist",
"id": d.get("uuid"), "id": d.get("uuid"),
"title": d.get("title"), "title": d.get("title"),
"cover": process_cover(cover), "cover": cover,
"numberOfTracks": d.get("numberOfTracks", 0), "numberOfTracks": d.get("numberOfTracks", 0),
} }
@ -213,7 +157,7 @@ def transform_userplaylist(d):
"type": "user-playlist", "type": "user-playlist",
"id": d.get("uuid"), "id": d.get("uuid"),
"name": d.get("title"), "name": d.get("title"),
"cover": process_cover(cover), "cover": cover,
"numberOfTracks": d.get("numberOfTracks", 0), "numberOfTracks": d.get("numberOfTracks", 0),
"username": creator.get("name"), "username": creator.get("name"),
} }
@ -258,8 +202,6 @@ def read_items(path):
# ── Main ────────────────────────────────────────────────────────────────────── # ── Main ──────────────────────────────────────────────────────────────────────
clear_images_dir()
FETCHERS = { FETCHERS = {
"album": (fetch_album, transform_album), "album": (fetch_album, transform_album),
"artist": (fetch_artist, transform_artist), "artist": (fetch_artist, transform_artist),

View file

@ -1952,6 +1952,19 @@ export class LosslessAPI {
return `https://resources.tidal.com/images/${formattedId}/${size}x${size}.jpg`; return `https://resources.tidal.com/images/${formattedId}/${size}x${size}.jpg`;
} }
getCoverSrcset(id) {
if (
!id ||
(typeof id === 'string' && (id.startsWith('http') || id.startsWith('blob:') || id.startsWith('assets/')))
) {
return '';
}
const formattedId = String(id).replace(/-/g, '/');
const baseUrl = `https://resources.tidal.com/images/${formattedId}`;
return `${baseUrl}/160x160.jpg 160w, ${baseUrl}/320x320.jpg 320w, ${baseUrl}/640x640.jpg 640w`;
}
getArtistPictureUrl(id, size = '320') { getArtistPictureUrl(id, size = '320') {
if (!id) { if (!id) {
return `https://picsum.photos/seed/${Math.random()}/${size}`; return `https://picsum.photos/seed/${Math.random()}/${size}`;
@ -1965,6 +1978,16 @@ export class LosslessAPI {
return `https://resources.tidal.com/images/${formattedId}/${size}x${size}.jpg`; return `https://resources.tidal.com/images/${formattedId}/${size}x${size}.jpg`;
} }
getArtistPictureSrcset(id) {
if (!id || (typeof id === 'string' && (id.startsWith('blob:') || id.startsWith('assets/')))) {
return '';
}
const formattedId = String(id).replace(/-/g, '/');
const baseUrl = `https://resources.tidal.com/images/${formattedId}`;
return `${baseUrl}/160x160.jpg 160w, ${baseUrl}/320x320.jpg 320w, ${baseUrl}/640x640.jpg 640w`;
}
getVideoCoverUrl(imageId, size = '1280') { getVideoCoverUrl(imageId, size = '1280') {
if (!imageId) { if (!imageId) {
return null; return null;

View file

@ -223,6 +223,13 @@ export class MusicAPI {
return this.tidalAPI.getCoverUrl(this.stripProviderPrefix(id), size); return this.tidalAPI.getCoverUrl(this.stripProviderPrefix(id), size);
} }
getCoverSrcset(id) {
if (typeof id === 'string' && id.startsWith('blob:')) {
return '';
}
return this.tidalAPI.getCoverSrcset(this.stripProviderPrefix(id));
}
getVideoCoverUrl(imageId, size = '1280') { getVideoCoverUrl(imageId, size = '1280') {
if (!imageId) { if (!imageId) {
return null; return null;
@ -260,6 +267,10 @@ export class MusicAPI {
return this.tidalAPI.getArtistPictureUrl(this.stripProviderPrefix(id), size); return this.tidalAPI.getArtistPictureUrl(this.stripProviderPrefix(id), size);
} }
getArtistPictureSrcset(id) {
return this.tidalAPI.getArtistPictureSrcset(this.stripProviderPrefix(id));
}
extractStreamUrlFromManifest(manifest) { extractStreamUrlFromManifest(manifest) {
return this.tidalAPI.extractStreamUrlFromManifest(manifest); return this.tidalAPI.extractStreamUrlFromManifest(manifest);
} }

View file

@ -307,8 +307,9 @@ export class Player {
if (coverEl) { if (coverEl) {
const videoCoverUrl = track.videoUrl || track.videoCoverUrl || track.album?.videoCoverUrl || null; const videoCoverUrl = track.videoUrl || track.videoCoverUrl || track.album?.videoCoverUrl || null;
const coverUrl = const coverId = track.image || track.cover || track.album?.cover;
videoCoverUrl || this.api.getCoverUrl(track.image || track.cover || track.album?.cover); const coverUrl = videoCoverUrl || this.api.getCoverUrl(coverId);
const coverSrcset = videoCoverUrl ? null : this.api.getCoverSrcset(coverId);
if (videoCoverUrl) { if (videoCoverUrl) {
if (coverEl.tagName === 'IMG') { if (coverEl.tagName === 'IMG') {
@ -326,14 +327,24 @@ export class Player {
coverEl.src = videoCoverUrl; coverEl.src = videoCoverUrl;
} }
} else { } else {
const setImgSrcset = (img) => {
if (img.getAttribute('src') !== coverUrl) img.src = coverUrl;
if (coverSrcset) {
img.setAttribute('srcset', coverSrcset);
img.setAttribute('sizes', '(max-width: 640px) 160px, (max-width: 1024px) 320px, 640px');
} else {
img.removeAttribute('srcset');
img.removeAttribute('sizes');
}
};
if (coverEl.tagName === 'VIDEO') { if (coverEl.tagName === 'VIDEO') {
const img = document.createElement('img'); const img = document.createElement('img');
img.src = coverUrl;
img.className = coverEl.className; img.className = coverEl.className;
img.id = coverEl.id; img.id = coverEl.id;
setImgSrcset(img);
coverEl.replaceWith(img); coverEl.replaceWith(img);
} else { } else {
coverEl.src = coverUrl; setImgSrcset(coverEl);
} }
} }
} }
@ -870,8 +881,19 @@ export class Player {
} else { } else {
if (coverEl) { if (coverEl) {
coverEl.style.display = 'block'; coverEl.style.display = 'block';
const coverUrl = this.api.getCoverUrl(track.image || track.cover || track.album?.cover); const coverId = track.image || track.cover || track.album?.cover;
if (coverEl.src !== coverUrl) coverEl.src = coverUrl; const coverUrl = this.api.getCoverUrl(coverId);
const coverSrcset = this.api.getCoverSrcset(coverId);
if (coverEl.getAttribute('src') !== coverUrl) {
coverEl.src = coverUrl;
if (coverSrcset) {
coverEl.setAttribute('srcset', coverSrcset);
coverEl.setAttribute('sizes', '(max-width: 640px) 160px, (max-width: 1024px) 320px, 640px');
} else {
coverEl.removeAttribute('srcset');
coverEl.removeAttribute('sizes');
}
}
} }
if (this.audio) { if (this.audio) {
const isInFullscreen = document.getElementById('fullscreen-cover-overlay')?.style.display === 'flex'; const isInFullscreen = document.getElementById('fullscreen-cover-overlay')?.style.display === 'flex';

View file

@ -543,11 +543,43 @@ export class UIRenderer {
`; `;
} }
getCoverHTML(cover, alt, className = 'card-image', loading = 'lazy', videoCoverUrl = null) { getCoverHTML(
const imageUrl = this.api.getCoverUrl(cover); cover,
alt,
className = 'card-image',
loading = 'lazy',
videoCoverUrl = null,
isEditorsPick = false,
type = 'album'
) {
let size = '320';
if (this.currentPage === 'search' || className === 'track-item-cover') {
size = '80';
} else if (type === 'artist') {
size = '160';
}
const imageUrl =
type === 'artist' ? this.api.getArtistPictureUrl(cover, size) : this.api.getCoverUrl(cover, size);
if (videoCoverUrl) { if (videoCoverUrl) {
return `<video src="${videoCoverUrl}" poster="${imageUrl}" class="${className}" alt="${alt}" preload="metadata" playsinline muted></video>`; return `<video src="${videoCoverUrl}" poster="${imageUrl}" class="${className}" alt="${alt}" preload="metadata" playsinline muted></video>`;
} }
if (
isEditorsPick &&
cover &&
typeof cover === 'string' &&
!cover.startsWith('http') &&
!cover.startsWith('blob:') &&
!cover.startsWith('assets/')
) {
const formattedId = String(cover).replace(/-/g, '/');
const tidalUrl = `https://resources.tidal.com/images/${formattedId}/320x320.jpg`;
const wsrvUrl = `https://wsrv.nl/?url=${encodeURIComponent(tidalUrl)}&w=250&h=250&output=webp`;
return `<img src="${wsrvUrl}" class="${className}" alt="${alt}" loading="${loading}">`;
}
return `<img src="${imageUrl}" class="${className}" alt="${alt}" loading="${loading}">`; return `<img src="${imageUrl}" class="${className}" alt="${alt}" loading="${loading}">`;
} }
@ -658,7 +690,15 @@ export class UIRenderer {
createUserPlaylistCardHTML(playlist, customSubtitle = null) { createUserPlaylistCardHTML(playlist, customSubtitle = null) {
let imageHTML = ''; let imageHTML = '';
if (playlist.cover) { if (playlist.cover) {
imageHTML = `<img src="${playlist.cover}" alt="${playlist.name}" class="card-image" loading="lazy">`; imageHTML = this.getCoverHTML(
playlist.cover,
escapeHtml(playlist.name),
'card-image',
playlist._lazy === false ? 'eager' : 'lazy',
null,
playlist._isEditorsPick || false,
'album'
);
} else { } else {
const tracks = playlist.tracks || []; const tracks = playlist.tracks || [];
let uniqueCovers = playlist.images || []; let uniqueCovers = playlist.images || [];
@ -750,7 +790,9 @@ export class UIRenderer {
escapeHtml(album.title), escapeHtml(album.title),
'card-image', 'card-image',
album._lazy === false ? 'eager' : 'lazy', album._lazy === false ? 'eager' : 'lazy',
album.videoCoverUrl album.videoCoverUrl,
album._isEditorsPick || false,
'album'
), ),
actionButtonsHTML: ` actionButtonsHTML: `
<button class="like-btn card-like-btn" data-action="toggle-like" data-type="album" title="Add to Liked"> <button class="like-btn card-like-btn" data-action="toggle-like" data-type="album" title="Add to Liked">
@ -821,7 +863,15 @@ export class UIRenderer {
href: `/artist/${artist.id}`, href: `/artist/${artist.id}`,
title: escapeHtml(artist.name), title: escapeHtml(artist.name),
subtitle: '', subtitle: '',
imageHTML: `<img src="${this.api.getArtistPictureUrl(artist.picture)}" alt="${escapeHtml(artist.name)}" class="card-image" loading="${artist._lazy === false ? 'eager' : 'lazy'}">`, imageHTML: this.getCoverHTML(
artist.picture,
escapeHtml(artist.name),
'card-image',
artist._lazy === false ? 'eager' : 'lazy',
null,
artist._isEditorsPick || false,
'artist'
),
actionButtonsHTML: ` actionButtonsHTML: `
<button class="like-btn card-like-btn" data-action="toggle-like" data-type="artist" title="Add to Liked"> <button class="like-btn card-like-btn" data-action="toggle-like" data-type="artist" title="Add to Liked">
${this.createHeartIcon(false)} ${this.createHeartIcon(false)}
@ -2622,6 +2672,7 @@ export class UIRenderer {
mediaMetadata: item.mediaMetadata, mediaMetadata: item.mediaMetadata,
type: 'ALBUM', type: 'ALBUM',
_lazy: cardsHTML.length >= 6, _lazy: cardsHTML.length >= 6,
_isEditorsPick: true,
}; };
cardsHTML.push(this.createAlbumCardHTML(album)); cardsHTML.push(this.createAlbumCardHTML(album));
itemsToStore.push({ el: null, data: album, type: 'album' }); itemsToStore.push({ el: null, data: album, type: 'album' });
@ -2630,6 +2681,7 @@ export class UIRenderer {
const result = await this.api.getAlbum(item.id); const result = await this.api.getAlbum(item.id);
if (result && result.album) { if (result && result.album) {
result.album._lazy = cardsHTML.length >= 6; result.album._lazy = cardsHTML.length >= 6;
result.album._isEditorsPick = true;
cardsHTML.push(this.createAlbumCardHTML(result.album)); cardsHTML.push(this.createAlbumCardHTML(result.album));
itemsToStore.push({ el: null, data: result.album, type: 'album' }); itemsToStore.push({ el: null, data: result.album, type: 'album' });
} }
@ -2653,6 +2705,7 @@ export class UIRenderer {
type: 'ALBUM', type: 'ALBUM',
_href: `/userplaylist/${item.id}`, _href: `/userplaylist/${item.id}`,
_lazy: cardsHTML.length >= 6, _lazy: cardsHTML.length >= 6,
_isEditorsPick: true,
}) })
); );
itemsToStore.push({ el: null, data: playlist, type: 'user-playlist' }); itemsToStore.push({ el: null, data: playlist, type: 'user-playlist' });
@ -2665,6 +2718,7 @@ export class UIRenderer {
name: item.name, name: item.name,
picture: item.picture, picture: item.picture,
_lazy: cardsHTML.length >= 6, _lazy: cardsHTML.length >= 6,
_isEditorsPick: true,
}; };
cardsHTML.push(this.createArtistCardHTML(artist)); cardsHTML.push(this.createArtistCardHTML(artist));
itemsToStore.push({ el: null, data: artist, type: 'artist' }); itemsToStore.push({ el: null, data: artist, type: 'artist' });
@ -2673,6 +2727,7 @@ export class UIRenderer {
const artist = await this.api.getArtist(item.id); const artist = await this.api.getArtist(item.id);
if (artist) { if (artist) {
artist._lazy = cardsHTML.length >= 6; artist._lazy = cardsHTML.length >= 6;
artist._isEditorsPick = true;
cardsHTML.push(this.createArtistCardHTML(artist)); cardsHTML.push(this.createArtistCardHTML(artist));
itemsToStore.push({ el: null, data: artist, type: 'artist' }); itemsToStore.push({ el: null, data: artist, type: 'artist' });
} }
@ -2689,6 +2744,8 @@ export class UIRenderer {
audioQuality: item.audioQuality, audioQuality: item.audioQuality,
mediaMetadata: item.mediaMetadata, mediaMetadata: item.mediaMetadata,
duration: item.duration, duration: item.duration,
_lazy: cardsHTML.length >= 6,
_isEditorsPick: true,
}; };
cardsHTML.push(this.createTrackCardHTML(track)); cardsHTML.push(this.createTrackCardHTML(track));
itemsToStore.push({ el: null, data: track, type: 'track' }); itemsToStore.push({ el: null, data: track, type: 'track' });
@ -2696,6 +2753,8 @@ export class UIRenderer {
// Fall back to API call // Fall back to API call
const track = await this.api.getTrackMetadata(item.id); const track = await this.api.getTrackMetadata(item.id);
if (track) { if (track) {
track._lazy = cardsHTML.length >= 6;
track._isEditorsPick = true;
cardsHTML.push(this.createTrackCardHTML(track)); cardsHTML.push(this.createTrackCardHTML(track));
itemsToStore.push({ el: null, data: track, type: 'track' }); itemsToStore.push({ el: null, data: track, type: 'track' });
} }
@ -2708,6 +2767,8 @@ export class UIRenderer {
cover: item.cover, cover: item.cover,
tracks: item.tracks || [], tracks: item.tracks || [],
numberOfTracks: item.numberOfTracks || (item.tracks ? item.tracks.length : 0), numberOfTracks: item.numberOfTracks || (item.tracks ? item.tracks.length : 0),
_lazy: cardsHTML.length >= 6,
_isEditorsPick: true,
}; };
const subtitle = item.username ? `by ${item.username}` : null; const subtitle = item.username ? `by ${item.username}` : null;
cardsHTML.push(this.createUserPlaylistCardHTML(playlist, subtitle)); cardsHTML.push(this.createUserPlaylistCardHTML(playlist, subtitle));
@ -2715,6 +2776,8 @@ export class UIRenderer {
} else { } else {
const playlist = await syncManager.getPublicPlaylist(item.id); const playlist = await syncManager.getPublicPlaylist(item.id);
if (playlist) { if (playlist) {
playlist._lazy = cardsHTML.length >= 6;
playlist._isEditorsPick = true;
const subtitle = item.username ? `by ${item.username}` : null; const subtitle = item.username ? `by ${item.username}` : null;
cardsHTML.push(this.createUserPlaylistCardHTML(playlist, subtitle)); cardsHTML.push(this.createUserPlaylistCardHTML(playlist, subtitle));
itemsToStore.push({ el: null, data: playlist, type: 'user-playlist' }); itemsToStore.push({ el: null, data: playlist, type: 'user-playlist' });