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:
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
if: steps.backoff.outputs.skip == 'false'
run: |
@ -68,14 +64,19 @@ jobs:
with open("public/editors-picks.json") as f:
current = json.load(f)
# Replace cover URLs with original UUIDs
url_pattern = re.compile(r'^https://monochrome\.tf/editors-picks-images/([a-f0-9-]+)\.webp$')
# Replace cover URLs with original UUIDs (handles both legacy and intermediate formats)
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 field in ['cover', 'picture', 'image']:
if field in item and item[field]:
match = url_pattern.match(item[field])
if match:
item[field] = match.group(1)
m1 = url_pattern1.match(item[field])
if m1:
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:
json.dump(current, f, indent=4)
@ -103,7 +104,7 @@ jobs:
run: |
git config user.name "github-actions[bot]"
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 commit -m "chore: update editors picks"
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 time
import os
import subprocess
import shutil
import tempfile
INPUT_FILE = "editors-picks-input.txt"
IMAGES_DIR = "public/editors-picks-images"
COUNTRY = "US"
# Tidal internal token replace when expired
@ -90,59 +87,6 @@ def fetch_podcast(feed_id):
# ── 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 ──────────────────────────────────────────────────────────────
def transform_album(d):
@ -155,7 +99,7 @@ def transform_album(d):
"name": d.get("artist", {}).get("name"),
},
"releaseDate": d.get("releaseDate"),
"cover": process_cover(d.get("cover")),
"cover": d.get("cover"),
"explicit": d.get("explicit"),
"audioQuality": d.get("audioQuality"),
"mediaMetadata": d.get("mediaMetadata"),
@ -167,7 +111,7 @@ def transform_artist(d):
"type": "artist",
"id": d.get("id"),
"name": d.get("name"),
"picture": process_cover(d.get("picture")),
"picture": d.get("picture"),
}
@ -184,7 +128,7 @@ def transform_track(d):
"album": {
"id": album.get("id"),
"title": album.get("title"),
"cover": process_cover(album.get("cover")),
"cover": album.get("cover"),
},
"duration": d.get("duration"),
"explicit": d.get("explicit"),
@ -200,7 +144,7 @@ def transform_playlist(d):
"type": "playlist",
"id": d.get("uuid"),
"title": d.get("title"),
"cover": process_cover(cover),
"cover": cover,
"numberOfTracks": d.get("numberOfTracks", 0),
}
@ -213,7 +157,7 @@ def transform_userplaylist(d):
"type": "user-playlist",
"id": d.get("uuid"),
"name": d.get("title"),
"cover": process_cover(cover),
"cover": cover,
"numberOfTracks": d.get("numberOfTracks", 0),
"username": creator.get("name"),
}
@ -258,8 +202,6 @@ def read_items(path):
# ── Main ──────────────────────────────────────────────────────────────────────
clear_images_dir()
FETCHERS = {
"album": (fetch_album, transform_album),
"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`;
}
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') {
if (!id) {
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`;
}
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') {
if (!imageId) {
return null;

View file

@ -223,6 +223,13 @@ export class MusicAPI {
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') {
if (!imageId) {
return null;
@ -260,6 +267,10 @@ export class MusicAPI {
return this.tidalAPI.getArtistPictureUrl(this.stripProviderPrefix(id), size);
}
getArtistPictureSrcset(id) {
return this.tidalAPI.getArtistPictureSrcset(this.stripProviderPrefix(id));
}
extractStreamUrlFromManifest(manifest) {
return this.tidalAPI.extractStreamUrlFromManifest(manifest);
}

View file

@ -307,8 +307,9 @@ export class Player {
if (coverEl) {
const videoCoverUrl = track.videoUrl || track.videoCoverUrl || track.album?.videoCoverUrl || null;
const coverUrl =
videoCoverUrl || this.api.getCoverUrl(track.image || track.cover || track.album?.cover);
const coverId = 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 (coverEl.tagName === 'IMG') {
@ -326,14 +327,24 @@ export class Player {
coverEl.src = videoCoverUrl;
}
} 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') {
const img = document.createElement('img');
img.src = coverUrl;
img.className = coverEl.className;
img.id = coverEl.id;
setImgSrcset(img);
coverEl.replaceWith(img);
} else {
coverEl.src = coverUrl;
setImgSrcset(coverEl);
}
}
}
@ -870,8 +881,19 @@ export class Player {
} else {
if (coverEl) {
coverEl.style.display = 'block';
const coverUrl = this.api.getCoverUrl(track.image || track.cover || track.album?.cover);
if (coverEl.src !== coverUrl) coverEl.src = coverUrl;
const coverId = track.image || track.cover || track.album?.cover;
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) {
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) {
const imageUrl = this.api.getCoverUrl(cover);
getCoverHTML(
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) {
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}">`;
}
@ -658,7 +690,15 @@ export class UIRenderer {
createUserPlaylistCardHTML(playlist, customSubtitle = null) {
let imageHTML = '';
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 {
const tracks = playlist.tracks || [];
let uniqueCovers = playlist.images || [];
@ -750,7 +790,9 @@ export class UIRenderer {
escapeHtml(album.title),
'card-image',
album._lazy === false ? 'eager' : 'lazy',
album.videoCoverUrl
album.videoCoverUrl,
album._isEditorsPick || false,
'album'
),
actionButtonsHTML: `
<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}`,
title: escapeHtml(artist.name),
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: `
<button class="like-btn card-like-btn" data-action="toggle-like" data-type="artist" title="Add to Liked">
${this.createHeartIcon(false)}
@ -2622,6 +2672,7 @@ export class UIRenderer {
mediaMetadata: item.mediaMetadata,
type: 'ALBUM',
_lazy: cardsHTML.length >= 6,
_isEditorsPick: true,
};
cardsHTML.push(this.createAlbumCardHTML(album));
itemsToStore.push({ el: null, data: album, type: 'album' });
@ -2630,6 +2681,7 @@ export class UIRenderer {
const result = await this.api.getAlbum(item.id);
if (result && result.album) {
result.album._lazy = cardsHTML.length >= 6;
result.album._isEditorsPick = true;
cardsHTML.push(this.createAlbumCardHTML(result.album));
itemsToStore.push({ el: null, data: result.album, type: 'album' });
}
@ -2653,6 +2705,7 @@ export class UIRenderer {
type: 'ALBUM',
_href: `/userplaylist/${item.id}`,
_lazy: cardsHTML.length >= 6,
_isEditorsPick: true,
})
);
itemsToStore.push({ el: null, data: playlist, type: 'user-playlist' });
@ -2665,6 +2718,7 @@ export class UIRenderer {
name: item.name,
picture: item.picture,
_lazy: cardsHTML.length >= 6,
_isEditorsPick: true,
};
cardsHTML.push(this.createArtistCardHTML(artist));
itemsToStore.push({ el: null, data: artist, type: 'artist' });
@ -2673,6 +2727,7 @@ export class UIRenderer {
const artist = await this.api.getArtist(item.id);
if (artist) {
artist._lazy = cardsHTML.length >= 6;
artist._isEditorsPick = true;
cardsHTML.push(this.createArtistCardHTML(artist));
itemsToStore.push({ el: null, data: artist, type: 'artist' });
}
@ -2689,6 +2744,8 @@ export class UIRenderer {
audioQuality: item.audioQuality,
mediaMetadata: item.mediaMetadata,
duration: item.duration,
_lazy: cardsHTML.length >= 6,
_isEditorsPick: true,
};
cardsHTML.push(this.createTrackCardHTML(track));
itemsToStore.push({ el: null, data: track, type: 'track' });
@ -2696,6 +2753,8 @@ export class UIRenderer {
// Fall back to API call
const track = await this.api.getTrackMetadata(item.id);
if (track) {
track._lazy = cardsHTML.length >= 6;
track._isEditorsPick = true;
cardsHTML.push(this.createTrackCardHTML(track));
itemsToStore.push({ el: null, data: track, type: 'track' });
}
@ -2708,6 +2767,8 @@ export class UIRenderer {
cover: item.cover,
tracks: item.tracks || [],
numberOfTracks: item.numberOfTracks || (item.tracks ? item.tracks.length : 0),
_lazy: cardsHTML.length >= 6,
_isEditorsPick: true,
};
const subtitle = item.username ? `by ${item.username}` : null;
cardsHTML.push(this.createUserPlaylistCardHTML(playlist, subtitle));
@ -2715,6 +2776,8 @@ export class UIRenderer {
} else {
const playlist = await syncManager.getPublicPlaylist(item.id);
if (playlist) {
playlist._lazy = cardsHTML.length >= 6;
playlist._isEditorsPick = true;
const subtitle = item.username ? `by ${item.username}` : null;
cardsHTML.push(this.createUserPlaylistCardHTML(playlist, subtitle));
itemsToStore.push({ el: null, data: playlist, type: 'user-playlist' });