├── assets ├── .gitignore ├── icon.icns ├── icon.ico ├── icon.png ├── Sonosano.ico ├── icon-big.png ├── icon-trans.png ├── readme │ ├── dis.png │ ├── lin.png │ ├── mac.png │ ├── win.png │ ├── Banner.png │ ├── lyrics.png │ ├── search.png │ ├── analysis.png │ ├── library.png │ └── playlist.png ├── SonosanoLogo.png ├── icons │ ├── 128x128.png │ ├── 16x16.png │ ├── 24x24.png │ ├── 256x256.png │ ├── 32x32.png │ ├── 48x48.png │ ├── 512x512.png │ ├── 64x64.png │ ├── 96x96.png │ └── 1024x1024.png ├── icon-trans-small.png ├── imgs │ ├── SonosanoLogo.png │ ├── playingnowbar.gif │ └── DefaultThumbnailPlaylist.jpg ├── fonts │ ├── CircularSonosanoText-Bold.otf │ ├── CircularSonosanoText-Light.otf │ └── CircularSonosanoText-Medium.otf ├── entitlements.mac.plist └── icon.svg ├── backend ├── src │ ├── api │ │ ├── __init__.py │ │ ├── playlist_routes.py │ │ └── search_routes.py │ ├── core │ │ ├── __init__.py │ │ ├── romanization_service.py │ │ ├── config_utils.py │ │ ├── file_watcher.py │ │ ├── playlist_service.py │ │ ├── audio_forensics.py │ │ └── library_service.py │ ├── models │ │ ├── __init__.py │ │ ├── library_models.py │ │ ├── system_models.py │ │ ├── search_models.py │ │ ├── playlist_models.py │ │ └── download_models.py │ ├── pynicotine │ │ ├── external │ │ │ ├── __init__.py │ │ │ ├── data │ │ │ │ ├── __init__.py │ │ │ │ └── ip_country_data.csv.license │ │ │ └── tinytag.py.license │ │ ├── __main__.py │ │ ├── notifications.py │ │ ├── portchecker.py │ │ └── i18n.py │ └── utils │ │ └── file_system_utils.py ├── requirements.txt └── sonosano.spec ├── .husky ├── pre-commit └── pre-push ├── app ├── api │ ├── index.ts │ └── backendUrl.ts ├── utils │ ├── constants.ts │ ├── metadata.ts │ ├── format.ts │ ├── language.ts │ ├── downloadEvents.ts │ ├── statusUtils.tsx │ ├── sessionCache.ts │ ├── coverCache.ts │ └── fileSorter.ts ├── i18n │ ├── languages.ts │ └── i18n.ts ├── lib │ ├── conveyor │ │ ├── conveyor.d.ts │ │ ├── api │ │ │ ├── app-api.ts │ │ │ ├── index.ts │ │ │ └── window-api.ts │ │ ├── schemas │ │ │ ├── app-schema.ts │ │ │ ├── index.ts │ │ │ └── window-schema.ts │ │ └── handlers │ │ │ ├── app-handler.ts │ │ │ └── window-handler.ts │ ├── utils.ts │ ├── preload │ │ ├── preload.ts │ │ └── shared.ts │ └── main │ │ ├── update-manager.ts │ │ ├── protocols.ts │ │ ├── shared.ts │ │ ├── app.ts │ │ └── main.ts ├── components │ ├── footer │ │ ├── Player │ │ │ ├── types │ │ │ │ └── Player.types.ts │ │ │ ├── player.module.css │ │ │ ├── TimeSlider │ │ │ │ ├── timeSlider.module.css │ │ │ │ └── TimeSlider.tsx │ │ │ └── Player.tsx │ │ ├── footer.module.css │ │ ├── TimerBar │ │ │ ├── timerBar.module.css │ │ │ └── TimerBar.tsx │ │ ├── SongConfig │ │ │ ├── VolumeSlider │ │ │ │ └── volumeSlider.module.css │ │ │ ├── TimerMenu │ │ │ │ ├── timerMenu.module.css │ │ │ │ └── TimerMenu.tsx │ │ │ ├── songConfig.module.css │ │ │ └── TimePicker │ │ │ │ ├── timePicker.module.css │ │ │ │ └── TimePicker.tsx │ │ ├── Footer.tsx │ │ ├── SongInfo │ │ │ ├── songInfo.module.css │ │ │ └── SongInfo.tsx │ │ └── AddToPlaylistMenu │ │ │ └── addToPlaylistMenu.module.css │ ├── common │ │ ├── info_popover │ │ │ ├── types │ │ │ │ └── infoPopover.types.ts │ │ │ ├── confirmationModal.module.css │ │ │ └── InfoPopover.tsx │ │ └── context_menu │ │ │ ├── types │ │ │ └── contextMenu.types.ts │ │ │ ├── Song │ │ │ └── ContextMenuSong.tsx │ │ │ ├── contextMenu.module.css │ │ │ └── Playlist │ │ │ └── ContextMenuPlaylist.tsx │ ├── playlists │ │ ├── editPlaylistModal.module.css │ │ └── createPlaylistModal.module.css │ ├── SystemStatusIndicator │ │ ├── SystemStatusIndicator.tsx │ │ └── systemStatusIndicator.module.css │ ├── MetadataModal │ │ ├── metadataModal.module.css │ │ └── MetadataModal.tsx │ └── LyricsDisplay │ │ └── LyricsDisplay.module.css ├── renderer │ ├── preload.d.ts │ ├── index.ejs │ ├── index.tsx │ ├── index.css │ └── app.module.css ├── global │ └── global.ts ├── styles │ ├── app.css │ ├── mui5 │ │ └── styles.ts │ ├── buttons.global.css │ └── globals.css ├── hooks │ ├── usePlaybackManager.ts │ ├── useContextMenu.ts │ ├── usePlaylistPlayback.ts │ ├── useLibrarySongs.ts │ ├── useLocalStorage.ts │ └── useDownloads.ts ├── renderer.tsx ├── index.html ├── providers │ ├── PlaybackProvider.tsx │ └── SongDetailSidebarProvider.tsx ├── pages │ ├── Search │ │ └── hooks │ │ │ └── useSongSearch.ts │ └── Lyrics │ │ └── LyricsPage.module.css └── types │ └── index.ts ├── .github ├── FUNDING.yml └── workflows │ └── build.yml ├── resources ├── build │ ├── icon.ico │ ├── icon.png │ ├── icon.icns │ └── entitlements.mac.plist └── icons │ └── icon.png ├── .editorconfig ├── .gitignore ├── .prettierrc ├── tsconfig.node.json ├── scripts ├── build-offline.bat └── build-backend.js ├── components.json ├── tsconfig.json ├── tsconfig.web.json ├── .vscode └── launch.json ├── electron-builder.yml ├── electron.vite.config.ts ├── package.json └── eslint.config.mjs /assets/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/src/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/src/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/src/models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/src/pynicotine/external/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx prettier --write . 2 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | npx eslint . --ext .ts,.tsx --fix -------------------------------------------------------------------------------- /app/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from './apiClient' 2 | -------------------------------------------------------------------------------- /backend/src/pynicotine/external/data/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: KRSHH 2 | buy_me_a_coffee: krshh 3 | -------------------------------------------------------------------------------- /assets/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KRSHH/Sonosano/HEAD/assets/icon.icns -------------------------------------------------------------------------------- /assets/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KRSHH/Sonosano/HEAD/assets/icon.ico -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KRSHH/Sonosano/HEAD/assets/icon.png -------------------------------------------------------------------------------- /assets/Sonosano.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KRSHH/Sonosano/HEAD/assets/Sonosano.ico -------------------------------------------------------------------------------- /assets/icon-big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KRSHH/Sonosano/HEAD/assets/icon-big.png -------------------------------------------------------------------------------- /assets/icon-trans.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KRSHH/Sonosano/HEAD/assets/icon-trans.png -------------------------------------------------------------------------------- /assets/readme/dis.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KRSHH/Sonosano/HEAD/assets/readme/dis.png -------------------------------------------------------------------------------- /assets/readme/lin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KRSHH/Sonosano/HEAD/assets/readme/lin.png -------------------------------------------------------------------------------- /assets/readme/mac.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KRSHH/Sonosano/HEAD/assets/readme/mac.png -------------------------------------------------------------------------------- /assets/readme/win.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KRSHH/Sonosano/HEAD/assets/readme/win.png -------------------------------------------------------------------------------- /assets/SonosanoLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KRSHH/Sonosano/HEAD/assets/SonosanoLogo.png -------------------------------------------------------------------------------- /assets/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KRSHH/Sonosano/HEAD/assets/icons/128x128.png -------------------------------------------------------------------------------- /assets/icons/16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KRSHH/Sonosano/HEAD/assets/icons/16x16.png -------------------------------------------------------------------------------- /assets/icons/24x24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KRSHH/Sonosano/HEAD/assets/icons/24x24.png -------------------------------------------------------------------------------- /assets/icons/256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KRSHH/Sonosano/HEAD/assets/icons/256x256.png -------------------------------------------------------------------------------- /assets/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KRSHH/Sonosano/HEAD/assets/icons/32x32.png -------------------------------------------------------------------------------- /assets/icons/48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KRSHH/Sonosano/HEAD/assets/icons/48x48.png -------------------------------------------------------------------------------- /assets/icons/512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KRSHH/Sonosano/HEAD/assets/icons/512x512.png -------------------------------------------------------------------------------- /assets/icons/64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KRSHH/Sonosano/HEAD/assets/icons/64x64.png -------------------------------------------------------------------------------- /assets/icons/96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KRSHH/Sonosano/HEAD/assets/icons/96x96.png -------------------------------------------------------------------------------- /assets/readme/Banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KRSHH/Sonosano/HEAD/assets/readme/Banner.png -------------------------------------------------------------------------------- /assets/readme/lyrics.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KRSHH/Sonosano/HEAD/assets/readme/lyrics.png -------------------------------------------------------------------------------- /assets/readme/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KRSHH/Sonosano/HEAD/assets/readme/search.png -------------------------------------------------------------------------------- /resources/build/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KRSHH/Sonosano/HEAD/resources/build/icon.ico -------------------------------------------------------------------------------- /resources/build/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KRSHH/Sonosano/HEAD/resources/build/icon.png -------------------------------------------------------------------------------- /resources/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KRSHH/Sonosano/HEAD/resources/icons/icon.png -------------------------------------------------------------------------------- /assets/icons/1024x1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KRSHH/Sonosano/HEAD/assets/icons/1024x1024.png -------------------------------------------------------------------------------- /assets/readme/analysis.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KRSHH/Sonosano/HEAD/assets/readme/analysis.png -------------------------------------------------------------------------------- /assets/readme/library.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KRSHH/Sonosano/HEAD/assets/readme/library.png -------------------------------------------------------------------------------- /assets/readme/playlist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KRSHH/Sonosano/HEAD/assets/readme/playlist.png -------------------------------------------------------------------------------- /resources/build/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KRSHH/Sonosano/HEAD/resources/build/icon.icns -------------------------------------------------------------------------------- /assets/icon-trans-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KRSHH/Sonosano/HEAD/assets/icon-trans-small.png -------------------------------------------------------------------------------- /assets/imgs/SonosanoLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KRSHH/Sonosano/HEAD/assets/imgs/SonosanoLogo.png -------------------------------------------------------------------------------- /assets/imgs/playingnowbar.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KRSHH/Sonosano/HEAD/assets/imgs/playingnowbar.gif -------------------------------------------------------------------------------- /app/utils/constants.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_PLAYLIST_THUMBNAIL = 'assets/imgs/DefaultThumbnailPlaylist.jpg' 2 | -------------------------------------------------------------------------------- /assets/imgs/DefaultThumbnailPlaylist.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KRSHH/Sonosano/HEAD/assets/imgs/DefaultThumbnailPlaylist.jpg -------------------------------------------------------------------------------- /assets/fonts/CircularSonosanoText-Bold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KRSHH/Sonosano/HEAD/assets/fonts/CircularSonosanoText-Bold.otf -------------------------------------------------------------------------------- /assets/fonts/CircularSonosanoText-Light.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KRSHH/Sonosano/HEAD/assets/fonts/CircularSonosanoText-Light.otf -------------------------------------------------------------------------------- /assets/fonts/CircularSonosanoText-Medium.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KRSHH/Sonosano/HEAD/assets/fonts/CircularSonosanoText-Medium.otf -------------------------------------------------------------------------------- /app/i18n/languages.ts: -------------------------------------------------------------------------------- 1 | export enum Language { 2 | ENGLISH = 'en', 3 | } 4 | 5 | export const availableLanguages = Object.values(Language) 6 | 7 | export default Language 8 | -------------------------------------------------------------------------------- /backend/src/pynicotine/external/data/ip_country_data.csv.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2001-2024 Hexasoft Development Sdn. Bhd. 2 | SPDX-License-Identifier: CC-BY-SA-4.0 3 | -------------------------------------------------------------------------------- /app/lib/conveyor/conveyor.d.ts: -------------------------------------------------------------------------------- 1 | import type { ConveyorApi } from '@/lib/conveyor/api' 2 | 3 | declare global { 4 | interface Window { 5 | conveyor: ConveyorApi 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /app/lib/conveyor/api/app-api.ts: -------------------------------------------------------------------------------- 1 | import { ConveyorApi } from '@/lib/preload/shared' 2 | 3 | export class AppApi extends ConveyorApi { 4 | version = () => this.invoke('version') 5 | } 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true -------------------------------------------------------------------------------- /app/lib/conveyor/schemas/app-schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const appIpcSchema = { 4 | version: { 5 | args: z.tuple([]), 6 | return: z.string(), 7 | }, 8 | } 9 | -------------------------------------------------------------------------------- /app/components/footer/Player/types/Player.types.ts: -------------------------------------------------------------------------------- 1 | import { Song } from '../../../../types' 2 | 3 | export interface PropsPlayer { 4 | volume: number 5 | changeSongInfo: (data: Song) => void 6 | } 7 | -------------------------------------------------------------------------------- /app/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from 'clsx' 2 | import { twMerge } from 'tailwind-merge' 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /app/renderer/preload.d.ts: -------------------------------------------------------------------------------- 1 | import { ElectronHandler } from '../main/preload' 2 | 3 | declare global { 4 | interface Window { 5 | electron: ElectronHandler 6 | } 7 | } 8 | 9 | export {} 10 | -------------------------------------------------------------------------------- /backend/src/pynicotine/__main__.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2021-2023 Nicotine+ Contributors 2 | # SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | import sys 5 | from pynicotine import run 6 | 7 | sys.exit(run()) 8 | -------------------------------------------------------------------------------- /backend/src/pynicotine/external/tinytag.py.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2020-2025 Nicotine+ Contributors 2 | SPDX-FileCopyrightText: 2014-2025 Tom Wallroth, Mat (mathiascode), et al. 3 | SPDX-License-Identifier: MIT 4 | 5 | -------------------------------------------------------------------------------- /backend/requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi>=0.68.0 2 | pydantic>=1.8.0 3 | mutagen>=1.45.0 4 | PyInstaller>=5.0.0 5 | uvicorn[standard] 6 | tinydb 7 | watchdog 8 | uroman 9 | requests 10 | librosa 11 | scipy 12 | tinytag 13 | bs4 14 | matplotlib -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | out 4 | .DS_Store 5 | *.log* 6 | temp 7 | 8 | # Python 9 | 10 | venv 11 | __pycache__ 12 | sonosano_data 13 | backend/src/config 14 | backend/src/downloads 15 | backend/src/core/downloads 16 | .kilocode -------------------------------------------------------------------------------- /app/lib/conveyor/handlers/app-handler.ts: -------------------------------------------------------------------------------- 1 | import { type App } from 'electron' 2 | import { handle } from '@/lib/main/shared' 3 | 4 | export const registerAppHandlers = (app: App) => { 5 | // App operations 6 | handle('version', () => app.getVersion()) 7 | } 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false, 4 | "printWidth": 120, 5 | "trailingComma": "es5", 6 | "tabWidth": 2, 7 | "endOfLine": "auto", 8 | "proseWrap": "preserve", 9 | "quoteProps": "as-needed", 10 | "useTabs": false 11 | } 12 | -------------------------------------------------------------------------------- /app/global/global.ts: -------------------------------------------------------------------------------- 1 | export const repositoryUrl: string = 'https://github.com/KRSHH/Sonosano/' 2 | 3 | export const coldStartRequestTimeout = 5000 4 | 5 | const Global = { 6 | repositoryUrl, 7 | coldStartRequestTimeout, 8 | } 9 | 10 | export default Global 11 | -------------------------------------------------------------------------------- /app/components/footer/footer.module.css: -------------------------------------------------------------------------------- 1 | .wrapperFooter { 2 | position: fixed; 3 | bottom: 0; 4 | left: 0; 5 | right: 0; 6 | width: 100%; 7 | height: var(--footer-height); 8 | background-color: var(--primary-black); 9 | padding: 0.5% 0 0.5% 0; 10 | z-index: 1000; 11 | } 12 | -------------------------------------------------------------------------------- /assets/entitlements.mac.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.allow-jit 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /backend/src/core/romanization_service.py: -------------------------------------------------------------------------------- 1 | import uroman as ur 2 | 3 | class RomanizationService: 4 | def __init__(self): 5 | self.uroman = ur.Uroman() 6 | 7 | def romanize(self, text: str) -> str: 8 | if not text: 9 | return "" 10 | return self.uroman.romanize_string(text) 11 | -------------------------------------------------------------------------------- /app/lib/conveyor/api/index.ts: -------------------------------------------------------------------------------- 1 | import { electronAPI } from '@electron-toolkit/preload' 2 | import { AppApi } from './app-api' 3 | import { WindowApi } from './window-api' 4 | 5 | export const conveyor = { 6 | app: new AppApi(electronAPI), 7 | window: new WindowApi(electronAPI), 8 | } 9 | 10 | export type ConveyorApi = typeof conveyor 11 | -------------------------------------------------------------------------------- /app/components/footer/TimerBar/timerBar.module.css: -------------------------------------------------------------------------------- 1 | .timerBarContainer { 2 | position: absolute; 3 | bottom: 0; 4 | left: 0; 5 | width: 100%; 6 | height: 3px; 7 | background-color: transparent; 8 | } 9 | 10 | .timerBarProgress { 11 | height: 100%; 12 | background-color: var(--primary-green); 13 | transition: width 1s linear; 14 | } 15 | -------------------------------------------------------------------------------- /app/styles/app.css: -------------------------------------------------------------------------------- 1 | @import './globals.css'; 2 | @import './window.css'; 3 | 4 | body { 5 | font-family: 6 | system-ui, 7 | -apple-system, 8 | Arial, 9 | Helvetica, 10 | sans-serif; 11 | font-size: 14px; 12 | margin: 0; 13 | overflow: hidden; 14 | } 15 | 16 | html, 17 | body, 18 | #app { 19 | height: 100%; 20 | margin: 0; 21 | line-height: 1.4; 22 | } 23 | -------------------------------------------------------------------------------- /app/api/backendUrl.ts: -------------------------------------------------------------------------------- 1 | let backendUrl = 'http://127.0.0.1:8000' 2 | 3 | export const getBackendUrl = () => { 4 | const storedUrl = localStorage.getItem('backendUrl') 5 | if (storedUrl) { 6 | backendUrl = storedUrl 7 | } 8 | return backendUrl 9 | } 10 | 11 | export const setBackendUrl = (url: string) => { 12 | backendUrl = url 13 | localStorage.setItem('backendUrl', url) 14 | } 15 | -------------------------------------------------------------------------------- /backend/src/models/library_models.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | from typing import Optional, Dict, Any 3 | 4 | class ShowInExplorerRequest(BaseModel): 5 | filePath: str 6 | 7 | class AddFileRequest(BaseModel): 8 | filePath: str 9 | metadata: Optional[Dict[str, Any]] = None 10 | 11 | class StoreMetadataRequest(BaseModel): 12 | filename: str 13 | metadata: Dict[str, Any] -------------------------------------------------------------------------------- /app/components/common/info_popover/types/infoPopover.types.ts: -------------------------------------------------------------------------------- 1 | export enum InfoPopoverType { 2 | ERROR = 'ERROR', 3 | SUCCESS = 'SUCCESS', 4 | CLIPBOARD = 'CLIPBOARD', 5 | } 6 | export interface PropsInfoPopover { 7 | title: string | undefined 8 | description: string | undefined 9 | type: InfoPopoverType | undefined 10 | triggerOpenConfirmationModal: boolean 11 | 12 | handleClose?: () => void 13 | } 14 | -------------------------------------------------------------------------------- /backend/src/models/system_models.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | from typing import Optional 3 | 4 | class SystemStatus(BaseModel): 5 | backend_status: str 6 | soulseek_status: str 7 | soulseek_username: Optional[str] = None 8 | active_uploads: int 9 | active_downloads: int 10 | 11 | class RomanizeRequest(BaseModel): 12 | text: str 13 | 14 | class ConfigRequest(BaseModel): 15 | dataPath: str -------------------------------------------------------------------------------- /app/styles/mui5/styles.ts: -------------------------------------------------------------------------------- 1 | export const modalStyle = { 2 | position: 'absolute' as const, 3 | top: '50%', 4 | left: '50%', 5 | transform: 'translate(-50%, -50%)', 6 | width: 500, 7 | bgcolor: '#1a1a1a', 8 | border: '1px solid #333', 9 | borderRadius: '12px', 10 | boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.4), 0 10px 10px -5px rgba(0, 0, 0, 0.2)', 11 | p: 4, 12 | color: '#fff', 13 | outline: 'none', 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@electron-toolkit/tsconfig/tsconfig.node.json", 3 | "include": ["app/lib/main/index.d.ts", "electron.vite.config.*", "app/lib/**/*", "resources/**/*", "app/**/*"], 4 | "compilerOptions": { 5 | "composite": true, 6 | "moduleResolution": "bundler", 7 | "types": ["electron-vite/node"], 8 | "baseUrl": ".", 9 | "paths": { 10 | "@/*": ["./*"] 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /app/lib/preload/preload.ts: -------------------------------------------------------------------------------- 1 | import { contextBridge } from 'electron' 2 | import { conveyor } from '../conveyor/api' 3 | 4 | declare global { 5 | interface Window { 6 | conveyor: typeof conveyor 7 | } 8 | } 9 | 10 | if (process.contextIsolated) { 11 | try { 12 | contextBridge.exposeInMainWorld('conveyor', conveyor) 13 | } catch (error) { 14 | console.error(error) 15 | } 16 | } else { 17 | window.conveyor = conveyor 18 | } 19 | -------------------------------------------------------------------------------- /app/components/common/context_menu/types/contextMenu.types.ts: -------------------------------------------------------------------------------- 1 | export interface PropsContextMenu { 2 | handleCloseParent: () => void 3 | } 4 | 5 | export interface PropsContextMenuPlaylist extends PropsContextMenu { 6 | playlistId: string 7 | onDelete?: () => void 8 | } 9 | 10 | export interface PropsContextMenuSong extends PropsContextMenu { 11 | songPath: string 12 | playlistId: string 13 | songName: string 14 | artistName: string 15 | } 16 | -------------------------------------------------------------------------------- /scripts/build-offline.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | echo "Running offline build script..." 3 | 4 | REM Activate virtual environment and install python dependencies 5 | echo "Installing Python dependencies..." 6 | call ..\backend\venv\Scripts\activate.bat 7 | pip install -r ..\backend\requirements.txt 8 | pip install pyinstaller 9 | 10 | REM Run the build script 11 | echo "Building the application for Windows..." 12 | npm run build:win 13 | 14 | echo "Build script finished." -------------------------------------------------------------------------------- /backend/src/models/search_models.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | from typing import Optional 3 | 4 | class SearchQuery(BaseModel): 5 | query: str 6 | artist: Optional[str] = None 7 | song: Optional[str] = None 8 | 9 | class SearchResult(BaseModel): 10 | path: str 11 | size: int 12 | username: str 13 | extension: Optional[str] = None 14 | bitrate: Optional[int] = None 15 | quality: Optional[str] = None 16 | length: Optional[str] = None -------------------------------------------------------------------------------- /resources/build/entitlements.mac.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.allow-jit 6 | 7 | com.apple.security.cs.allow-unsigned-executable-memory 8 | 9 | com.apple.security.cs.allow-dyld-environment-variables 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "app/styles/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "@/app/components", 14 | "utils": "@/lib/utils", 15 | "ui": "@/app/components/ui", 16 | "lib": "@/lib", 17 | "hooks": "@/lib/hooks" 18 | }, 19 | "iconLibrary": "lucide" 20 | } 21 | -------------------------------------------------------------------------------- /app/lib/main/update-manager.ts: -------------------------------------------------------------------------------- 1 | import { autoUpdater } from 'electron-updater' 2 | import { ipcMain } from 'electron' 3 | 4 | export class UpdateManager { 5 | constructor() { 6 | autoUpdater.autoDownload = true 7 | 8 | autoUpdater.on('update-downloaded', () => { 9 | ipcMain.emit('update-ready') 10 | }) 11 | 12 | ipcMain.on('install-update', () => { 13 | autoUpdater.quitAndInstall() 14 | }) 15 | } 16 | 17 | public checkForUpdates() { 18 | autoUpdater.checkForUpdatesAndNotify() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/lib/preload/shared.ts: -------------------------------------------------------------------------------- 1 | import type { ElectronAPI, IpcRenderer } from '@electron-toolkit/preload' 2 | import type { ChannelName, ChannelArgs, ChannelReturn } from '@/lib/conveyor/schemas' 3 | 4 | export abstract class ConveyorApi { 5 | protected renderer: IpcRenderer 6 | 7 | constructor(electronApi: ElectronAPI) { 8 | this.renderer = electronApi.ipcRenderer 9 | } 10 | 11 | invoke = async (channel: T, ...args: ChannelArgs): Promise> => { 12 | return this.renderer.invoke(channel, ...args) as Promise> 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /app/hooks/usePlaybackManager.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react' 2 | import { PlaybackContext } from '../providers/PlaybackProvider' 3 | 4 | /** 5 | * Custom hook to access the PlaybackManager instance from the PlaybackContext. 6 | * This is the sole method components should use to interact with the playback system. 7 | * @returns The PlaybackManager instance. 8 | */ 9 | export const usePlaybackManager = () => { 10 | const context = useContext(PlaybackContext) 11 | 12 | if (context === undefined) { 13 | throw new Error('usePlaybackManager must be used within a PlaybackProvider') 14 | } 15 | 16 | return context 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "@/*": ["./*"], 6 | "@/app/*": ["./app/*"], 7 | "@/lib/*": ["./app/lib/*"], 8 | "@/resources/*": ["./resources/*"], 9 | "components/*": ["app/components/*"], 10 | "pages/*": ["app/pages/*"], 11 | "hooks/*": ["app/hooks/*"], 12 | "providers/*": ["app/providers/*"], 13 | "utils/*": ["app/utils/*"], 14 | "global/*": ["app/global/*"], 15 | "i18n/*": ["app/i18n/*"], 16 | "types/*": ["app/types/*"] 17 | } 18 | }, 19 | "files": [], 20 | "references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }] 21 | } 22 | -------------------------------------------------------------------------------- /backend/src/models/playlist_models.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | from typing import List, Optional 3 | 4 | class Playlist(BaseModel): 5 | id: str 6 | name: str 7 | description: Optional[str] = None 8 | thumbnail: Optional[str] = None 9 | songs: List[str] = [] 10 | createdAt: str 11 | updatedAt: str 12 | 13 | class CreatePlaylistRequest(BaseModel): 14 | name: str 15 | description: Optional[str] = None 16 | thumbnail: Optional[str] = None 17 | 18 | class UpdatePlaylistRequest(BaseModel): 19 | name: Optional[str] = None 20 | description: Optional[str] = None 21 | thumbnail: Optional[str] = None 22 | 23 | class AddSongToPlaylistRequest(BaseModel): 24 | song_path: str -------------------------------------------------------------------------------- /app/lib/main/protocols.ts: -------------------------------------------------------------------------------- 1 | import { protocol, net } from 'electron' 2 | import { join } from 'path' 3 | import { pathToFileURL } from 'url' 4 | 5 | export function registerResourcesProtocol() { 6 | protocol.handle('res', async (request) => { 7 | try { 8 | const url = new URL(request.url) 9 | // Combine hostname and pathname to get the full path 10 | const fullPath = join(url.hostname, url.pathname.slice(1)) 11 | const filePath = join(__dirname, '../../resources', fullPath) 12 | return net.fetch(pathToFileURL(filePath).toString()) 13 | } catch (error) { 14 | console.error('Protocol error:', error) 15 | return new Response('Resource not found', { status: 404 }) 16 | } 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /app/renderer/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | Sonosano 16 | 17 | 18 | 19 | 20 |
21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /app/utils/metadata.ts: -------------------------------------------------------------------------------- 1 | import { AudioMetadata } from '../types' 2 | 3 | // Extract basic metadata from filename if not provided 4 | export const extractMetadataFromFilename = (filename: string): AudioMetadata => { 5 | // Remove extension 6 | const nameWithoutExt = filename.replace(/\.[^/.]+$/, '') 7 | 8 | // Remove track number if present (e.g., "03 - " or "03. ") 9 | const cleanedName = nameWithoutExt.replace(/^\d+\s*[-.]\s*/, '') 10 | 11 | // Try to parse Artist - Title format 12 | const parts = cleanedName.split(' - ') 13 | if (parts.length >= 2) { 14 | return { 15 | artist: parts[0].trim(), 16 | title: parts.slice(1).join(' - ').trim(), 17 | } 18 | } 19 | 20 | // Default to cleaned filename as title 21 | return { 22 | title: cleanedName, 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /backend/src/models/download_models.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | from typing import Optional, Dict, Any, List 3 | from .system_models import SystemStatus 4 | 5 | class DownloadRequest(BaseModel): 6 | username: str 7 | file_path: str 8 | size: int 9 | metadata: Optional[Dict[str, Any]] = None 10 | 11 | class DownloadStatus(BaseModel): 12 | status: str 13 | progress: int 14 | total: int 15 | percent: float 16 | speed: Optional[int] = None 17 | queuePosition: Optional[int] = None 18 | errorMessage: Optional[str] = None 19 | 20 | class DownloadedFile(BaseModel): 21 | name: str 22 | path: str 23 | size: int 24 | extension: str 25 | 26 | class DownloadsAndStatusResponse(BaseModel): 27 | downloads: List[Dict[str, Any]] 28 | system_status: SystemStatus -------------------------------------------------------------------------------- /tsconfig.web.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@electron-toolkit/tsconfig/tsconfig.web.json", 3 | "include": [ 4 | "app/index.d.ts", 5 | "app/**/*", 6 | "app/lib/**/*", 7 | "app/lib/conveyor/*.d.ts", 8 | "resources/**/*", 9 | "app/renderer/preload.d.ts" 10 | ], 11 | "compilerOptions": { 12 | "composite": true, 13 | "jsx": "react-jsx", 14 | "baseUrl": ".", 15 | "types": ["electron-vite/node"], 16 | "paths": { 17 | "@/*": ["./*"], 18 | "components/*": ["app/components/*"], 19 | "pages/*": ["app/pages/*"], 20 | "hooks/*": ["app/hooks/*"], 21 | "providers/*": ["app/providers/*"], 22 | "utils/*": ["app/utils/*"], 23 | "global/*": ["app/global/*"], 24 | "i18n/*": ["app/i18n/*"], 25 | "types/*": ["app/types/*"] 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/components/footer/SongConfig/VolumeSlider/volumeSlider.module.css: -------------------------------------------------------------------------------- 1 | .buttonMargins { 2 | margin-right: 4%; 3 | position: relative; 4 | z-index: 10; 5 | } 6 | 7 | .volumeIcon { 8 | width: 16px; 9 | height: 16px; 10 | fill: currentColor; 11 | transition: fill 0.2s ease-in-out; 12 | } 13 | 14 | .volumeButton { 15 | outline: none; 16 | border: none; 17 | box-shadow: none; 18 | background: transparent; 19 | padding: 8px; 20 | cursor: pointer; 21 | position: relative; 22 | z-index: 10; 23 | } 24 | 25 | .volumeButton:focus { 26 | outline: none; 27 | border: none; 28 | box-shadow: none; 29 | } 30 | 31 | .volumeButton:active { 32 | outline: none; 33 | border: none; 34 | box-shadow: none; 35 | } 36 | 37 | .volumeButton:focus-visible { 38 | outline: none; 39 | border: none; 40 | box-shadow: none; 41 | } 42 | -------------------------------------------------------------------------------- /app/utils/format.ts: -------------------------------------------------------------------------------- 1 | export const formatBytes = (bytes: number, decimals = 2): string => { 2 | if (!bytes || bytes === 0) return '0 Bytes' 3 | const k = 1024 4 | const dm = decimals < 0 ? 0 : decimals 5 | const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'] 6 | const i = Math.floor(Math.log(bytes) / Math.log(k)) 7 | return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}` 8 | } 9 | 10 | export const formatDuration = (duration: number | undefined): string => { 11 | if (!duration || isNaN(duration) || duration < 0) return '0:00' 12 | // Assume duration can be in seconds or milliseconds 13 | const totalSeconds = duration > 10000 ? Math.floor(duration / 1000) : Math.floor(duration) 14 | const minutes = Math.floor(totalSeconds / 60) 15 | const seconds = totalSeconds % 60 16 | return `${minutes}:${seconds.toString().padStart(2, '0')}` 17 | } 18 | -------------------------------------------------------------------------------- /app/utils/language.ts: -------------------------------------------------------------------------------- 1 | import Language from 'i18n/languages' 2 | 3 | const LANGUAGE_TOKEN_LOCAL_STORAGE_KEY = 'lang' 4 | 5 | export function getLanguageFromString(languageValue: string) { 6 | const genre = Object.values(Language).find((x) => x === languageValue) 7 | if (!genre) { 8 | throw new Error(`Cannot get Genre from ${languageValue}`) 9 | } 10 | 11 | return genre 12 | } 13 | 14 | export const getLanguageFromStorage = (): Language => { 15 | const languageValue = localStorage.getItem(LANGUAGE_TOKEN_LOCAL_STORAGE_KEY) 16 | if (!languageValue) return Language.ENGLISH 17 | const languageMapped = getLanguageFromString(languageValue) 18 | if (!languageMapped) return Language.ENGLISH 19 | return languageMapped 20 | } 21 | 22 | export const setLanguageStorage = (language: Language) => { 23 | localStorage.setItem(LANGUAGE_TOKEN_LOCAL_STORAGE_KEY, language) 24 | } 25 | -------------------------------------------------------------------------------- /backend/src/utils/file_system_utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | 4 | def is_audio_file(path, ext): 5 | """Checks if a file is an audio file based on its extension.""" 6 | audio_extensions = {'.mp3', '.wav', '.flac', '.m4a', '.aac', '.ogg', '.wma', '.opus'} 7 | return ext in audio_extensions or any(path.lower().endswith(audio_ext) for audio_ext in audio_extensions) 8 | 9 | def load_or_create_misc_config(): 10 | """Loads or creates the misc config file.""" 11 | config_path = os.path.join(os.path.dirname(__file__), '..', 'config', 'misc.json') 12 | if not os.path.exists(config_path): 13 | os.makedirs(os.path.dirname(config_path), exist_ok=True) 14 | with open(config_path, 'w') as f: 15 | json.dump({'credentials': {'username': '', 'password': ''}}, f, indent=4) 16 | with open(config_path, 'r') as f: 17 | return json.load(f) -------------------------------------------------------------------------------- /app/lib/main/shared.ts: -------------------------------------------------------------------------------- 1 | import { ipcMain } from 'electron' 2 | import { ipcSchemas, validateArgs, validateReturn, type ChannelArgs, type ChannelReturn } from '@/lib/conveyor/schemas' 3 | 4 | /** 5 | * Helper to register IPC handlers 6 | * @param channel - The IPC channel to register the handler for 7 | * @param handler - The handler function to register 8 | * @returns void 9 | */ 10 | export const handle = ( 11 | channel: T, 12 | handler: (...args: ChannelArgs) => ChannelReturn 13 | ) => { 14 | ipcMain.handle(channel, async (_, ...args) => { 15 | try { 16 | const validatedArgs = validateArgs(channel, args) 17 | const result = await handler(...validatedArgs) 18 | 19 | return validateReturn(channel, result) 20 | } catch (error) { 21 | console.error(`IPC Error in ${channel}:`, error) 22 | throw error 23 | } 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /app/components/footer/SongConfig/TimerMenu/timerMenu.module.css: -------------------------------------------------------------------------------- 1 | .menuContainer { 2 | background-color: #1f1f1f; 3 | border-radius: 8px; 4 | box-shadow: 0 8px 16px rgba(0, 0, 0, 0.8); 5 | color: #fff; 6 | width: 220px; 7 | font-family: 'CircularSp', 'Helvetica Neue', Helvetica, Arial, sans-serif; 8 | border: 1px solid #282828; 9 | padding: 8px; 10 | } 11 | 12 | .menuContainer ul { 13 | list-style: none; 14 | margin: 0; 15 | padding: 0; 16 | } 17 | 18 | .menuContainer li { 19 | padding: 14px 18px; 20 | cursor: pointer; 21 | transition: background-color 0.2s; 22 | font-size: 15px; 23 | border-radius: 4px; 24 | font-weight: 500; 25 | } 26 | 27 | .menuContainer li:hover { 28 | background-color: #363636; 29 | } 30 | 31 | .cancelOption { 32 | color: #b3b3b3; 33 | border-top: 1px solid #282828; 34 | margin-top: 8px; 35 | padding-top: 16px; 36 | } 37 | 38 | .cancelOption:hover { 39 | color: #fff; 40 | } 41 | -------------------------------------------------------------------------------- /app/renderer/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client' 2 | import { HashRouter } from 'react-router-dom' 3 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query' 4 | import { initializeI18n } from 'i18n/i18n' 5 | 6 | import 'bootstrap/dist/css/bootstrap.min.css' 7 | 8 | import { StrictMode } from 'react' 9 | import App from './App' 10 | import './index.css' 11 | 12 | const container = document.getElementById('root') as HTMLElement 13 | const root = createRoot(container) 14 | const queryClient = new QueryClient() 15 | 16 | initializeI18n() 17 | .then(() => { 18 | return root.render( 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | ) 27 | }) 28 | .catch((err) => { 29 | console.error(`Error initializing app: ${err}`) 30 | }) 31 | -------------------------------------------------------------------------------- /backend/src/core/config_utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | def get_documents_folder(): 5 | """Get the user's documents folder path.""" 6 | if sys.platform == "win32": 7 | import ctypes 8 | from ctypes.wintypes import MAX_PATH 9 | dll = ctypes.windll.shell32 10 | buf = ctypes.create_unicode_buffer(MAX_PATH + 1) 11 | if dll.SHGetSpecialFolderPathW(None, buf, 0x0005, False): 12 | return buf.value 13 | else: 14 | return os.path.join(os.path.expanduser("~"), "Documents") 15 | else: 16 | return os.path.expanduser("~/Documents") 17 | 18 | def get_config_path(): 19 | """Get the path to the config file in Documents/sonosano_config/pref.ini.""" 20 | documents_folder = get_documents_folder() 21 | config_dir = os.path.join(documents_folder, "sonosano_config") 22 | os.makedirs(config_dir, exist_ok=True) 23 | return os.path.join(config_dir, "pref.ini") -------------------------------------------------------------------------------- /app/i18n/i18n.ts: -------------------------------------------------------------------------------- 1 | import i18n, { Resource } from 'i18next' 2 | import { initReactI18next } from 'react-i18next' 3 | import Language, { availableLanguages } from './languages' 4 | 5 | const loadTranslationFiles = async (): Promise => { 6 | const resources: Resource = {} 7 | 8 | for (const lang of availableLanguages) { 9 | const translation = await import(`./localization/${lang}.json`) 10 | resources[lang] = { translation: translation.default || translation } 11 | } 12 | 13 | return resources 14 | } 15 | 16 | export const initializeI18n = async () => { 17 | const resources = await loadTranslationFiles() 18 | 19 | i18n.use(initReactI18next).init({ 20 | resources, 21 | lng: Language.ENGLISH, 22 | fallbackLng: Language.ENGLISH, 23 | interpolation: { 24 | escapeValue: false, 25 | }, 26 | }) 27 | } 28 | 29 | export const changeLanguage = (language: Language) => { 30 | i18n.changeLanguage(language) 31 | } 32 | -------------------------------------------------------------------------------- /app/renderer.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client' 2 | import { HashRouter } from 'react-router-dom' 3 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query' 4 | import { initializeI18n } from '@/app/i18n/i18n' 5 | 6 | import 'bootstrap/dist/css/bootstrap.min.css' 7 | 8 | import { StrictMode } from 'react' 9 | import App from './renderer/App' 10 | import './renderer/index.css' 11 | 12 | const container = document.getElementById('root') as HTMLElement 13 | const root = createRoot(container) 14 | const queryClient = new QueryClient() 15 | 16 | initializeI18n() 17 | .then(() => { 18 | return root.render( 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | ) 27 | }) 28 | .catch((err) => { 29 | console.error(`Error initializing app: ${err}`) 30 | }) 31 | -------------------------------------------------------------------------------- /scripts/build-backend.js: -------------------------------------------------------------------------------- 1 | const { exec } = require('child_process') 2 | const fs = require('fs') 3 | const path = require('path') 4 | 5 | const backendDir = path.join(__dirname, '..', 'backend') 6 | const distDir = path.join(backendDir, 'dist') 7 | const mainPy = path.join(backendDir, 'src', 'main.py') 8 | 9 | // Clean up previous builds 10 | if (fs.existsSync(distDir)) { 11 | fs.rmSync(distDir, { recursive: true, force: true }) 12 | } 13 | 14 | const specFile = path.join(backendDir, 'sonosano.spec') 15 | const command = `pyinstaller --noconfirm "${specFile}" --distpath "${distDir}"` 16 | 17 | const pyinstaller = exec(command, { cwd: backendDir }) 18 | 19 | pyinstaller.stdout.on('data', (data) => { 20 | console.log(data) 21 | }) 22 | 23 | pyinstaller.stderr.on('data', (data) => { 24 | console.error(data) 25 | }) 26 | 27 | pyinstaller.on('close', (code) => { 28 | console.log(`PyInstaller process exited with code ${code}`) 29 | if (code !== 0) { 30 | process.exit(1) 31 | } 32 | }) 33 | -------------------------------------------------------------------------------- /backend/sonosano.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python ; coding: utf-8 -*- 2 | 3 | from PyInstaller.utils.hooks import collect_data_files 4 | 5 | datas = [('src/pynicotine', 'pynicotine')] 6 | datas += collect_data_files('uroman') 7 | 8 | a = Analysis( 9 | ['src/main.py'], 10 | pathex=[], 11 | binaries=[], 12 | datas=datas, 13 | hiddenimports=[], 14 | hookspath=[], 15 | hooksconfig={}, 16 | runtime_hooks=[], 17 | excludes=[], 18 | noarchive=False, 19 | ) 20 | pyz = PYZ(a.pure) 21 | 22 | exe = EXE( 23 | pyz, 24 | a.scripts, 25 | a.binaries, 26 | a.zipfiles, 27 | a.datas, 28 | [], 29 | name='sonosano-backend', 30 | debug=False, 31 | bootloader_ignore_signals=False, 32 | strip=False, 33 | upx=True, 34 | upx_exclude=[], 35 | runtime_tmpdir=None, 36 | console=False, 37 | disable_windowed_traceback=False, 38 | argv_emulation=False, 39 | target_arch=None, 40 | codesign_identity=None, 41 | entitlements_file=None, 42 | ) -------------------------------------------------------------------------------- /app/utils/downloadEvents.ts: -------------------------------------------------------------------------------- 1 | class DownloadEventEmitter extends EventTarget { 2 | private static instance: DownloadEventEmitter 3 | 4 | private constructor() { 5 | super() 6 | } 7 | 8 | static getInstance(): DownloadEventEmitter { 9 | if (!DownloadEventEmitter.instance) { 10 | DownloadEventEmitter.instance = new DownloadEventEmitter() 11 | } 12 | return DownloadEventEmitter.instance 13 | } 14 | 15 | emitDownloadStarted(download: any) { 16 | this.dispatchEvent(new CustomEvent('downloadStarted', { detail: download })) 17 | } 18 | 19 | emitDownloadStatusChanged(download: any) { 20 | this.dispatchEvent(new CustomEvent('downloadStatusChanged', { detail: download })) 21 | } 22 | 23 | emitDownloadFailed(downloadId: string, error: string) { 24 | this.dispatchEvent( 25 | new CustomEvent('downloadFailed', { 26 | detail: { id: downloadId, error }, 27 | }) 28 | ) 29 | } 30 | } 31 | 32 | export const downloadEvents = DownloadEventEmitter.getInstance() 33 | -------------------------------------------------------------------------------- /app/hooks/useContextMenu.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, MouseEvent } from 'react' 2 | 3 | interface ContextMenuPosition { 4 | top: number 5 | left: number 6 | } 7 | 8 | export const useContextMenu = () => { 9 | const [isOpen, setIsOpen] = useState(false) 10 | const [anchorPosition, setAnchorPosition] = useState(null) 11 | 12 | const handleOpen = (event: MouseEvent) => { 13 | event.preventDefault() 14 | setIsOpen(!isOpen) 15 | setAnchorPosition({ 16 | top: event.clientY, 17 | left: event.clientX, 18 | }) 19 | } 20 | 21 | const handleClose = () => { 22 | setAnchorPosition(null) 23 | setIsOpen(false) 24 | } 25 | 26 | useEffect(() => { 27 | if (!isOpen) { 28 | handleClose() 29 | } 30 | }, [isOpen]) 31 | 32 | const open = Boolean(anchorPosition) 33 | const id = open ? 'parent-popover' : undefined 34 | 35 | return { 36 | open, 37 | anchorPosition, 38 | handleOpen, 39 | handleClose, 40 | id, 41 | } 42 | } 43 | 44 | export type { ContextMenuPosition } 45 | -------------------------------------------------------------------------------- /app/components/footer/SongConfig/TimerMenu/TimerMenu.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useTranslation } from 'react-i18next' 3 | import styles from './timerMenu.module.css' 4 | 5 | interface TimerMenuProps { 6 | onSelect: (minutes: number | 'custom') => void 7 | onCancel: () => void 8 | } 9 | 10 | export default function TimerMenu({ onSelect, onCancel }: TimerMenuProps) { 11 | const { t } = useTranslation() 12 | return ( 13 |
14 |
    15 |
  • onSelect(15)}>{t('timer.minutes', { count: 15 })}
  • 16 |
  • onSelect(30)}>{t('timer.minutes', { count: 30 })}
  • 17 |
  • onSelect(60)}>{t('timer.hour', { count: 1 })}
  • 18 |
  • onSelect(120)}>{t('timer.hour', { count: 2 })}
  • 19 |
  • onSelect('custom')}>{t('timer.custom')}
  • 20 |
  • 21 | {t('common.cancel')} 22 |
  • 23 |
