feat(services): add LRCLIB service, scan utility, and context menus

This commit is contained in:
2025-10-04 23:56:58 -04:00
parent 38db835973
commit 25ce2d676e
7 changed files with 877 additions and 0 deletions

View File

@@ -55,6 +55,9 @@
},
{
"url": "http://*.dzcdn.net/**"
},
{
"url": "https://lrclib.net/**"
}
]
},

View File

@@ -4,6 +4,8 @@
import { playback } from '$lib/stores/playback';
import ContextMenu, { type MenuItem } from '$lib/components/ContextMenu.svelte';
import PageDecoration from '$lib/components/PageDecoration.svelte';
import { fetchAndSaveLyrics } from '$lib/services/lrclib';
import { setSuccess, setWarning, setError } from '$lib/stores/status';
interface Props {
title: string;
@@ -60,6 +62,32 @@
};
}
async function handleFetchLyrics(trackIndex: number) {
const track = tracks[trackIndex];
if (!track) return;
try {
const result = await fetchAndSaveLyrics(track.path, {
title: track.metadata.title || 'Unknown',
artist: track.metadata.artist || 'Unknown Artist',
album: track.metadata.album || 'Unknown Album',
duration: track.metadata.duration || 0
});
if (result.success) {
if (result.instrumental) {
setWarning(`${track.metadata.title || track.filename} is instrumental`);
} else if (result.hasLyrics) {
setSuccess(`Lyrics fetched for ${track.metadata.title || track.filename}`);
}
} else {
setWarning(`No lyrics found for ${track.metadata.title || track.filename}`);
}
} catch (error) {
setError(`Failed to fetch lyrics for ${track.metadata.title || track.filename}`);
}
}
function getContextMenuItems(trackIndex: number): MenuItem[] {
return [
{
@@ -73,6 +101,10 @@
{
label: 'Play Next',
action: () => playback.playNext([tracks[trackIndex]])
},
{
label: 'Fetch Lyrics via LRCLIB',
action: () => handleFetchLyrics(trackIndex)
}
];
}

View File

@@ -0,0 +1,137 @@
/**
* Library scanner for tracks without lyrics files
*/
import { readDir, exists, readFile } from '@tauri-apps/plugin-fs';
import { parseBuffer } from 'music-metadata';
import type { AudioFormat } from '$lib/types/track';
export interface TrackWithoutLyrics {
path: string;
filename: string;
title: string;
artist: string;
album: string;
duration: number; // in seconds
format: AudioFormat;
}
/**
* Check if a track has an accompanying .lrc file
*/
async function hasLyricsFile(audioFilePath: string): Promise<boolean> {
const lrcPath = audioFilePath.replace(/\.[^.]+$/, '.lrc');
return await exists(lrcPath);
}
/**
* Get audio format from file extension
*/
function getAudioFormat(filename: string): AudioFormat {
const ext = filename.toLowerCase().split('.').pop();
switch (ext) {
case 'flac':
return 'flac';
case 'mp3':
return 'mp3';
case 'opus':
return 'opus';
case 'ogg':
return 'ogg';
case 'm4a':
return 'm4a';
case 'wav':
return 'wav';
default:
return 'unknown';
}
}
/**
* Scan a single directory for audio files without lyrics
*/
async function scanDirectoryForMissingLyrics(
dirPath: string,
results: TrackWithoutLyrics[]
): Promise<void> {
const audioExtensions = ['.flac', '.mp3', '.opus', '.ogg', '.m4a', '.wav'];
try {
const entries = await readDir(dirPath);
for (const entry of entries) {
const fullPath = `${dirPath}/${entry.name}`;
if (entry.isDirectory) {
// Recursively scan subdirectories
await scanDirectoryForMissingLyrics(fullPath, results);
} else {
// Check if it's an audio file
const hasAudioExt = audioExtensions.some(ext =>
entry.name.toLowerCase().endsWith(ext)
);
if (hasAudioExt) {
// Check if it has a .lrc file
const hasLyrics = await hasLyricsFile(fullPath);
if (!hasLyrics) {
// Read metadata
try {
const fileData = await readFile(fullPath);
const metadata = await parseBuffer(
fileData,
{ mimeType: `audio/${getAudioFormat(entry.name)}` },
{ duration: true, skipCovers: true }
);
const title = metadata.common.title || entry.name.replace(/\.[^.]+$/, '');
const artist = metadata.common.artist || metadata.common.albumartist || 'Unknown Artist';
const album = metadata.common.album || 'Unknown Album';
const duration = metadata.format.duration || 0;
// Only add if we have minimum required metadata
if (title && artist && album && duration > 0) {
results.push({
path: fullPath,
filename: entry.name,
title,
artist,
album,
duration,
format: getAudioFormat(entry.name)
});
}
} catch (error) {
console.warn(`[LyricScanner] Could not read metadata for ${fullPath}:`, error);
}
}
}
}
}
} catch (error) {
console.error(`[LyricScanner] Error scanning directory ${dirPath}:`, error);
}
}
/**
* Scan the music library for tracks without .lrc files
*/
export async function scanForTracksWithoutLyrics(
musicFolderPath: string,
onProgress?: (current: number, total: number, message: string) => void
): Promise<TrackWithoutLyrics[]> {
const results: TrackWithoutLyrics[] = [];
if (onProgress) {
onProgress(0, 0, 'Scanning for tracks without lyrics...');
}
await scanDirectoryForMissingLyrics(musicFolderPath, results);
if (onProgress) {
onProgress(results.length, results.length, `Found ${results.length} tracks without lyrics`);
}
return results;
}

197
src/lib/services/lrclib.ts Normal file
View File

@@ -0,0 +1,197 @@
/**
* LRCLIB API client for fetching lyrics
* https://lrclib.net/
*/
import { fetch } from '@tauri-apps/plugin-http';
import { writeFile } from '@tauri-apps/plugin-fs';
const LRCLIB_API_BASE = 'https://lrclib.net/api';
const USER_AGENT = 'Shark Music Player v1.0.0 (https://github.com/soulshark)';
export interface LRCLIBLyrics {
id: number;
trackName: string;
artistName: string;
albumName: string;
duration: number;
instrumental: boolean;
plainLyrics: string | null;
syncedLyrics: string | null;
}
export interface LRCLIBSearchParams {
trackName: string;
artistName: string;
albumName: string;
duration: number; // in seconds
}
/**
* Check if LRCLIB API is available
*/
export async function checkApiStatus(): Promise<boolean> {
try {
const response = await fetch(`${LRCLIB_API_BASE}/get/1`, {
method: 'GET',
headers: {
'User-Agent': USER_AGENT
}
});
return response.ok || response.status === 404; // 404 is fine, means API is up
} catch (error) {
console.error('[LRCLIB] API check failed:', error);
return false;
}
}
/**
* Get lyrics for a track by its signature
* Searches external sources if not in LRCLIB database
*/
export async function getLyrics(params: LRCLIBSearchParams): Promise<LRCLIBLyrics | null> {
try {
const queryParams = new URLSearchParams({
track_name: params.trackName,
artist_name: params.artistName,
album_name: params.albumName,
duration: params.duration.toString()
});
const response = await fetch(`${LRCLIB_API_BASE}/get?${queryParams}`, {
method: 'GET',
headers: {
'User-Agent': USER_AGENT
}
});
if (response.status === 404) {
console.log('[LRCLIB] No lyrics found for:', params.trackName);
return null;
}
if (!response.ok) {
throw new Error(`LRCLIB API error: ${response.status}`);
}
const data = await response.json();
return data as LRCLIBLyrics;
} catch (error) {
console.error('[LRCLIB] Error fetching lyrics:', error);
return null;
}
}
/**
* Get lyrics from cache only (no external search)
*/
export async function getLyricsCached(params: LRCLIBSearchParams): Promise<LRCLIBLyrics | null> {
try {
const queryParams = new URLSearchParams({
track_name: params.trackName,
artist_name: params.artistName,
album_name: params.albumName,
duration: params.duration.toString()
});
const response = await fetch(`${LRCLIB_API_BASE}/get-cached?${queryParams}`, {
method: 'GET',
headers: {
'User-Agent': USER_AGENT
}
});
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(`LRCLIB API error: ${response.status}`);
}
const data = await response.json();
return data as LRCLIBLyrics;
} catch (error) {
console.error('[LRCLIB] Error fetching cached lyrics:', error);
return null;
}
}
/**
* Search for lyrics by keywords
*/
export async function searchLyrics(query: string): Promise<LRCLIBLyrics[]> {
try {
const queryParams = new URLSearchParams({ q: query });
const response = await fetch(`${LRCLIB_API_BASE}/search?${queryParams}`, {
method: 'GET',
headers: {
'User-Agent': USER_AGENT
}
});
if (!response.ok) {
throw new Error(`LRCLIB API error: ${response.status}`);
}
const data = await response.json();
return data as LRCLIBLyrics[];
} catch (error) {
console.error('[LRCLIB] Error searching lyrics:', error);
return [];
}
}
/**
* Save lyrics as .lrc file next to the audio file
*/
export async function saveLyricsFile(audioFilePath: string, lyrics: string): Promise<void> {
const lrcPath = audioFilePath.replace(/\.[^.]+$/, '.lrc');
await writeFile(lrcPath, new TextEncoder().encode(lyrics));
console.log('[LRCLIB] Saved lyrics to:', lrcPath);
}
/**
* Fetch and save lyrics for a track
* Returns true if successful, false otherwise
*/
export async function fetchAndSaveLyrics(
trackPath: string,
metadata: {
title: string;
artist: string;
album: string;
duration: number; // in seconds
}
): Promise<{ success: boolean; hasLyrics: boolean; instrumental: boolean }> {
try {
const lyrics = await getLyrics({
trackName: metadata.title,
artistName: metadata.artist,
albumName: metadata.album,
duration: Math.round(metadata.duration)
});
if (!lyrics) {
return { success: false, hasLyrics: false, instrumental: false };
}
if (lyrics.instrumental) {
return { success: true, hasLyrics: false, instrumental: true };
}
// Prefer synced lyrics, fall back to plain lyrics
const lyricsText = lyrics.syncedLyrics || lyrics.plainLyrics;
if (!lyricsText) {
return { success: false, hasLyrics: false, instrumental: false };
}
await saveLyricsFile(trackPath, lyricsText);
return { success: true, hasLyrics: true, instrumental: false };
} catch (error) {
console.error('[LRCLIB] Error fetching and saving lyrics:', error);
return { success: false, hasLyrics: false, instrumental: false };
}
}

