From cba49ce41135e3340f832fa47de4ea57416a9892 Mon Sep 17 00:00:00 2001 From: Markury Date: Sun, 5 Oct 2025 01:07:22 -0400 Subject: [PATCH] feat(library): add ipod-safe emoji encoding for playlist names --- src/lib/library/m3u8.ts | 8 ++-- src/lib/library/playlist.ts | 27 +++++++++++- src/lib/library/scanner.ts | 14 +++++-- src/lib/utils/emoji.ts | 82 +++++++++++++++++++++++++++++++++++++ 4 files changed, 124 insertions(+), 7 deletions(-) create mode 100644 src/lib/utils/emoji.ts diff --git a/src/lib/library/m3u8.ts b/src/lib/library/m3u8.ts index c66a87f..55f98ee 100644 --- a/src/lib/library/m3u8.ts +++ b/src/lib/library/m3u8.ts @@ -1,5 +1,6 @@ import { writeFile } from '@tauri-apps/plugin-fs'; import { sanitizeFilename } from '$lib/services/deezer/paths'; +import { encodeEmojis } from '$lib/utils/emoji'; export interface M3U8Track { duration: number; // in seconds @@ -22,14 +23,15 @@ export async function writeM3U8( tracks: M3U8Track[], playlistsFolder: string ): Promise { - // Sanitize playlist name for filename - const sanitizedName = sanitizeFilename(playlistName); + // Encode emojis and sanitize playlist name for filename + const encodedName = encodeEmojis(playlistName); + const sanitizedName = sanitizeFilename(encodedName); const playlistPath = `${playlistsFolder}/${sanitizedName}.m3u8`; // Build m3u8 content const lines: string[] = [ '#EXTM3U', - `#PLAYLIST:${playlistName}`, + `#PLAYLIST:${encodedName}`, '#EXTENC:UTF-8', '' ]; diff --git a/src/lib/library/playlist.ts b/src/lib/library/playlist.ts index cae4a73..ceafcbe 100644 --- a/src/lib/library/playlist.ts +++ b/src/lib/library/playlist.ts @@ -3,6 +3,7 @@ import { invoke } from '@tauri-apps/api/core'; import type { Track, AudioFormat, PlaylistWithTracks, TrackMetadata } from '$lib/types/track'; import { findAlbumArt } from './album'; import { sanitizeFilename } from '$lib/services/deezer/paths'; +import { decodeEmojis } from '$lib/utils/emoji'; /** * Get audio format from file extension @@ -36,6 +37,30 @@ export interface ParsedPlaylistTrack { }; } +/** + * Extract playlist name from #PLAYLIST: metadata line in m3u8 file + * Returns decoded emoji name, or undefined if not found + */ +export async function parsePlaylistName(playlistPath: string): Promise { + try { + const content = await readTextFile(playlistPath); + const lines = content.split('\n'); + + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith('#PLAYLIST:')) { + const encodedName = trimmed.substring('#PLAYLIST:'.length); + return decodeEmojis(encodedName); + } + } + + return undefined; + } catch (error) { + console.error('Error reading playlist name:', error); + return undefined; + } +} + /** * Parse M3U/M3U8 playlist file * Supports both basic M3U and extended M3U8 format @@ -52,7 +77,7 @@ export async function parsePlaylist(playlistPath: string): Promise= 0x1F600 && codePoint <= 0x1F64F) || // Emoticons + (codePoint >= 0x1F300 && codePoint <= 0x1F5FF) || // Misc Symbols and Pictographs + (codePoint >= 0x1F680 && codePoint <= 0x1F6FF) || // Transport and Map + (codePoint >= 0x1F900 && codePoint <= 0x1F9FF) || // Supplemental Symbols + (codePoint >= 0x1F1E6 && codePoint <= 0x1F1FF) || // Flags + (codePoint >= 0x2600 && codePoint <= 0x26FF) || // Misc symbols + (codePoint >= 0x2700 && codePoint <= 0x27BF) || // Dingbats + (codePoint >= 0xFE00 && codePoint <= 0xFE0F) || // Variation Selectors + (codePoint >= 0x1F000 && codePoint <= 0x1F02F) || // Mahjong Tiles + (codePoint >= 0x1F0A0 && codePoint <= 0x1F0FF) || // Playing Cards + (codePoint >= 0x1FA70 && codePoint <= 0x1FAFF) || // Symbols and Pictographs Extended-A + (codePoint >= 0x200D) || // Zero Width Joiner (used in emoji sequences) + (codePoint >= 0x231A && codePoint <= 0x231B) || // Watch, Hourglass + (codePoint >= 0x23E9 && codePoint <= 0x23F3) || // Media controls + (codePoint >= 0x25AA && codePoint <= 0x25AB) || // Geometric shapes + (codePoint >= 0x25B6) || // Play button + (codePoint >= 0x2934 && codePoint <= 0x2935) || // Arrows + (codePoint >= 0x2B05 && codePoint <= 0x2B07) || // Arrows + (codePoint >= 0x3030) || // Wavy dash + (codePoint >= 0x303D) || // Part alternation mark + (codePoint >= 0x3297) || // Japanese symbols + (codePoint >= 0x3299) // Japanese symbols + ); +} + +/** + * Encode emojis in text to [U+XXXXX] format + * Example: "hello 👀" → "hello [U+1F440]" + */ +export function encodeEmojis(text: string): string { + let result = ''; + + // Iterate through Unicode code points (not just chars, to handle surrogate pairs) + for (const char of text) { + const codePoint = char.codePointAt(0); + + if (codePoint !== undefined && isEmoji(codePoint)) { + // Convert to hex string with uppercase + const hex = codePoint.toString(16).toUpperCase(); + result += `[U+${hex}]`; + } else { + result += char; + } + } + + return result; +} + +/** + * Decode [U+XXXXX] format back to emojis + * Example: "hello [U+1F440]" → "hello 👀" + */ +export function decodeEmojis(text: string): string { + // Match [U+XXXXX] patterns (hex can be 4-6 digits for Unicode) + return text.replace(/\[U\+([0-9A-Fa-f]+)\]/g, (match, hex) => { + try { + const codePoint = parseInt(hex, 16); + return String.fromCodePoint(codePoint); + } catch { + // If parsing fails, return the original match + return match; + } + }); +}