24 |
25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug Main Process", 6 | "type": "node", 7 | "request": "launch", 8 | "cwd": "${workspaceRoot}", 9 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite", 10 | "windows": { 11 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.exe" 12 | }, 13 | "runtimeArgs": ["--sourcemap"], 14 | "env": { 15 | "REMOTE_DEBUGGING_PORT": "9222" 16 | } 17 | }, 18 | { 19 | "name": "Debug Renderer Process", 20 | "port": 9222, 21 | "request": "attach", 22 | "type": "chrome", 23 | "webRoot": "${workspaceFolder}/app", 24 | "timeout": 60000, 25 | "presentation": { 26 | "hidden": true 27 | } 28 | } 29 | ], 30 | "compounds": [ 31 | { 32 | "name": "Debug All", 33 | "configurations": ["Debug Main Process", "Debug Renderer Process"], 34 | "presentation": { 35 | "order": 1 36 | } 37 | } 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /app/hooks/usePlaylistPlayback.ts: -------------------------------------------------------------------------------- 1 | import { usePlaybackManager } from './usePlaybackManager' 2 | import { useLibrarySongs } from './useLibrarySongs' 3 | import { Playlist } from '../types' 4 | 5 | export const usePlaylistPlayback = (playlist: Playlist | null) => { 6 | const { playbackState } = usePlaybackManager() 7 | const { data: libraryFiles = [] } = useLibrarySongs() 8 | const { isPlaying, currentSong, currentPlaylist } = playbackState 9 | 10 | if (!playlist) { 11 | return { 12 | isCurrentPlaylist: false, 13 | isPlaying: false, 14 | currentlyPlayingFile: null, 15 | playlistSongs: [], 16 | } 17 | } 18 | 19 | const playlistSongs = libraryFiles.filter((file) => playlist.songs.includes(file.path)) 20 | 21 | const isCurrentPlaylist = currentPlaylist === 'current' && playlistSongs.some((song) => song.id === currentSong?.id) 22 | 23 | const currentlyPlayingFile = isCurrentPlaylist ? currentSong : null 24 | 25 | return { 26 | isCurrentPlaylist, 27 | isPlaying: isCurrentPlaylist && isPlaying, 28 | currentlyPlayingFile, 29 | playlistSongs, 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | Sonosano 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /app/lib/conveyor/schemas/index.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { windowIpcSchema } from './window-schema' 3 | import { appIpcSchema } from './app-schema' 4 | 5 | // Define all IPC channel schemas in one place 6 | export const ipcSchemas = { 7 | ...windowIpcSchema, 8 | ...appIpcSchema, 9 | } as const 10 | 11 | // Extract types from Zod schemas 12 | export type IPCChannels = { 13 | [K in keyof typeof ipcSchemas]: { 14 | args: z.infer<(typeof ipcSchemas)[K]['args']> 15 | return: z.infer<(typeof ipcSchemas)[K]['return']> 16 | } 17 | } 18 | 19 | export type ChannelName = keyof typeof ipcSchemas 20 | export type ChannelArgs = IPCChannels[T]['args'] 21 | export type ChannelReturn = IPCChannels[T]['return'] 22 | 23 | // Runtime validation helpers 24 | export const validateArgs = (channel: T, args: unknown[]): ChannelArgs => { 25 | return ipcSchemas[channel].args.parse(args) as ChannelArgs 26 | } 27 | 28 | export const validateReturn = (channel: T, data: unknown): ChannelReturn => { 29 | return ipcSchemas[channel].return.parse(data) as ChannelReturn 30 | } 31 | -------------------------------------------------------------------------------- /app/components/common/info_popover/confirmationModal.module.css: -------------------------------------------------------------------------------- 1 | .wrapperConfirmationModal { 2 | border-radius: var(--border-radius-thumbnails); 3 | background-color: var(--primary-white) !important; 4 | overflow-x: hidden; 5 | 6 | padding: 3% 5% 0 5% !important; 7 | border: 2px solid var(--secondary-green) !important; 8 | } 9 | 10 | .wrapperConfirmationModalHeader { 11 | display: grid; 12 | grid-template-columns: 5fr 5fr; 13 | grid-gap: 10px; 14 | } 15 | 16 | .wrapperConfirmationModalText { 17 | display: flex; 18 | flex-direction: column; 19 | justify-content: center; 20 | padding-left: 4%; 21 | } 22 | 23 | .wrapperConfirmationModalText span { 24 | font-weight: bold; 25 | font-size: 1.3rem; 26 | } 27 | 28 | .wrapperButton { 29 | margin-top: 3% !important; 30 | margin-bottom: 3% !important; 31 | padding: 0% !important; 32 | } 33 | 34 | .wrapperButton button { 35 | background-color: var(--secondary-green); 36 | border-radius: var(--border-radius-rounded-button); 37 | border: 1px solid var(--secondary-black); 38 | padding-top: 2%; 39 | padding-bottom: 2%; 40 | color: var(--primary-white); 41 | font-size: 1.1rem; 42 | font-weight: bold; 43 | } 44 | -------------------------------------------------------------------------------- /app/providers/PlaybackProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, ReactNode, useEffect, useState } from 'react' 2 | import { PlaybackManager, PlaybackState } from '../lib/managers/PlaybackManager' 3 | 4 | export const PlaybackContext = createContext< 5 | | { 6 | playbackManager: PlaybackManager 7 | playbackState: PlaybackState 8 | } 9 | | undefined 10 | >(undefined) 11 | 12 | interface PlaybackProviderProps { 13 | children: ReactNode 14 | } 15 | 16 | /** 17 | * Provides the PlaybackManager instance to the entire application. 18 | * It also listens for playback events and triggers re-renders when the state changes. 19 | */ 20 | export const PlaybackProvider: React.FC = ({ children }) => { 21 | const [playbackManager] = useState(() => PlaybackManager.getInstance()) 22 | const [playbackState, setPlaybackState] = useState(playbackManager.getState()) 23 | 24 | useEffect(() => { 25 | const unsubscribe = playbackManager.subscribe(setPlaybackState) 26 | return unsubscribe 27 | }, [playbackManager]) 28 | 29 | return {children} 30 | } 31 | -------------------------------------------------------------------------------- /app/hooks/useLibrarySongs.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query' 2 | import { apiClient } from '../api' 3 | import { Song } from '../types' 4 | 5 | const fetchLibrarySongs = (): Promise => { 6 | return apiClient.getLibrarySongs().then((data) => 7 | data.map((song: any) => { 8 | const metadata = song.metadata || {} 9 | if (metadata.coverArt && !metadata.coverArt.startsWith('data:') && !metadata.coverArt.startsWith('http')) { 10 | metadata.coverArt = apiClient.getCoverUrl(metadata.coverArt) 11 | } 12 | return { 13 | id: song.path, 14 | name: metadata.title || song.path.split(/[\\/]/).pop() || 'Unknown', 15 | path: song.path, 16 | size: metadata.size, 17 | extension: metadata.extension, 18 | metadata: metadata, 19 | dateAdded: song.date_added ? new Date(song.date_added * 1000).toISOString() : new Date().toISOString(), 20 | playCount: 0, 21 | } 22 | }) 23 | ) 24 | } 25 | 26 | export const useLibrarySongs = () => { 27 | return useQuery({ 28 | queryKey: ['librarySongs'], 29 | queryFn: fetchLibrarySongs, 30 | staleTime: 5 * 60 * 1000, 31 | }) 32 | } 33 | -------------------------------------------------------------------------------- /app/components/footer/SongConfig/songConfig.module.css: -------------------------------------------------------------------------------- 1 | .settingsContainer { 2 | justify-content: center; 3 | align-items: center; 4 | padding-left: 0 !important; 5 | } 6 | 7 | .settingsContainer button { 8 | color: var(--secondary-white); 9 | padding: 0; 10 | margin: 0 8px; 11 | font-size: 1rem; 12 | border: none; 13 | background: transparent; 14 | } 15 | 16 | .settingsContainer button:hover { 17 | color: var(--pure-white); 18 | } 19 | 20 | .settingsContainer button:focus { 21 | outline: none !important; 22 | border: none !important; 23 | box-shadow: none !important; 24 | } 25 | 26 | .settingsContainer button:active { 27 | outline: none !important; 28 | border: none !important; 29 | box-shadow: none !important; 30 | } 31 | 32 | .settingsContainer button:focus-visible { 33 | outline: none !important; 34 | border: none !important; 35 | box-shadow: none !important; 36 | } 37 | 38 | .settingsContainer button.active { 39 | color: var(--secondary-green); 40 | } 41 | 42 | .timerButtonContainer { 43 | position: relative; 44 | } 45 | 46 | .timerMenuWrapper { 47 | position: absolute; 48 | bottom: calc(100% + 10px); 49 | right: 50%; 50 | transform: translateX(50%); 51 | z-index: 1001; 52 | } 53 | -------------------------------------------------------------------------------- /backend/src/pynicotine/notifications.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020-2025 Nicotine+ Contributors 2 | # SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | from pynicotine.events import events 5 | 6 | 7 | class Notifications: 8 | __slots__ = () 9 | 10 | def show_notification(self, message, title=None): 11 | events.emit("show-notification", message, title=title) 12 | 13 | def show_chatroom_notification(self, room, message, title=None, high_priority=False): 14 | events.emit("show-chatroom-notification", room, message, title=title, high_priority=high_priority) 15 | 16 | def show_download_notification(self, message, title=None, high_priority=False): 17 | events.emit("show-download-notification", message, title=title, high_priority=high_priority) 18 | 19 | def show_private_chat_notification(self, username, message, title=None): 20 | events.emit("show-private-chat-notification", username, message, title=title) 21 | 22 | def show_search_notification(self, search_token, message, title=None): 23 | events.emit("show-search-notification", search_token, message, title=title) 24 | 25 | def show_upload_notification(self, message, title=None): 26 | events.emit("show-upload-notification", message, title=title) 27 | -------------------------------------------------------------------------------- /app/components/footer/TimerBar/TimerBar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import styles from './timerBar.module.css' 3 | 4 | interface TimerBarProps { 5 | startTime: number | null 6 | duration: number | null 7 | } 8 | 9 | export default function TimerBar({ startTime, duration }: TimerBarProps) { 10 | const [progress, setProgress] = useState(100) 11 | 12 | useEffect(() => { 13 | if (startTime === null || duration === null) { 14 | setProgress(0) 15 | return 16 | } 17 | 18 | const interval = setInterval(() => { 19 | const elapsedTime = Date.now() - startTime 20 | const remainingTime = duration - elapsedTime 21 | const progressPercentage = (remainingTime / duration) * 100 22 | 23 | if (remainingTime > 0) { 24 | setProgress(progressPercentage) 25 | } else { 26 | setProgress(0) 27 | clearInterval(interval) 28 | } 29 | }, 1000) 30 | 31 | return () => clearInterval(interval) 32 | }, [startTime, duration]) 33 | 34 | if (startTime === null || duration === null) { 35 | return null 36 | } 37 | 38 | return ( 39 |
40 |
41 |
42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /app/pages/Search/hooks/useSongSearch.ts: -------------------------------------------------------------------------------- 1 | import { useState, useCallback } from 'react' 2 | import { useQuery, useQueryClient } from '@tanstack/react-query' 3 | import { apiClient } from '../../../api' 4 | import useLocalStorage from '../../../hooks/useLocalStorage' 5 | 6 | export const useSongSearch = () => { 7 | useQueryClient() 8 | const [searchEnabled, setSearchEnabled] = useState(false) 9 | const [query, setQuery] = useState('') 10 | const [searchMode] = useLocalStorage('searchMode', 'apple_music') 11 | 12 | const searchResults = useQuery({ 13 | queryKey: ['search', searchMode, query], 14 | queryFn: () => { 15 | if (!query) return Promise.resolve([]) 16 | return apiClient.search(searchMode, query) 17 | }, 18 | enabled: searchEnabled, 19 | staleTime: 5 * 60 * 1000, // 5 minutes 20 | }) 21 | 22 | const performSearch = useCallback((newQuery: string) => { 23 | if (!newQuery.trim()) { 24 | setSearchEnabled(false) 25 | setQuery('') 26 | return 27 | } 28 | 29 | setQuery(newQuery) 30 | setSearchEnabled(true) 31 | }, []) 32 | 33 | return { 34 | searchResults: searchResults.data || [], 35 | isSearching: searchResults.isLoading, 36 | searchError: searchResults.error?.message || null, 37 | performSearch, 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /backend/src/core/file_watcher.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | from watchdog.events import FileSystemEventHandler 4 | from core.library_service import LibraryService 5 | from core.song_processor import SongProcessor 6 | 7 | class MusicFileHandler(FileSystemEventHandler): 8 | def __init__(self, library_service: LibraryService, song_processor: SongProcessor, music_directory: str): 9 | self.library_service = library_service 10 | self.song_processor = song_processor 11 | self.music_directory = music_directory 12 | 13 | def on_created(self, event): 14 | if not event.is_directory and self._is_audio_file(event.src_path): 15 | logging.info(f"New audio file detected: {event.src_path}") 16 | # Process with the absolute path 17 | self.song_processor.process_new_song(event.src_path) 18 | 19 | def on_deleted(self, event): 20 | if not event.is_directory and self._is_audio_file(event.src_path): 21 | logging.info(f"Audio file deleted: {event.src_path}") 22 | # Convert to relative path for removal from DB 23 | relative_path = os.path.relpath(event.src_path, self.music_directory) 24 | self.library_service.remove_song(relative_path) 25 | 26 | def _is_audio_file(self, path: str) -> bool: 27 | return path.lower().endswith(('.mp3', '.wav', '.flac', '.m4a', '.aac', '.ogg', '.wma', '.opus')) -------------------------------------------------------------------------------- /app/hooks/useLocalStorage.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | 3 | function useLocalStorage(key: string, initialValue: T): [T, (value: T) => void] { 4 | const [storedValue, setStoredValue] = useState(() => { 5 | try { 6 | const item = window.localStorage.getItem(key) 7 | return item ? JSON.parse(item) : initialValue 8 | } catch (error) { 9 | console.error(error) 10 | return initialValue 11 | } 12 | }) 13 | 14 | const setValue = (value: T) => { 15 | try { 16 | const valueToStore = value instanceof Function ? value(storedValue) : value 17 | setStoredValue(valueToStore) 18 | window.localStorage.setItem(key, JSON.stringify(valueToStore)) 19 | } catch (error) { 20 | console.error(error) 21 | } 22 | } 23 | 24 | useEffect(() => { 25 | const handleStorageChange = (e: StorageEvent) => { 26 | if (e.key === key) { 27 | try { 28 | setStoredValue(e.newValue ? JSON.parse(e.newValue) : initialValue) 29 | } catch (error) { 30 | console.error(error) 31 | } 32 | } 33 | } 34 | 35 | window.addEventListener('storage', handleStorageChange) 36 | 37 | return () => { 38 | window.removeEventListener('storage', handleStorageChange) 39 | } 40 | }, [key, initialValue]) 41 | 42 | return [storedValue, setValue] 43 | } 44 | 45 | export default useLocalStorage 46 | -------------------------------------------------------------------------------- /app/components/footer/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import styles from './footer.module.css' 3 | import SongInfo from './SongInfo/SongInfo' 4 | import SongConfig from './SongConfig/SongConfig' 5 | import Player from './Player/Player' 6 | import { Song } from '../../types' 7 | import TimerBar from './TimerBar/TimerBar' 8 | 9 | export default function Footer() { 10 | const [volume, setVolume] = useState(50) 11 | const [songInfo, setSongInfo] = useState({ 12 | id: '', 13 | path: '', 14 | name: '', 15 | metadata: { 16 | artist: '', 17 | coverArt: '', 18 | }, 19 | }) 20 | const [timerStartTime, setTimerStartTime] = useState(null) 21 | const [timerDuration, setTimerDuration] = useState(null) 22 | 23 | const handleSetTimerState = (startTime: number | null, duration: number | null) => { 24 | setTimerStartTime(startTime) 25 | setTimerDuration(duration) 26 | } 27 | 28 | return ( 29 |
30 | 31 | 32 | {/* Unified Player component */} 33 | 34 | 35 | 36 | 37 |
38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /electron-builder.yml: -------------------------------------------------------------------------------- 1 | appId: com.sonosano.app 2 | productName: Sonosano 3 | directories: 4 | output: 'dist' 5 | buildResources: 'resources' 6 | files: 7 | - 'out/main/**/*' 8 | - 'out/preload/**/*' 9 | - 'out/renderer/**/*' 10 | - 'package.json' 11 | asarUnpack: [] 12 | extraResources: 13 | - from: 'backend/dist' 14 | to: 'backend' 15 | filter: 16 | - '**/*' 17 | win: 18 | executableName: Sonosano 19 | icon: assets/icon.ico 20 | target: 21 | - 'nsis' 22 | nsis: 23 | artifactName: ${name}-setup.${ext} 24 | shortcutName: ${productName} 25 | uninstallDisplayName: ${productName} 26 | createDesktopShortcut: always 27 | mac: 28 | icon: assets/icon.icns 29 | entitlementsInherit: resources/build/entitlements.mac.plist 30 | extendInfo: 31 | - NSCameraUsageDescription: Application requests access to the device's camera. 32 | - NSMicrophoneUsageDescription: Application requests access to the device's microphone. 33 | - NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder. 34 | - NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder. 35 | notarize: false 36 | dmg: 37 | artifactName: ${name}.${ext} 38 | linux: 39 | icon: assets/icon.png 40 | target: 41 | - deb 42 | maintainer: electronjs.org 43 | category: Utility 44 | appImage: 45 | artifactName: ${name}.${ext} 46 | npmRebuild: false 47 | publish: 48 | provider: github 49 | owner: KRSHH 50 | repo: Sonosano 51 | -------------------------------------------------------------------------------- /backend/src/core/playlist_service.py: -------------------------------------------------------------------------------- 1 | import os 2 | import requests 3 | import logging 4 | from typing import Dict, Any, Optional 5 | 6 | class PlaylistService: 7 | def __init__(self, data_path: str): 8 | self.data_path = data_path 9 | self.covers_path = os.path.join(self.data_path, 'covers') 10 | os.makedirs(self.covers_path, exist_ok=True) 11 | 12 | def download_playlist_thumbnail(self, playlist_name: str, thumbnail_url: Optional[str]) -> Optional[str]: 13 | if not thumbnail_url: 14 | return None 15 | 16 | try: 17 | response = requests.get(thumbnail_url, stream=True) 18 | response.raise_for_status() 19 | 20 | # Create a safe filename for the playlist thumbnail 21 | safe_playlist_name = "".join([c for c in playlist_name if c.isalpha() or c.isdigit() or c==' ']).rstrip() 22 | file_name = f"playlist_{safe_playlist_name}.jpg".replace(' ', '_') 23 | thumbnail_path = os.path.join(self.covers_path, file_name) 24 | 25 | with open(thumbnail_path, 'wb') as f: 26 | for chunk in response.iter_content(chunk_size=8192): 27 | f.write(chunk) 28 | 29 | logging.info(f"Successfully downloaded and saved playlist thumbnail to {thumbnail_path}") 30 | return file_name.replace('\\', '/') 31 | 32 | except requests.exceptions.RequestException as e: 33 | logging.error(f"Error downloading playlist thumbnail from {thumbnail_url}: {e}") 34 | return None 35 | -------------------------------------------------------------------------------- /backend/src/pynicotine/portchecker.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 Nicotine+ Contributors 2 | # SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | import threading 5 | 6 | import pynicotine 7 | from pynicotine.events import events 8 | from pynicotine.logfacility import log 9 | 10 | 11 | class PortChecker: 12 | __slots__ = ("_thread",) 13 | 14 | def __init__(self): 15 | self._thread = None 16 | 17 | def check_status(self, port): 18 | 19 | threading.Thread( 20 | target=self._check_status, args=(port,), name="PortChecker", daemon=True 21 | ).start() 22 | 23 | def _check_status(self, port): 24 | 25 | try: 26 | is_successful = self._retrieve_status(port) 27 | 28 | except Exception as error: 29 | log.add_debug("Unable to check status of port %s: %s", (port, error)) 30 | is_successful = None 31 | 32 | events.emit_main_thread("check-port-status", port, is_successful) 33 | 34 | def _retrieve_status(self, port): 35 | 36 | from urllib.request import urlopen 37 | 38 | with urlopen(pynicotine.__port_checker_url__ % port, timeout=5) as response: 39 | response_body = response.read().lower() 40 | 41 | if f"{port}/tcp open".encode() in response_body: 42 | is_successful = True 43 | 44 | elif f"{port}/tcp closed".encode() in response_body: 45 | is_successful = False 46 | 47 | else: 48 | raise ValueError(f"Unknown response from port checker: {response_body}") 49 | 50 | return is_successful 51 | -------------------------------------------------------------------------------- /app/lib/conveyor/api/window-api.ts: -------------------------------------------------------------------------------- 1 | import { ConveyorApi } from '@/lib/preload/shared' 2 | 3 | export class WindowApi extends ConveyorApi { 4 | // Generate window methods 5 | windowInit = () => this.invoke('window-init') 6 | windowIsMinimizable = () => this.invoke('window-is-minimizable') 7 | windowIsMaximizable = () => this.invoke('window-is-maximizable') 8 | windowMinimize = () => this.invoke('window-minimize') 9 | windowMaximize = () => this.invoke('window-maximize') 10 | windowClose = () => this.invoke('window-close') 11 | windowMaximizeToggle = () => this.invoke('window-maximize-toggle') 12 | 13 | // Generate web methods 14 | webUndo = () => this.invoke('web-undo') 15 | webRedo = () => this.invoke('web-redo') 16 | webCut = () => this.invoke('web-cut') 17 | webCopy = () => this.invoke('web-copy') 18 | webPaste = () => this.invoke('web-paste') 19 | webDelete = () => this.invoke('web-delete') 20 | webSelectAll = () => this.invoke('web-select-all') 21 | webReload = () => this.invoke('web-reload') 22 | webForceReload = () => this.invoke('web-force-reload') 23 | webToggleDevtools = () => this.invoke('web-toggle-devtools') 24 | webActualSize = () => this.invoke('web-actual-size') 25 | webZoomIn = () => this.invoke('web-zoom-in') 26 | webZoomOut = () => this.invoke('web-zoom-out') 27 | webToggleFullscreen = () => this.invoke('web-toggle-fullscreen') 28 | webOpenUrl = (url: string) => this.invoke('web-open-url', url) 29 | openFolderDialog = () => this.invoke('open-folder-dialog') 30 | copyToClipboard = (text: string) => this.invoke('web-copy-text', text) 31 | } 32 | -------------------------------------------------------------------------------- /app/utils/statusUtils.tsx: -------------------------------------------------------------------------------- 1 | import { DownloadItem } from '../types' 2 | import styles from '../components/RecentlyPlayed/recentlyPlayed.module.css' 3 | 4 | export const getStatusIcon = (_status: DownloadItem['status']) => {} 5 | 6 | export const getStatusColor = (status: DownloadItem['status'] | string) => { 7 | switch (status) { 8 | case 'queued': 9 | case 'Queued': 10 | return styles.statusQueued 11 | case 'downloading': 12 | case 'Transferring': 13 | return styles.statusDownloading 14 | case 'completed': 15 | case 'Finished': 16 | return styles.statusCompleted 17 | case 'failed': 18 | case 'Error': 19 | case 'Connection timeout': 20 | case 'Connection closed': 21 | case 'User logged off': 22 | return styles.statusFailed 23 | case 'paused': 24 | case 'Paused': 25 | return styles.statusPaused 26 | default: 27 | return '' 28 | } 29 | } 30 | 31 | export const getStatusMessageKey = (status: string): string => { 32 | switch (status) { 33 | case 'Queued': 34 | return 'status.queued' 35 | case 'Transferring': 36 | return 'status.downloading' 37 | case 'Finished': 38 | return 'status.completed' 39 | case 'Paused': 40 | return 'status.paused' 41 | case 'Cancelled': 42 | return 'status.cancelled' 43 | case 'Error': 44 | case 'Connection timeout': 45 | case 'Connection closed': 46 | case 'User logged off': 47 | return 'status.failed' 48 | case 'Getting status': 49 | return 'status.gettingStatus' 50 | default: 51 | return '' 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /electron.vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path' 2 | import react from '@vitejs/plugin-react' 3 | import tailwindcss from '@tailwindcss/vite' 4 | import { defineConfig, externalizeDepsPlugin } from 'electron-vite' 5 | 6 | // Shared alias configuration 7 | const aliases = { 8 | '@/app': resolve(__dirname, 'app'), 9 | '@/lib': resolve(__dirname, 'app/lib'), 10 | '@/resources': resolve(__dirname, 'resources'), 11 | components: resolve(__dirname, 'app/components'), 12 | pages: resolve(__dirname, 'app/pages'), 13 | hooks: resolve(__dirname, 'app/hooks'), 14 | providers: resolve(__dirname, 'app/providers'), 15 | utils: resolve(__dirname, 'app/utils'), 16 | i18n: resolve(__dirname, 'app/i18n'), 17 | types: resolve(__dirname, 'app/types'), 18 | } 19 | 20 | export default defineConfig({ 21 | main: { 22 | build: { 23 | rollupOptions: { 24 | input: { 25 | main: resolve(__dirname, 'app/lib/main/main.ts'), 26 | }, 27 | }, 28 | }, 29 | resolve: { 30 | alias: aliases, 31 | }, 32 | plugins: [externalizeDepsPlugin()], 33 | }, 34 | preload: { 35 | build: { 36 | rollupOptions: { 37 | input: { 38 | preload: resolve(__dirname, 'app/lib/preload/preload.ts'), 39 | }, 40 | }, 41 | }, 42 | resolve: { 43 | alias: aliases, 44 | }, 45 | plugins: [externalizeDepsPlugin()], 46 | }, 47 | renderer: { 48 | root: './app', 49 | build: { 50 | rollupOptions: { 51 | input: { 52 | index: resolve(__dirname, 'app/index.html'), 53 | }, 54 | }, 55 | }, 56 | resolve: { 57 | alias: aliases, 58 | }, 59 | plugins: [tailwindcss(), react()], 60 | }, 61 | }) 62 | -------------------------------------------------------------------------------- /app/components/playlists/editPlaylistModal.module.css: -------------------------------------------------------------------------------- 1 | .modalHeader { 2 | display: flex; 3 | justify-content: space-between; 4 | align-items: center; 5 | margin-bottom: 2rem; 6 | } 7 | 8 | .modalHeader h2 { 9 | margin: 0; 10 | font-size: 1.5rem; 11 | font-weight: 600; 12 | color: #fff; 13 | } 14 | 15 | .closeButton { 16 | background: none; 17 | border: none; 18 | color: #999; 19 | cursor: pointer; 20 | padding: 0.5rem; 21 | border-radius: 6px; 22 | font-size: 1.1rem; 23 | transition: all 0.2s ease; 24 | } 25 | 26 | .closeButton:hover { 27 | background: #333; 28 | color: #fff; 29 | } 30 | 31 | .modalForm { 32 | display: flex; 33 | flex-direction: column; 34 | gap: 1.5rem; 35 | } 36 | 37 | .formGroup { 38 | display: flex; 39 | flex-direction: column; 40 | gap: 0.5rem; 41 | } 42 | 43 | .formGroup label { 44 | font-weight: 500; 45 | color: #fff; 46 | font-size: 0.9rem; 47 | } 48 | 49 | .modalActions { 50 | display: flex; 51 | justify-content: flex-end; 52 | gap: 1rem; 53 | margin-top: 1rem; 54 | padding-top: 1.5rem; 55 | border-top: 1px solid #333; 56 | } 57 | 58 | .cancelButton { 59 | padding: 0.75rem 1.5rem; 60 | background: #333; 61 | color: #fff; 62 | border: 1px solid #555; 63 | border-radius: 6px; 64 | font-size: 0.9rem; 65 | cursor: pointer; 66 | transition: all 0.2s ease; 67 | } 68 | 69 | .cancelButton:hover { 70 | background: #444; 71 | border-color: #1db954; 72 | } 73 | 74 | .saveButton { 75 | padding: 0.75rem 1.5rem; 76 | background: #1db954; 77 | color: white; 78 | border: none; 79 | border-radius: 6px; 80 | font-size: 0.9rem; 81 | font-weight: 600; 82 | cursor: pointer; 83 | transition: all 0.2s ease; 84 | } 85 | 86 | .saveButton:hover:not(:disabled) { 87 | background: #1ed760; 88 | transform: translateY(-1px); 89 | } 90 | 91 | .saveButton:disabled { 92 | background: #666; 93 | cursor: not-allowed; 94 | transform: none; 95 | } 96 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | 8 | jobs: 9 | build: 10 | permissions: 11 | contents: write 12 | runs-on: ${{ matrix.os }} 13 | 14 | strategy: 15 | matrix: 16 | os: [windows-latest, macos-latest, ubuntu-latest] 17 | 18 | steps: 19 | - name: Checkout code 20 | uses: actions/checkout@v3 21 | 22 | - name: Set up Node.js 23 | uses: actions/setup-node@v3 24 | with: 25 | node-version: '18' 26 | 27 | - name: Set up Python 28 | uses: actions/setup-python@v4 29 | with: 30 | python-version: '3.11' 31 | 32 | - name: Install Python dependencies 33 | run: | 34 | python -m pip install --upgrade pip 35 | pip install -r backend/requirements.txt 36 | pip install pyinstaller 37 | 38 | - name: Install dependencies 39 | run: npm install 40 | 41 | - name: Build the app (Windows) 42 | if: matrix.os == 'windows-latest' 43 | run: npm run build:win 44 | env: 45 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 46 | 47 | - name: Build the app (macOS) 48 | if: matrix.os == 'macos-latest' 49 | run: npm run build:mac 50 | env: 51 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 52 | 53 | - name: Install snapcraft 54 | if: matrix.os == 'ubuntu-latest' 55 | run: sudo snap install snapcraft --classic 56 | - name: Build the app (Linux) 57 | if: matrix.os == 'ubuntu-latest' 58 | run: npm run build:linux 59 | env: 60 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 61 | 62 | - name: Create Release 63 | uses: softprops/action-gh-release@v2 64 | if: startsWith(github.ref, 'refs/tags/') 65 | with: 66 | files: | 67 | dist/Sonosano-setup.exe 68 | dist/Sonosano.dmg 69 | dist/Sonosano_*.deb 70 | dist/backend/sonosano-backend* 71 | env: 72 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 73 | -------------------------------------------------------------------------------- /app/lib/main/app.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow, shell, app } from 'electron' 2 | import { join } from 'path' 3 | import { nativeImage } from 'electron' 4 | import path from 'path' 5 | import { registerResourcesProtocol } from './protocols' 6 | import { registerWindowHandlers } from '../conveyor/handlers/window-handler' 7 | import { registerAppHandlers } from '../conveyor/handlers/app-handler' 8 | 9 | export function createAppWindow(): BrowserWindow { 10 | // Register custom protocol for resources 11 | registerResourcesProtocol() 12 | 13 | // Create the main window. 14 | const mainWindow = new BrowserWindow({ 15 | width: 900, 16 | height: 670, 17 | show: false, 18 | backgroundColor: '#1c1c1c', 19 | icon: nativeImage.createFromPath( 20 | app.isPackaged 21 | ? path.join(process.resourcesPath, 'assets', 'icon.ico') 22 | : path.join(__dirname, '..', '..', '..', 'assets', 'icon.ico') 23 | ), 24 | frame: false, 25 | ...(process.platform === 'darwin' 26 | ? { 27 | titleBarStyle: 'hidden', 28 | trafficLightPosition: { x: 20, y: 25 }, 29 | } 30 | : {}), 31 | title: 'Sonosano', 32 | maximizable: true, 33 | resizable: true, 34 | webPreferences: { 35 | preload: join(__dirname, '../preload/preload.js'), 36 | sandbox: false, 37 | }, 38 | }) 39 | 40 | // Register IPC events for the main window. 41 | registerWindowHandlers(mainWindow) 42 | registerAppHandlers(app) 43 | 44 | mainWindow.on('ready-to-show', () => { 45 | mainWindow.show() 46 | }) 47 | 48 | mainWindow.webContents.setWindowOpenHandler((details) => { 49 | shell.openExternal(details.url) 50 | return { action: 'deny' } 51 | }) 52 | 53 | // HMR for renderer base on electron-vite cli. 54 | // Load the remote URL for development or the local html file for production. 55 | if (!app.isPackaged && process.env['ELECTRON_RENDERER_URL']) { 56 | mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) 57 | } else { 58 | mainWindow.loadFile(join(__dirname, '../renderer/index.html')) 59 | } 60 | 61 | return mainWindow 62 | } 63 | -------------------------------------------------------------------------------- /app/components/SystemStatusIndicator/SystemStatusIndicator.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useSystemStatus } from '../../hooks/useDownloads' 3 | import styles from './systemStatusIndicator.module.css' 4 | import { useTranslation } from 'react-i18next' 5 | 6 | const SystemStatusIndicator: React.FC = () => { 7 | const { data: systemStatus, isLoading } = useSystemStatus() 8 | const { t } = useTranslation() 9 | 10 | const getStatusInfo = () => { 11 | if (isLoading || !systemStatus) { 12 | return { 13 | className: styles.offline, 14 | tooltip: t('systemStatus.loading'), 15 | } 16 | } 17 | 18 | // 1. Red when there is no backend 19 | if (systemStatus.backend_status !== 'Online') { 20 | return { 21 | className: styles.offline, 22 | tooltip: t('systemStatus.backendOffline'), 23 | } 24 | } 25 | 26 | // 2. Yellow when backend is on but soulseek is not connected 27 | if (systemStatus.soulseek_status !== 'Connected') { 28 | return { 29 | className: styles.connecting, // Yellow 30 | tooltip: t('systemStatus.soulseekConnecting'), 31 | } 32 | } 33 | 34 | // 4. Blue when backend is on and soulseek is connected and there are active transfers 35 | if (systemStatus.active_downloads > 0 || systemStatus.active_uploads > 0) { 36 | return { 37 | className: styles.transferring, // Blue 38 | tooltip: t('systemStatus.transferringData', { 39 | downloads: systemStatus.active_downloads, 40 | uploads: systemStatus.active_uploads, 41 | }), 42 | } 43 | } 44 | 45 | // 3. Green when backend is on and soulseek is connected but no active transfers 46 | return { 47 | className: styles.online, // Green 48 | tooltip: t('systemStatus.online', { 49 | username: systemStatus.soulseek_username, 50 | }), 51 | } 52 | } 53 | 54 | const { className, tooltip } = getStatusInfo() 55 | 56 | return ( 57 |
58 |
59 |
60 |
61 | ) 62 | } 63 | 64 | export default SystemStatusIndicator 65 | -------------------------------------------------------------------------------- /app/styles/buttons.global.css: -------------------------------------------------------------------------------- 1 | button { 2 | -webkit-app-region: no-drag !important; 3 | cursor: pointer; 4 | outline: none; 5 | position: relative; 6 | user-select: none; 7 | -webkit-user-select: none; 8 | } 9 | 10 | button * { 11 | -webkit-app-region: no-drag !important; 12 | pointer-events: none; 13 | } 14 | 15 | button:focus-visible { 16 | outline: 2px solid rgba(30, 215, 96, 0.5); 17 | outline-offset: 2px; 18 | } 19 | 20 | button::-moz-focus-inner { 21 | border: 0; 22 | padding: 0; 23 | } 24 | 25 | a { 26 | -webkit-app-region: no-drag !important; 27 | cursor: pointer; 28 | } 29 | 30 | input, 31 | textarea, 32 | select { 33 | -webkit-app-region: no-drag !important; 34 | } 35 | 36 | .icon-button { 37 | min-width: 32px; 38 | min-height: 32px; 39 | display: flex; 40 | align-items: center; 41 | justify-content: center; 42 | } 43 | 44 | [role='button'], 45 | [onclick], 46 | [data-clickable='true'] { 47 | -webkit-app-region: no-drag !important; 48 | cursor: pointer; 49 | } 50 | 51 | *[class*='hover']:hover { 52 | -webkit-app-region: no-drag !important; 53 | } 54 | 55 | button, 56 | [role='button'] { 57 | -webkit-touch-callout: none; 58 | -webkit-user-select: none; 59 | -khtml-user-select: none; 60 | -moz-user-select: none; 61 | -ms-user-select: none; 62 | user-select: none; 63 | } 64 | 65 | li[onclick], 66 | li[role='button'], 67 | li.clickable { 68 | -webkit-app-region: no-drag !important; 69 | cursor: pointer; 70 | } 71 | 72 | .draggable { 73 | -webkit-app-region: drag; 74 | } 75 | 76 | .draggable button, 77 | .draggable a, 78 | .draggable input, 79 | .draggable [role='button'], 80 | .draggable .clickable { 81 | -webkit-app-region: no-drag !important; 82 | } 83 | 84 | button i, 85 | button svg, 86 | a i, 87 | a svg, 88 | [role='button'] i, 89 | [role='button'] svg { 90 | pointer-events: none; 91 | } 92 | 93 | button:not(:disabled):hover { 94 | opacity: 0.9; 95 | } 96 | 97 | button:not(:disabled):active { 98 | transform: scale(0.98); 99 | } 100 | 101 | button:disabled { 102 | cursor: not-allowed; 103 | opacity: 0.5; 104 | } 105 | 106 | button { 107 | z-index: auto; 108 | } 109 | 110 | .fixed button, 111 | .absolute button, 112 | [style*='position: fixed'] button, 113 | [style*='position: absolute'] button { 114 | -webkit-app-region: no-drag !important; 115 | } 116 | -------------------------------------------------------------------------------- /app/lib/conveyor/handlers/window-handler.ts: -------------------------------------------------------------------------------- 1 | import type { BrowserWindow } from 'electron' 2 | import { shell, dialog, clipboard } from 'electron' 3 | import { handle } from '@/lib/main/shared' 4 | import { electronAPI } from '@electron-toolkit/preload' 5 | 6 | export const registerWindowHandlers = (window: BrowserWindow) => { 7 | // Window operations 8 | handle('window-init', () => { 9 | const { width, height } = window.getBounds() 10 | const minimizable = window.isMinimizable() 11 | const maximizable = window.isMaximizable() 12 | const platform = electronAPI.process.platform 13 | 14 | return { width, height, minimizable, maximizable, platform } 15 | }) 16 | 17 | handle('window-is-minimizable', () => window.isMinimizable()) 18 | handle('window-is-maximizable', () => window.isMaximizable()) 19 | handle('window-minimize', () => window.minimize()) 20 | handle('window-maximize', () => window.maximize()) 21 | handle('window-close', () => window.close()) 22 | handle('window-maximize-toggle', () => (window.isMaximized() ? window.unmaximize() : window.maximize())) 23 | 24 | // Web content operations 25 | const webContents = window.webContents 26 | handle('web-undo', () => webContents.undo()) 27 | handle('web-redo', () => webContents.redo()) 28 | handle('web-cut', () => webContents.cut()) 29 | handle('web-copy', () => webContents.copy()) 30 | handle('web-paste', () => webContents.paste()) 31 | handle('web-delete', () => webContents.delete()) 32 | handle('web-select-all', () => webContents.selectAll()) 33 | handle('web-reload', () => webContents.reload()) 34 | handle('web-force-reload', () => webContents.reloadIgnoringCache()) 35 | handle('web-toggle-devtools', () => webContents.toggleDevTools()) 36 | handle('web-actual-size', () => webContents.setZoomLevel(0)) 37 | handle('web-zoom-in', () => webContents.setZoomLevel(webContents.zoomLevel + 0.5)) 38 | handle('web-zoom-out', () => webContents.setZoomLevel(webContents.zoomLevel - 0.5)) 39 | handle('web-toggle-fullscreen', () => window.setFullScreen(!window.fullScreen)) 40 | handle('web-open-url', (url: string) => shell.openExternal(url)) 41 | handle('open-folder-dialog', async () => { 42 | const { canceled, filePaths } = await dialog.showOpenDialog(window, { 43 | properties: ['openDirectory'], 44 | }) 45 | if (canceled) { 46 | return [] 47 | } 48 | return filePaths 49 | }) 50 | handle('web-copy-text', (text: string) => clipboard.writeText(text)) 51 | } 52 | -------------------------------------------------------------------------------- /app/types/index.ts: -------------------------------------------------------------------------------- 1 | export interface Song { 2 | id: string 3 | name: string 4 | path: string 5 | size?: number 6 | extension?: string 7 | metadata?: { 8 | title?: string 9 | artist?: string 10 | album?: string 11 | duration?: number 12 | coverArt?: string 13 | year?: string 14 | bitrate?: number 15 | sampleRate?: number 16 | bitsPerSample?: number 17 | display_quality?: string 18 | is_fake?: boolean 19 | } 20 | dateAdded?: string 21 | lastPlayed?: string 22 | playCount?: number 23 | } 24 | 25 | export interface Playlist { 26 | id: string 27 | name: string 28 | description: string 29 | thumbnail: string 30 | songs: string[] 31 | createdAt: string 32 | updatedAt: string 33 | } 34 | 35 | export interface AudioMetadata { 36 | title: string 37 | artist?: string 38 | album?: string 39 | duration?: number 40 | coverArt?: string 41 | year?: string 42 | } 43 | 44 | export interface DownloadItem { 45 | id: string 46 | fileName: string 47 | path: string 48 | size: number 49 | username?: string 50 | metadata?: AudioMetadata 51 | status: 'queued' | 'downloading' | 'completed' | 'failed' | 'paused' 52 | progress: number 53 | downloadSpeed?: number 54 | timeRemaining?: number 55 | queuePosition?: number 56 | errorMessage?: string 57 | timestamp: Date 58 | } 59 | export interface SoulseekFile { 60 | id: string 61 | path: string 62 | size: number 63 | username: string 64 | extension?: string 65 | bitrate?: number 66 | quality?: string 67 | length?: string 68 | } 69 | 70 | export interface DownloadStatus { 71 | status: string 72 | progress: number 73 | total: number 74 | percent: number 75 | speed?: number 76 | queuePosition?: number 77 | errorMessage?: string 78 | } 79 | export interface PlaybackState { 80 | isPlaying: boolean 81 | currentTime: number 82 | duration: number 83 | volume: number 84 | isMuted: boolean 85 | currentSong: Song | null 86 | currentPlaylist: string | null 87 | currentPlaylistSongs: Song[] 88 | currentSongIndex: number 89 | isShuffle: boolean 90 | isLoop: boolean 91 | } 92 | 93 | export interface SystemStatus { 94 | backend_status: string 95 | soulseek_status: string 96 | soulseek_username?: string 97 | active_uploads: number 98 | active_downloads: number 99 | } 100 | 101 | export interface DownloadsAndStatusResponse { 102 | downloads: DownloadItem[] 103 | system_status: SystemStatus 104 | } 105 | -------------------------------------------------------------------------------- /backend/src/core/audio_forensics.py: -------------------------------------------------------------------------------- 1 | import os 2 | import librosa 3 | import numpy as np 4 | from scipy import signal 5 | from tinytag import TinyTag 6 | 7 | def analyze_audio_final(file_path): 8 | """ 9 | Analyzes an audio file to determine if it is a genuine lossless file or a 10 | lossy transcode. Returns a simple string verdict. 11 | """ 12 | if not os.path.exists(file_path): 13 | return "Error" 14 | 15 | try: 16 | # 1. --- File Integrity and Basic Info --- 17 | y, sr = librosa.load(file_path, sr=None, mono=False) 18 | actual_duration = librosa.get_duration(y=y, sr=sr) 19 | metadata = TinyTag.get(file_path) 20 | declared_duration = metadata.duration 21 | 22 | if declared_duration and abs(actual_duration - declared_duration) > 2.0: 23 | return "Corrupted" 24 | 25 | # 2. --- Frequency Cutoff Detection --- 26 | is_stereo = y.ndim >= 2 and y.shape[0] >= 2 27 | y_mono = y[0] if is_stereo else y 28 | 29 | S = librosa.feature.melspectrogram(y=y_mono, sr=sr, n_mels=256, fmax=sr/2) 30 | S_dB = librosa.power_to_db(S, ref=np.max) 31 | max_freq_energy = np.max(S_dB, axis=1) 32 | threshold_db = np.max(max_freq_energy) - 60 33 | significant_bins = np.where(max_freq_energy > threshold_db)[0] 34 | 35 | if len(significant_bins) == 0: 36 | return "Undetermined" # Or handle as an error/specific case 37 | 38 | highest_freq_bin = significant_bins[-1] 39 | mel_freqs = librosa.mel_frequencies(n_mels=256, fmax=sr/2) 40 | cutoff_freq = mel_freqs[highest_freq_bin] 41 | 42 | # 3. --- High-Frequency Stereo Analysis --- 43 | stereo_correlation = None 44 | if is_stereo and sr >= 44100 and cutoff_freq > 16000: 45 | b, a = signal.butter(4, 16000 / (sr / 2), btype='high') 46 | left_hf = signal.filtfilt(b, a, y[0]) 47 | right_hf = signal.filtfilt(b, a, y[1]) 48 | correlation_matrix = np.corrcoef(left_hf, right_hf) 49 | stereo_correlation = correlation_matrix[0, 1] 50 | 51 | # 4. --- Final Verdict Logic --- 52 | if cutoff_freq > 21000: 53 | return "Real" 54 | elif cutoff_freq > 19800: 55 | if is_stereo and stereo_correlation is not None and stereo_correlation < 0.95: 56 | return "Real" 57 | else: 58 | return "Fake" 59 | else: 60 | return "Fake" 61 | 62 | except Exception: 63 | return "Error" -------------------------------------------------------------------------------- /app/components/footer/Player/player.module.css: -------------------------------------------------------------------------------- 1 | .playerBarContainer p { 2 | margin-bottom: 0; 3 | font-size: 0.75rem; 4 | color: var(--primary-white); 5 | } 6 | 7 | .playerBarContainer { 8 | justify-content: center; 9 | align-items: center; 10 | flex-grow: 1; 11 | } 12 | 13 | .buttonsPlayerContainer { 14 | justify-content: center; 15 | padding-top: 1.1%; 16 | align-items: center; 17 | transform: translateY(-3px); 18 | } 19 | 20 | .buttonsPlayerContainer button { 21 | color: var(--secondary-white); 22 | padding: 0; 23 | margin: 0 16px; 24 | font-size: 1.5rem; 25 | border: none; 26 | background-color: transparent; 27 | } 28 | 29 | .buttonsPlayerContainer button:hover { 30 | color: var(--pure-white); 31 | } 32 | 33 | .activeControl { 34 | color: var(--primary-green) !important; 35 | } 36 | 37 | .active { 38 | color: var(--primary-green) !important; 39 | } 40 | 41 | .buttonsPlayerContainer button:nth-child(3) { 42 | color: var(--pure-white); 43 | font-size: 2rem; 44 | } 45 | 46 | .buttonsPlayerContainer button svg { 47 | width: 16px; 48 | height: 16px; 49 | fill: currentColor; 50 | } 51 | 52 | .buttonsPlayerContainer .playButton { 53 | background-color: var(--pure-white); 54 | border-radius: 50%; 55 | width: 32px; 56 | height: 32px; 57 | display: flex; 58 | align-items: center; 59 | justify-content: center; 60 | transition: transform 0.1s ease; 61 | } 62 | 63 | .buttonsPlayerContainer .playButton:hover { 64 | transform: scale(1.04); 65 | } 66 | 67 | .buttonsPlayerContainer .playButton svg { 68 | width: 16px; 69 | height: 16px; 70 | fill: var(--primary-black); 71 | } 72 | 73 | .playerContainer { 74 | white-space: nowrap; 75 | padding-bottom: 1%; 76 | } 77 | 78 | .downloadStatus { 79 | text-align: center; 80 | padding: 0 20px; 81 | margin-top: 5px; 82 | } 83 | 84 | .downloadStatus small { 85 | color: var(--secondary-white); 86 | font-size: 0.7rem; 87 | } 88 | 89 | .progressBar { 90 | position: relative; 91 | height: 3px; 92 | background: var(--primary-black); 93 | border-radius: 2px; 94 | margin-top: 5px; 95 | overflow: hidden; 96 | } 97 | 98 | .downloadProgress { 99 | position: absolute; 100 | height: 100%; 101 | background: var(--primary-green); 102 | transition: width 0.3s ease; 103 | } 104 | 105 | .bufferProgress { 106 | position: absolute; 107 | height: 100%; 108 | background: var(--secondary-white); 109 | opacity: 0.3; 110 | transition: width 0.3s ease; 111 | } 112 | -------------------------------------------------------------------------------- /app/components/footer/Player/TimeSlider/timeSlider.module.css: -------------------------------------------------------------------------------- 1 | .pSlider { 2 | width: 50px; 3 | min-width: 50px; 4 | max-width: 50px; 5 | text-align: center; 6 | font-size: 0.75rem; 7 | margin: 0 12px; 8 | flex-shrink: 0; 9 | white-space: nowrap; 10 | overflow: hidden; 11 | cursor: default; 12 | pointer-events: none; 13 | opacity: 0.7; 14 | } 15 | 16 | .sliderContainer { 17 | width: 120%; 18 | display: flex; 19 | align-items: center; 20 | } 21 | 22 | .progressBarWrapper { 23 | flex-grow: 1; 24 | min-height: 20px; 25 | display: flex; 26 | align-items: center; 27 | cursor: pointer; 28 | padding: 8px 0; 29 | position: relative; 30 | 31 | width: 100%; 32 | box-sizing: border-box; 33 | } 34 | 35 | .progressBarBackground { 36 | background-color: rgba(255, 255, 255, 0.3); 37 | border-radius: 2px; 38 | height: 4px; 39 | width: 100%; 40 | position: relative; 41 | cursor: pointer; 42 | overflow: hidden; 43 | } 44 | 45 | .progressBarForeground { 46 | background-color: var(--primary-white); 47 | border-radius: 2px; 48 | height: 100%; 49 | width: 100%; 50 | position: absolute; 51 | top: 0; 52 | left: 0; 53 | cursor: pointer; 54 | 55 | transform-origin: left; 56 | transform: scaleX(var(--progress-percent, 0)); 57 | 58 | will-change: transform; 59 | backface-visibility: hidden; 60 | perspective: 1000px; 61 | 62 | transition: background-color 0.2s ease; 63 | } 64 | 65 | .progressBarThumb { 66 | position: absolute; 67 | top: 50%; 68 | left: calc(var(--progress-percent, 0) * 100%); 69 | width: 12px; 70 | height: 12px; 71 | background-color: var(--primary-white); 72 | border-radius: 50%; 73 | transform: translate(-50%, -50%) scale(0); 74 | z-index: 10; 75 | cursor: pointer; 76 | pointer-events: none; 77 | 78 | opacity: 0; 79 | 80 | transition: 81 | transform 0.2s ease, 82 | opacity 0.2s ease; 83 | 84 | will-change: transform, opacity, scale; 85 | backface-visibility: hidden; 86 | } 87 | 88 | .progressBarWrapper:hover .progressBarThumb { 89 | opacity: 1; 90 | transform: translate(-50%, -50%) scale(1); 91 | } 92 | 93 | .progressBarWrapper.hovered .progressBarThumb { 94 | opacity: 1; 95 | transform: translate(-50%, -50%) scale(1); 96 | } 97 | 98 | .progressBarWrapper:hover .progressBarForeground { 99 | background-color: var(--primary-green); 100 | } 101 | 102 | .progressBarWrapper.hovered .progressBarForeground { 103 | background-color: var(--primary-green); 104 | } 105 | -------------------------------------------------------------------------------- /app/lib/conveyor/schemas/window-schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const windowIpcSchema = { 4 | 'window-init': { 5 | args: z.tuple([]), 6 | return: z.object({ 7 | width: z.number(), 8 | height: z.number(), 9 | minimizable: z.boolean(), 10 | maximizable: z.boolean(), 11 | platform: z.string(), 12 | }), 13 | }, 14 | 'window-is-minimizable': { 15 | args: z.tuple([]), 16 | return: z.boolean(), 17 | }, 18 | 'window-is-maximizable': { 19 | args: z.tuple([]), 20 | return: z.boolean(), 21 | }, 22 | 'window-minimize': { 23 | args: z.tuple([]), 24 | return: z.void(), 25 | }, 26 | 'window-maximize': { 27 | args: z.tuple([]), 28 | return: z.void(), 29 | }, 30 | 'window-close': { 31 | args: z.tuple([]), 32 | return: z.void(), 33 | }, 34 | 'window-maximize-toggle': { 35 | args: z.tuple([]), 36 | return: z.void(), 37 | }, 38 | 39 | // Web content operations 40 | 'web-undo': { 41 | args: z.tuple([]), 42 | return: z.void(), 43 | }, 44 | 'web-redo': { 45 | args: z.tuple([]), 46 | return: z.void(), 47 | }, 48 | 'web-cut': { 49 | args: z.tuple([]), 50 | return: z.void(), 51 | }, 52 | 'web-copy': { 53 | args: z.tuple([]), 54 | return: z.void(), 55 | }, 56 | 'web-paste': { 57 | args: z.tuple([]), 58 | return: z.void(), 59 | }, 60 | 'web-delete': { 61 | args: z.tuple([]), 62 | return: z.void(), 63 | }, 64 | 'web-select-all': { 65 | args: z.tuple([]), 66 | return: z.void(), 67 | }, 68 | 'web-reload': { 69 | args: z.tuple([]), 70 | return: z.void(), 71 | }, 72 | 'web-force-reload': { 73 | args: z.tuple([]), 74 | return: z.void(), 75 | }, 76 | 'web-toggle-devtools': { 77 | args: z.tuple([]), 78 | return: z.void(), 79 | }, 80 | 'web-actual-size': { 81 | args: z.tuple([]), 82 | return: z.void(), 83 | }, 84 | 'web-zoom-in': { 85 | args: z.tuple([]), 86 | return: z.void(), 87 | }, 88 | 'web-zoom-out': { 89 | args: z.tuple([]), 90 | return: z.void(), 91 | }, 92 | 'web-toggle-fullscreen': { 93 | args: z.tuple([]), 94 | return: z.void(), 95 | }, 96 | 'web-open-url': { 97 | args: z.tuple([z.string()]), 98 | return: z.void(), 99 | }, 100 | 'open-folder-dialog': { 101 | args: z.tuple([]), 102 | return: z.array(z.string()), 103 | }, 104 | 'web-copy-text': { 105 | args: z.tuple([z.string()]), 106 | return: z.void(), 107 | }, 108 | } 109 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Sonosano", 3 | "version": "0.3.2", 4 | "description": "P2P Song Player", 5 | "main": "./out/main/main.js", 6 | "license": "Apache-2.0 license", 7 | "author": { 8 | "name": "KRSHH", 9 | "url": "https://github.com/KRSHH/Sonosano" 10 | }, 11 | "scripts": { 12 | "dev": "electron-vite dev", 13 | "build": "node scripts/build-backend.js && electron-vite build", 14 | "build:frontend": "electron-vite build", 15 | "start": "electron-builder --dir", 16 | "format": "prettier --write .", 17 | "lint": "eslint . --ext .ts,.tsx --fix", 18 | "postinstall": "electron-builder install-app-deps", 19 | "build:win": "npm run build && electron-builder --win", 20 | "build:mac": "npm run build && electron-builder --mac", 21 | "build:linux": "npm run build && electron-builder --linux", 22 | "build:all": "npm run build && electron-builder -wml", 23 | "prepare": "husky" 24 | }, 25 | "engines": { 26 | "npm": ">=8.0.0", 27 | "yarn": "please-use-npm", 28 | "pnpm": "please-use-npm" 29 | }, 30 | "repository": { 31 | "type": "git", 32 | "url": "https://github.com/KRSHH/Sonosano" 33 | }, 34 | "dependencies": { 35 | "@electron-toolkit/preload": "^3.0.2", 36 | "@electron-toolkit/utils": "^4.0.0", 37 | "@emotion/react": "^11.14.0", 38 | "@emotion/styled": "^11.14.1", 39 | "@mui/icons-material": "^7.3.2", 40 | "@mui/material": "^7.3.2", 41 | "@tanstack/react-query": "^5.90.2", 42 | "bootstrap": "^5.3.8", 43 | "clsx": "^2.1.1", 44 | "electron-updater": "^6.6.2", 45 | "extract-colors": "^4.2.1", 46 | "fuse.js": "^7.1.0", 47 | "i18next": "^25.5.2", 48 | "react-i18next": "^15.7.3", 49 | "react-router-dom": "^7.9.2", 50 | "react-transition-group": "^4.4.5", 51 | "tailwind-merge": "^3.3.1", 52 | "tree-kill": "^1.2.2", 53 | "zod": "^4.1.3" 54 | }, 55 | "devDependencies": { 56 | "@electron-toolkit/tsconfig": "^1.0.1", 57 | "@eslint/js": "^9.34.0", 58 | "@tailwindcss/vite": "^4.1.12", 59 | "@types/node": "^24.5.2", 60 | "@types/react": "^19.1.11", 61 | "@types/react-dom": "^19.1.8", 62 | "@vitejs/plugin-react": "^5.0.1", 63 | "electron": "^37.3.1", 64 | "electron-builder": "^26.0.12", 65 | "electron-vite": "^4.0.0", 66 | "eslint": "^9.34.0", 67 | "eslint-plugin-react": "^7.37.5", 68 | "eslint-plugin-react-hooks": "^5.2.0", 69 | "husky": "^9.1.7", 70 | "prettier": "^3.6.2", 71 | "react": "^19.1.1", 72 | "react-dom": "^19.1.1", 73 | "tailwindcss": "^4.1.12", 74 | "typescript": "^5.9.2", 75 | "typescript-eslint": "^8.41.0", 76 | "vite": "^7.1.3" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /app/components/common/context_menu/Song/ContextMenuSong.tsx: -------------------------------------------------------------------------------- 1 | import { useState, MouseEvent } from 'react' 2 | import InfoPopover from '../../info_popover/InfoPopover' 3 | import { InfoPopoverType } from '../../info_popover/types/infoPopover.types' 4 | import { useNavigate } from 'react-router-dom' 5 | import { t } from 'i18next' 6 | import styles from '../contextMenu.module.css' 7 | import { PropsContextMenuSong } from '../types/contextMenu.types' 8 | import { apiClient } from '../../../../api' 9 | 10 | export default function ContextMenuSong({ 11 | songName, 12 | artistName, 13 | playlistId, 14 | handleCloseParent, 15 | songPath, 16 | }: PropsContextMenuSong) { 17 | const navigate = useNavigate() 18 | 19 | const urlArtist = `/artist/${artistName}` 20 | 21 | const handleClose = () => { 22 | handleCloseParent() 23 | } 24 | 25 | const handleClickGoToArtist = (event: MouseEvent) => { 26 | event.preventDefault() 27 | event.stopPropagation() 28 | navigate(urlArtist) 29 | } 30 | 31 | const [triggerOpenConfirmationModal, setTriggerOpenConfirmationModal] = useState(false) 32 | 33 | const handleCopyToClipboard = (): void => { 34 | const songInfoText = `${artistName} - ${songName}` 35 | window.conveyor.window.copyToClipboard(songInfoText) 36 | setTriggerOpenConfirmationModal(true) 37 | } 38 | 39 | const handleDeleteSongFromPlaylist = async () => { 40 | if (!playlistId || !songPath) return 41 | try { 42 | await apiClient.removeSongFromPlaylist(playlistId, songPath) 43 | handleClose() 44 | } catch (err) { 45 | console.error('Failed to delete song:', err) 46 | } 47 | } 48 | 49 | return ( 50 |
51 |
    52 |
  • 53 | 56 |
  • 57 |
  • 58 | {playlistId !== '' && ( 59 | 62 | )} 63 |
  • 64 |
  • 65 | 68 |
  • 69 |
70 | 71 | 78 |
79 | ) 80 | } 81 | -------------------------------------------------------------------------------- /app/components/footer/SongInfo/songInfo.module.css: -------------------------------------------------------------------------------- 1 | .songInfoContainer { 2 | padding-right: 0% !important; 3 | align-items: center; 4 | } 5 | 6 | .songDetailsContainer { 7 | padding: 0 5% 0 3%; 8 | justify-content: center; 9 | } 10 | 11 | .thumbnailContainer { 12 | width: 64px; 13 | height: 64px; 14 | flex-shrink: 0; 15 | } 16 | 17 | .songInfoContainer img { 18 | width: 64px; 19 | height: 64px; 20 | object-fit: cover; 21 | border-radius: var(--border-radius-thumbnails); 22 | } 23 | 24 | .thumbnailPlaceholder { 25 | width: 64px; 26 | height: 64px; 27 | border-radius: var(--border-radius-thumbnails); 28 | background: linear-gradient(135deg, var(--hover-white) 0%, var(--separator-white) 100%); 29 | display: flex; 30 | align-items: center; 31 | justify-content: center; 32 | } 33 | 34 | .thumbnailPlaceholder i { 35 | font-size: 24px; 36 | color: var(--secondary-white); 37 | } 38 | 39 | .songDetailsContainer button { 40 | border: none; 41 | background-color: transparent; 42 | text-align: start; 43 | } 44 | 45 | .songDetailsContainer button:hover { 46 | color: var(--primary-white) !important; 47 | text-decoration: underline; 48 | } 49 | 50 | .songDetailsContainer button:first-child { 51 | font-size: var(--font-size-song-footer); 52 | color: var(--primary-white); 53 | font-size: var(--font-size-song-footer); 54 | } 55 | 56 | .songDetailsContainer button:nth-child(2) { 57 | font-size: var(--font-size-artist-footer); 58 | color: var(--secondary-white); 59 | } 60 | 61 | .likeContainer { 62 | justify-content: center; 63 | color: var(--secondary-white); 64 | } 65 | 66 | .likeContainer i { 67 | color: var(--secondary-white); 68 | } 69 | 70 | .likeContainer button:hover i { 71 | color: var(--primary-white) !important; 72 | } 73 | 74 | .addButton svg { 75 | width: 16px; 76 | height: 16px; 77 | fill: var(--secondary-white); 78 | transition: fill 0.2s ease; 79 | } 80 | 81 | .addButton:hover svg { 82 | fill: var(--primary-white); 83 | } 84 | 85 | .addButton:focus, 86 | .addButton:focus-visible, 87 | .addButton:active { 88 | outline: none !important; 89 | box-shadow: none !important; 90 | border: none !important; 91 | } 92 | 93 | .addButtonLiked svg { 94 | width: 16px; 95 | height: 16px; 96 | fill: var(--primary-green); 97 | transition: fill 0.2s ease; 98 | } 99 | 100 | .addButtonLiked:hover svg { 101 | fill: var(--primary-white); 102 | } 103 | 104 | :focus:not(:focus-visible) { 105 | outline: none; 106 | box-shadow: none; 107 | } 108 | 109 | .menuWrapper { 110 | position: relative; 111 | } 112 | 113 | .menuContainer { 114 | position: absolute; 115 | bottom: 100%; 116 | left: 50%; 117 | transform: translateX(5%); 118 | margin-bottom: 10px; 119 | z-index: 1001; 120 | } 121 | -------------------------------------------------------------------------------- /backend/src/api/playlist_routes.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, HTTPException 2 | from models.playlist_models import Playlist, CreatePlaylistRequest, UpdatePlaylistRequest, AddSongToPlaylistRequest 3 | from core.library_service import LibraryService 4 | from core.playlist_service import PlaylistService 5 | from typing import List 6 | import uuid 7 | from datetime import datetime 8 | 9 | router = APIRouter() 10 | 11 | library_service: LibraryService 12 | playlist_service: PlaylistService 13 | 14 | @router.post("/playlists", response_model=Playlist) 15 | async def create_playlist(request: CreatePlaylistRequest): 16 | playlist_id = str(uuid.uuid4()) 17 | now = datetime.utcnow().isoformat() 18 | 19 | thumbnail_path = playlist_service.download_playlist_thumbnail(request.name, request.thumbnail) 20 | 21 | playlist = Playlist( 22 | id=playlist_id, 23 | name=request.name, 24 | description=request.description, 25 | thumbnail=thumbnail_path, 26 | songs=[], 27 | createdAt=now, 28 | updatedAt=now 29 | ) 30 | return library_service.create_playlist(playlist) 31 | 32 | @router.get("/playlists", response_model=List[Playlist]) 33 | async def get_all_playlists(): 34 | return library_service.get_all_playlists() 35 | 36 | @router.get("/playlists/{playlist_id}", response_model=Playlist) 37 | async def get_playlist(playlist_id: str): 38 | playlist = library_service.get_playlist(playlist_id) 39 | if not playlist: 40 | raise HTTPException(status_code=404, detail="Playlist not found") 41 | return playlist 42 | 43 | @router.put("/playlists/{playlist_id}", response_model=Playlist) 44 | async def update_playlist(playlist_id: str, request: UpdatePlaylistRequest): 45 | updates = request.dict(exclude_unset=True) 46 | if 'thumbnail' in updates and updates['thumbnail']: 47 | playlist = library_service.get_playlist(playlist_id) 48 | if playlist: 49 | thumbnail_path = playlist_service.download_playlist_thumbnail(playlist.name, updates['thumbnail']) 50 | updates['thumbnail'] = thumbnail_path 51 | 52 | return library_service.update_playlist(playlist_id, updates) 53 | 54 | @router.delete("/playlists/{playlist_id}", status_code=204) 55 | async def delete_playlist(playlist_id: str): 56 | library_service.delete_playlist(playlist_id) 57 | return 58 | 59 | @router.post("/playlists/{playlist_id}/songs", response_model=Playlist) 60 | async def add_song_to_playlist(playlist_id: str, request: AddSongToPlaylistRequest): 61 | return library_service.add_song_to_playlist(playlist_id, request.song_path) 62 | 63 | @router.delete("/playlists/{playlist_id}/songs/{song_path:path}", response_model=Playlist) 64 | async def remove_song_from_playlist(playlist_id: str, song_path: str): 65 | return library_service.remove_song_from_playlist(playlist_id, song_path) -------------------------------------------------------------------------------- /app/components/SystemStatusIndicator/systemStatusIndicator.module.css: -------------------------------------------------------------------------------- 1 | .statusIndicator { 2 | width: 24px; 3 | height: 24px; 4 | border-radius: 50%; 5 | margin-left: 8px; 6 | position: relative; 7 | background-color: transparent; 8 | transition: all 0.3s ease; 9 | cursor: default; 10 | display: flex; 11 | align-items: center; 12 | justify-content: center; 13 | } 14 | 15 | .centerDot { 16 | width: 8px; 17 | height: 8px; 18 | border-radius: 50%; 19 | position: absolute; 20 | top: 50%; 21 | left: 50%; 22 | transform: translate(-50%, -50%); 23 | box-shadow: 24 | 0 0 5px, 25 | 0 0 10px; 26 | } 27 | 28 | .radarPing { 29 | position: absolute; 30 | top: 50%; 31 | left: 50%; 32 | border-radius: 50%; 33 | transform: translate(-50%, -50%); 34 | animation: radar 2s linear infinite; 35 | } 36 | 37 | .statusIndicator.online .radarPing { 38 | background-color: #00e600; 39 | box-shadow: 40 | 0 0 10px #00e600, 41 | 0 0 20px #00e600, 42 | 0 0 30px #00e600; 43 | } 44 | 45 | .statusIndicator.online { 46 | background-color: rgba(0, 230, 0, 0.15); 47 | } 48 | .statusIndicator.online .centerDot { 49 | background-color: #00ff00; 50 | box-shadow: 51 | 0 0 5px #00ff00, 52 | 0 0 10px #00ff00; 53 | } 54 | 55 | .statusIndicator.connecting .radarPing { 56 | background-color: #e6e600; 57 | box-shadow: 58 | 0 0 10px #e6e600, 59 | 0 0 20px #e6e600, 60 | 0 0 30px #e6e600; 61 | } 62 | 63 | .statusIndicator.connecting { 64 | background-color: rgba(230, 230, 0, 0.15); 65 | } 66 | .statusIndicator.connecting .centerDot { 67 | background-color: #ffff00; 68 | box-shadow: 69 | 0 0 5px #ffff00, 70 | 0 0 10px #ffff00; 71 | } 72 | 73 | .statusIndicator.offline .radarPing { 74 | background-color: #e60000; 75 | box-shadow: 76 | 0 0 10px #e60000, 77 | 0 0 20px #e60000, 78 | 0 0 30px #e60000; 79 | } 80 | 81 | .statusIndicator.offline { 82 | background-color: rgba(230, 0, 0, 0.15); 83 | } 84 | .statusIndicator.offline .centerDot { 85 | background-color: #ff0000; 86 | box-shadow: 87 | 0 0 5px #ff0000, 88 | 0 0 10px #ff0000; 89 | } 90 | 91 | .statusIndicator.transferring .radarPing { 92 | background-color: #0000e6; 93 | box-shadow: 94 | 0 0 10px #0000e6, 95 | 0 0 20px #0000e6, 96 | 0 0 30px #0000e6; 97 | } 98 | 99 | .statusIndicator.transferring { 100 | background-color: rgba(0, 0, 230, 0.15); 101 | } 102 | .statusIndicator.transferring .centerDot { 103 | background-color: #0000ff; 104 | box-shadow: 105 | 0 0 5px #0000ff, 106 | 0 0 10px #0000ff; 107 | } 108 | 109 | @keyframes radar { 110 | 0% { 111 | width: 0; 112 | height: 0; 113 | opacity: 0.8; 114 | } 115 | 100% { 116 | width: 100%; 117 | height: 100%; 118 | opacity: 0; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /app/components/common/context_menu/contextMenu.module.css: -------------------------------------------------------------------------------- 1 | .wrapperContextMenu { 2 | background-color: var(--hover-white); 3 | width: 15svw; 4 | border-radius: 4px; 5 | color: hsla(0, 0%, 100%, 0.9); 6 | padding: 4px; 7 | } 8 | 9 | .wrapperContextMenu ul { 10 | margin: 0px; 11 | padding: 1%; 12 | } 13 | 14 | .wrapperContextMenu ul li { 15 | padding: 1%; 16 | border-top: 1px solid var(--separator-white); 17 | border-bottom: 1px solid var(--separator-white); 18 | } 19 | 20 | .wrapperContextMenu ul li:first-child { 21 | border: none; 22 | border-bottom: 1px solid var(--separator-white); 23 | } 24 | 25 | .wrapperContextMenu ul li:last-child { 26 | border: none; 27 | } 28 | 29 | .wrapperContextMenu ul li + li { 30 | border-top: none; 31 | } 32 | 33 | .wrapperContextMenu ul li button { 34 | background-color: transparent; 35 | border: none; 36 | width: 100%; 37 | display: flex; 38 | justify-content: flex-start; 39 | color: inherit; 40 | font-size: 0.875rem; 41 | padding: 12px; 42 | border-radius: 2px; 43 | text-align: start; 44 | } 45 | 46 | .wrapperContextMenu ul button:hover { 47 | background-color: hsla(0, 0%, 100%, 0.1); 48 | } 49 | 50 | .wrapperContextMenuAddToPlaylist { 51 | width: 15svw; 52 | height: 40svh; 53 | overflow-y: auto; 54 | overflow-x: hidden; 55 | scrollbar-width: thin; 56 | scrollbar-color: rgba(255, 255, 255, 0.15) transparent; 57 | } 58 | 59 | .wrapperContextMenuAddToPlaylist::-webkit-scrollbar { 60 | display: block; 61 | width: 12px; 62 | height: 12px; 63 | } 64 | 65 | .wrapperContextMenuAddToPlaylist::-webkit-scrollbar-track { 66 | background: rgba(255, 255, 255, 0.05); 67 | border-radius: 100px; 68 | margin: 6px 0; 69 | } 70 | 71 | .wrapperContextMenuAddToPlaylist::-webkit-scrollbar-thumb { 72 | background: rgba(255, 255, 255, 0.2); 73 | border-radius: 100px; 74 | border: 3px solid transparent; 75 | background-clip: content-box; 76 | min-height: 30px; 77 | transition: all 0.2s ease; 78 | } 79 | 80 | .wrapperContextMenuAddToPlaylist:hover::-webkit-scrollbar-thumb { 81 | background: rgba(255, 255, 255, 0.3); 82 | border: 3px solid transparent; 83 | background-clip: content-box; 84 | } 85 | 86 | .wrapperContextMenuAddToPlaylist::-webkit-scrollbar-thumb:hover { 87 | background: rgba(255, 255, 255, 0.45); 88 | border: 2px solid transparent; 89 | background-clip: content-box; 90 | } 91 | 92 | .wrapperContextMenuAddToPlaylist::-webkit-scrollbar-thumb:active { 93 | background: rgba(255, 255, 255, 0.6); 94 | border: 2px solid transparent; 95 | background-clip: content-box; 96 | } 97 | 98 | .wrapperContextMenuAddToPlaylist::-webkit-scrollbar-corner { 99 | background: transparent; 100 | } 101 | 102 | .wrapperContextMenuAddToPlaylist li:not(:nth-child(2)) { 103 | border: none; 104 | } 105 | -------------------------------------------------------------------------------- /app/hooks/useDownloads.ts: -------------------------------------------------------------------------------- 1 | import { useQuery, useQueryClient } from '@tanstack/react-query' 2 | import { apiClient } from '../api' 3 | import { DownloadItem, SystemStatus } from '../types' 4 | 5 | const fetchDownloadsAndStatus = async (queryClient: any): Promise => { 6 | try { 7 | const data = await apiClient.getDownloadsStatus() 8 | 9 | // Update system status in cache 10 | queryClient.setQueryData(['systemStatus'], data.system_status) 11 | 12 | const downloads: DownloadItem[] = data.downloads.map((item: any) => { 13 | const progress = parseFloat(item.percent) || 0 14 | 15 | return { 16 | id: item.id || item.file_path, 17 | fileName: item.file_name || item.path?.split('\\').pop() || 'Unknown', 18 | path: item.file_path || item.path, 19 | size: item.size || 0, 20 | metadata: item.metadata, 21 | status: mapStatus(item.status), 22 | progress: progress, 23 | downloadSpeed: item.speed, 24 | timeRemaining: item.time_remaining, 25 | queuePosition: item.queue_position, 26 | errorMessage: item.error_message, 27 | timestamp: new Date(item.timestamp * 1000 || Date.now()), 28 | } 29 | }) 30 | 31 | downloads.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()) 32 | 33 | return downloads 34 | } catch (error) { 35 | console.error('Failed to fetch downloads and status:', error) 36 | // If the fetch fails, update the system status to reflect that the backend is offline 37 | queryClient.setQueryData(['systemStatus'], { 38 | backend_status: 'Offline', 39 | soulseek_status: 'Disconnected', 40 | soulseek_username: null, 41 | active_uploads: 0, 42 | active_downloads: 0, 43 | }) 44 | return [] // Return empty array for downloads 45 | } 46 | } 47 | 48 | const mapStatus = (status: string): DownloadItem['status'] => { 49 | const statusMap: { [key: string]: DownloadItem['status'] } = { 50 | Queued: 'queued', 51 | Transferring: 'downloading', 52 | Finished: 'completed', 53 | Error: 'failed', 54 | Paused: 'paused', 55 | 'Connection timeout': 'failed', 56 | 'User logged off': 'failed', 57 | } 58 | return statusMap[status] || 'queued' 59 | } 60 | 61 | export const useDownloads = () => { 62 | const queryClient = useQueryClient() 63 | return useQuery({ 64 | queryKey: ['downloadStatus'], 65 | queryFn: () => fetchDownloadsAndStatus(queryClient), 66 | refetchInterval: 2000, 67 | refetchIntervalInBackground: true, 68 | }) 69 | } 70 | 71 | export const useSystemStatus = () => { 72 | return useQuery({ 73 | queryKey: ['systemStatus'], 74 | // The data is set by fetchDownloadsAndStatus, so we don't need a queryFn here. 75 | // It will be populated by the downloads query. 76 | staleTime: Infinity, // Data is always fresh from the other query 77 | }) 78 | } 79 | -------------------------------------------------------------------------------- /app/components/footer/SongConfig/TimePicker/timePicker.module.css: -------------------------------------------------------------------------------- 1 | .modalBackdrop, 2 | .modalContent { 3 | font-family: 'SonosanoCircular', 'Helvetica Neue', Helvetica, Arial, sans-serif; 4 | } 5 | 6 | .modalBackdrop { 7 | position: fixed; 8 | top: 0; 9 | left: 0; 10 | width: 100%; 11 | height: 100%; 12 | background-color: rgba(0, 0, 0, 0.85); 13 | display: flex; 14 | justify-content: center; 15 | align-items: center; 16 | z-index: 2000; 17 | backdrop-filter: blur(8px); 18 | } 19 | 20 | .modalContent { 21 | background-color: #191919; 22 | padding: 24px; 23 | border-radius: 12px; 24 | width: 360px; 25 | border: 1px solid #282828; 26 | box-shadow: 0 16px 48px rgba(0, 0, 0, 0.7); 27 | } 28 | 29 | .header { 30 | display: flex; 31 | justify-content: space-between; 32 | align-items: center; 33 | margin-bottom: 24px; 34 | } 35 | 36 | .header h2 { 37 | margin: 0; 38 | font-size: 16px; 39 | font-weight: 700; 40 | color: #fff; 41 | } 42 | 43 | .closeButton { 44 | background: none; 45 | border: none; 46 | color: #b3b3b3; 47 | font-size: 24px; 48 | cursor: pointer; 49 | transition: 50 | color 0.2s, 51 | transform 0.2s; 52 | } 53 | 54 | .closeButton:hover { 55 | color: #fff; 56 | transform: rotate(90deg); 57 | } 58 | 59 | .timeDisplayContainer { 60 | display: flex; 61 | justify-content: center; 62 | align-items: center; 63 | background-color: #282828; 64 | border-radius: 8px; 65 | padding: 16px; 66 | margin-bottom: 24px; 67 | } 68 | 69 | .timeInputWrapper { 70 | position: relative; 71 | display: flex; 72 | flex-direction: column; 73 | align-items: center; 74 | } 75 | 76 | .timeInput { 77 | width: 90px; 78 | background-color: transparent; 79 | border: none; 80 | color: #fff; 81 | text-align: center; 82 | font-size: 48px; 83 | font-weight: 700; 84 | -moz-appearance: textfield; 85 | } 86 | 87 | .timeInput::-webkit-outer-spin-button, 88 | .timeInput::-webkit-inner-spin-button { 89 | -webkit-appearance: none; 90 | margin: 0; 91 | } 92 | 93 | .timeInput:focus { 94 | outline: none; 95 | } 96 | 97 | .arrow { 98 | background: none; 99 | border: none; 100 | color: #b3b3b3; 101 | font-size: 18px; 102 | cursor: pointer; 103 | transition: color 0.2s; 104 | padding: 4px 12px; 105 | line-height: 1; 106 | } 107 | 108 | .arrow:hover { 109 | color: #fff; 110 | } 111 | 112 | .upArrow { 113 | margin-bottom: 8px; 114 | } 115 | 116 | .downArrow { 117 | margin-top: 8px; 118 | } 119 | 120 | .separator { 121 | font-size: 48px; 122 | font-weight: 700; 123 | color: #fff; 124 | margin: 0 12px; 125 | } 126 | 127 | .footer { 128 | text-align: center; 129 | } 130 | 131 | .saveButton { 132 | background-color: #ffffff; 133 | color: #000000; 134 | border: none; 135 | padding: 12px 64px; 136 | border-radius: 500px; 137 | cursor: pointer; 138 | font-weight: 300; 139 | font-size: 14px; 140 | transition: all 0.2s; 141 | letter-spacing: 0.5px; 142 | } 143 | 144 | .saveButton:hover { 145 | background-color: #f0f0f0; 146 | } 147 | -------------------------------------------------------------------------------- /app/components/common/context_menu/Playlist/ContextMenuPlaylist.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { useNavigate } from 'react-router-dom' 3 | import InfoPopover from '../../info_popover/InfoPopover' 4 | import { InfoPopoverType } from '../../info_popover/types/infoPopover.types' 5 | import { t } from 'i18next' 6 | import styles from '../contextMenu.module.css' 7 | import { PropsContextMenuPlaylist } from '../types/contextMenu.types' 8 | import { apiClient } from '../../../../api' 9 | 10 | interface ConfirmationMenuData { 11 | title: string 12 | type: InfoPopoverType 13 | description: string 14 | } 15 | 16 | export default function ContextMenuPlaylist({ playlistId, handleCloseParent, onDelete }: PropsContextMenuPlaylist) { 17 | const navigate = useNavigate() 18 | 19 | const [confirmationData, setConfirmationData] = useState(null) 20 | 21 | const [triggerOpenConfirmationModal, setTriggerOpenConfirmationModal] = useState(false) 22 | 23 | const displayConfirmationModal = (type: InfoPopoverType, title: string, description: string) => { 24 | setConfirmationData({ type, title, description }) 25 | setTriggerOpenConfirmationModal(true) 26 | } 27 | 28 | const handleDeletePlaylist = async () => { 29 | try { 30 | await apiClient.deletePlaylist(playlistId) 31 | displayConfirmationModal( 32 | InfoPopoverType.SUCCESS, 33 | t('contextMenu.deleteSuccessTitle'), 34 | t('contextMenu.deleteSuccessDescription') 35 | ) 36 | if (onDelete) { 37 | onDelete() 38 | } 39 | } catch (err) { 40 | console.error(err) 41 | displayConfirmationModal( 42 | InfoPopoverType.ERROR, 43 | t('contextMenu.deleteErrorTitle'), 44 | t('contextMenu.deleteErrorDescription') 45 | ) 46 | } finally { 47 | handleCloseParent() 48 | } 49 | } 50 | 51 | const handleEditPlaylistData = () => { 52 | navigate(`/playlists/${playlistId}?edit=true`, { replace: true }) 53 | 54 | handleCloseParent() 55 | } 56 | 57 | const handleClose = () => { 58 | setTriggerOpenConfirmationModal(false) 59 | handleCloseParent() 60 | } 61 | 62 | return ( 63 |
64 |
    65 |
  • 66 | 70 |
  • 71 |
  • 72 | 76 |
  • 77 |
78 | 79 | {confirmationData && ( 80 | 87 | )} 88 |
89 | ) 90 | } 91 | -------------------------------------------------------------------------------- /app/components/playlists/createPlaylistModal.module.css: -------------------------------------------------------------------------------- 1 | .modalHeader { 2 | display: flex; 3 | justify-content: space-between; 4 | align-items: center; 5 | margin-bottom: 2rem; 6 | font-family: 7 | SonosanoCircular, 8 | -apple-system, 9 | BlinkMacSystemFont, 10 | 'Segoe UI', 11 | sans-serif; 12 | } 13 | 14 | .modalHeader h2 { 15 | margin: 0; 16 | font-size: 1.5rem; 17 | font-weight: 600; 18 | color: #fff; 19 | font-family: 20 | SonosanoCircular, 21 | -apple-system, 22 | BlinkMacSystemFont, 23 | 'Segoe UI', 24 | sans-serif; 25 | } 26 | 27 | .closeButton { 28 | background: none; 29 | border: none; 30 | color: #999; 31 | cursor: pointer; 32 | padding: 0.5rem; 33 | border-radius: 6px; 34 | font-size: 1.1rem; 35 | transition: all 0.2s ease; 36 | } 37 | 38 | .closeButton:hover { 39 | background: #333; 40 | color: #fff; 41 | } 42 | 43 | .modalForm { 44 | display: flex; 45 | flex-direction: column; 46 | gap: 1.5rem; 47 | font-family: 48 | SonosanoCircular, 49 | -apple-system, 50 | BlinkMacSystemFont, 51 | 'Segoe UI', 52 | sans-serif; 53 | } 54 | 55 | .formGroup { 56 | display: flex; 57 | flex-direction: column; 58 | gap: 0.5rem; 59 | font-family: 60 | SonosanoCircular, 61 | -apple-system, 62 | BlinkMacSystemFont, 63 | 'Segoe UI', 64 | sans-serif; 65 | } 66 | 67 | .formGroup label { 68 | font-weight: 500; 69 | color: #fff; 70 | font-size: 0.9rem; 71 | font-family: 72 | SonosanoCircular, 73 | -apple-system, 74 | BlinkMacSystemFont, 75 | 'Segoe UI', 76 | sans-serif; 77 | } 78 | 79 | .modalActions { 80 | display: flex; 81 | justify-content: flex-end; 82 | gap: 1rem; 83 | margin-top: 1rem; 84 | padding-top: 1.5rem; 85 | border-top: 1px solid #333; 86 | font-family: 87 | SonosanoCircular, 88 | -apple-system, 89 | BlinkMacSystemFont, 90 | 'Segoe UI', 91 | sans-serif; 92 | } 93 | 94 | .cancelButton { 95 | padding: 0.75rem 1.5rem; 96 | background: #333; 97 | color: #fff; 98 | border: none; 99 | outline: none; 100 | border-radius: 500px; 101 | font-size: 0.9rem; 102 | cursor: pointer; 103 | transition: all 0.2s ease; 104 | font-family: 105 | SonosanoCircular, 106 | -apple-system, 107 | BlinkMacSystemFont, 108 | 'Segoe UI', 109 | sans-serif; 110 | } 111 | 112 | .cancelButton:hover { 113 | background: #444; 114 | } 115 | 116 | .saveButton { 117 | padding: 0.75rem 1.5rem; 118 | background: #1db954; 119 | color: white; 120 | border: none; 121 | outline: none; 122 | border-radius: 500px; 123 | font-size: 0.9rem; 124 | font-weight: 600; 125 | cursor: pointer; 126 | transition: all 0.2s ease; 127 | font-family: 128 | SonosanoCircular, 129 | -apple-system, 130 | BlinkMacSystemFont, 131 | 'Segoe UI', 132 | sans-serif; 133 | } 134 | 135 | .saveButton:hover:not(:disabled) { 136 | background: #1ed760; 137 | transform: translateY(-1px); 138 | } 139 | 140 | .saveButton:disabled { 141 | background: #666; 142 | cursor: not-allowed; 143 | transform: none; 144 | } 145 | -------------------------------------------------------------------------------- /app/lib/main/main.ts: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow } from 'electron' 2 | import { electronApp, optimizer } from '@electron-toolkit/utils' 3 | import { createAppWindow } from './app' 4 | import { UpdateManager } from './update-manager' 5 | import { spawn, ChildProcess } from 'child_process' 6 | import path from 'path' 7 | import kill from 'tree-kill' 8 | 9 | let backendProcess: ChildProcess | null 10 | 11 | function startBackend() { 12 | let backendExecutable = 'sonosano-backend' 13 | if (process.platform === 'win32') { 14 | backendExecutable += '.exe' 15 | } 16 | 17 | const backendPath = app.isPackaged 18 | ? path.join(process.resourcesPath, 'backend', backendExecutable) 19 | : path.join(__dirname, '..', '..', '..', 'backend', 'dist', 'sonosano-backend', backendExecutable) 20 | 21 | backendProcess = spawn(backendPath) 22 | 23 | if (backendProcess.stdout) { 24 | backendProcess.stdout.on('data', (data) => { 25 | console.warn(`Backend: ${data}`) 26 | }) 27 | } 28 | 29 | if (backendProcess.stderr) { 30 | backendProcess.stderr.on('data', (data) => { 31 | console.error(`Backend Error: ${data}`) 32 | }) 33 | } 34 | } 35 | 36 | // This method will be called when Electron has finished 37 | // initialization and is ready to create browser windows. 38 | // Some APIs can only be used after this event occurs. 39 | app.whenReady().then(() => { 40 | // Set app user model id for windows 41 | electronApp.setAppUserModelId('com.sonosano.app') 42 | 43 | if (app.isPackaged) { 44 | startBackend() 45 | } 46 | 47 | // Create app window 48 | createAppWindow() 49 | 50 | const updateManager = new UpdateManager() 51 | updateManager.checkForUpdates() 52 | 53 | // Default open or close DevTools by F12 in development 54 | // and ignore CommandOrControl + R in production. 55 | // see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils 56 | app.on('browser-window-created', (_, window) => { 57 | optimizer.watchWindowShortcuts(window) 58 | }) 59 | 60 | app.on('activate', function () { 61 | // On macOS it's common to re-create a window in the app when the 62 | // dock icon is clicked and there are no other windows open. 63 | if (BrowserWindow.getAllWindows().length === 0) { 64 | createAppWindow() 65 | } 66 | }) 67 | }) 68 | 69 | // Quit when all windows are closed, except on macOS. There, it's common 70 | // for applications and their menu bar to stay active until the user quits 71 | // explicitly with Cmd + Q. 72 | app.on('window-all-closed', () => { 73 | app.quit() 74 | }) 75 | 76 | app.on('before-quit', (event) => { 77 | if (backendProcess) { 78 | event.preventDefault() // Prevent the app from quitting immediately 79 | kill(backendProcess.pid!, (err) => { 80 | if (err) { 81 | console.error('Failed to kill backend process:', err) 82 | } else { 83 | console.warn('Backend process killed successfully.') 84 | } 85 | backendProcess = null 86 | app.quit() // Now, quit the app 87 | }) 88 | } 89 | }) 90 | 91 | // In this file, you can include the rest of your app's specific main process 92 | // code. You can also put them in separate files and import them here. 93 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js' 2 | import tseslint from 'typescript-eslint' 3 | import reactPlugin from 'eslint-plugin-react' 4 | import reactHooksPlugin from 'eslint-plugin-react-hooks' 5 | 6 | export default [ 7 | { 8 | ignores: [ 9 | 'node_modules/**', 10 | 'dist/**', 11 | 'build/**', 12 | 'out/**', 13 | '.vscode/**', 14 | '.git/**', 15 | '.gitignore', 16 | '.eslintignore', 17 | '.eslintrc', 18 | '.prettierrc', 19 | 'assets/assets.d.ts', 20 | 'scripts/build-backend.js', 21 | 'backend/venv/**', 22 | ], 23 | }, 24 | eslint.configs.recommended, 25 | ...tseslint.configs.recommended, 26 | { 27 | files: ['**/*.{js,jsx,ts,tsx}'], 28 | plugins: { 29 | react: reactPlugin, 30 | 'react-hooks': reactHooksPlugin, 31 | }, 32 | languageOptions: { 33 | ecmaVersion: 'latest', 34 | sourceType: 'module', 35 | parser: tseslint.parser, 36 | parserOptions: { 37 | ecmaFeatures: { jsx: true }, 38 | projectService: true, 39 | }, 40 | globals: { 41 | // Browser globals that should be readonly 42 | window: 'readonly', 43 | document: 'readonly', 44 | location: 'readonly', 45 | history: 'readonly', 46 | navigator: 'readonly', 47 | 48 | // Browser globals that can be modified 49 | console: 'writable', 50 | localStorage: 'writable', 51 | sessionStorage: 'writable', 52 | 53 | // Timer functions that can be modified 54 | setTimeout: 'writable', 55 | clearTimeout: 'writable', 56 | setInterval: 'writable', 57 | clearInterval: 'writable', 58 | 59 | // Node.js globals 60 | process: 'readonly', 61 | __dirname: 'readonly', 62 | __filename: 'readonly', 63 | 64 | // React globals 65 | React: 'readonly', 66 | }, 67 | }, 68 | settings: { 69 | react: { 70 | version: 'detect', 71 | }, 72 | }, 73 | rules: { 74 | // React specific rules 75 | 'react/react-in-jsx-scope': 'off', 76 | 'react-hooks/rules-of-hooks': 'error', 77 | 'react-hooks/exhaustive-deps': 'warn', 78 | 79 | // TypeScript specific rules 80 | '@typescript-eslint/no-unused-vars': [ 81 | 'warn', 82 | { 83 | argsIgnorePattern: '^_', 84 | varsIgnorePattern: '^_', 85 | }, 86 | ], 87 | 88 | // General rules 89 | 'no-console': ['warn', { allow: ['warn', 'error'] }], 90 | '@typescript-eslint/no-explicit-any': 'off', 91 | 92 | // Global modification rules 93 | 'no-global-assign': [ 94 | 'error', 95 | { 96 | exceptions: ['console', 'localStorage', 'sessionStorage'], 97 | }, 98 | ], 99 | }, 100 | }, 101 | // Add specific configuration for preload files 102 | { 103 | files: ['app/**/*.ts', 'lib/**/*.ts', 'app/**/*.tsx', 'lib/**/*.tsx'], 104 | languageOptions: { 105 | globals: { 106 | process: 'readonly', 107 | console: 'readonly', 108 | window: 'readonly', 109 | }, 110 | }, 111 | }, 112 | ] 113 | -------------------------------------------------------------------------------- /app/styles/globals.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | @source '@/app'; 3 | @source '@/lib'; 4 | 5 | @theme { 6 | --color-background: var(--background); 7 | --color-foreground: var(--foreground); 8 | --color-card: var(--card); 9 | --color-card-foreground: var(--card-foreground); 10 | --color-popover: var(--popover); 11 | --color-popover-foreground: var(--popover-foreground); 12 | --color-primary: var(--primary); 13 | --color-primary-foreground: var(--primary-foreground); 14 | --color-secondary: var(--secondary); 15 | --color-secondary-foreground: var(--secondary-foreground); 16 | --color-muted: var(--muted); 17 | --color-muted-foreground: var(--muted-foreground); 18 | --color-accent: var(--accent); 19 | --color-accent-foreground: var(--accent-foreground); 20 | --color-destructive: var(--destructive); 21 | --color-destructive-foreground: var(--destructive-foreground); 22 | --color-border: var(--border); 23 | --color-input: var(--input); 24 | --color-ring: var(--ring); 25 | --color-chart-1: var(--chart-1); 26 | --color-chart-2: var(--chart-2); 27 | --color-chart-3: var(--chart-3); 28 | --color-chart-4: var(--chart-4); 29 | --color-chart-5: var(--chart-5); 30 | --radius-lg: var(--radius); 31 | --radius-md: calc(var(--radius) - 2px); 32 | --radius-sm: calc(var(--radius) - 4px); 33 | } 34 | 35 | @layer base { 36 | :root { 37 | --background: var(--color-neutral-50); 38 | --foreground: var(--color-neutral-900); 39 | --card: hsl(0 0% 100%); 40 | --card-foreground: hsl(0 0% 3.9%); 41 | --popover: hsl(0 0% 100%); 42 | --popover-foreground: hsl(0 0% 3.9%); 43 | --primary: hsl(0 0% 9%); 44 | --primary-foreground: hsl(0 0% 98%); 45 | --secondary: hsl(0 0% 96.1%); 46 | --secondary-foreground: hsl(0 0% 9%); 47 | --muted: hsl(0 0% 96.1%); 48 | --muted-foreground: hsl(0 0% 45.1%); 49 | --accent: hsl(0 0% 96.1%); 50 | --accent-foreground: hsl(0 0% 9%); 51 | --destructive: hsl(0 84.2% 60.2%); 52 | --destructive-foreground: hsl(0 0% 98%); 53 | --border: hsl(0 0% 89.8%); 54 | --input: hsl(0 0% 89.8%); 55 | --ring: hsl(0 0% 3.9%); 56 | --chart-1: hsl(12 76% 61%); 57 | --chart-2: hsl(173 58% 39%); 58 | --chart-3: hsl(197 37% 24%); 59 | --chart-4: hsl(43 74% 66%); 60 | --chart-5: hsl(27 87% 67%); 61 | --radius: 0.5rem; 62 | } 63 | 64 | .dark { 65 | --background: var(--color-neutral-950); 66 | --foreground: var(--color-neutral-100); 67 | --card: hsl(0, 0%, 3.9%); 68 | --card-foreground: hsl(0 0% 98%); 69 | --popover: hsl(0 0% 3.9%); 70 | --popover-foreground: hsl(0 0% 98%); 71 | --primary: hsl(0 0% 98%); 72 | --primary-foreground: hsl(0 0% 9%); 73 | --secondary: hsl(0 0% 14.9%); 74 | --secondary-foreground: hsl(0 0% 98%); 75 | --muted: hsl(0 0% 14.9%); 76 | --muted-foreground: hsl(0 0% 63.9%); 77 | --accent: hsl(0 0% 14.9%); 78 | --accent-foreground: hsl(0 0% 98%); 79 | --destructive: hsl(0 62.8% 30.6%); 80 | --destructive-foreground: hsl(0 0% 98%); 81 | --border: hsl(0 0% 14.9%); 82 | --input: hsl(0 0% 14.9%); 83 | --ring: hsl(0 0% 83.1%); 84 | --chart-1: hsl(220 70% 50%); 85 | --chart-2: hsl(160 60% 45%); 86 | --chart-3: hsl(30 80% 55%); 87 | --chart-4: hsl(280 65% 60%); 88 | --chart-5: hsl(340 75% 55%); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /backend/src/pynicotine/i18n.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020-2025 Nicotine+ Contributors 2 | # SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | import gettext 5 | import locale 6 | import os 7 | import sys 8 | 9 | CURRENT_PATH = os.path.dirname(os.path.realpath(__file__)) 10 | BASE_PATH = os.path.normpath(os.path.join(CURRENT_PATH, "..")) 11 | LOCALE_PATH = os.path.join(CURRENT_PATH, "locale") 12 | TRANSLATION_DOMAIN = "nicotine" 13 | LANGUAGES = ( 14 | ("ca", "Català"), 15 | ("cs", "Čeština"), 16 | ("de", "Deutsch"), 17 | ("en", "English"), 18 | ("es_CL", "Español (Chile)"), 19 | ("es_ES", "Español (España)"), 20 | ("et", "Eesti"), 21 | ("fr", "Français"), 22 | ("hu", "Magyar"), 23 | ("it", "Italiano"), 24 | ("lv", "Latviešu"), 25 | ("nl", "Nederlands"), 26 | ("pl", "Polski"), 27 | ("pt_BR", "Português (Brasil)"), 28 | ("pt_PT", "Português (Portugal)"), 29 | ("ru", "Русский"), 30 | ("ta", "தமிழ்"), 31 | ("tr", "Türkçe"), 32 | ("uk", "Українська"), 33 | ("zh_CN", "汉语") 34 | ) 35 | 36 | 37 | def _set_system_language(language=None): 38 | """Extracts the default system locale/language and applies it on systems that 39 | don't set the 'LC_ALL/LANGUAGE' environment variables by default (Windows, 40 | macOS)""" 41 | 42 | default_locale = None 43 | 44 | if sys.platform == "win32": 45 | import ctypes 46 | windll = ctypes.windll.kernel32 47 | 48 | if not default_locale: 49 | default_locale = locale.windows_locale.get(windll.GetUserDefaultLCID()) 50 | 51 | if not language and "LANGUAGE" not in os.environ: 52 | language = locale.windows_locale.get(windll.GetUserDefaultUILanguage()) 53 | 54 | elif sys.platform == "darwin": 55 | import plistlib 56 | os_preferences_path = os.path.join( 57 | os.path.expanduser("~"), "Library", "Preferences", ".GlobalPreferences.plist") 58 | 59 | try: 60 | with open(os_preferences_path, "rb") as file_handle: 61 | os_preferences = plistlib.load(file_handle) 62 | 63 | except Exception as error: 64 | os_preferences = {} 65 | print(f"Cannot load global preferences: {error}") 66 | 67 | # macOS provides locales with additional @ specifiers, e.g. en_GB@rg=US (region). 68 | # Remove them, since they are not supported. 69 | default_locale = next(iter(os_preferences.get("AppleLocale", "").split("@", maxsplit=1))) 70 | 71 | if default_locale.endswith("_ES"): 72 | # *_ES locale is currently broken on macOS (crashes when sorting strings). 73 | # Disable it for now. 74 | default_locale = "pt_PT" 75 | 76 | if not language and "LANGUAGE" not in os.environ: 77 | languages = os_preferences.get("AppleLanguages", [""]) 78 | language = next(iter(languages)).replace("-", "_") 79 | 80 | if default_locale: 81 | os.environ["LC_ALL"] = default_locale 82 | 83 | if language: 84 | os.environ["LANGUAGE"] = language 85 | 86 | 87 | def apply_translations(language=None): 88 | 89 | # Use the same language as the rest of the system 90 | _set_system_language(language) 91 | 92 | # Install translations for Python 93 | gettext.install(TRANSLATION_DOMAIN, LOCALE_PATH) 94 | -------------------------------------------------------------------------------- /assets/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /app/renderer/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | scroll-behavior: smooth; 3 | -webkit-font-smoothing: antialiased; 4 | font-synthesis: none; 5 | text-rendering: optimizeLegibility; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | 9 | --primary-black: #000000; 10 | --secondary-black: #121212; 11 | 12 | --pure-white: #ffffff; 13 | --primary-white: #dfdfdf; 14 | --secondary-white: #b3b3b3; 15 | --third-black: #161616; 16 | --hover-white: #272727; 17 | --separator-white: #3e3e3e; 18 | --focus-grey-background: #333; 19 | 20 | --primary-green: #1db954; 21 | --secondary-green: #228822; 22 | 23 | --grey: #888; 24 | 25 | --sticky-header-blue: #1e0f44; 26 | 27 | --app-logo-color: #9ad9ea; 28 | --app-logo-color-darker: #56afc8; 29 | 30 | --sidebar-width: min(20svw, 450px); 31 | --sidebar-height: 90svh; 32 | 33 | --footer-height: min(10svh, 100px); 34 | 35 | --border-radius-thumbnails: 8px; 36 | --border-radius-rounded-button: 25px; 37 | 38 | --font-size-song-footer: 1.05rem; 39 | --font-size-artist-footer: 0.85rem; 40 | 41 | --pading-top-sticky-header: 3rem; 42 | } 43 | 44 | .wrapperNavbar, 45 | .header, 46 | .sidebar-header, 47 | body > div:first-child { 48 | -webkit-app-region: drag; 49 | } 50 | 51 | header, 52 | .app-header, 53 | .main-header, 54 | .sidebar-header, 55 | .top-header, 56 | .wrapperStickyHeader { 57 | -webkit-app-region: drag; 58 | } 59 | 60 | button, 61 | a, 62 | input, 63 | select, 64 | [role='button'], 65 | textarea, 66 | label, 67 | .interactive { 68 | -webkit-app-region: no-drag !important; 69 | } 70 | 71 | * { 72 | -webkit-user-select: none; 73 | -moz-user-select: none; 74 | -ms-user-select: none; 75 | user-select: none; 76 | cursor: default; 77 | } 78 | 79 | img { 80 | -webkit-user-drag: none; 81 | -khtml-user-drag: none; 82 | -moz-user-drag: none; 83 | -o-user-drag: none; 84 | user-drag: none; 85 | pointer-events: none; 86 | } 87 | 88 | input, 89 | textarea { 90 | -webkit-user-select: text; 91 | -moz-user-select: text; 92 | -ms-user-select: text; 93 | user-select: text; 94 | cursor: text; 95 | } 96 | 97 | html { 98 | height: 100%; 99 | } 100 | 101 | body { 102 | margin: 0; 103 | height: 100%; 104 | overflow: hidden; 105 | background-color: var(--primary-black); 106 | } 107 | 108 | a { 109 | text-decoration: none; 110 | color: inherit; 111 | } 112 | 113 | a:hover { 114 | color: inherit; 115 | } 116 | 117 | ul { 118 | list-style: none; 119 | margin-bottom: 0 !important; 120 | } 121 | 122 | h5 { 123 | margin: 0; 124 | } 125 | 126 | p { 127 | margin: 0; 128 | } 129 | 130 | ::-webkit-scrollbar { 131 | display: none; 132 | } 133 | 134 | input:focus-visible, 135 | textarea:focus-visible { 136 | outline: none !important; 137 | } 138 | 139 | button, 140 | select, 141 | a, 142 | [role='button'], 143 | .interactive-element { 144 | -webkit-app-region: no-drag !important; 145 | cursor: pointer; 146 | } 147 | 148 | input, 149 | textarea { 150 | -webkit-app-region: no-drag !important; 151 | cursor: text !important; 152 | } 153 | 154 | @media (max-width: 1600px) { 155 | :root { 156 | --sidebar-height: 88svh; 157 | 158 | --footer-height: min(12svh, 100px) !important; 159 | 160 | --font-size-song-footer: 0.85rem !important; 161 | --font-size-artist-footer: 0.65rem !important; 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /app/components/MetadataModal/metadataModal.module.css: -------------------------------------------------------------------------------- 1 | .modalOverlay { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | right: 0; 6 | bottom: 0; 7 | background-color: rgba(0, 0, 0, 0.8); 8 | display: flex; 9 | align-items: center; 10 | justify-content: center; 11 | z-index: 1000; 12 | animation: fadeIn 0.2s ease-out; 13 | } 14 | 15 | @keyframes fadeIn { 16 | from { 17 | opacity: 0; 18 | } 19 | to { 20 | opacity: 1; 21 | } 22 | } 23 | 24 | .modalContent { 25 | background-color: #282828; 26 | border-radius: 12px; 27 | padding: 32px; 28 | width: 480px; 29 | max-width: 90vw; 30 | box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6); 31 | animation: slideIn 0.2s ease-out; 32 | border: 1px solid rgba(255, 255, 255, 0.1); 33 | } 34 | 35 | @keyframes slideIn { 36 | from { 37 | opacity: 0; 38 | transform: translateY(-20px) scale(0.95); 39 | } 40 | to { 41 | opacity: 1; 42 | transform: translateY(0) scale(1); 43 | } 44 | } 45 | 46 | .modalTitle { 47 | color: #fff; 48 | font-size: 24px; 49 | font-weight: 600; 50 | margin: 0 0 24px 0; 51 | text-align: center; 52 | } 53 | 54 | .fileName { 55 | display: flex; 56 | align-items: center; 57 | gap: 12px; 58 | padding: 16px; 59 | background-color: rgba(255, 255, 255, 0.05); 60 | border-radius: 8px; 61 | margin-bottom: 24px; 62 | border: 1px solid rgba(255, 255, 255, 0.1); 63 | } 64 | 65 | .fileName i { 66 | color: #1db954; 67 | font-size: 20px; 68 | } 69 | 70 | .fileName span { 71 | color: #b3b3b3; 72 | font-size: 14px; 73 | word-break: break-all; 74 | } 75 | 76 | .formGroup { 77 | margin-bottom: 20px; 78 | } 79 | 80 | .formGroup label { 81 | display: block; 82 | color: #fff; 83 | font-size: 14px; 84 | font-weight: 500; 85 | margin-bottom: 8px; 86 | } 87 | 88 | .formGroup input { 89 | width: 100%; 90 | padding: 12px 16px; 91 | background-color: rgba(255, 255, 255, 0.1); 92 | border: 1px solid rgba(255, 255, 255, 0.2); 93 | border-radius: 8px; 94 | color: #fff; 95 | font-size: 14px; 96 | outline: none; 97 | transition: all 0.2s; 98 | box-sizing: border-box; 99 | } 100 | 101 | .formGroup input:focus { 102 | background-color: rgba(255, 255, 255, 0.15); 103 | border-color: #1db954; 104 | box-shadow: 0 0 0 2px rgba(29, 185, 84, 0.2); 105 | } 106 | 107 | .formGroup input::placeholder { 108 | color: #b3b3b3; 109 | } 110 | 111 | .modalActions { 112 | display: flex; 113 | gap: 12px; 114 | justify-content: flex-end; 115 | margin-top: 32px; 116 | } 117 | 118 | .cancelButton, 119 | .saveButton { 120 | padding: 12px 24px; 121 | border-radius: 24px; 122 | font-size: 14px; 123 | font-weight: 600; 124 | cursor: pointer; 125 | transition: all 0.2s; 126 | border: none; 127 | min-width: 100px; 128 | } 129 | 130 | .cancelButton { 131 | background-color: transparent; 132 | color: #b3b3b3; 133 | border: 1px solid rgba(255, 255, 255, 0.2); 134 | } 135 | 136 | .cancelButton:hover { 137 | background-color: rgba(255, 255, 255, 0.1); 138 | color: #fff; 139 | } 140 | 141 | .saveButton { 142 | background-color: #1db954; 143 | color: #000; 144 | } 145 | 146 | .saveButton:hover:not(:disabled) { 147 | background-color: #1ed760; 148 | transform: scale(1.02); 149 | } 150 | 151 | .saveButton:disabled { 152 | background-color: #535353; 153 | color: #b3b3b3; 154 | cursor: not-allowed; 155 | transform: none; 156 | } 157 | -------------------------------------------------------------------------------- /app/components/MetadataModal/MetadataModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import { useTranslation } from 'react-i18next' 3 | import styles from './metadataModal.module.css' 4 | 5 | interface MetadataModalProps { 6 | isOpen: boolean 7 | fileName: string 8 | onSave: (metadata: { title: string; artist: string }) => void 9 | onCancel: () => void 10 | } 11 | 12 | export default function MetadataModal({ isOpen, fileName, onSave, onCancel }: MetadataModalProps) { 13 | const { t } = useTranslation() 14 | const [title, setTitle] = useState('') 15 | const [artist, setArtist] = useState('') 16 | 17 | useEffect(() => { 18 | if (isOpen && fileName) { 19 | const nameWithoutExt = fileName.replace(/\.[^/.]+$/, '') 20 | 21 | const parts = nameWithoutExt.split(' - ') 22 | if (parts.length >= 2) { 23 | setArtist(parts[0].trim()) 24 | setTitle(parts.slice(1).join(' - ').trim()) 25 | } else { 26 | setTitle(nameWithoutExt.trim()) 27 | setArtist('') 28 | } 29 | } 30 | }, [isOpen, fileName]) 31 | 32 | const handleSave = () => { 33 | if (title.trim()) { 34 | onSave({ 35 | title: title.trim(), 36 | artist: artist.trim(), 37 | }) 38 | 39 | setTitle('') 40 | setArtist('') 41 | } 42 | } 43 | 44 | const handleCancel = () => { 45 | onCancel() 46 | 47 | setTitle('') 48 | setArtist('') 49 | } 50 | 51 | const handleKeyDown = (e: React.KeyboardEvent) => { 52 | if (e.key === 'Enter') { 53 | e.preventDefault() 54 | handleSave() 55 | } else if (e.key === 'Escape') { 56 | e.preventDefault() 57 | handleCancel() 58 | } 59 | } 60 | 61 | if (!isOpen) return null 62 | 63 | return ( 64 |
65 |
66 |

{t('metadataModal.title')}

67 | 68 |
69 | 70 | {fileName} 71 |
72 | 73 |
74 | 75 | setTitle(e.target.value)} 80 | onKeyDown={handleKeyDown} 81 | placeholder={t('metadataModal.songTitlePlaceholder')} 82 | autoFocus 83 | /> 84 |
85 | 86 |
87 | 88 | setArtist(e.target.value)} 93 | onKeyDown={handleKeyDown} 94 | placeholder={t('metadataModal.artistNamePlaceholder')} 95 | /> 96 |
97 | 98 |
99 | 102 | 105 |
106 |
107 |
108 | ) 109 | } 110 | -------------------------------------------------------------------------------- /app/components/common/info_popover/InfoPopover.tsx: -------------------------------------------------------------------------------- 1 | import Modal from '@mui/material/Modal' 2 | import { useEffect, useState } from 'react' 3 | import Box from '@mui/material/Box' 4 | import PriorityHighIcon from '@mui/icons-material/PriorityHigh' 5 | import CheckIcon from '@mui/icons-material/Check' 6 | import ContentPasteIcon from '@mui/icons-material/ContentPaste' 7 | import { t } from 'i18next' 8 | import { PropsInfoPopover, InfoPopoverType } from './types/infoPopover.types' 9 | import styles from './confirmationModal.module.css' 10 | 11 | export default function InfoPopover({ 12 | title, 13 | description, 14 | type, 15 | triggerOpenConfirmationModal, 16 | handleClose, 17 | }: PropsInfoPopover) { 18 | const style = { 19 | position: 'absolute', 20 | top: '50%', 21 | left: '50%', 22 | transform: 'translate(-50%, -50%)', 23 | width: '50svw', 24 | bgcolor: 'background.paper', 25 | border: '2px solid #000', 26 | boxShadow: 24, 27 | p: 2, 28 | } 29 | 30 | const [openConfirmationModal, setOpenConfirmationModal] = useState(false) 31 | 32 | const handleCloseConfirmationModal = () => { 33 | setOpenConfirmationModal(false) 34 | if (handleClose) { 35 | handleClose() 36 | } 37 | } 38 | 39 | useEffect(() => { 40 | if (triggerOpenConfirmationModal) { 41 | setOpenConfirmationModal(true) 42 | } 43 | }, [triggerOpenConfirmationModal]) 44 | 45 | return ( 46 |
47 | 54 | 55 |
56 |
57 | {title} 58 |

{description}

59 |
60 | 61 |
62 | {type === InfoPopoverType.SUCCESS && ( 63 | 69 | )} 70 | 71 | {type === InfoPopoverType.ERROR && ( 72 | 78 | )} 79 | 80 | {type === InfoPopoverType.CLIPBOARD && ( 81 | 87 | )} 88 |
89 |
90 |
91 | 94 |
95 |
96 |
97 |
98 | ) 99 | } 100 | -------------------------------------------------------------------------------- /backend/src/api/search_routes.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, HTTPException 2 | from core.search_service import search_service 3 | from models.search_models import SearchQuery, SearchResult 4 | from core.soulseek_manager import SoulseekManager 5 | from pynicotine.events import events 6 | 7 | router = APIRouter() 8 | 9 | soulseek_manager: SoulseekManager 10 | 11 | @router.get("/search") 12 | async def search(provider: str, q: str): 13 | """ 14 | Performs a search using the specified provider. 15 | """ 16 | if not q: 17 | raise HTTPException(status_code=400, detail="Query parameter 'q' is required.") 18 | 19 | results = search_service.search(provider, q) 20 | 21 | if "error" in results: 22 | raise HTTPException(status_code=500, detail=results["error"]) 23 | 24 | return results 25 | 26 | @router.post("/search/soulseek") 27 | async def search_files(query: SearchQuery): 28 | """Start a search on Soulseek network with fallback logic.""" 29 | if not soulseek_manager.logged_in: 30 | raise HTTPException(status_code=503, detail="Not connected to Soulseek") 31 | 32 | try: 33 | token, actual_query = soulseek_manager.perform_search_with_fallback(query.artist, query.song, query.query) 34 | 35 | if token is None: 36 | return {"search_token": None, "actual_query": actual_query} 37 | 38 | soulseek_manager.search_tokens[token] = actual_query 39 | 40 | return {"search_token": token, "actual_query": actual_query} 41 | except Exception as e: 42 | if "cancelled" in str(e).lower(): 43 | return {"search_token": None, "actual_query": "", "cancelled": True} 44 | else: 45 | raise e 46 | 47 | search_completion_status = {} 48 | last_result_count = {} 49 | 50 | @router.get("/search/soulseek/results/{token}") 51 | async def get_search_results(token: int): 52 | """Get current search results for a given token.""" 53 | events.process_thread_events() 54 | 55 | results = soulseek_manager.search_results.get(token, []) 56 | formatted_results = [ 57 | SearchResult( 58 | path=result['path'], 59 | size=result['size'], 60 | username=result['username'], 61 | extension=result.get('extension'), 62 | bitrate=result.get('bitrate'), 63 | quality=result.get('quality'), 64 | length=result.get('length') 65 | ) for result in results 66 | ] 67 | 68 | current_count = len(formatted_results) 69 | 70 | if token not in last_result_count: 71 | last_result_count[token] = 0 72 | search_completion_status[token] = 0 73 | 74 | if current_count == last_result_count[token] and current_count > 0: 75 | search_completion_status[token] += 1 76 | else: 77 | search_completion_status[token] = 0 78 | 79 | last_result_count[token] = current_count 80 | 81 | is_complete = ( 82 | (current_count > 0 and search_completion_status[token] >= 3) or 83 | current_count >= 100 or 84 | (token not in soulseek_manager.search_tokens and current_count > 0) 85 | ) 86 | 87 | if is_complete and token in last_result_count: 88 | del last_result_count[token] 89 | del search_completion_status[token] 90 | 91 | actual_query = soulseek_manager.search_tokens.get(token, "") 92 | 93 | return { 94 | "results": formatted_results, 95 | "is_complete": is_complete, 96 | "result_count": len(formatted_results), 97 | "actual_query": actual_query 98 | } -------------------------------------------------------------------------------- /app/renderer/app.module.css: -------------------------------------------------------------------------------- 1 | .appBackground { 2 | background-color: var(--primary-black); 3 | font-family: SonosanoCircular !important; 4 | margin: 0; 5 | padding: 0; 6 | } 7 | 8 | .layoutContainer { 9 | display: flex; 10 | width: 100vw; 11 | height: 100vh; 12 | position: relative; 13 | overflow: hidden; 14 | padding-top: 64px; 15 | padding-bottom: calc(var(--footer-height) + 8px); 16 | gap: 8px; 17 | padding-left: 8px; 18 | padding-right: 8px; 19 | } 20 | 21 | .layoutContainer.withRightSidebar { 22 | padding-right: 8px; 23 | } 24 | 25 | .mainContentWrapper { 26 | background-color: var(--secondary-black) !important; 27 | border-radius: 14px; 28 | margin: 0; 29 | overflow-x: hidden; 30 | overflow-y: auto; 31 | padding: 0 !important; 32 | height: 100%; 33 | position: relative; 34 | display: flex; 35 | flex-direction: column; 36 | box-sizing: border-box; 37 | flex: 1; 38 | transition: margin-right 0.3s ease; 39 | } 40 | 41 | .leftSidebar { 42 | display: flex; 43 | flex-direction: column; 44 | overflow: hidden; 45 | background-color: var(--primary-black); 46 | height: 100%; 47 | transition: 48 | width 0.3s ease, 49 | min-width 0.3s ease, 50 | max-width 0.3s ease; 51 | flex-shrink: 0; 52 | } 53 | 54 | .leftSidebar.collapsed { 55 | width: 72px; 56 | min-width: 72px; 57 | max-width: 72px; 58 | } 59 | 60 | .leftSidebar.expanded { 61 | width: var(--sidebar-width); 62 | min-width: 400px; 63 | max-width: 450px; 64 | } 65 | 66 | .rightSidebar { 67 | position: fixed; 68 | top: 64px; 69 | right: 8px; 70 | bottom: calc(var(--footer-height) + 8px); 71 | width: var(--sidebar-width); 72 | min-width: 200px; 73 | max-width: var(--sidebar-width); 74 | padding: 0; 75 | display: flex; 76 | flex-direction: column; 77 | overflow: hidden; 78 | background-color: var(--primary-black); 79 | z-index: 2; 80 | } 81 | 82 | .pageTransition { 83 | width: 100%; 84 | height: 100%; 85 | } 86 | 87 | .fade-enter { 88 | opacity: 0; 89 | transform: translateX(20px); 90 | } 91 | 92 | .fade-enter-active { 93 | opacity: 1; 94 | transform: translateX(0); 95 | transition: 96 | opacity 300ms, 97 | transform 300ms; 98 | } 99 | 100 | .fade-exit { 101 | opacity: 1; 102 | transform: translateX(0); 103 | } 104 | 105 | .fade-exit-active { 106 | opacity: 0; 107 | transform: translateX(-20px); 108 | transition: 109 | opacity 300ms, 110 | transform 300ms; 111 | } 112 | 113 | @font-face { 114 | font-family: SonosanoCircular; 115 | src: url(../../assets/fonts/CircularSonosanoText-Bold.otf); 116 | font-weight: bold; 117 | } 118 | 119 | @font-face { 120 | font-family: SonosanoCircular; 121 | src: url(../../assets/fonts/CircularSonosanoText-Medium.otf); 122 | font-weight: normal; 123 | } 124 | 125 | @font-face { 126 | font-family: SonosanoCircular; 127 | src: url(../../assets/fonts/CircularSonosanoText-Light.otf); 128 | font-weight: 300; 129 | } 130 | 131 | .appBackground img, 132 | .mainContentWrapper img, 133 | .leftSidebar img, 134 | .rightSidebar img { 135 | -webkit-user-drag: none; 136 | -khtml-user-drag: none; 137 | -moz-user-drag: none; 138 | -o-user-drag: none; 139 | user-drag: none; 140 | pointer-events: none; 141 | } 142 | 143 | @media (max-width: 1000px) { 144 | .leftSidebar.expanded { 145 | width: 96px; 146 | min-width: 96px; 147 | max-width: 96px; 148 | } 149 | 150 | .rightSidebar { 151 | width: calc(100vw - 96px - 24px); 152 | min-width: calc(100vw - 96px - 24px); 153 | max-width: calc(100vw - 96px - 24px); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /app/components/footer/SongInfo/SongInfo.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import { useTranslation } from 'react-i18next' 3 | import { useNavigate } from 'react-router-dom' 4 | import styles from './songInfo.module.css' 5 | import { usePlaybackManager } from '../../../hooks/usePlaybackManager' 6 | import AddToPlaylistMenu from '../AddToPlaylistMenu/AddToPlaylistMenu' 7 | import { Song } from '../../../types' 8 | 9 | interface SongInfoProps { 10 | name: string 11 | metadata?: Song['metadata'] 12 | } 13 | 14 | export default function SongInfo({ name, metadata }: SongInfoProps) { 15 | const { t } = useTranslation() 16 | const artist = metadata?.artist 17 | const thumbnail = metadata?.coverArt 18 | const navigate = useNavigate() 19 | const { playbackState } = usePlaybackManager() 20 | const { currentSong } = playbackState 21 | 22 | const [imageError, setImageError] = useState(false) 23 | const [isMenuOpen, setIsMenuOpen] = useState(false) 24 | 25 | const handleAddToPlaylistClick = () => { 26 | setIsMenuOpen(!isMenuOpen) 27 | } 28 | 29 | const handleClose = () => { 30 | setIsMenuOpen(false) 31 | } 32 | 33 | const handleClickArtist = () => { 34 | navigate(`/artist/${artist}`) 35 | } 36 | 37 | useEffect(() => { 38 | setImageError(false) 39 | }, [thumbnail]) 40 | 41 | return ( 42 |
46 | {name && ( 47 | <> 48 |
49 | {imageError || !thumbnail ? ( 50 |
51 | 52 |
53 | ) : ( 54 | song thumbnail setImageError(true)} 60 | /> 61 | )} 62 |
63 |
64 | 67 | 70 |
71 |
72 | {currentSong && ( 73 | <> 74 | {isMenuOpen && ( 75 |
76 | 77 |
78 | )} 79 | 91 | 92 | )} 93 |
94 | 95 | )} 96 |
97 | ) 98 | } 99 | -------------------------------------------------------------------------------- /app/utils/sessionCache.ts: -------------------------------------------------------------------------------- 1 | import { LyricsData } from '../services/lyricsService' 2 | import { ColorPalette } from '../utils/colorExtraction' 3 | 4 | interface CachedSongData { 5 | lyrics: LyricsData | null 6 | colorPalette: ColorPalette | null 7 | isLyricsLoading: boolean 8 | isColorLoading: boolean 9 | lyricsError: string | null 10 | timestamp: number 11 | } 12 | 13 | interface SongIdentifier { 14 | name: string 15 | artist: string 16 | album?: string 17 | } 18 | 19 | class SessionCache { 20 | private cache: Map = new Map() 21 | private readonly CACHE_EXPIRY = 1000 * 60 * 60 * 2 22 | 23 | private generateKey(songInfo: SongIdentifier): string { 24 | return `${songInfo.artist?.toLowerCase() || 'unknown'}-${songInfo.name?.toLowerCase() || 'unknown'}-${songInfo.album?.toLowerCase() || ''}` 25 | } 26 | 27 | private isValid(data: CachedSongData): boolean { 28 | return Date.now() - data.timestamp < this.CACHE_EXPIRY 29 | } 30 | 31 | getCachedData(songInfo: SongIdentifier): CachedSongData | null { 32 | const key = this.generateKey(songInfo) 33 | const cached = this.cache.get(key) 34 | 35 | if (cached && this.isValid(cached)) { 36 | return cached 37 | } 38 | 39 | if (cached) { 40 | this.cache.delete(key) 41 | } 42 | 43 | return null 44 | } 45 | 46 | initializeCacheEntry(songInfo: SongIdentifier): CachedSongData { 47 | const key = this.generateKey(songInfo) 48 | const initialData: CachedSongData = { 49 | lyrics: null, 50 | colorPalette: null, 51 | isLyricsLoading: false, 52 | isColorLoading: false, 53 | lyricsError: null, 54 | timestamp: Date.now(), 55 | } 56 | 57 | this.cache.set(key, initialData) 58 | return initialData 59 | } 60 | 61 | updateLyrics(songInfo: SongIdentifier, lyrics: LyricsData | null, error: string | null = null): void { 62 | const key = this.generateKey(songInfo) 63 | const existing = this.cache.get(key) || this.initializeCacheEntry(songInfo) 64 | 65 | existing.lyrics = lyrics 66 | existing.lyricsError = error 67 | existing.isLyricsLoading = false 68 | existing.timestamp = Date.now() 69 | 70 | this.cache.set(key, existing) 71 | } 72 | 73 | updateColorPalette(songInfo: SongIdentifier, colorPalette: ColorPalette): void { 74 | const key = this.generateKey(songInfo) 75 | const existing = this.cache.get(key) || this.initializeCacheEntry(songInfo) 76 | 77 | existing.colorPalette = colorPalette 78 | existing.isColorLoading = false 79 | existing.timestamp = Date.now() 80 | 81 | this.cache.set(key, existing) 82 | } 83 | 84 | setLyricsLoading(songInfo: SongIdentifier, loading: boolean): void { 85 | const key = this.generateKey(songInfo) 86 | const existing = this.cache.get(key) || this.initializeCacheEntry(songInfo) 87 | 88 | existing.isLyricsLoading = loading 89 | this.cache.set(key, existing) 90 | } 91 | 92 | setColorLoading(songInfo: SongIdentifier, loading: boolean): void { 93 | const key = this.generateKey(songInfo) 94 | const existing = this.cache.get(key) || this.initializeCacheEntry(songInfo) 95 | 96 | existing.isColorLoading = loading 97 | this.cache.set(key, existing) 98 | } 99 | 100 | clearExpired(): void { 101 | const now = Date.now() 102 | for (const [key, data] of this.cache.entries()) { 103 | if (now - data.timestamp > this.CACHE_EXPIRY) { 104 | this.cache.delete(key) 105 | } 106 | } 107 | } 108 | 109 | clearAll(): void { 110 | this.cache.clear() 111 | } 112 | 113 | getSize(): number { 114 | return this.cache.size 115 | } 116 | 117 | getCachedKeys(): string[] { 118 | return Array.from(this.cache.keys()) 119 | } 120 | } 121 | 122 | export const sessionCache = new SessionCache() 123 | export type { SongIdentifier, CachedSongData } 124 | -------------------------------------------------------------------------------- /app/components/footer/SongConfig/TimePicker/TimePicker.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { useTranslation } from 'react-i18next' 3 | import styles from './timePicker.module.css' 4 | 5 | interface TimePickerProps { 6 | onSave: (hours: number, minutes: number) => void 7 | onClose: () => void 8 | } 9 | 10 | export default function TimePicker({ onSave, onClose }: TimePickerProps) { 11 | const { t } = useTranslation() 12 | const [hour, setHour] = useState(0) 13 | const [minute, setMinute] = useState(0) 14 | 15 | const handleSave = () => { 16 | onSave(hour, minute) 17 | } 18 | 19 | const handleHourChange = (e: React.ChangeEvent) => { 20 | const value = e.target.value.replace(/[^0-9]/g, '').slice(0, 2) 21 | let numValue = parseInt(value, 10) 22 | if (isNaN(numValue) || numValue < 0) numValue = 0 23 | if (numValue > 23) numValue = 23 24 | setHour(numValue) 25 | } 26 | 27 | const handleMinuteChange = (e: React.ChangeEvent) => { 28 | const value = e.target.value.replace(/[^0-9]/g, '').slice(0, 2) 29 | let numValue = parseInt(value, 10) 30 | if (isNaN(numValue) || numValue < 0) numValue = 0 31 | if (numValue > 59) numValue = 59 32 | setMinute(numValue) 33 | } 34 | 35 | const incrementHour = () => setHour((h) => (h >= 23 ? 0 : h + 1)) 36 | const decrementHour = () => setHour((h) => (h <= 0 ? 23 : h - 1)) 37 | const incrementMinute = () => setMinute((m) => (m >= 59 ? 0 : m + 1)) 38 | const decrementMinute = () => setMinute((m) => (m <= 0 ? 59 : m - 1)) 39 | 40 | return ( 41 |
42 |
e.stopPropagation()}> 43 |
44 |

{t('timer.setTimer')}

45 | 48 |
49 | 50 |
51 |
52 | 55 | 62 | 65 |
66 | 67 | : 68 | 69 |
70 | 73 | 80 | 83 |
84 |
85 | 86 |
87 | 99 |
100 |
101 |
102 | ) 103 | } 104 | -------------------------------------------------------------------------------- /app/utils/coverCache.ts: -------------------------------------------------------------------------------- 1 | import { apiClient } from '../api' 2 | import { getBackendUrl } from '../api/backendUrl' 3 | 4 | interface CoverCacheResult { 5 | coverArt: string // Either local path or original URL 6 | isLocal: boolean 7 | } 8 | 9 | // Generate a safe filename from artist and title 10 | function generateCoverFileName(artist: string | undefined, title: string | undefined, url: string): string { 11 | // Create a base name from artist and title 12 | const artistClean = (artist || 'unknown').replace(/[^a-zA-Z0-9]/g, '_').substring(0, 50) 13 | const titleClean = (title || 'unknown').replace(/[^a-zA-Z0-9]/g, '_').substring(0, 50) 14 | 15 | // Add a hash of the URL to ensure uniqueness 16 | let hash = 0 17 | for (let i = 0; i < url.length; i++) { 18 | const char = url.charCodeAt(i) 19 | hash = (hash << 5) - hash + char 20 | hash = hash & hash // Convert to 32bit integer 21 | } 22 | 23 | return `${artistClean}_${titleClean}_${Math.abs(hash)}.jpg` 24 | } 25 | 26 | // Check if a cover already exists locally 27 | async function checkLocalCover(fileName: string): Promise { 28 | try { 29 | const response = await apiClient.checkLocalCover(fileName) 30 | 31 | if (response.ok) { 32 | // Return the URL to access the local cover 33 | const url = response.url.replace('/cover', '/api/cover') 34 | return url 35 | } 36 | return null 37 | } catch (error) { 38 | console.error('Error checking local cover:', error) 39 | return null 40 | } 41 | } 42 | 43 | // Download and save cover to local covers folder 44 | async function downloadCover(url: string, fileName: string): Promise { 45 | try { 46 | // Send request to backend to download and save the cover 47 | const response = await apiClient.downloadCover(url, fileName) 48 | if (response && response.file_path) { 49 | return apiClient.getCoverUrl(response.file_path) 50 | } 51 | return null 52 | } catch (error) { 53 | console.error('Error downloading cover:', error) 54 | return null 55 | } 56 | } 57 | 58 | // Main function to get or cache cover 59 | export async function getCachedCoverUrl( 60 | coverUrl: string | undefined, 61 | artist?: string, 62 | title?: string 63 | ): Promise { 64 | // If no cover URL provided, return as is 65 | if (!coverUrl) { 66 | return { coverArt: coverUrl || '', isLocal: false } 67 | } 68 | 69 | // Skip if already a local URL 70 | if (coverUrl.includes(getBackendUrl())) { 71 | return { coverArt: coverUrl, isLocal: true } 72 | } 73 | 74 | try { 75 | // Generate a unique filename for this cover 76 | const fileName = generateCoverFileName(artist, title, coverUrl) 77 | 78 | // Check if we already have this cover locally 79 | const localUrl = await checkLocalCover(fileName) 80 | if (localUrl) { 81 | return { coverArt: localUrl, isLocal: true } 82 | } 83 | 84 | // Download the cover if not cached 85 | const downloadedUrl = await downloadCover(coverUrl, fileName) 86 | if (downloadedUrl) { 87 | return { coverArt: downloadedUrl, isLocal: true } 88 | } 89 | 90 | // Fallback to original URL if download failed 91 | return { coverArt: coverUrl, isLocal: false } 92 | } catch (error) { 93 | console.error('Cover cache error:', error) 94 | // Fallback to original URL on any error 95 | return { coverArt: coverUrl, isLocal: false } 96 | } 97 | } 98 | 99 | // Update metadata with local cover path 100 | export async function updateMetadataWithLocalCover(metadata: any, artist?: string, title?: string): Promise { 101 | if (!metadata?.coverArt) { 102 | return metadata 103 | } 104 | 105 | const cacheResult = await getCachedCoverUrl(metadata.coverArt, artist || metadata.artist, title || metadata.title) 106 | 107 | return { 108 | ...metadata, 109 | coverArt: cacheResult.coverArt, 110 | coverArtCached: cacheResult.isLocal, 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /app/components/footer/AddToPlaylistMenu/addToPlaylistMenu.module.css: -------------------------------------------------------------------------------- 1 | .menuContainer { 2 | background-color: #1f1f1f; 3 | border-radius: 8px; 4 | box-shadow: 0 8px 16px rgba(0, 0, 0, 0.8); 5 | color: #fff; 6 | width: 280px; 7 | max-height: 40vh; 8 | display: flex; 9 | flex-direction: column; 10 | font-family: 'CircularSp', 'Helvetica Neue', Helvetica, Arial, sans-serif; 11 | border: 1px solid #282828; 12 | } 13 | 14 | .header { 15 | padding: 10px 14px; 16 | text-align: center; 17 | border-bottom: 1px solid #282828; 18 | } 19 | 20 | .header h3 { 21 | margin: 0; 22 | font-size: 13px; 23 | font-weight: 700; 24 | color: #b3b3b3; 25 | } 26 | 27 | .searchContainer { 28 | padding: 10px 14px; 29 | position: relative; 30 | } 31 | 32 | .searchInput { 33 | width: 100%; 34 | background-color: #363636; 35 | border: 1px solid #3e3e3e; 36 | border-radius: 4px; 37 | color: #fff; 38 | padding: 7px 10px 7px 32px; 39 | font-size: 13px; 40 | } 41 | 42 | .searchInput:focus { 43 | outline: none; 44 | border-color: #535353; 45 | } 46 | 47 | .searchIcon { 48 | position: absolute; 49 | left: 24px; 50 | top: 50%; 51 | transform: translateY(-50%); 52 | width: 14px; 53 | height: 14px; 54 | fill: #b3b3b3; 55 | } 56 | 57 | .loading { 58 | padding: 16px; 59 | text-align: center; 60 | color: #b3b3b3; 61 | } 62 | 63 | .playlistList { 64 | list-style: none; 65 | margin: 0; 66 | padding: 0 8px; 67 | overflow-y: auto; 68 | flex-grow: 1; 69 | min-height: 0; 70 | position: relative; 71 | box-shadow: 72 | inset 0 5px 5px -5px rgba(0, 0, 0, 0.4), 73 | inset 0 -12px 8px -8px rgba(0, 0, 0, 0.4); 74 | } 75 | 76 | .playlistList li { 77 | display: flex; 78 | align-items: center; 79 | justify-content: space-between; 80 | padding: 5px; 81 | cursor: pointer; 82 | transition: background-color 0.2s; 83 | border-radius: 4px; 84 | } 85 | 86 | .playlistList li:hover { 87 | background-color: #363636; 88 | } 89 | 90 | .playlistInfo, 91 | .playlistInfo * { 92 | cursor: pointer; 93 | } 94 | 95 | .playlistInfo { 96 | display: flex; 97 | align-items: center; 98 | gap: 12px; 99 | } 100 | 101 | .thumbnail { 102 | width: 30px; 103 | height: 30px; 104 | object-fit: cover; 105 | border-radius: 4px; 106 | } 107 | 108 | .playlistDetails { 109 | display: flex; 110 | flex-direction: column; 111 | } 112 | 113 | .playlistName { 114 | font-size: 14px; 115 | font-weight: 500; 116 | } 117 | 118 | .songCount { 119 | font-size: 12px; 120 | color: #b3b3b3; 121 | } 122 | 123 | .checkIcon { 124 | fill: #1ed760; 125 | width: 20px; 126 | height: 20px; 127 | cursor: pointer; 128 | } 129 | 130 | .checkbox { 131 | width: 18px; 132 | height: 18px; 133 | border: 2px solid #b3b3b3; 134 | border-radius: 50%; 135 | transition: all 0.2s; 136 | cursor: pointer; 137 | } 138 | 139 | .playlistList li:hover .checkbox { 140 | border-color: #fff; 141 | } 142 | 143 | .footer { 144 | padding: 8px; 145 | display: flex; 146 | justify-content: flex-end; 147 | gap: 16px; 148 | border-top: 1px solid #282828; 149 | } 150 | 151 | .cancelButton, 152 | .doneButton { 153 | border: none; 154 | border-radius: 500px; 155 | padding: 10px 28px; 156 | font-weight: 700; 157 | font-size: 13px; 158 | cursor: pointer; 159 | transition: all 0.2s; 160 | } 161 | 162 | .cancelButton { 163 | background-color: transparent; 164 | color: #b3b3b3; 165 | } 166 | 167 | .cancelButton:hover { 168 | color: #fff; 169 | } 170 | 171 | .doneButton { 172 | background-color: #fff; 173 | color: #000; 174 | } 175 | 176 | .doneButton:hover { 177 | transform: scale(1); 178 | } 179 | 180 | .playlistList::-webkit-scrollbar { 181 | width: 8px; 182 | } 183 | 184 | .playlistList::-webkit-scrollbar-track { 185 | background: transparent; 186 | } 187 | 188 | .playlistList::-webkit-scrollbar-thumb { 189 | background-color: #535353; 190 | border-radius: 4px; 191 | } 192 | 193 | .playlistList::-webkit-scrollbar-thumb:hover { 194 | background-color: #6b6b6b; 195 | } 196 | -------------------------------------------------------------------------------- /app/utils/fileSorter.ts: -------------------------------------------------------------------------------- 1 | import Fuse from 'fuse.js' 2 | import { SoulseekFile } from '../types' 3 | 4 | // Calculate quality score for each file (0-100) 5 | const getQualityScore = (file: SoulseekFile): number => { 6 | let score = 0 7 | 8 | // Bitrate contribution (up to 50 points) 9 | if (file.bitrate) { 10 | // FLAC/lossless typically shows as very high bitrate or specific quality string 11 | if (file.quality?.toLowerCase().includes('lossless')) { 12 | score += 50 13 | } else if (file.bitrate >= 320) { 14 | score += 45 15 | } else if (file.bitrate >= 256) { 16 | score += 35 17 | } else if (file.bitrate >= 192) { 18 | score += 25 19 | } else if (file.bitrate >= 128) { 20 | score += 15 21 | } else { 22 | score += 5 23 | } 24 | } 25 | 26 | // File extension contribution (up to 30 points) 27 | const ext = file.extension?.toLowerCase() || file.path.split('.').pop()?.toLowerCase() 28 | if (ext === 'flac' || ext === 'wav') { 29 | score += 30 30 | } else if (ext === 'mp3' && file.bitrate && file.bitrate >= 320) { 31 | score += 25 32 | } else if (ext === 'mp3' && file.bitrate && file.bitrate >= 256) { 33 | score += 20 34 | } else if (ext === 'mp3') { 35 | score += 15 36 | } else if (ext === 'm4a' || ext === 'aac') { 37 | score += 20 38 | } else if (ext === 'ogg' || ext === 'opus') { 39 | score += 18 40 | } 41 | 42 | // File size contribution (up to 20 points) - larger usually means better quality 43 | if (file.size > 50000000) { 44 | // > 50MB 45 | score += 20 46 | } else if (file.size > 20000000) { 47 | // > 20MB 48 | score += 15 49 | } else if (file.size > 10000000) { 50 | // > 10MB 51 | score += 10 52 | } else if (file.size > 5000000) { 53 | // > 5MB 54 | score += 5 55 | } 56 | 57 | return score 58 | } 59 | 60 | export const sortSoulseekFiles = (soulseekFiles: SoulseekFile[], searchQuery: string): SoulseekFile[] => { 61 | if (!soulseekFiles || soulseekFiles.length === 0 || !searchQuery) { 62 | return soulseekFiles 63 | } 64 | 65 | // Prepare files with quality scores 66 | const filesWithScores = soulseekFiles.map((file) => ({ 67 | ...file, 68 | qualityScore: getQualityScore(file), 69 | })) 70 | 71 | // Configure Fuse.js for searching file paths 72 | const fuseOptions = { 73 | keys: ['path'], 74 | threshold: 0.4, // Adjust for more/less strict matching 75 | location: 0, 76 | distance: 100, 77 | includeScore: true, 78 | shouldSort: true, 79 | minMatchCharLength: 2, 80 | ignoreLocation: false, 81 | useExtendedSearch: false, 82 | } 83 | 84 | // Create Fuse instance 85 | const fuse = new Fuse(filesWithScores, fuseOptions) 86 | 87 | // Search for files matching the query 88 | const searchResults = fuse.search(searchQuery) 89 | 90 | // Files that matched the search 91 | const matchedFiles = searchResults.map((result) => ({ 92 | ...result.item, 93 | relevanceScore: 1 - (result.score || 0), // Convert Fuse score (0=perfect) to relevance (1=perfect) 94 | })) 95 | 96 | // Files that didn't match the search 97 | const unmatchedFiles = filesWithScores 98 | .filter((file) => !matchedFiles.find((matched) => matched.id === file.id)) 99 | .map((file) => ({ 100 | ...file, 101 | relevanceScore: 0, 102 | })) 103 | 104 | // Combine all files 105 | const allFiles = [...matchedFiles, ...unmatchedFiles] 106 | 107 | // Sort by combined score (relevance + quality) 108 | // Give 60% weight to relevance and 40% to quality 109 | const sorted = allFiles.sort((a, b) => { 110 | const scoreA = a.relevanceScore * 60 + a.qualityScore * 0.4 111 | const scoreB = b.relevanceScore * 60 + b.qualityScore * 0.4 112 | return scoreB - scoreA // Higher scores first 113 | }) 114 | 115 | // Remove the temporary scoring properties and limit to top 100 116 | return sorted 117 | .slice(0, 100) // Take only the top 100 files 118 | .map(({ ...file }) => file as SoulseekFile) 119 | } 120 | -------------------------------------------------------------------------------- /app/components/footer/Player/TimeSlider/TimeSlider.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef, useCallback } from 'react' 2 | import { formatDuration } from '../../../../utils/format' 3 | import styles from './timeSlider.module.css' 4 | 5 | interface PropsTimeSlider { 6 | playBackTime: number 7 | initialSongDuration: number 8 | changePlayBackTime: (playBackTime: number) => void 9 | isPlaying: boolean 10 | } 11 | 12 | export default function TimeSlider({ playBackTime, initialSongDuration, changePlayBackTime }: PropsTimeSlider) { 13 | const [isHovered, setIsHovered] = useState(false) 14 | const [isDragging, setIsDragging] = useState(false) 15 | const progressBarRef = useRef(null) 16 | const thumbRef = useRef(null) 17 | const wrapperRef = useRef(null) 18 | 19 | const songDuration = initialSongDuration || 0 20 | const songDurationMinutesSeconds = formatDuration(songDuration) 21 | const songPlayBackTimeMinutesSeconds = formatDuration(playBackTime) 22 | 23 | useEffect(() => { 24 | if (!isDragging && songDuration > 0) { 25 | const progress = Math.max(0, Math.min(1, playBackTime / songDuration)) 26 | const progressPercent = progress.toString() 27 | if (progressBarRef.current) { 28 | progressBarRef.current.style.setProperty('--progress-percent', progressPercent) 29 | } 30 | if (thumbRef.current) { 31 | thumbRef.current.style.setProperty('--progress-percent', progressPercent) 32 | } 33 | } 34 | }, [playBackTime, songDuration, isDragging]) 35 | 36 | const handleSeek = useCallback( 37 | (e: MouseEvent | React.MouseEvent): number | undefined => { 38 | if (!wrapperRef.current || songDuration === 0) return 39 | 40 | const rect = wrapperRef.current.getBoundingClientRect() 41 | const clickPositionX = e.clientX - rect.left 42 | const width = rect.width 43 | const seekFraction = Math.max(0, Math.min(1, clickPositionX / width)) 44 | const newTime = seekFraction * songDuration 45 | 46 | const progressPercent = seekFraction.toString() 47 | if (progressBarRef.current) { 48 | progressBarRef.current.style.setProperty('--progress-percent', progressPercent) 49 | } 50 | if (thumbRef.current) { 51 | thumbRef.current.style.setProperty('--progress-percent', progressPercent) 52 | } 53 | 54 | return newTime 55 | }, 56 | [songDuration] 57 | ) 58 | 59 | const handleMouseDown = useCallback(() => { 60 | setIsDragging(true) 61 | 62 | const handleMouseMove = (event: MouseEvent) => { 63 | handleSeek(event) 64 | } 65 | 66 | const handleMouseUp = (event: MouseEvent) => { 67 | const finalTime = handleSeek(event) 68 | if (finalTime !== undefined) { 69 | changePlayBackTime(finalTime) 70 | } 71 | setIsDragging(false) 72 | window.removeEventListener('mousemove', handleMouseMove) 73 | window.removeEventListener('mouseup', handleMouseUp) 74 | } 75 | 76 | window.addEventListener('mousemove', handleMouseMove) 77 | window.addEventListener('mouseup', handleMouseUp) 78 | }, [handleSeek, changePlayBackTime]) 79 | 80 | return ( 81 |
82 |

{songPlayBackTimeMinutesSeconds}

83 | 84 |
setIsHovered(true)} 89 | onMouseLeave={() => setIsHovered(false)} 90 | > 91 |
92 |
97 |
98 |
103 |
104 | 105 |