View File

@@ -140,6 +140,10 @@
<img src="/icons/deezer.png" alt="" class="nav-icon" />
Deezer
</a>
<a href="/services/lrclib" class="nav-item nav-subitem">
<img src="/icons/lrclib-logo.svg" alt="" class="nav-icon" />
LRCLIB
</a>
<!-- <a href="/services/soulseek" class="nav-item nav-subitem">
<img src="/icons/soulseek.png" alt="" class="nav-icon" />
Soulseek

View File

@@ -0,0 +1,468 @@
<script lang="ts">
import { onMount } from 'svelte';
import { settings } from '$lib/stores/settings';
import { setSuccess, setWarning, setError, setInfo } from '$lib/stores/status';
import { checkApiStatus, fetchAndSaveLyrics } from '$lib/services/lrclib';
import { scanForTracksWithoutLyrics, type TrackWithoutLyrics } from '$lib/library/lyricScanner';
import ContextMenu, { type MenuItem } from '$lib/components/ContextMenu.svelte';
type ViewMode = 'tracks' | 'info';
let viewMode = $state<ViewMode>('tracks');
let apiAvailable = $state<boolean | null>(null);
let checkingApi = $state(false);
let scanning = $state(false);
let scanProgress = $state<string | null>(null);
let tracks = $state<TrackWithoutLyrics[]>([]);
let selectedTrackIndex = $state<number | null>(null);
let contextMenu = $state<{ x: number; y: number; trackIndex: number } | null>(null);
onMount(async () => {
await checkApi();
});
async function checkApi() {
checkingApi = true;
apiAvailable = await checkApiStatus();
checkingApi = false;
}
async function handleScan() {
if (!$settings.musicFolder || scanning) {
return;
}
scanning = true;
scanProgress = 'Starting scan...';
tracks = [];
try {
const foundTracks = await scanForTracksWithoutLyrics(
$settings.musicFolder,
(current, total, message) => {
scanProgress = message;
}
);
tracks = foundTracks;
if (tracks.length === 0) {
setInfo('All tracks have lyrics!');
} else {
setInfo(`Found ${tracks.length} track${tracks.length !== 1 ? 's' : ''} without lyrics`);
}
} catch (error) {
setError('Error scanning library: ' + (error instanceof Error ? error.message : String(error)));
} finally {
scanning = false;
scanProgress = null;
}
}
async function fetchLyricsForTrack(index: number) {
const track = tracks[index];
if (!track) return;
try {
const result = await fetchAndSaveLyrics(track.path, {
title: track.title,
artist: track.artist,
album: track.album,
duration: track.duration
});
if (result.success) {
if (result.instrumental) {
setInfo(`Track marked as instrumental: ${track.title}`);
} else if (result.hasLyrics) {
setSuccess(`Lyrics fetched for ${track.title}`);
}
// Remove from list on success
tracks = tracks.filter((_, i) => i !== index);
} else {
setWarning(`No lyrics found for ${track.title}`);
}
} catch (error) {
setError(`Failed to fetch lyrics for ${track.title}`);
}
}
async function fetchLyricsForAllTracks() {
if (tracks.length === 0) return;
let successCount = 0;
let failCount = 0;
setInfo(`Fetching lyrics for ${tracks.length} tracks...`, 0);
const tracksCopy = [...tracks];
for (let i = 0; i < tracksCopy.length; i++) {
const track = tracksCopy[i];
try {
const result = await fetchAndSaveLyrics(track.path, {
title: track.title,
artist: track.artist,
album: track.album,
duration: track.duration
});
if (result.success && (result.hasLyrics || result.instrumental)) {
successCount++;
} else {
failCount++;
}
} catch (error) {
failCount++;
}
// Update progress
if ((i + 1) % 10 === 0 || i === tracksCopy.length - 1) {
setInfo(`Fetching lyrics... ${i + 1}/${tracksCopy.length}`, 0);
}
}
// Rescan to update the list
tracks = [];
await handleScan();
// Show completion message
if (successCount > 0 && failCount > 0) {
setSuccess(`Lyrics found for ${successCount} track${successCount !== 1 ? 's' : ''} (${failCount} failed)`);
} else if (successCount > 0) {
setSuccess(`Lyrics found for ${successCount} track${successCount !== 1 ? 's' : ''}`);
} else {
setWarning('No lyrics found for any tracks');
}
}
function handleTrackClick(index: number) {
selectedTrackIndex = index;
}
function handleTrackDoubleClick(index: number) {
fetchLyricsForTrack(index);
}
function handleContextMenu(e: MouseEvent, index: number) {
e.preventDefault();
contextMenu = {
x: e.clientX,
y: e.clientY,
trackIndex: index
};
}
function getContextMenuItems(trackIndex: number): MenuItem[] {
return [
{
label: 'Fetch Lyrics',
action: () => fetchLyricsForTrack(trackIndex)
}
];
}
function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${String(secs).padStart(2, '0')}`;
}
</script>
<div class="lrclib-wrapper">
<h2 style="padding: 8px">LRCLIB</h2>
<section class="lrclib-content">
<!-- Tabs -->
<!--
svelte-ignore a11y_no_noninteractive_element_to_interactive_role
Reason: 98.css library requires <menu role="tablist"> for proper tab styling.
-->
<menu role="tablist">
<li role="tab" aria-selected={viewMode === 'tracks'}>
<button onclick={() => viewMode = 'tracks'}>Missing Lyrics</button>
</li>
<li role="tab" aria-selected={viewMode === 'info'}>
<button onclick={() => viewMode = 'info'}>Info</button>
</li>
</menu>
<!-- Tab Content -->
<div class="window tab-content" role="tabpanel">
<div class="window-body">
{#if viewMode === 'tracks'}
<!-- Tracks View -->
<div class="tab-header">
<span>{tracks.length} track{tracks.length !== 1 ? 's' : ''} found</span>
<div class="actions-row">
<button onclick={handleScan} disabled={scanning || !$settings.musicFolder}>
{scanning ? 'Scanning...' : 'Scan Library'}
</button>
{#if tracks.length > 0}
<button onclick={fetchLyricsForAllTracks} disabled={scanning}>
Fetch All ({tracks.length})
</button>
{/if}
</div>
</div>
{#if scanProgress}
<div class="progress-banner">
{scanProgress}
</div>
{/if}
{#if !$settings.musicFolder}
<div class="help-banner">
Please set a music folder in Settings first
</div>
{/if}
<!-- Results Table -->
{#if tracks.length > 0}
<div class="sunken-panel table-container">
<table class="interactive">
<thead>
<tr>
<th>Title</th>
<th>Artist</th>
<th>Album</th>
<th>Duration</th>
<th>Format</th>
</tr>
</thead>
<tbody>
{#each tracks as track, i}
<tr
class:highlighted={selectedTrackIndex === i}
onclick={() => handleTrackClick(i)}
ondblclick={() => handleTrackDoubleClick(i)}
oncontextmenu={(e) => handleContextMenu(e, i)}
>
<td>{track.title}</td>
<td>{track.artist}</td>
<td>{track.album}</td>
<td class="duration">{formatDuration(track.duration)}</td>
<td class="format">{track.format.toUpperCase()}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else if !scanning}
<div class="empty-state">
<p>No tracks without lyrics found. Click "Scan Library" to check your library.</p>
</div>
{/if}
{:else if viewMode === 'info'}
<!-- Info View -->
<div class="info-container">
<fieldset>
<legend>API Status</legend>
<div class="field-row">
<span class="field-label">Status:</span>
{#if checkingApi}
<span>Checking...</span>
{:else if apiAvailable === true}
<span class="status-indicator status-ok">✓ Available</span>
{:else if apiAvailable === false}
<span class="status-indicator status-error">✗ Unavailable</span>
{:else}
<span class="status-indicator">Unknown</span>
{/if}
</div>
<div class="field-row">
<button onclick={checkApi} disabled={checkingApi}>
{checkingApi ? 'Checking...' : 'Check API'}
</button>
</div>
</fieldset>
<fieldset>
<legend>About LRCLIB</legend>
<p>LRCLIB is a free, open API for fetching synchronized and plain lyrics for music tracks.</p>
<p>For more info, see <a href="https://lrclib.net/" target="_blank" rel="noopener noreferrer">lrclib.net</a></p>
</fieldset>
</div>
{/if}
</div>
</div>
</section>
</div>
{#if contextMenu}
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
items={getContextMenuItems(contextMenu.trackIndex)}
onClose={() => contextMenu = null}
/>
{/if}
<style>
.lrclib-wrapper {
height: 100%;
display: flex;
flex-direction: column;
}
h2 {
margin: 0;
flex-shrink: 0;
}
.lrclib-content {
margin: 0;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.tab-content {
margin-top: -2px;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.tab-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px;
border-bottom: 1px solid var(--button-shadow, #808080);
flex-shrink: 0;
}
.status-row {
display: flex;
align-items: center;
gap: 12px;
}
.status-indicator {
font-weight: bold;
font-size: 11px;
}
.status-ok {
color: #00aa00;
}
.status-error {
color: #ff6b6b;
}
.actions-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.progress-banner {
padding: 8px;
background: var(--button-shadow, #2a2a2a);
border-bottom: 1px solid var(--button-shadow, #808080);
font-size: 11px;
text-align: center;
flex-shrink: 0;
}
.help-banner {
padding: 8px;
background: var(--button-shadow, #2a2a2a);
border-bottom: 1px solid var(--button-shadow, #808080);
font-size: 11px;
color: #808080;
text-align: center;
flex-shrink: 0;
}
.window-body {
padding: 0;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.table-container {
flex: 1;
overflow-y: auto;
min-height: 0;
}
table {
width: 100%;
}
thead {
position: sticky;
top: 0;
z-index: 1;
background: #121212;
}
th {
text-align: left;
}
.duration {
font-family: monospace;
font-size: 0.9em;
text-align: center;
width: 80px;
}
.format {
font-family: monospace;
font-size: 0.85em;
text-transform: uppercase;
text-align: center;
width: 80px;
}
.empty-state {
padding: 32px 16px;
text-align: center;
opacity: 0.6;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.empty-state p {
margin: 0;
}
.info-container {
padding: 16px;
overflow-y: auto;
flex: 1;
}
.info-container fieldset {
margin-bottom: 16px;
}
.info-container p {
margin: 8px 0;
line-height: 1.4;
}
.field-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.field-label {
font-weight: bold;
min-width: 60px;
}
</style>

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<defs>
<style>
.cls-1 {
fill: #111041;
}
.cls-2 {
fill: #fdfdfd;
}
</style>
</defs>
<g id="QGdPvo.tif">
<g>
<path class="cls-1" d="M16.663,126.482C7.066,121.586,1.49,116.223,0,104.918V23.048C1.071,14.257,6.144,6.818,13.95,2.709c.515-.271,1.772-.711,3.089-1.26l1.247-.473C19.133.695,21.597.003,23.055,0h81.726c12.256,1.082,21.845,10.748,23.218,22.883l-.029,82.497c-1.337,10.446-8.245,18.709-18.356,21.676-.66.194-3.565.748-3.753.787-.191.04-.422.039-.627.073l-82.5.039c-1.539-.285-4.031-.886-4.833-1.085s-1.241-.389-1.241-.389ZM101.882,30.168c.536-.844-1.616-4.825-2.279-5.633-3.824-4.665-12.876-4.358-17.059-.33-5.699,5.488-5.939,20.919-.088,26.331,4.514,4.175,13.651,3.675,17.647-1.066.837-.993,3.255-4.753,1.639-5.583-.465-.239-5.132-1.468-5.462-1.368-.442.134-.908,1.926-1.294,2.49-1.213,1.769-3.804,2.347-5.808,1.761-4.917-1.438-4.464-13.841-2.336-17.285,1.674-2.708,5.696-3.17,7.586-.432.509.738.686,2.309,1.433,2.506.299.079,5.856-1.13,6.021-1.391ZM46.689,46.266h-11.65l-.246-.246v-24.114c0-.186-.672-.521-.913-.564-.655-.116-5.135-.084-5.608.114-.2.084-.435.269-.502.483l-.076,30.358.41.41,18.885.093c.342-.122.401-.565.441-.872.093-.711.076-4.809-.184-5.234-.078-.128-.426-.398-.557-.427ZM58.257,51.926v-10.991l.246-.246h3.446c.68,0,5.287,10.795,6.181,12.111.272.158,5.816.124,6.285.024.325-.069.597-.346.676-.65.287-1.101-5.527-10.991-6.005-12.888,1.046-.912,2.315-1.428,3.287-2.612,4.498-5.479.959-13.534-5.907-14.938-2.907-.594-11.097-.796-14.037-.408-.632.084-.93.259-1.053.916l.025,29.809c.117.379.295.683.707.77.543.115,5.498.041,5.754-.145.093-.067.373-.63.395-.753ZM97.498,85.496c.047-.09,1.235-.965,1.536-1.333,2.362-2.89,2.058-8.084-.66-10.663-3.188-3.025-11.839-2.811-16.085-2.633-.561.024-3.636.2-3.811.374l-.05,30.729.741.408c7.002-.546,20.72,2.585,22.058-7.492.348-2.626.038-5.033-1.701-7.117-.403-.483-2.268-1.812-2.028-2.272ZM46.896,96.011c-.662-.664-11.744.254-12.284-.433-.152-.194-.16-.429-.157-.663l-.027-23.267c-.083-.326-.176-.534-.538-.611-.571-.122-5.752-.039-6.081.153l-.148,30.845,18.961.262c.135-.04.247-.079.328-.203.269-.413.248-5.782-.053-6.084ZM58.703,71.073c-.327.095-.628.511-.619.858l.044,29.664c.117.481.41.675.878.763.698.131,4.515.126,5.229,0,.475-.083.929-.356.921-.884l-.013-29.694c-.079-.37-.368-.659-.738-.738-.47-.1-5.305-.084-5.701.031Z"/>
<g>
<g>
<path class="cls-2" d="M46.896,96.011c.301.302.322,5.671.053,6.084-.081.124-.193.163-.328.203l-18.961-.262.148-30.845c.329-.191,5.51-.274,6.081-.153.361.077.455.285.538.611l.027,23.267c-.003.234.005.469.157.663.54.687,11.622-.231,12.284.433Z"/>
<path class="cls-2" d="M58.703,71.073c.396-.115,5.232-.131,5.701-.031.37.079.66.368.738.738l.013,29.694c.008.528-.446.801-.921.884-.714.125-4.531.131-5.229,0-.468-.088-.761-.282-.878-.763l-.044-29.664c-.009-.347.292-.763.619-.858Z"/>
<g>
<path class="cls-1" d="M85.167,96.3v-7.382h5.825c.15,0,1.228.41,1.447.522,3.393,1.743,1.775,6.86-1.447,6.86h-5.825Z"/>
<path class="cls-1" d="M85.167,83.012v-5.906h5.989c.325,0,1.698.849,1.959,1.158,1.689,2.004-.298,4.747-2.615,4.747h-5.333Z"/>
<path class="cls-2" d="M97.498,85.496c-.24.46,1.625,1.789,2.028,2.272,1.738,2.084,2.049,4.492,1.701,7.117-1.338,10.078-15.056,6.947-22.058,7.492l-.741-.408.05-30.729c.175-.174,3.249-.35,3.811-.374,4.246-.178,12.896-.392,16.085,2.633,2.718,2.579,3.022,7.772.66,10.663-.301.368-1.489,1.243-1.536,1.333ZM85.167,83.012h5.333c2.317,0,4.304-2.743,2.615-4.747-.261-.31-1.634-1.158-1.959-1.158h-5.989v5.906ZM85.167,96.3h5.825c3.222,0,4.84-5.117,1.447-6.86-.219-.112-1.297-.522-1.447-.522h-5.825v7.382Z"/>
</g>
</g>
<g>
<path class="cls-2" d="M58.257,51.926c-.022.123-.302.686-.395.753-.256.185-5.211.259-5.754.145-.412-.087-.589-.391-.707-.77l-.025-29.809c.123-.657.421-.832,1.053-.916,2.94-.389,11.13-.187,14.037.408,6.866,1.404,10.405,9.459,5.907,14.938-.972,1.184-2.24,1.7-3.287,2.612.478,1.897,6.291,11.786,6.005,12.888-.079.304-.351.58-.676.65-.469.1-6.013.133-6.285-.024-.894-1.316-5.501-12.111-6.181-12.111h-3.446l-.246.246v10.991ZM58.257,34.619h5.005c1.232,0,3.441-1.02,3.682-2.388.12-.682.075-2.405-.251-3.011-.288-.536-1.879-1.655-2.447-1.655h-5.907c-.639,0,.119,6.472-.082,7.054Z"/>
<path class="cls-2" d="M101.882,30.168c-.166.261-5.723,1.47-6.021,1.391-.747-.197-.924-1.768-1.433-2.506-1.89-2.738-5.912-2.277-7.586.432-2.128,3.444-2.581,15.847,2.336,17.285,2.004.586,4.596.008,5.808-1.761.386-.563.852-2.356,1.294-2.49.331-.1,4.997,1.129,5.462,1.368,1.616.829-.802,4.59-1.639,5.583-3.996,4.741-13.133,5.241-17.647,1.066-5.851-5.412-5.611-20.843.088-26.331,4.183-4.028,13.235-4.335,17.059.33.663.808,2.815,4.789,2.279,5.633Z"/>
<path class="cls-2" d="M46.689,46.266c.131.029.479.299.557.427.259.426.277,4.523.184,5.234-.04.307-.099.75-.441.872l-18.885-.093-.41-.41.076-30.358c.067-.213.302-.399.502-.483.473-.198,4.953-.23,5.608-.114.241.042.913.378.913.564v24.114l.246.246h11.65Z"/>
</g>
</g>
<path class="cls-1" d="M58.257,34.619c.201-.582-.557-7.054.082-7.054h5.907c.568,0,2.159,1.118,2.447,1.655.325.606.371,2.329.251,3.011-.241,1.368-2.451,2.388-3.682,2.388h-5.005Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.3 KiB