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 { writeFile } from '@tauri-apps/plugin-fs';
|
||||||
import { sanitizeFilename } from '$lib/services/deezer/paths';
|
import { sanitizeFilename } from '$lib/services/deezer/paths';
|
||||||
|
import { encodeEmojis } from '$lib/utils/emoji';
|
||||||
|
|
||||||
export interface M3U8Track {
|
export interface M3U8Track {
|
||||||
duration: number; // in seconds
|
duration: number; // in seconds
|
||||||
@@ -22,14 +23,15 @@ export async function writeM3U8(
|
|||||||
tracks: M3U8Track[],
|
tracks: M3U8Track[],
|
||||||
playlistsFolder: string
|
playlistsFolder: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
// Sanitize playlist name for filename
|
// Encode emojis and sanitize playlist name for filename
|
||||||
const sanitizedName = sanitizeFilename(playlistName);
|
const encodedName = encodeEmojis(playlistName);
|
||||||
|
const sanitizedName = sanitizeFilename(encodedName);
|
||||||
const playlistPath = `${playlistsFolder}/${sanitizedName}.m3u8`;
|
const playlistPath = `${playlistsFolder}/${sanitizedName}.m3u8`;
|
||||||
|
|
||||||
// Build m3u8 content
|
// Build m3u8 content
|
||||||
const lines: string[] = [
|
const lines: string[] = [
|
||||||
'#EXTM3U',
|
'#EXTM3U',
|
||||||
`#PLAYLIST:${playlistName}`,
|
`#PLAYLIST:${encodedName}`,
|
||||||
'#EXTENC:UTF-8',
|
'#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 type { Track, AudioFormat, PlaylistWithTracks, TrackMetadata } from '$lib/types/track';
|
||||||
import { findAlbumArt } from './album';
|
import { findAlbumArt } from './album';
|
||||||
import { sanitizeFilename } from '$lib/services/deezer/paths';
|
import { sanitizeFilename } from '$lib/services/deezer/paths';
|
||||||
|
import { decodeEmojis } from '$lib/utils/emoji';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get audio format from file extension
|
* 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
|
* Parse M3U/M3U8 playlist file
|
||||||
* Supports both basic M3U and extended M3U8 format
|
* 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++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
const line = lines[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'))) {
|
if (!line || (line.startsWith('#') && !line.startsWith('#EXTINF'))) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { readDir, readFile } from '@tauri-apps/plugin-fs';
|
import { readDir, readFile } from '@tauri-apps/plugin-fs';
|
||||||
import { parseBuffer } from 'music-metadata';
|
import { parseBuffer } from 'music-metadata';
|
||||||
import type { Album, ArtistWithAlbums, AudioFormat } from '$lib/types/track';
|
import type { Album, ArtistWithAlbums, AudioFormat } from '$lib/types/track';
|
||||||
|
import { parsePlaylistName } from './playlist';
|
||||||
|
|
||||||
export interface Artist {
|
export interface Artist {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -283,11 +284,18 @@ export async function scanPlaylists(playlistsFolderPath: string): Promise<Playli
|
|||||||
if (!entry.isDirectory) {
|
if (!entry.isDirectory) {
|
||||||
const isPlaylist = entry.name.endsWith('.m3u') || entry.name.endsWith('.m3u8');
|
const isPlaylist = entry.name.endsWith('.m3u') || entry.name.endsWith('.m3u8');
|
||||||
if (isPlaylist) {
|
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 nameWithoutExt = entry.name.replace(/\.(m3u8?|M3U8?)$/, '');
|
||||||
|
const displayName = metadataName || nameWithoutExt;
|
||||||
|
|
||||||
playlists.push({
|
playlists.push({
|
||||||
name: nameWithoutExt,
|
name: displayName,
|
||||||
path: `${playlistsFolderPath}/${entry.name}`
|
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