feat(library): add ipod-safe emoji encoding for playlist names

This commit is contained in:
2025-10-05 01:07:22 -04:00
parent 369ea9df02
commit cba49ce411
4 changed files with 124 additions and 7 deletions

View File

@@ -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<string> {
// 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',
''
];

View File

@@ -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<string | undefined> {
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<ParsedPlaylis
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Skip empty lines and non-EXTINF comments
// Skip empty lines and comments (except EXTINF)
if (!line || (line.startsWith('#') && !line.startsWith('#EXTINF'))) {
continue;
}

View File

@@ -1,6 +1,7 @@
import { readDir, readFile } from '@tauri-apps/plugin-fs';
import { parseBuffer } from 'music-metadata';
import type { Album, ArtistWithAlbums, AudioFormat } from '$lib/types/track';
import { parsePlaylistName } from './playlist';
export interface Artist {
name: string;
@@ -283,11 +284,18 @@ export async function scanPlaylists(playlistsFolderPath: string): Promise<Playli
if (!entry.isDirectory) {
const isPlaylist = entry.name.endsWith('.m3u') || entry.name.endsWith('.m3u8');
if (isPlaylist) {
// Remove extension for display name
const playlistPath = `${playlistsFolderPath}/${entry.name}`;
// Try to read playlist name from #PLAYLIST: metadata (with emoji decoding)
const metadataName = await parsePlaylistName(playlistPath);
// Fallback to filename without extension if no metadata found
const nameWithoutExt = entry.name.replace(/\.(m3u8?|M3U8?)$/, '');
const displayName = metadataName || nameWithoutExt;
playlists.push({
name: nameWithoutExt,
path: `${playlistsFolderPath}/${entry.name}`
name: displayName,
path: playlistPath
});
}
}

82
src/lib/utils/emoji.ts Normal file
View File

@@ -0,0 +1,82 @@
/**
* Emoji encoding/decoding utilities for filesystem-safe names
* Converts emojis to [U+XXXXX] format for safe storage
*/
/**
* Check if a character is an emoji
* Emojis are in Unicode ranges:
* - Basic Emoticons: U+1F600 - U+1F64F
* - Dingbats: U+2700 - U+27BF
* - Miscellaneous Symbols: U+2600 - U+26FF
* - Transport and Map: U+1F680 - U+1F6FF
* - Supplemental Symbols: U+1F900 - U+1F9FF
* - Flags: U+1F1E6 - U+1F1FF
* - And many more...
*/
function isEmoji(codePoint: number): boolean {
return (
(codePoint >= 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;
}
});
}