{songDurationMinutesSeconds}

106 |
107 | ) 108 | } 109 | -------------------------------------------------------------------------------- /app/pages/Lyrics/LyricsPage.module.css: -------------------------------------------------------------------------------- 1 | .lyricsPage { 2 | width: 100%; 3 | height: 100vh; 4 | background: var(--secondary-black); 5 | overflow: hidden; 6 | } 7 | 8 | .lyricsContainer { 9 | width: 100%; 10 | height: 100%; 11 | position: relative; 12 | } 13 | 14 | .loadingContainer { 15 | display: flex; 16 | flex-direction: column; 17 | align-items: center; 18 | justify-content: center; 19 | height: 100vh; 20 | gap: 24px; 21 | } 22 | 23 | .loadingSpinner { 24 | width: 60px; 25 | height: 60px; 26 | border: 4px solid rgba(255, 255, 255, 0.1); 27 | border-top-color: #1db954; 28 | border-radius: 50%; 29 | animation: spin 1s linear infinite; 30 | } 31 | 32 | @keyframes spin { 33 | to { 34 | transform: rotate(360deg); 35 | } 36 | } 37 | 38 | .loadingContainer p { 39 | font-size: 1.5rem; 40 | font-weight: 500; 41 | margin: 0; 42 | } 43 | 44 | .noSongContainer { 45 | display: flex; 46 | flex-direction: column; 47 | align-items: center; 48 | justify-content: center; 49 | height: 100vh; 50 | gap: 24px; 51 | text-align: center; 52 | padding: 40px; 53 | } 54 | 55 | .noSongIcon { 56 | width: 120px; 57 | height: 120px; 58 | opacity: 0.5; 59 | } 60 | 61 | .noSongContainer h2 { 62 | font-size: 2.5rem; 63 | font-weight: 700; 64 | margin: 0; 65 | } 66 | 67 | .noSongContainer p { 68 | font-size: 1.3rem; 69 | font-weight: 400; 70 | margin: 0; 71 | opacity: 0.8; 72 | } 73 | 74 | .errorContainer { 75 | display: flex; 76 | flex-direction: column; 77 | align-items: center; 78 | justify-content: center; 79 | height: 400px; 80 | gap: 20px; 81 | text-align: center; 82 | padding: 40px; 83 | } 84 | 85 | .errorIcon { 86 | width: 80px; 87 | height: 80px; 88 | opacity: 0.6; 89 | } 90 | 91 | .errorContainer h3 { 92 | font-size: 2rem; 93 | font-weight: 600; 94 | margin: 0; 95 | } 96 | 97 | .errorContainer p { 98 | font-size: 1.2rem; 99 | font-weight: 400; 100 | margin: 0; 101 | opacity: 0.8; 102 | max-width: 500px; 103 | line-height: 1.4; 104 | } 105 | 106 | .retryButton { 107 | padding: 12px 24px; 108 | border: none; 109 | border-radius: 8px; 110 | font-size: 1rem; 111 | font-weight: 600; 112 | cursor: pointer; 113 | transition: all 0.3s ease; 114 | } 115 | 116 | .retryButton:hover { 117 | transform: translateY(-2px); 118 | opacity: 0.9; 119 | } 120 | 121 | .retryButton:active { 122 | transform: translateY(0); 123 | } 124 | 125 | @media (max-width: 1024px) { 126 | } 127 | 128 | @media (max-width: 768px) { 129 | .noSongContainer { 130 | padding: 24px; 131 | } 132 | 133 | .noSongIcon { 134 | width: 100px; 135 | height: 100px; 136 | } 137 | 138 | .noSongContainer h2 { 139 | font-size: 2rem; 140 | } 141 | 142 | .noSongContainer p { 143 | font-size: 1.2rem; 144 | } 145 | 146 | .errorContainer { 147 | height: 350px; 148 | gap: 18px; 149 | padding: 32px; 150 | } 151 | 152 | .errorIcon { 153 | width: 70px; 154 | height: 70px; 155 | } 156 | 157 | .errorContainer h3 { 158 | font-size: 1.8rem; 159 | } 160 | 161 | .errorContainer p { 162 | font-size: 1.1rem; 163 | } 164 | } 165 | 166 | @media (max-width: 480px) { 167 | .loadingContainer { 168 | padding: 20px; 169 | } 170 | 171 | .loadingSpinner { 172 | width: 50px; 173 | height: 50px; 174 | border-width: 3px; 175 | } 176 | 177 | .loadingContainer p { 178 | font-size: 1.3rem; 179 | } 180 | 181 | .noSongContainer { 182 | padding: 20px; 183 | } 184 | 185 | .noSongIcon { 186 | width: 80px; 187 | height: 80px; 188 | } 189 | 190 | .noSongContainer h2 { 191 | font-size: 1.8rem; 192 | } 193 | 194 | .noSongContainer p { 195 | font-size: 1.1rem; 196 | } 197 | 198 | .errorContainer { 199 | height: 300px; 200 | gap: 16px; 201 | padding: 24px; 202 | } 203 | 204 | .errorIcon { 205 | width: 60px; 206 | height: 60px; 207 | } 208 | 209 | .errorContainer h3 { 210 | font-size: 1.6rem; 211 | } 212 | 213 | .errorContainer p { 214 | font-size: 1rem; 215 | } 216 | 217 | .retryButton { 218 | padding: 10px 20px; 219 | font-size: 0.9rem; 220 | } 221 | } 222 | 223 | @media (prefers-reduced-motion: reduce) { 224 | .loadingSpinner { 225 | animation: none; 226 | } 227 | } 228 | 229 | @media (prefers-contrast: high) { 230 | } 231 | -------------------------------------------------------------------------------- /app/components/LyricsDisplay/LyricsDisplay.module.css: -------------------------------------------------------------------------------- 1 | .lyricsContainer { 2 | position: relative; 3 | width: 100%; 4 | height: 100%; 5 | overflow-y: auto; 6 | overflow-x: hidden; 7 | scrollbar-width: none; 8 | -ms-overflow-style: none; 9 | display: flex; 10 | flex-direction: column; 11 | padding: 0; 12 | } 13 | 14 | .lyricsContainer::-webkit-scrollbar { 15 | display: none; 16 | } 17 | 18 | .lyricsContent { 19 | flex: 1; 20 | padding: 60px 80px 150px; 21 | text-align: left; 22 | position: relative; 23 | z-index: 1; 24 | max-width: 1400px; 25 | margin: 0 auto; 26 | } 27 | 28 | .syncedLyrics { 29 | display: flex; 30 | flex-direction: column; 31 | gap: 20px; 32 | max-width: 100%; 33 | margin: 0; 34 | } 35 | 36 | .lyricLine { 37 | font-size: 4rem; 38 | font-weight: 900; 39 | line-height: 1.3; 40 | transition: all 0.3s ease; 41 | opacity: 0.4; 42 | cursor: pointer; 43 | padding: 12px 0; 44 | word-wrap: break-word; 45 | hyphens: auto; 46 | } 47 | 48 | .lyricLine.active { 49 | opacity: 1; 50 | font-weight: 950; 51 | } 52 | 53 | .lyricLine.past { 54 | opacity: 0.3; 55 | } 56 | 57 | .lyricLine.future { 58 | opacity: 0.6; 59 | } 60 | 61 | .lyricLine:hover { 62 | opacity: 1; 63 | color: #ffffff !important; 64 | text-decoration: underline; 65 | text-decoration-thickness: 2px; 66 | text-underline-offset: 8px; 67 | } 68 | 69 | .lyricLine.active:hover { 70 | opacity: 1; 71 | color: #ffffff !important; 72 | text-decoration: underline; 73 | text-decoration-thickness: 2px; 74 | text-underline-offset: 8px; 75 | } 76 | 77 | .plainLyrics { 78 | display: flex; 79 | flex-direction: column; 80 | gap: 20px; 81 | max-width: 100%; 82 | margin: 0; 83 | } 84 | 85 | .plainLyricLine { 86 | font-size: 4rem; 87 | font-weight: 900; 88 | line-height: 1.3; 89 | opacity: 0.8; 90 | word-wrap: break-word; 91 | hyphens: auto; 92 | padding: 12px 0; 93 | cursor: pointer; 94 | transition: all 0.3s ease; 95 | } 96 | 97 | .plainLyricLine:hover { 98 | opacity: 1; 99 | color: #ffffff !important; 100 | text-decoration: underline; 101 | text-decoration-thickness: 2px; 102 | text-underline-offset: 8px; 103 | } 104 | 105 | .loadingState { 106 | display: flex; 107 | flex-direction: column; 108 | align-items: center; 109 | justify-content: center; 110 | height: 300px; 111 | gap: 20px; 112 | } 113 | 114 | .loadingSpinner { 115 | width: 40px; 116 | height: 40px; 117 | border: 3px solid rgba(255, 255, 255, 0.1); 118 | border-radius: 50%; 119 | border-top-color: #1db954; 120 | animation: spin 1s linear infinite; 121 | } 122 | 123 | .spinnerInner { 124 | width: 100%; 125 | height: 100%; 126 | border-radius: 50%; 127 | } 128 | 129 | @keyframes spin { 130 | to { 131 | transform: rotate(360deg); 132 | } 133 | } 134 | 135 | .noLyricsState { 136 | display: flex; 137 | flex-direction: column; 138 | align-items: center; 139 | justify-content: center; 140 | height: 300px; 141 | gap: 16px; 142 | opacity: 0.7; 143 | } 144 | 145 | .noLyricsIcon { 146 | width: 64px; 147 | height: 64px; 148 | opacity: 0.5; 149 | } 150 | 151 | .noLyricsState p { 152 | font-size: 1.2rem; 153 | margin: 0; 154 | text-align: center; 155 | } 156 | 157 | @media (max-width: 1200px) { 158 | .lyricsContent { 159 | padding: 60px 60px 100px; 160 | } 161 | 162 | .lyricLine { 163 | font-size: 3.6rem; 164 | } 165 | 166 | .plainLyricLine { 167 | font-size: 3.6rem; 168 | } 169 | } 170 | 171 | @media (max-width: 768px) { 172 | .lyricsContent { 173 | padding: 40px 40px 80px; 174 | } 175 | 176 | .syncedLyrics { 177 | gap: 18px; 178 | } 179 | 180 | .plainLyrics { 181 | gap: 18px; 182 | } 183 | 184 | .lyricLine { 185 | font-size: 3.2rem; 186 | padding: 10px 0; 187 | } 188 | 189 | .plainLyricLine { 190 | font-size: 3.2rem; 191 | padding: 10px 0; 192 | } 193 | } 194 | 195 | @media (max-width: 480px) { 196 | .lyricsContent { 197 | padding: 30px 30px 60px; 198 | } 199 | 200 | .syncedLyrics { 201 | gap: 16px; 202 | } 203 | 204 | .plainLyrics { 205 | gap: 16px; 206 | } 207 | 208 | .lyricLine { 209 | font-size: 2.6rem; 210 | padding: 8px 0; 211 | } 212 | 213 | .plainLyricLine { 214 | font-size: 2.6rem; 215 | padding: 8px 0; 216 | } 217 | } 218 | 219 | @media (prefers-reduced-motion: reduce) { 220 | .lyricLine { 221 | transition: none; 222 | } 223 | 224 | .loadingSpinner { 225 | animation: none; 226 | } 227 | } 228 | 229 | @media (prefers-contrast: high) { 230 | .lyricLine.active { 231 | font-weight: 800; 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /app/providers/SongDetailSidebarProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useState, useContext, ReactNode } from 'react' 2 | import { MusicBrainzRecording, SoulseekFile, DownloadStatus } from '../types' 3 | import { apiClient } from '../api' 4 | import { downloadEvents } from '../utils/downloadEvents' 5 | 6 | interface SidebarContextType { 7 | selectedSong: MusicBrainzRecording | null 8 | setSelectedSong: (song: MusicBrainzRecording | null) => void 9 | soulseekFiles: SoulseekFile[] 10 | setSoulseekFiles: (files: SoulseekFile[]) => void 11 | downloadStatus: Record 12 | setDownloadStatus: (status: Record) => void 13 | sidebarLoading: boolean 14 | setSidebarLoading: (loading: boolean) => void 15 | searchQuery: string 16 | setSearchQuery: (query: string) => void 17 | searchAbortController: AbortController | null 18 | setSearchAbortController: (controller: AbortController | null) => void 19 | error: string | null 20 | setError: (error: string | null) => void 21 | handleDownloadFile: (file: SoulseekFile) => Promise 22 | } 23 | 24 | const SongDetailSidebarContext = createContext(undefined) 25 | 26 | export const SongDetailSidebarProvider = ({ children }: { children: ReactNode }) => { 27 | const [selectedSong, setSelectedSong] = useState(null) 28 | const [soulseekFiles, setSoulseekFiles] = useState([]) 29 | const [downloadStatus, setDownloadStatus] = useState>({}) 30 | const [sidebarLoading, setSidebarLoading] = useState(false) 31 | const [searchQuery, setSearchQuery] = useState('') 32 | const [searchAbortController, setSearchAbortController] = useState(null) 33 | const [error, setError] = useState(null) 34 | 35 | const handleDownloadFile = async (file: SoulseekFile) => { 36 | try { 37 | // Prepare metadata from the file 38 | const metadata: any = {} 39 | 40 | // Add quality/bitrate info from Soulseek file 41 | metadata.quality = file.quality 42 | metadata.bitrate = file.bitrate 43 | metadata.length = file.length 44 | 45 | // Add song metadata if available from selected song 46 | if (selectedSong) { 47 | metadata.title = selectedSong.title 48 | metadata.artist = selectedSong.artist 49 | metadata.album = selectedSong.album 50 | metadata.coverArt = selectedSong.coverArt 51 | } 52 | 53 | // Create a unique ID for this download 54 | const downloadId = `${file.username}_${file.path}_${Date.now()}` 55 | 56 | // Emit download started event immediately (optimistic update) 57 | downloadEvents.emitDownloadStarted({ 58 | id: downloadId, 59 | fileName: file.path.split('\\').pop() || file.path.split('/').pop() || 'Unknown', 60 | path: file.path, 61 | size: file.size || 0, 62 | metadata: metadata, 63 | }) 64 | 65 | // Update download status to show it's queued 66 | setDownloadStatus((prev) => ({ 67 | ...prev, 68 | [file.id]: { 69 | status: 'Queued', 70 | progress: 0, 71 | total: file.size || 0, 72 | percent: 0, 73 | }, 74 | })) 75 | 76 | // Send request to backend 77 | await apiClient.startDownload(file.username, file.path, file.size || 0, metadata, downloadId) 78 | } catch (err) { 79 | console.error('Download failed:', err) 80 | 81 | // Emit download failed event 82 | downloadEvents.emitDownloadFailed(file.id, 'Failed to start download') 83 | 84 | // Update status to show error 85 | setDownloadStatus((prev) => ({ 86 | ...prev, 87 | [file.id]: { 88 | status: 'Error', 89 | progress: 0, 90 | total: file.size || 0, 91 | percent: 0, 92 | errorMessage: 'Failed to start download', 93 | }, 94 | })) 95 | } 96 | } 97 | 98 | const value = { 99 | selectedSong, 100 | setSelectedSong, 101 | soulseekFiles, 102 | setSoulseekFiles, 103 | downloadStatus, 104 | setDownloadStatus, 105 | sidebarLoading, 106 | setSidebarLoading, 107 | searchQuery, 108 | setSearchQuery, 109 | searchAbortController, 110 | setSearchAbortController, 111 | error, 112 | setError, 113 | handleDownloadFile, 114 | } 115 | 116 | return {children} 117 | } 118 | 119 | export const useSongDetailSidebar = () => { 120 | const context = useContext(SongDetailSidebarContext) 121 | if (!context) { 122 | throw new Error('useSongDetailSidebar must be used within a SongDetailSidebarProvider') 123 | } 124 | return context 125 | } 126 | -------------------------------------------------------------------------------- /backend/src/core/library_service.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import glob 4 | import sys 5 | import threading 6 | from typing import List, Dict, Any 7 | from tinydb import TinyDB, Query 8 | from .metadata_service import MetadataService 9 | from pynicotine.config import config 10 | from models.playlist_models import Playlist 11 | from datetime import datetime 12 | import logging 13 | 14 | class LibraryService: 15 | def __init__(self, metadata_service: MetadataService, data_path: str): 16 | self.metadata_service = metadata_service 17 | self.data_path = data_path 18 | db_path = os.path.join(data_path, 'library.db') 19 | logging.info(f"Initializing database at: {db_path}") 20 | self.db_lock = threading.Lock() 21 | 22 | # Ensure the db file is not empty 23 | if not os.path.exists(db_path) or os.path.getsize(db_path) == 0: 24 | with open(db_path, 'w') as f: 25 | f.write('{}') 26 | 27 | self.db = TinyDB(db_path) 28 | self.songs_table = self.db.table('songs') 29 | self.lyrics_table = self.db.table('lyrics') 30 | self.playlists_table = self.db.table('playlists') 31 | self.download_metadata = {} 32 | 33 | def get_all_songs(self) -> List[Dict]: 34 | with self.db_lock: 35 | return self.songs_table.all() 36 | 37 | def add_or_update_song(self, song_data: Dict): 38 | with self.db_lock: 39 | self.songs_table.upsert(song_data, Query().path == song_data['path']) 40 | 41 | def remove_song(self, file_path: str): 42 | with self.db_lock: 43 | self.songs_table.remove(Query().path == file_path) 44 | 45 | def get_lyrics(self, file_path: str): 46 | with self.db_lock: 47 | return self.lyrics_table.get(Query().file_path == file_path) 48 | 49 | def upsert_lyrics(self, lyrics_data: Dict, file_path: str): 50 | with self.db_lock: 51 | self.lyrics_table.upsert(lyrics_data, Query().file_path == file_path) 52 | 53 | def add_download_metadata(self, filename: str, metadata: Dict[str, Any]): 54 | """ 55 | Stores metadata for a file that is being downloaded. 56 | This data is persisted and used later by the SongProcessor. 57 | """ 58 | # The key for the metadata dictionary is the simple filename. 59 | self.download_metadata[filename] = metadata 60 | logging.info(f"Stored download-time metadata for '{filename}'") 61 | 62 | def create_playlist(self, playlist: Playlist) -> Playlist: 63 | with self.db_lock: 64 | self.playlists_table.insert(playlist.dict()) 65 | return playlist 66 | 67 | def get_all_playlists(self) -> List[Playlist]: 68 | with self.db_lock: 69 | return [Playlist(**p) for p in self.playlists_table.all()] 70 | 71 | def get_playlist(self, playlist_id: str) -> Playlist: 72 | with self.db_lock: 73 | data = self.playlists_table.get(Query().id == playlist_id) 74 | return Playlist(**data) if data else None 75 | 76 | def update_playlist(self, playlist_id: str, updates: Dict[str, Any]) -> Playlist: 77 | with self.db_lock: 78 | updates['updatedAt'] = datetime.utcnow().isoformat() 79 | self.playlists_table.update(updates, Query().id == playlist_id) 80 | data = self.playlists_table.get(Query().id == playlist_id) 81 | return Playlist(**data) if data else None 82 | 83 | def delete_playlist(self, playlist_id: str): 84 | with self.db_lock: 85 | self.playlists_table.remove(Query().id == playlist_id) 86 | 87 | def add_song_to_playlist(self, playlist_id: str, song_path: str) -> Playlist: 88 | with self.db_lock: 89 | playlist_data = self.playlists_table.get(Query().id == playlist_id) 90 | if playlist_data: 91 | playlist = Playlist(**playlist_data) 92 | if song_path not in playlist.songs: 93 | playlist.songs.append(song_path) 94 | playlist.updatedAt = datetime.utcnow().isoformat() 95 | self.playlists_table.update(playlist.dict(), Query().id == playlist_id) 96 | 97 | return playlist 98 | return None 99 | 100 | def remove_song_from_playlist(self, playlist_id: str, song_path: str) -> Playlist: 101 | with self.db_lock: 102 | playlist_data = self.playlists_table.get(Query().id == playlist_id) 103 | if playlist_data: 104 | playlist = Playlist(**playlist_data) 105 | if song_path in playlist.songs: 106 | playlist.songs.remove(song_path) 107 | playlist.updatedAt = datetime.utcnow().isoformat() 108 | self.playlists_table.update(playlist.dict(), Query().id == playlist_id) 109 | 110 | return playlist 111 | return None 112 | -------------------------------------------------------------------------------- /app/components/footer/Player/Player.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import { useTranslation } from 'react-i18next' 3 | import { usePlaybackManager } from '../../../hooks/usePlaybackManager' 4 | import styles from './player.module.css' 5 | import TimeSlider from './TimeSlider/TimeSlider' 6 | import { PropsPlayer } from './types/Player.types' 7 | 8 | export default function Player({ volume, changeSongInfo }: PropsPlayer) { 9 | const { t } = useTranslation() 10 | const { playbackManager, playbackState } = usePlaybackManager() 11 | const { isPlaying, currentTime, duration, currentSong, isShuffle, isLoop } = playbackState 12 | 13 | useEffect(() => { 14 | playbackManager.setVolume(volume) 15 | }, [volume, playbackManager]) 16 | 17 | useEffect(() => { 18 | if (currentSong) { 19 | changeSongInfo(currentSong) 20 | } 21 | }, [currentSong, changeSongInfo]) 22 | 23 | const handlePlayPause = () => { 24 | if (isPlaying) { 25 | playbackManager.pause() 26 | } else { 27 | playbackManager.resume() 28 | } 29 | } 30 | 31 | const handleSeek = (time: number) => { 32 | playbackManager.seek(time) 33 | } 34 | 35 | const handleShuffle = () => { 36 | playbackManager.toggleShuffle() 37 | } 38 | 39 | const handleLoop = () => { 40 | playbackManager.toggleLoop() 41 | } 42 | 43 | return ( 44 |
45 |
46 | 60 | 70 | 87 | 97 | 110 |
111 | 112 | 118 |
119 | ) 120 | } 121 | --------------------------------------------------------------------------------