Merge branch 'main' of github.com:monochrome-music/monochrome
This commit is contained in:
commit
b4a7f116f9
7 changed files with 168 additions and 84 deletions
21
.github/workflows/editors-picks.yml
vendored
21
.github/workflows/editors-picks.yml
vendored
|
|
@ -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
22
fix-gen.py
Normal 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)
|
||||
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
23
js/api.js
23
js/api.js
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
34
js/player.js
34
js/player.js
|
|
@ -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';
|
||||
|
|
|
|||
73
js/ui.js
73
js/ui.js
|
|
@ -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' });
|
||||
|
|
|
|||
Loading…
Reference in a new issue