mirror of
https://github.com/markuryy/shark.git
synced 2025-12-12 11:41:02 +00:00
feat(library): add ipod-safe emoji encoding for playlist names
This commit is contained in:
@@ -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',
|
||||
''
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
82
src/lib/utils/emoji.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user