Repository: elibroftw/music-caster Branch: master Commit: 203400531a45 Files: 98 Total size: 875.3 KB Directory structure: gitextract_zrp3by6s/ ├── .dockerignore ├── .github/ │ └── workflows/ │ ├── build.yml │ └── winget.yml ├── .gitignore ├── CHANGELOG.txt ├── Dockerfile ├── LICENSE ├── README.md ├── build.cmd ├── build.py ├── build_files/ │ ├── TkinterDnD2/ │ │ ├── TkinterDnD.py │ │ └── __init__.py │ ├── Updater.cs.txt │ ├── Updater.exe.MANIFEST │ ├── daemon.spec │ ├── flatpak-pip-generator.py │ ├── mc_version_info.txt │ ├── mcu_version_info.txt │ ├── onedir.spec │ ├── portable.spec │ ├── pyaudio-0.2.14-cp314-cp314-win_amd64.whl │ ├── pyinstaller-6.16.0-py3-none-any.whl │ ├── setup_script.iss │ ├── tkdnd2.9.2/ │ │ ├── pkgIndex.tcl │ │ ├── tkdnd.tcl │ │ ├── tkdnd2.9.2.lib │ │ ├── tkdnd_compat.tcl │ │ ├── tkdnd_generic.tcl │ │ ├── tkdnd_macosx.tcl │ │ ├── tkdnd_unix.tcl │ │ ├── tkdnd_utils.tcl │ │ └── tkdnd_windows.tcl │ └── updater.spec ├── conftest.py ├── linux_install.py ├── linux_install.sh ├── music_caster.desktop ├── pyproject.toml ├── requirements-dev.txt ├── requirements.txt ├── resources/ │ ├── Music Caster Icon.psd │ ├── assets.psd │ ├── favicons/ │ │ ├── browserconfig.xml │ │ └── site.webmanifest │ ├── gude-2023-11-11.log │ └── icons/ │ └── icon.icns ├── scripts/ │ ├── arch-install.sh │ ├── debian-install.sh │ ├── fedora-install.sh │ ├── pre-req.sh │ └── suse-install.sh └── src/ ├── audio_player.py ├── b64_images.py ├── ca.elijahlopez.MusicCaster.yml ├── experiments.py ├── go.mod ├── go.sum ├── gui/ │ ├── __init__.py │ ├── components.py │ └── views.py ├── knownpaths.py ├── languages/ │ ├── da.txt │ ├── de.txt │ ├── en.txt │ ├── es.txt │ ├── fr.txt │ ├── it.txt │ ├── nl.txt │ ├── pt-br.txt │ ├── ru.txt │ ├── sk.txt │ └── uk.txt ├── meta.py ├── modules/ │ ├── db.py │ ├── error_reporting.py │ ├── iph1papi.py │ ├── playing_status.py │ ├── resolution_switcher.py │ ├── url_metadata.py │ └── win32_media_controls.py ├── music_caster.bat ├── music_caster.py ├── pyoxidizer.bzl ├── shared.py ├── static/ │ └── style.css ├── sys_tray.py ├── templates/ │ └── index.html ├── test_cases/ │ └── ipconfig.py ├── test_harness.py ├── theme/ │ ├── LICENSE │ ├── dark.tcl │ ├── light.tcl │ └── sun-valley.tcl ├── updater.go ├── utils.py ├── vlc_lib/ │ ├── libvlc.so.5.6.0 │ └── libvlccore.so.9.0.0 └── webview_demo.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ .ruff_cache/ .pytest_cache/ git/ github/ .idea/ .vscode/ __pycache__/ src/venv/ src/.venv/ build/ _build/ dist/ images/ test_files/ venv/ .venv/ src/.flatpak-builder .env settings.json settings.json.bak test.* demo.py error.log music_caster.log* resources/assets.png .vs/ *.rsuser *.suo *.user *.userosscache *.sln.docstates *.userprefs [Dd]ebug/ [Dd]ebugPublic/ [Rr]elease/ [Rr]eleases/ x64/ x86/ [Ww][Ii][Nn]32/ [Aa][Rr][Mm]/ [Aa][Rr][Mm]64/ bld/ [Bb]in/ [Oo]bj/ [Ll]og/ [Ll]ogs/ test.txt music_caster.log music_caster.log1 resources/assets.png settings.json.bak *.syso *.pid music_caster.lock music_caster.db src/phantomjs/ ================================================ FILE: .github/workflows/build.yml ================================================ name: App Builder on: push: branches: master workflow_dispatch: concurrency: cancel-in-progress: true # we want to group based on the push type not the ref group: ${{ github.ref_type }} env: ARTIFACT_BASE_NAME: 'music_caster_artifacts' # + '_${runsOn}' github: ${{ secrets.GITHUB_TOKEN }} jobs: build: strategy: matrix: platform: [windows-latest] runs-on: ${{matrix.platform}} steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: 3.14 cache: pip - uses: actions/setup-go@v5 - name: Install Inno Setup run: choco install innosetup --yes - name: Install system deps run: python build.py --deps - name: Create Python venv run: | python -m venv .venv - name: Install venv dependencies run: | .venv\Scripts\Activate.ps1 python build.py --deps - name: Build run: | .venv\Scripts\Activate.ps1 python build.py --ci --no-deps --no-tests --no-install - name: Test build id: test run: | .venv\Scripts\Activate.ps1 python build.py --ci --no-deps --no-build --no-install - name: Save Failed Build if: failure() && steps.test.outcome == 'failure' uses: actions/upload-artifact@v4 with: name: failed-build path: | dist/Music Caster Setup.exe # upload the build continuously to avoid duplicate build on a tag # script will exit with zero if release for VERSION exists since --ci is used - name: Upload build if: ${{ github.event_name == 'push' && github.ref_name == 'master' }} run: | .venv\Scripts\Activate.ps1 python build.py -u --ci --no-deps --no-build --no-tests --no-install # TODO: if/when MacOS is supported? # release: # needs: build # runs-on: ubuntu-latest ================================================ FILE: .github/workflows/winget.yml ================================================ name: Publish to WinGet on: release: types: [released] jobs: publish: runs-on: windows-latest # action can only be run on windows steps: - uses: vedantmgoyal2009/winget-releaser@v2 with: identifier: ElijahLopez.MusicCaster token: ${{ secrets.WINGET_TOKEN }} ================================================ FILE: .gitignore ================================================ .idea/ .vscode/ __pycache__/ src/venv/ src/.venv/ build/ _build/ dist/ images/ test_files/ venv/ .venv/ src/.flatpak-builder .env settings.json settings.json.bak test.* demo.py error.log music_caster.log* resources/assets.png .vs/ *.rsuser *.suo *.user *.userosscache *.sln.docstates *.userprefs [Dd]ebug/ [Dd]ebugPublic/ [Rr]elease/ [Rr]eleases/ x64/ x86/ [Ww][Ii][Nn]32/ [Aa][Rr][Mm]/ [Aa][Rr][Mm]64/ bld/ [Bb]in/ [Oo]bj/ [Ll]og/ [Ll]ogs/ test.txt music_caster.log music_caster.log1 resources/assets.png settings.json.bak *.syso *.pid music_caster.lock music_caster.db src/phantomjs/ src/music_caster.db-journal src/thumb.jpg app/node_modules/ app/src-tauri/target/ stats.html ================================================ FILE: CHANGELOG.txt ================================================ Music Caster Changelog 5.25.2 - [Fix] URL processing 5.25.1 - [Fix] Settings save/load ? - [Fix] Typing 5.25.0 - [Fix] Forwards compatibility for v6 - [Fix] Maintenance (Python 3.14) 5.24.0 - [Feat] Performance when indexing many URLs - [Fix] Maintenance (Python 3.14) 5.23.8 - [Fix] URL processing 5.23.7 - [Fix] Death loop 5.23.6 - [Fix] URL processing 5.23.5 - [Fix] URL processing 5.23.4 - [Fix] URL processing 5.23.3 - [Fix] URL processing 5.23.2 - [Fix] Reopen GUI on Restart 5.23.1 - [Fix] URL processing 5.23.0 - [UI] Added Slovak translation 5.22.22 - [Fix] Radio-like streams 5.22.21 - [Fix] Volume syncing - [Fix] Improved reconnecting upon abrupt disconnect 5.22.20 - [Fix] Album art for cast devices 5.22.19 - Upgrade dependencies 5.22.18 - [Fix] Album art optimization for cast devices 5.22.17 - [Fix] Translation for restarting 5.22.16 - [Fix] Album art optimization for cast devices 5.22.15 - [Fix] Album art optimization for cast devices 5.22.14 - [Fix] Track position syncing with cast 5.22.13 - [Misc] Add more logging 5.22.12 - [Fix] Do not stop if cast app id is None 5.22.11 - [Misc] Add more logging 5.22.10 - [Fix] Track numbers from MP4/M4A files 5.22.9 - [Fix] Save file corruption on system crash 5.22.8 - [Fix] Save file corruption on system crash - [Fix] URL processing 5.22.7 - [Fix] URL processing 5.22.6 - [Fix] URL processing 5.22.5 - [Fix] DE translation 5.22.4 - [Fix] Playing from shell (REST API) 5.22.3 - [Feat] Support "System Audio" in REST API 5.22.2 - [Feat] Support "System Audio" in CLI 5.22.1 - [Fix] URL processing 5.22.0 - [UI] Added Português (Brazil) translation - [Fix] URL processing 5.21.3 - [Fix] Auto update - [Fix] Button font legibility2 5.21.2 - [Fix] Notification app id - [Fix] Security update 5.21.1 - [Fix] Open web gui 5.21.0 - [UI] Rounded rectangle buttons - [UI] Improve spacing and alignments 5.20.2 - [Fix] Hide install update button 5.20.1 - [Fix] Getting local IP address for casting - [Fix] Update notifier 5.20.0 - [Fix] Getting local IP address for casting 5.19.13 - [Fix] Code cleanup - [Fix] Sort Library by artist by default (don't know why I took so many years to do this) 5.19.12 - [Fix] Local playback scrubbing 5.19.11 - [Experiment] Better connection? 5.19.10 - [Fix] Unexpected crash - chromecast related 5.19.9 - [Fix] Playing offline if URL in queue 5.19.8 - [Fix] Unexpected crash when chromecast stops working 5.19.7 - [Fix] Unexpected metadata while playing (dirty patch) 5.19.6 - [Fix] Experimental theme setting - [Fix] Saving metadata without album art 5.19.5 - [Fix] Handling unresponsive cast device 5.19.4 - [Fix] Crash when playing 5.19.3 - [Fix] Cast syncing 5.19.2 - [Fix] Update from GUI 5.19.1 - [Fix] Windows-specific issues 5.19.0 - [Feat] Update from GUI 5.18.9 - [Fix] Locally played track scrubbing 5.18.8 - [Fix] Improve track scrubbing performance 5.18.7 - [Fix] logging 5.18.6 - [Fix] GUI freezing from seeking - [Fix] GUI improve set volume performance 5.18.5 - [Fix] GUI freezing from seeking 5.18.4 - [Fix] Increase wait timeout 5.18.3 - [Fix] Handle experimental feature error 5.18.2 - [Fix] Changing cast device 5.18.1 - [Fix] Security update - [Fix] Play AIFF from explorer 5.18.0 - [Feat] Local support for AIFF? 5.17.7 - [GUI] Timer tab translation 5.17.6 - [Fix] Better exception handling when pausing Chromecast 5.17.5 - [Fix] Setting position of Chromecast from GUI again 5.17.4 - [Fix] Volume Chromecast 5.17.3 - [Fix] Upgrade pychromecast 5.17.2 - [Fix] Pausing Chromecast 5.17.1 - [Fix] Setting position of Chromecast from GUI 5.17.0 - [Feat] Migrated error capturing endpoint from pipedream to under mine (lenerva) 5.16.8 - [Fix] Chromecast status 5.16.7 - [Fix] Chromecast status 5.16.6 - [Fix] Chromecast status callback 5.16.5 - [Fix] Chromecast status callback 5.16.4 - [Fix] pychromecast v14 support 5.16.3 - [Fix] use GitHub downloads instead of instances 5.16.2 - [Fix] Metadata time modified error - [Fix] Clear queue 5.16.1 - [Fix] Remove old startup shortcuts + background thread 5.16.0 - [Feat] Linux support - [Fix] Remove old startup shortcuts 5.15.0 - [Fix] Security update 5.14.0 - [Dev] New build process; might break app 5.13.43 - [Fix] Stop chromecast after 5 minutes of pausing 5.13.42 - [Fix] Install size - [Fix] False positive not playing on chromecast 5.13.41 - [Fix] Use latest Pillow 5.13.40 - [Fix] Revert PyInstaller upgrade causing anti-virus detection 5.13.39 - [Fix] Exit program 5.13.38 - [Fix] Added error handling for Metadata editor artwork 5.13.37 - [Fix] Select metadata art from other audio files 5.13.36 - [Fix] Linux local audio player - [Fix] Default music folder 5.13.35 - [Fix] register handlers 5.13.34 - [Fix] Start on login 5.13.33 - [Fix] Playlist delete 5.13.32 - [Fix] Fetching local IPV4 5.13.31 - [UI] Danish supported - [Fix] Playlist tab 5.13.30 - [Feat] Playlist length - [UI] ListBox controls alignment 5.13.29 - [Fix] Experimental UI tooltip text now legible 5.13.28 - [UI] Add url with - [UI] UI informs when playlist was saved by user 5.13.27 - [Fix] URL formatting 5.13.26 - [Fix] web UI and API 5.13.25 - [Fix] web UI and API 5.13.24 - [Fix] get_ipv4 fallback bug 5.13.23 - [Fix] Next up bug - [Fix] Don't cache spotify playlist - [Fix] Metadata bug - [Fix] Locate track bug 5.13.22 - [UI] Tooltip for all settings 5.13.21 - [UI] Added French translation 5.13.20 - [UI] Added Russian translation - [Fix] Sort order when playing folder with missing album metadata - [Fix] Ukrainian translation 5.13.19 - [UI] Show artist as unknown in queue 5.13.18 - [UI] Added Ukrainian translation 5.13.17 - Remove old code 5.13.16 - Re-read metadata if file was modified 5.13.15 - Upgrade dependencies 5.13.14 - [Fix] set_pos crash when not connected 5.13.13 - [Fix] Shift + P for previous track 5.13.12 - [Fix] Ignore copyright errors - [Fix] create_shortcut_windows 5.13.11 - [Fix] Metadata select artwork allows JPG/JPEG images 5.13.10 - Upgrade dependencies 5.13.9 - Rollback dependencies 5.13.8 - Upgrade dependencies 5.13.7 - Upgrade dependencies 5.13.6 - [Fix] Opening GUI with timer 5.13.5 - [Fix] PhantomJS auto install 5.13.4 - [Fix] SSL links 5.13.3 - [Fix] Playing YouTube playlists 5.13.2 - [Fix] Local address fetcher 5.13.1 - [Fix] Local address fetcher 5.13.0 - [Feat] Works with VPN 5.12.12 - [Fix] ytdlp 5.12.11 - [Fix] ytdlp 5.12.10 - [Fix] ytdlp 5.12.9 - [Fix] Open PhantomJS download page 5.12.8 - [Fix] Weird metadata 5.12.7 - [Fix] Invalid URL crash 5.12.6 - [Fix] Resolution switcher - Upgrade dependencies 5.12.5 - [Fix] Ctrl + C for queue 5.12.4 - [Fix] Deezer, Spotify search 5.12.3 - [Fix] Exit app crash 5.12.2 - Upgrade dependencies 5.12.1 - [Fix] Queue action accuracy - [Note] New REST API 5.12.0 - [Feat] Edit metadata of a track in the queue - [Feat] Save selected items in queue to playlist - [Feat] Alternative UI (Settings > Misc > Experimental features) - [Fix] Handle user prematurely removing bad uri 5.11.0 - [Feat] Copy playlist URIs - [Fix] upgrade PyInstaller 5.10.4 - [Fix] locate uri 5.10.3 - [Fix] Volume sync with chromecast 5.10.2 - [Fix] Exit program after sleeping 5.10.1 - [Fix] Relative audio files in Portable mode 5.10.0 - [Feat] Copy URIs (text only) - [Fix] Move track up - [Fix] Discord presence blocking 5.9.13 - [Fix] Play from stopped 5.9.12 - [Fix] Resolution switcher 5.9.11 - [Fix] Replaying queue from GUI 5.9.10 - [Fix] Non existent files 5.9.9 - [Fix] Covert art playlist videos 5.9.8 - [UX] YouTube source URL 5.9.7 - [Fix] Covert art 5.9.6 - [Fix] Long lived GUI bug 5.9.5 - [Optimization] YouTube playlists 5.9.4 - [Fix] Volume 5.9.3 - [Fix] Cast volume 5.9.2 - [Fix] No files selected - [Fix] Playing URLs 5.9.1 - [Fix] Audio playback 5.9.0 - [Feat] YouTube livestreams 5.8.10 - [Fix] Improve API 5.8.9 - [Fix] Cast errors 5.8.8 - [Fix] Deezer URLs 5.8.7 - [Fix] Resolution setter 5.8.6 - [Fix] Deezer URLs 5.8.5 - [Fix] Update checker 5.8.4 - [Fix] Deezer URLs 5.8.3 - [Fix] URL metadata - [Fix] Crashing when Cast NotConnected 5.8.2 - [Fix] Memory leaks 5.8.1 - [Fix] Also dynamically update refresh rate 5.8.0 - [Feat] Battery Resolution Switcher - [Fix] Upgrade dependencies 5.7.9 - [Fix] Account for edge case 5.7.8 - [Fix] URL track number 5.7.7 - [Fix] Radio urls 5.7.6 - [Fix] Radio urls 5.7.5 - [Fix] FLAC album art 5.7.4 - [Fix] Cancel timer - [Feat] Window only keyboard shortcuts 5.7.3 - [Fix] MP4/M4A track number 5.7.2 - [Fix] Album sorting - [Fix] Files with no track number 5.7.1 - [Fix] German translation 5.7.0 - [UI] Added German / Deutsch translation 5.6.14 - [Fix] Improve GUI error handling 5.6.13 - [Fix] Improved sorting 5.6.12 - [Fix] Improved sorting for albums 5.6.11 - [Fix] Window position edge case 5.6.10 - [Fix] Cast not connected edge case 5.6.9 - [Fix] Folder select edge case 5.6.8 - [Fix] Locate track 5.6.7 - [Fix] Drag and drop files 5.6.6 - [Fix] Initial dir for dialog 5.6.5 - [Fix] TclError 5.6.4 - [Fix] URLs 5.6.3 - [Fix] Audio only URLs 5.6.2 - [Fix] Website integration 5.6.1 - [Fix] ujson security update 5.6.0 - [Feat] Save queue also saves position 5.5.7 - [Fix] YouTube URL Metadata 5.5.6 - [Fix] Web player UX 5.5.5 - [Fix] Open settings file 5.5.4 - [Fix] YouTube comment parsing - [Fix] Queue multiple folders 5.5.3 - [Feat] &alb format 5.5.2 - [Fix] URL support 5.5.1 - [Fix] Volume GUI shortcut 5.5.0 - [Feat] Support more URLs 5.4.11 - [Fix] Volume 5.4.10 - [Fix] Volume 5.4.9 - [Fix] Web GUI settings - [Fix] Background errors 5.4.8 - [Fix] VLC Race Condition 5.4.7 - [Fix] Better error handling 5.4.6 - [Fix] Reading Metadata - [Fix] Spotify Playlists 5.4.5 - [Fix] Opus track number 5.4.4 - [Fix] Casting race conditions 5.4.3 - [Fix] Playback after startup race condition 5.4.2 - [Fix] Playlist Listbox 5.4.1 - [Fix] GUI 5.4.0 - [UI] Add device selection - [UI] Simplified file/folder actions - [UI] Fix Listbox height when artwork is off 5.3.1 - [UI] Update Web UI settings modal 5.3.0 - [Feat] Exit app on GUI close 5.2.5 - [Fix] Viewing changelog in python or portable mode 5.2.4 - [Fix] Race conditions 5.2.3 - [Fix] Cast pausing after playing 5.2.2 - [Fix] Unexpected behaviour after turning off cast 5.2.1 - [Fix] PyInstaller 5.2.0 - [UI] New Web UI layout - [OS] Better Linux support 5.1.24 - [Fix] Single instance 5.1.23 - [Fix] Spotify playlists local files 5.1.22 - [Fix] Open settings.json 5.1.21 - [Fix] command line arguments 5.1.20 - [Feat] --device= option added to specify the device to use - [Feat] Ctrl + Shift + Q in GUI to exit program 5.1.19 - [Fix] URL casting 5.1.18 - [Fix] Youtube Playlist Web 5.1.17 - [Fix] Portable 5.1.16 - [Fix] Corner cases 5.1.15 - [Fix] URL Metadata 5.1.14 - [Fix] Encoding 5.1.13 - [Fix] Metadata 5.1.12 - [Fix] Startup 5.1.11 - [Fix] GUI 5.1.10 - [Fix] URL Metadata 5.1.9 - [Fix] URL Metadata 5.1.8 - [Fix] URL Metadata 5.1.7 - [Fix] URL Metadata 5.1.6 - [Fix] start_playing 5.1.5 - [Fix] Spelling 5.1.4 - [Fix] Chromecast connection 5.1.3 - [Fix] GUI Listbox 5.1.2 - [Optimization] Faster startup - [Fix] Queue from explorer - [Fix] Prev track - [Fix] Cast updates 5.1.1 - [Fix] Override track format - [Fix] URL Metadata 5.1.0 - [UI] Override track format - [Fix] Dialog blocking 5.0.13 - [Fix] Long track names 5.0.12 - [Fix] M4A files 5.0.11 - [Fix] Setting png as cover art 5.0.10 - [Fix] Performance 5.0.9 - [Fix] TCLError Mini-mode 5.0.8 - [Fix] Temp TCLError fix 5.0.7 - [Fix] Prev track with next queue 5.0.6 - [Fix] Chromecast - [Fix] URL metadata 5.0.5 - [Fix] Dependencies 5.0.4 - [Fix] Catch errors 5.0.3 - [Fix] Smart queue - [Fix] Logging - [Fix] Tray device names 5.0.2 - [Fix] TKdnd error 5.0.1 - [Fix] Better end of list behaviour 5.0.0 - [New] Using Python 3.10 64-bit 4.90.157 - [Fix] Invalid audio file error 4.90.156 - [Fix] Loading settings 4.90.155 - [Optimization] Mini-mode 4.90.154 - [Fix] Error handling 4.90.153 - [Fix] Chromecast connection issues 4.90.152 - [Fix] AAC, MP4, OggVorbis 4.90.151 - [Fix] Explorer play/playnext/queue behaviour 4.90.150 - [Fix] Opus file cover art - [Fix] Keyboard shortcuts 4.90.149 - [Fix] Spotify links 4.90.148 - [Fix] Keyboard - [Fix] Chromecast errors 4.90.147 - [Fix] URL 4.90.146 - [Fix] URL and Chromecast errors 4.90.145 - [Fix] Next/Previous track from queue 4.90.144 - [Fix] Handle chromecast disconnect 4.90.143 - [Fix] Previous track 4.90.142 - [Fix] Web GUI 4.90.141 - [Fix] Dutch 4.90.140 - [Fix] Change Chromecast 4.90.139 - [Fix] IPv6 4.90.138 - [Fix] Import playlist 4.90.137 - [Fix] Timer - [Fix] Export playlist - [UI] Added Dutch translation 4.90.136 - [Fix] URL support 4.90.135 - [Fix] get ipv4 4.90.134 - [Feat] Mobile playback - [Fix] URL local muted playback 4.90.133 - [Fix] GUI activation 4.90.132 - [Fix] URL support 4.90.131 - [Fix] Metadata Tab - [Fix] Discord Presence 4.90.130 - [Fix] GUI activation 4.90.129 - [Fix] APIs 4.90.128 - [Fix] YouTube and GUI focus 4.90.127 - [Fix] ComboBox button color 4.90.126 - [UX] Support music.youtube.com - [UI] Added link to GitHub - [UX] Flash border of mini-mode 4.90.125 - [Fix] Improved pause and resume logging 4.90.124 - [Fix] Log file location 4.90.123 - [Fix] Tray not closing 4.90.122 - [Fix] Move track down 4.90.121 - [Fix] Focus on first window creation 4.90.120 - [Fix] json serialization 4.90.119 - [Fix] Activate instance using IPv6 4.90.118 - [Fix] Race condition if previous_device cannot be found - [Fix] Allow IPv6 connections 4.90.117 - [Fix] Playing from command line arguments - [Fix] Chromecast errors 4.90.116 - [Fix] Cleaner dual instance exit - [Fix] Improved /state/ and 4.90.115 - [Fix] Cleaner exit 4.90.114 - [Fix] Revert minimizing when closing 4.90.113 - [Fix] Chromecast errors - [Fix] Discord rich presence - [Optimization] Improve startup time by reducing top level imports - [Optimization] Using ujson to load and save settings faster - [Optimization] Auto-update does not block usage 4.90.112 - [Fix] Don't send known errors 4.90.111 - [Fix] URL parsing if URL results in 404 4.90.110 - [Fix] Maybe fix cast connection issues 4.90.109 - [Fix] Tray closing on non-fatal error 4.90.108 - [Fix] Auto-update on system startup 4.90.107 - [Fix] Disalbed Discord RPC For Sure 4.90.106 - [Fix] More error catching 4.90.105 - [Fix] Tray theme 4.90.104 - [Fix] Already connected errors 4.90.103 - [Fix] Portable 4.90.102 - [Fix] Updated pychromecast and zeroconf 4.90.101 - [Fix] Disabled Discord RPC 4.90.100 - [Fix] Files web UI 4.90.99 - [Fix] Fix mini-mode bug 4.90.98 - [Fix] Fix reading explicit/rating tag 4.90.97 - [Fix] Add more DEBUG features 4.90.96 - [Fix] Better YouTube playback 4.90.95 - [Fix] Handle bad metadata input - [Fix] Update Flask semantics 4.90.94 - [Fix] Improved --help output 4.90.93 - [Fix] Web UI upload button 4.90.92 - [Fix] Web UI modal CSS 4.90.91 - [Fix] Web UI Mobile CSS - [Feat] Web UI Dark Theme 4.90.90 - [Fix] Folder action when using a different language 4.90.89 - [Feat] New /devices/ API - [Fix] Translation for "locate track" 4.90.88 - [Fix] Removed polling - [Fix] Fixed anti-virus detection? 4.90.87 - [Fix] Polling (Hibernation bug) 4.90.86 - [Fix] Polling 4.90.85 - [Fix] Strip URL's 4.90.84 - [Fix] Proxies 4.90.83 - [Fix] Polling 4.90.82 - [Fix] Polling 4.90.81 - [Fix] Window focus - [Feat] Specify delay (beta) 4.90.80 - [Fix] Auto-update restart minimized 4.90.79 - [Feat] Open window when run 4.90.78 - [Fix] Update dependencies 4.90.77 - [Fix] Fix YouTube link expiry 4.90.76 - [Fix] Better tray exit behaviour 4.90.75 - [Fix] Timer 24hr support 4.90.74 - [Fix] YT Comment Parser 4.90.73 - [Fix] Timer settings Web GUI 4.90.72 - [Fix] Default volume now 50% 4.90.71 - [Fix] YT Comment Parser 4.90.70 - [Fix] Added more logging 4.90.69 - [Fix] Web GUI 4.90.68 - [Fix] Metadata 4.90.67 - [Fix] Mini-mode drag 4.90.66 - [Fix] YouTube parsing 4.90.65 - [Fix] Scanning 4.90.64 - [Fix] Tray devices 4.90.63 - [Fix] Combo boxes 4.90.62 - [Fix] Library selection colors 4.90.61 - [Fix] URL 4.90.60 - [Fix] GUI 4.90.59 - [Feat] Upload files to PC 4.90.58 - [Fix] Buffering 4.90.57 - [Fix] Buffering 4.90.56 - [Fix] Icon 4.90.55 - [Fix] GUI 4.90.54 - [Fix] M3U files 4.90.53 - [Fix] Timer API 4.90.52 - [Fix] GUI 4.90.51 - [Fix] GUI 4.90.50 - [Fix] MP4/M4A/AAC 4.90.49 - [Fix] Export playlist 4.90.48 - [Fix] Web GUI - [Fix] Playlist tab 4.90.47 - [Fix] MP4/M4A/AAC 4.90.46 - [Fix] MP4/M4A/AAC 4.90.45 - [Fix] Playlist - [Fix] MP4/M4A/AAC 4.90.44 - [Fix] Flac files - [Fix] Better proxy support 4.90.43 - [Fix] Favicon 4.90.42 - [Fix] Favicon 4.90.41 - [Fix] Settings tooltip 4.90.40 - [Fix] Metadata tab 4.90.39 - [Fix] Mini-mode 4.90.38 - [Fix] Notifications and upgrade Python 4.90.37 - [Fix] Mini-mode 4.90.36 - [Optimization] GUI - [Optimization] Scrubbing 4.90.35 - [Fix] Auto next track 4.90.34 - [Optimization] Smaller executable 4.90.33 - [Fix] Expired URLs 4.90.32 - [Fix] Cut/Copy url inputs 4.90.31 - [Fix] GUI - [Fix] Stop 4.90.30 - [Optimization] Memory 4.90.29 - [Fix] Metadata setting 4.90.28 - [Fix] Switching devices - [Fix] Seeking 4.90.27 - [Fix] Web GUI 4.90.26 - [Fix] Queue all 4.90.25 - [Feat] Add Mini-mode shortcut - [Fix] Album art setting 4.90.24 - [Fix] Spotify 4.90.23 - [Fix] Italian translation - [Fix] DND URL support 4.90.22 - [Fix] Timer button 4.90.21 - [Fix] Cookie parsing 4.90.20 - [Fix] Queue prev/next track 4.90.19 - [Fix] YouTube comment parsing - [Fix] Up and Down change volume - [Fix] Default URL input for playlists tab 4.90.18 - [Fix] Registry errors - [Fix] YouTube comment parsing 4.90.17 - [Fix] Deleted video handler 4.90.16 - [Fix] Playlist name input 4.90.15 - [Fix] GUI queue 4.90.14 - [Fix] Sorting (DND) files - [Fix] Spotify tracks 4.90.13 - [Fix] Improve chapter parsing 4.90.12 - [Fix] Proxy errors 4.90.11 - [Fix] Persistent queue 4.90.10 - [Fix] Deezer support 4.90.9 - [Fix] URL added feedback - Italian translation 4.90.8 - [UI] URL added feedback 4.90.7 - [Fix] System audio streaming related 4.90.6 - [Fix] YouTube comment parsing - [Fix] Dynamic artwork 4.90.5 - [Fix] Better internal clocks - [Fix] Mini-mode 4.90.4 - [Fix] Removed blocking logic 4.90.3 - [Fix] Main GUI 4.90.2 - [Fix] Library tab 4.90.1 - [Fix] YouTube playlists 4.90.0 - [Feat] Drag and Drop 4.89.0 - [Feat] Smart URL F-FWD and RWD 4.88.0 - [Feat] Metadata setter 4.87.6 - [Fix] Playlists tab 4.87.5 - [Fix] Playlists tab 4.87.4 - [Feat] Twitch support 4.87.3 - [Fix] Playlists tab - [Fix] Move to next queue 4.87.2 - [Fix] Volume slider - [Fix] URL resumption after long time - [Fix] Prevent sleep after resuming playback 4.87.1 - [Fix] Multiple URL input support 4.87.0 - [Feat] Smart queue (auto skip) 4.86.6 - [Fix] Mini-mode 4.86.5 - [Fix] Library tab 4.86.4 - [Fix] Fix web GUI 4.86.3 - [Fix] Library sorting 4.86.2 - [Fix] Web GUI search 4.86.1 - [Optimization] Library table 4.86.0 - [UI] Added library tab - [UI] Improve multi-selections 4.85.2 - [UI] Better (rounded) buttons - [UI] Web GUI playlist options - [UI] Web GUI file options 4.85.1 - [Fix] Deezer support 4.85.0 - [Fix] Registry - [Feat] URL Protocol music-caster 4.84.3 - [Fix] Audio Player 4.84.2 - [Fix] Playlist format - [Fix] Folder and file actions - [UI] Playlist metadata scanning - [Optimization] Spotify support 4.84.1 - [Fix] Resume playing with keyboard - [Fix] Mini-mode with unknown metadata - [Fix] Shuffle - [Fix] Playing URL's 4.84.0 - [Feat] Deezer support - [Fix] Spotify album support - [Fix] System audio - [Fix] Move tracks within next queue 4.83.0 - [Feat] Locate tracks in playlists - [Feat] Added option to remember selected folder - [Optimization] Spotify URL - [Fix] YouTube/Soundcloud playlist/set support 4.82.14 - [Fix] Better persistent queue - [Fix] Web GUI translation - [Fix] M3U(8) File Handler 4.82.13 - [Fix] Web GUI auto-fresh 4.82.12 - [UI] Translation framework 4.82.11 - [Fix] Play URL double count 4.82.10 - [Fix] Main GUI when repeat is disabled 4.82.9 - [Fix] URL support - [Fix] Soundcloud support 4.82.8 - [Fix] URL support 4.82.7 - [Fix] Tray select files - [Fix] Metadata error - [Fix] Commandline arguments - [Optimization] Lowered CPU usage - [Feat] Queue library always 4.82.6 - [Fix] Delete old files 4.82.5 - [Fix] Sharp noise when playing audio - [Fix] Unknown album errors 4.82.4 - [Fix] Spotify handling 4.82.3 - [Fix] Unknown property bugs - [Fix] Better web GUI 4.82.2 - [Fix] Prevent Windows from sleeping 4.82.1 - [Fix] M3U support 4.82.0 - [Feat] M3U(8) beta support - [Fix] Translations - [Fix] Playlists tab 4.81.14 - [Fix] URL Tab auto paste - [UI] Playlists tab 4.81.13 - [Fix] Translations - [Fix] Web GUI auto reload 4.81.12 - [Fix] Web GUI 4.81.11 - [Fix] URL expiry 4.81.10 - [UI] Added Italian translation 4.81.9 - [Fix] Local url playback - [Fix] Improve YouTube local playback quality - [Fix] Instance resolver 4.81.8 - [Fix] Locate uri 4.81.7 - [Fix] Locate uri 4.81.6 - [Fix] Scan queued urls 4.81.5 - [UI] Clicking title/artist/album text opens url or explorer - [Fix] Locate track for mini-mode 4.81.4 - [Fix] Spotify URL 4.81.3 - [Fix] Spotify URL 4.81.2 - [Fix] Spotify URL 4.81.1 - [Fix] Spotify URL 4.81.0 - [Feat] Added Spotify support 4.80.6 - [Fix] URL metadata 4.80.5 - [Fix] Persistent URL's 4.80.4 - [Fix] Web GUI 4.80.3 - [Optimization] Blazing fast startup + open GUI 4.80.2 - [Fix] Vertical GUI size - [Fix] Repeat button tooltip 4.80.1 - [Fix] Missing translation - [Fix] High pitch noise - [Fix] Locate track - [Feat] Add option to pick language - [UI] Improved fonts and UI looks 4.80.0 - [Feat] Play URL on local device - [Fix] Tray menu 4.79.5 - [Fix] Translation framework 4.79.4 - [UI] Added Spanish translation - [Fix] Translation framework - [Fix] Metadata 4.79.3 - [UI] Web GUI improvements 4.79.2 - [Optimization] Selecting from queue 4.79.1 - [Fix] Auto update notification 4.79.0 - [Feat] Added option to hide queue index - [Feat] Web GUI interactive queue - [UI] Better queue - [UI] More intuitive mini-mode behaviour - [UI] Integrated URL window with main window - [Optimization] Moving tracks 4.78.1 - [Fix] Web GUI 4.78.0 - [UI] Created framework for translations 4.77.0 - [Feat] Support for YouTube playlists and SoundCloud Sets - [Fix] Playing YouTube links 4.76.4 - [Fix] Folder browse and playlists add tracks 4.76.3 - [Fix] Tray not showing up 4.76.2 - [Fix] Get latest release 4.76.1 - [Fix] Registry error 4.76.0 - [Feat] Move track in queue to next up - [UI] Better queue button - [Optimization] Tray has its own process to reduce drag stuttering and high cpu usage 4.75.1 - [Fix] Album and Title alignment resizing window 4.75.0 - [Feat] Live shuffle and un-shuffle the queue using the shuffle button - [Optimization] Using deques instead of lists for internal queues (faster for massive queues) 4.74.28 - [Fix] Sorting improvements - [Fix] Improved Web GUI searching - [Fix] Improved Web GUI file display 4.74.27 - [Fix] No space error handling while saving settings 4.74.26 - [Fix] Album art display 4.74.25 - [Fix] Better download error handling 4.74.24 - [Fix] Playlists 4.74.23 - [Fix] Better scrubbing 4.74.22 - [Fix] Better scrubbing 4.74.21 - [Fix] Better image handling 4.74.20 - [Fix] Folder cover override UX 4.74.19 - [Fix] Improved binaries 4.74.18 - [Fix] Web GUI timer options 4.74.17 - [Fix] Removed internet available calls 4.74.16 - [Fix] Error handling 4.74.15 - [Fix] Loading settings 4.74.14 - [Fix] Wav file error 4.74.13 - [Fix] Loading numerical settings 4.74.12 - [Fix] Saving settings 4.74.11 - [Fix] Populate queue on startup - [Fix] Loading settings - [Fix] Saving settings - [Fix] Improve web GUI 4.74.10 - [Fix] Improved natural sort 4.74.9 - [Fix] Metadata scanning for folders 4.74.8 - [Fix] Add folder in settings - [Fix] Fixed Portable.zip compression 4.74.7 - [Fix] Playlist tab 4.74.6 - [Fix] Mini-mode 4.74.5 - [Fix] Mute icon staying when slider is moved 4.74.4 - [Fix] Shuffle and repeat off by default 4.74.3 - [Fix] Timer tab 4.74.2 - [UI] Sort playlists alphabetically - [UI] Open Changelog file from settings - [UI] Settings icons - [Fix] Mute button starting tooltip 4.74.1 - [Fix] Mini-mode 4.74.0 - [UI] Show album name in now playing - [Optimized] Metadata scanning 4.73.8 - [Fix] Volume control on startup - [Fix] Volume control in GUI 4.73.7 - [Fix] Hot-key window focus 4.73.6 - [Fix] Discord presence errors 4.73.5 - [Optimized] Faster GUI activation via hot-key 4.73.4 - [Fix] Web shuffle - [Fix] Main window activation through second instance 4.73.3 - [Fix] Tray icon 4.73.2 - [Fix] Stops at the end of tracks 4.73.1 - [UI] Improved mini-mode 4.73.0 - [Feat] Added shuffle to controls 4.72.1 - [Fix] Random stops 4.72.0 - [Feat] Added setting to disable folder scan 4.71.43 - [Fix] Removed default folder re-addition 4.71.42 - [Fix] Switching device error - [Fix] Cleaner code 4.71.41 - [Fix] Cleaner code - [Feat] Improved privacy by hashing MAC 4.71.40 - [Fix] Play from explorer - [Fix] Album art issues 4.71.39 - [Fix] Chromecast not connected case 4.71.38 - [Fix] Resume after long pause - [Fix] Random stopping 4.71.37 - [Fix] Web GUI - [Fix] Improved GUI performance when playing non-library files - [Fix] Code quality 4.71.36 - [Fix] Playlist tab moving a track up or down 4.71.35 - [Fix] Thumbnail fetching - [Fix] Album art resizing issue - [Fix] Portable auto-update on cancel - [Fix] Invalid WAV files 4.71.34 - [Fix] Discord RPC error 4.71.33 - [Fix] Debugging keyboard errors 4.71.32 - [Fix] Better web error handling 4.71.31 - [Fix] Fix build 4.71.30 - [Fix] Improve artist parsing for metadata that uses '/' as a separator 4.71.29 - [Fix] Ctrl + # in mini mode 4.71.28 - [Fix] Fix using j,k,l on playlist tab - [Fix] Fix using k to pause in mini-mode - [Fix] Improve typing speed for main window 4.71.27 - [Fix] Delete playlist behaviour 4.71.26 - [Fix] Removing current track from queue - [Fix] Set timer with HH:MM 4.71.25 - [Optimized] Caching 4.71.24 - [UI] Improved tray menu 4.71.23 - [Fix] Double Digit Track Numbers 4.71.22 - [Fix] Exit 4.71.21 - [Fix] Removed a dependency 4.71.20 - [Fix] Pressing Enter while using mini-mode 4.71.19 - [Fix] Improved mini-mode border padding 4.71.18 - [Web] Added version to web GUI settings 4.71.17 - [Fix] Better timing - [Fix] Random stopping - [Fix] Playing YouTube links - [Fix] Playing tooltip 4.71.16 - [Fix] GUI volume slider not updating 4.71.15 - [Fix] Switching devices while paused 4.71.14 - [Fix] Window Icon 4.71.13 - [Fix] Web GUI search bar 4.71.12 - [Fix] files.html 4.71.11 - [Fix] Instance checker 4.71.10 - [Fix] Instance checker 4.71.9 - [Fix] Fixed playing youtube links - [Fix] Changed locate file to locate track - [Fix] Added save icon to playlist tab 4.71.8 - [Fix] Patched more errors 4.71.7 - [Fix] Update youtube-dl 4.71.6 - [Fix] Improved error handling 4.71.5 - [Fix] Improved logging 4.71.4 - [Fix] Improved logging 4.71.3 - [Fix] Auto-update True by default - [Fix] Better default music folder 4.71.2 - [Fix] Main GUI 4.71.1 - [Fix] Web GUI play file - [Fix] Web GUI styling - [Fix] Web GUI view files 4.71.0 - [Feat] Added option to reverse play next behaviour 4.70.11 - [Fix] Handle no space bug 4.70.10 - [Fix] Web GUI improvements 4.70.9 - [Fix] Remove old installed files 4.70.8 - [Fix] Web GUI shuffle option 4.70.7 - [Fix] Fix log lines in email 4.70.6 - [Fix] Playlists tab 4.70.5 - [Fix] Queue from explorer 4.70.4 - [Fix] Rescan library from web GUI 4.70.3 - [Fix] Folder cover art 4.70.2 - [Fix] Reduce size 4.70.1 - [Fix] Track numbering 4.70.0 - [Feat] Added more to Web GUI - [Feat] Added timer options to Web GUI - [Fix] Web GUI resume - [Fix] Man window tabs order 4.69.0 - [Feat] Playlist Tab (play, queue, edit) - [Feat] Removed playlist related windows - [Feat] Playlists support URLs (youtube and soundcloud) - [Fix] Better email link 4.68.3 - [Fix] Folder dir as album cover - [Fix] Improved error handling 4.68.2 - [Fix] Track number not showing 4.68.1 - [Fix] Settings field 4.68.0 - [Feat] Added format template in settings.json - [Fix] Queue formatting 4.67.0 - [Feat] Queue all button - [Feat] Show track number - [Fix] Play file queueing 1+ files - [Fix] Enable folder cover override - [Fix] Disable folder context menu 4.66.0 - [Feat] Exit command - [Feat] Shutdown API 4.65.24 - [Fix] Random crashes - [Fix] Discord Rich Presence 4.65.23 - [Fix] Improved command-line argument updating 4.65.22 - [Fix] Play / queue from explorer 4.65.21 - [Feat] Command line supports playlist names 4.65.20 - [Fix] Playing at launch - [Fix] Better local volume control 4.65.19 - [Fix] Play / queue from explorer for multiple files 4.65.18 - [Fix] Play / queue from explorer 4.65.17 - [Feat] Folder cover override option - [Feat] Queue from explorer - [Fix] Remove folder context menu - [Fix] Play from explorer 4.65.16 - [Fix] Added first run notification 4.65.15 - [Fix] Improved command line 4.65.14 - [Fix] Album art bugs 4.65.13 - [Fix] Main window queue bug 4.65.12 - [Fix] .wav file bug 4.65.11 - [UI] Improved queue look 4.65.10 - [Fix] Better folder action sorting 4.65.9 - [Fix] Discord RPC 4.65.8 - [Fix] Antivirus False Positives 4.65.7 - [Misc] Updated icon 4.65.6 - [Fix] Play URL Next from tray if something is playing 4.65.5 - [Fix] Validate color codes in settings 4.65.4 - [Fix] Validate color codes in settings 4.65.3 - [Fix] Switch device LIVE audio 4.65.2 - [Fix] Roll back multi folder dialog 4.65.1 - [Fix] Exit behaviour 4.65.0 - [Feat] URL actions links pasted by default 4.64.25 - [Feat] Multi dir support - [Fix] Weird looking folder selection 4.64.24 - [Fix] File action GUI queue population (not noticeable) 4.64.23 - [Fix] Getting files in folders - [Fix] Folder action GUI queue population 4.64.22 - [Fix] Switching to local device 4.64.21 - [Fix] Random stopping 4.64.20 - [Fix] Show default art when stopped 4.64.19 - [Fix] Background tasks fixes - [Fix] YouTube thumbnails 4.64.18 - [Fix] Update on exit 4.64.17 - [UI] Better Mini Mode - [Optimized] Portable updater in C# - [Fix] Double Instance (Faster now too) 4.64.16 - [Fix] Update on exit 4.64.15 - [Fix] Main GUI length parameter 4.64.14 - [Fix] Better logging 4.64.13 - [Fix] Better logging (smaller log file) 4.64.11 - [Fix] Premature stopping 4.64.10 - [Fix] Better logging 4.64.9 - [Fix] !show album art 4.64.8 - [Fix] Album art mini mode - [Fix] Persistent Queues 4.64.7 - [Fix] Automated uploading 4.64.6 - [Fix] Improved local audio player - [Fix] Improved logging 4.64.5 - [Fix] Offline playback - [Fix] Starting with arguments - [Fix] Settings modal Web GUI 4.64.4 - [Fix] Improved behaviour with unresponsive devices pt. 1 - [Fix] Improved debugging 4.64.3 - [Fix] Better exit behaviour 4.64.2 - [Fix] Main GUI queue rendering - [Fix] Log file location 4.64.1 - [Fix] Main GUI queue rendering 4.64.0 - [Feat] Save queue as a playlist - [Feat] Update on exit - [Feat] Notify when updates available - [UI] Better play URL window - [UI] Move Mini Mode button above clear queue - [Optimized] Better next and prev track from keyboard shortcuts - [Fix] Mini-mode keyboard shortcuts - [Fix] Mini-mode not respecting show album art setting - [Fix] Stop from the tray 4.63.11 - [Fix] Reset progress - [Fix] Switching device bug - [Fix] Improved logging 4.63.10 - [Fix] Better keyboard pause/resume handling - [Fix] Save Mini-mode position - [Fix] Progress bar text - [UI] Converted 3 buttons into images 4.63.9 - [Fix] Main Window UX 4.63.8 - [Fix] Mini mode sliders - [UI] Check for updates periodically 4.63.7 - [UI] Playlist drop-down only shows if playlists exist - [UI] Better Vertical GUI - [Fix] No album art setting 4.63.6 - [Fix] Volume scrubbing in mini mode 4.63.4 - [Fix] Metadata again - [Fix] If files don't exist - [Fix] Live Audio 4.63.3 - [Fix] Seeking in mini-mode - [Fix] Metadata 4.63.2 - [Fix] Play tracks 4.63.1 - [Fix] Settings tooltip 4.63.0 - [Fix] Playing an updated file - [UI] Vertical UI - [UI] Mini mode UI - [UI] Album art is optional 4.62.2 - [Fix] Main GUI 4.62.1 - [Fix] Live Audio uses default output device now - [Fix] Live Audio follows default output device - [Fix] Stop() fix 4.62.0 - [UI] Album art in Main Window - [UI] Better buttons with images 4.61.1 - [Fix] GUI when playing live audio 4.61.0 - [Feat] Cast Live System Audio 4.60.8 - [Web GUI] Added "Refresh Devices" to settings modal - [UI] More tray play URL options - [Optimized] Reduced memory footprint - [Optimized] File indexing - [Fix] Play folder window - [Fix] Corrupt file handling - [Fix] Internet cutting out scenarios 4.60.7 - [Fix] Pausing / Resuming local playback bugs - [Fix] Play file with new instance 4.60.6 - [Fix] Refresh Devices 4.60.5 - [Fix] Maxing volume on local device 4.60.4 - [Fix] Main window queuing 4.60.3 - [Fix] Mute behaviour 4.60.2 - [UI] Better UI styling - [Fix] Timer error text - [Fix] Support for .mpeg files 4.60.1 - [Fix] Discord Presence Errors - [Fix] Better Registry Modifications 4.60.0 (August 2020) - [Feat] Register Music Caster in the registry as a program to open audio files and folders 4.59.5 - [Optimized] Delete old files when updating 4.59.4 - [Optimized] images folder no longer needed - [Fix] Album art not showing on devices 4.59.3 - [UI] Slightly better icon - [UI] New start menu tile (matte black style) - [UI] Renamed Song to Track - [UI] Better queue modification experience - [Fix] Move track up in the queue - [Fix] Web GUI selected device 4.59.2 - [Dependency] Updated youtube-dl 4.59.1 - [Fix] Change device 4.59.0 - [Feat] Better progress bar - [Feat] Support for all formats locally (thanks to VLC bindings) - [Feat] 32-bit support 4.58.0 - [Feat] See and download available files through /files/ 4.57.0 - [Feat] Play URL supports SoundCloud - [Fix] Play URL - [Fix] WAV files 4.56.4 - [Fix] Music Queue with non-existent files 4.56.3 - [Fix] Switching device after a long time 4.56.2 - [Fix] Cancelling file selector closes the window 4.56.1 - [UI] Made buttons wider 4.56.0 - [UI] Merged timer window as a tab on the main window - [UI] Added Queue URL to the main window - [UI] Added Ctrl + {1, 2, 3} for main window tab control - [UI] Use Ctrl + Shift + Alt + M to launch main window - [UI] Added fast-forward, rewind, and pause keyboard shortcuts (L, J, K) - [UI] Added more play options to the main window - [Optimized] Cached YouTube URLs - [Optimized] Even faster startup - [Fix] Made populate and save session queues mutually exclusive - [Fix] Queue files - [Fix] Get metadata - [Fix] Discord rich presence - [Fix] Playlist Selector and Editor 4.55.0 - [Optimized] Reduced installation size - [Fix] Error handling - [Fix] Resume playback after long time 4.54.8 - [Fix] Auto-updating 4.54.7 - [UI] Sort cast devices by groups first 4.54.6 - [Fix] Cast groups detection (zeroconf == 0.24.5) 4.54.5 - [Fix] Exit after downloading update 4.54.4 - [Fix] No metadata WAV files 4.54.2 - [Fix] Auto-update 4.54.1 - [Fix] Auto-update - [Feat] Better url support 4.54.0 - [Feat] Change device via web GUI 4.53.1 - [Optimized] Faster startup time (-70%) - [Fix] Auto update 4.53.0 - [UI] Removed ugly border from buttons - [UI] Merged Tab's 1 and 2 for Main Window - [UI] Merged Settings with Main Window - [Optimized] Settings Window - [Optimized] Faster update checking - [Optimized] Better Portable Updater - [Fix] Queue file - [Fix] Save window positions 4.52.0 - [Feat] Play URL works alright now (youtube only) - [Fix] Web shortcut icons 4.51.3 - [Optimized] Main Tray - [UI] Swapped "Controls" and "Play" in the tray menu - [UI] New text color #d7d7d7 (either delete settings.json or enter it manually) - [Fix] Startup error if MC is already running - [Fix] Population/Save queue will load queue now 4.51.2 - [Fix] Settings loading 4.51.1 - [Fix] Main window closing after cancelling queue file 4.51.0 - [Feat] Populate queue on startup - [Feat] Save queue between sessions 4.50.2 - [Fix] Startup error 4.50.1 - [Optimized] Threaded file selection windows - [Fix] Play Folder 4.50.0 - [Feat] Music library will build in the background - [Feat] Added "Refresh Library" to tray 4.49.5 - [Fix] Better update error handling 4.49.4 - [Fix] Pause bug - [Fix] Main Window file picking close 4.49.3 - [Fix] Tooltips - [Fix] Play All 4.49.2 - [Fix] Discord rich presence bug 4.49.0 - [Feat] Added do nothing to timer - [Fix] .opus files 4.48.1 - [Fix] Icon Quality 4.48.0 - [Feat] Improved Quality of Icon 4.47.1 - [Fix] Forgot to update version 4.47.0 - [Feat] Reorganized Main Window Tab 2 - [Feat] Added support for .wma files (cast only) 4.46.0 - [Feat] Queue file now supports multiple files - [Feat] Play Youtube links (EXPERIMENTAL) - Does not support pause, skip, or any repeating - [Optimized] Settings file loading 4.45.0 - [Feat] Scroll to the playing song for Web GUI music queue 4.44.0 - [Feat] Added support for .wav - [Fix] Better metadata logic 4.43.3 - [Fix] Web GUI style fix 4.43.2 - [Fix] Volume control actually works now 4.43.1 - [Fix] Auto-update will be disabled if something goes wrong while updating 4.43.0 - [Feat] Added View queue to Web GUI - [Feat] Added Volume Control to Web GUI 4.42.2 - [Feat] Changed default setting for discord RPC to false - [Feat] Web GUI title now includes the PC's name 4.42.1 - [Fix] Shortcut creation 4.42.0 - [Feat] Added setting UI to Web GUI 4.41.3 - [Fix] Music Queue double click to play 4.41.2 - [Fix] Web GUI 4.41.1 - [Feat] Web GUI on mobile doesn't make the keyboard come up 4.41.0 - [Feat] Added support for {*.flac,*.m4a,*.mp4,*.aac,*.ogg,*.opus,*.wav} (for non-local devices) 4.40.0 - [Optimized] Reduced startup time by ~2 seconds - [Optimized] No more copying files from different drives, the solution was so simple but took ~10 months to find! - [Fix] Stopped Music Caster collisions between two devices on the same network - [Security] Server will only serve music files and only if the correct UUID is passed 4.39.5 - [Optimized] Instance checker, startup time reduce by ~9 seconds! - [Fix] Reverted how keyboard commands work 4.39.4 - [Fix] Music queue remove 4.39.3 - [Fix] Music queue 4.39.2 - [Fix] Music queue - [Fix] Better email hyperlink 4.39.1 - [Fix] Web GUI 4.39.0 - [Feat] Added search to web GUI - [Feat] Better mobile web GUI 4.38.1 - [Fix] Handled more metadata errors - [Fix] Installer installs all files now 4.38.0 - [Feat] Can now play files from the web GUI - [Fix] Removed duplicate code - [Fix] Better Chromecast detection 4.37.0 - [Feat] Added notification if MC was updated - [Feat] Double click a song in the music queue to play the song - [Feat] Locate File moved to second tab - [Fix] Opening Music Caster a second time 4.36.1 - [Fix] Handled HeaderNotFoundError 4.36.0 - [Optimized] Embedded Updater (+ updating notification) - [Optimized] Less cpu usage when idle 4.35.0 - [Optimized] Image assets - [Feat] Main GUI volume image acts like mute/unmute button - [Fix] Settings window error 4.34.2 - [Fix] Add folder works with save window positions 4.34.1 - [Fix] Errors are actually sent to me now 4.34.0 - [Feat] Added repeat off option (click repeat button 2x) - [Feat] Added "Save window positions" setting - [Optimized] Custom Chromecast finder (email me if MC doesn't detect any chromecasts) - [Optimized] Better CPU usage - [Optimized] More threading 4.33.3 - [Fix] lag caused by Discord RPC 4.33.2 - [Feat] Added tooltips for the QR Code and web GUI link - Removed "Email:" text and added tooltip to the email link 4.33.1 - [Fix] Discord RPC bug when using web GUI 4.33.0 - [Feat] Added QR Code to settings for quick access to the web GUI - [Fix] Error when handling exception - [Fix] Web GUI when no file is playing 4.32.3 - [Fix] Updater.exe AGAIN - [Feat] Added basic optional Discord RPC integration - [Fix] playlist editor window (window position is not saved for now) 4.31.0 - [Optimized] Executables size and startup time - [Optimized] Portable updater now in C# - [Feat] Window positions are now remembered - [Feat] Added some tooltips - [Fix] tray menu updates again if repeat is pressed 4.30.4 - [Fix] Main GUI and shortcut creation 4.30.3 - [Fix] Playlist editor arrow key handler 4.30.2 - [Feat] Beefed up error messages that are sent to me - [Fix] Better temp music folder handling 4.30.1 - [Feat] You can now open a file with Music Caster - File handling in the context menu 4.29.0 - [Feat] Added "Queue Folder" to the main GUI (Tab 2) - [Feat] Added "Clear Queue" to main GUI (Tab 2) - [Fix] Handled ID3 error when playing files - Reordered tray play menu 4.28.0 - [Feat] Added play folder to tray (found under tray play menu) - Moved Playlists to Play tray menu 4.27.4 - [Fix] Fixed Web GUI album art 4.27.3 - [Fix] Volume scrolling on main window popup - [Error handling] Updater raises fewer errors - [Fix] Error handling bug 4.27.2 - [Fix] Better handling of errors - [Fix] Play file works even without any folders 4.27.1 - Better duplicate detection - Fixed compile all songs bugs 4.27.0 - Select device no devices Fix - WEB GUI fix - Better Auto-update 4.26.2 - Fixed bug 4.26.1 - Removed irrelevant data that was sent to me (install folder) - Added POST request so I know how many users use Music Caster 4.26.0 - Better Play All (+ fixed a bug I created in the process before releasing) - Because of this bug, I have added a helper function to time my functions - Optimized `change_settings` (faster skips, although negligible) - Building a GUI tab to show all music - Update Web UI (looks good on mobile now, hard to access though) - Changed setup name - Fixed auto-update error handling (ironic) - Fixed volume not changing when scrolling bug 4.25.0 - Added locate file option to main GUI (folder icon) - Fixed play file next bug - Updated timer window GUI 4.24.4 - Fixed volume scrolling behavior - Removed volume from settings window (use the main window instead) 4.24.3 - Better error handling - Added more error logging information 4.24.2 - Scrolling in the settings or main window by default changes volume 4.24.1 - Fixes 4.24.0 - Web GUI fully functional - ID3 tags reading fix - Fixed song position bug 4.23.0 - Volume and scrubbing now support mouse scrolling! - Settings window "Enable notifications" -> "Notifications" - Tray tooltip now says "Download Update..." instead of disappearing - Fixed restart on error? 4.22.4 - Fixed IndexError's when no file sin queue 4.22.3 - Fixed bugs to do with changing the music queue 4.22.2 - Fixed web access - Fixed song position bug (maybe) - Fixed port conflict bug 4.22.1 - Added button text color option - Fixed bug (reactivating a window through the tray) - Fixed volume slider bug 4.22.0 - New main window update - Added image for the repeat button - Added volume control - Added music queue control - GUI volume slider's update when the volume is updated through the home app - Added a checkmark next to repeat tray menu if the repeating song is True - Main GUI is now accessible to all! Just click the icon - Change accent color - Added settings.json options to change accent color (requires restart) - Fixed keyboard shortcut option 4.21.2 - bigger try-except to catch more bugs 4.21.1 - fixed bug where "open settings.json" would fail if the user had no JSON file handler 4.21.0 - Errors are now sent to me automatically - Information that is shared with me - Music Caster Version - OS platform and version - Traceback error message - (see bottom of music_caster.py) - Updated error.log to include Music Caster's version 4.20.1 - Fixed no response bug when you switch devices while song is paused 4.20.0 - Works if song is scrubbed from home app - Better song timing (when the next song will play) 4.19.1 - FIXED: now playing text (MAIN GUI) was displaying song when playback was stopped - Can now use Up/Down and Page Up/Down to move through music queue GUI (EXPERIMENTAL) - CPU won't spike anymore for a few seconds when trying to play something on cast device 4.19.0 - Changed tooltip to have song info - Tray shows which device is currently selected 4.18.6 - Support for subfolders when using "Play All" 4.18.5 - Fixed bug where all devices showed the same name 4.18.4 - Minor refactoring + change in selecting device logic - Change in chromecast devices sorting criteria (alpha, then UUID) 4.18.3 - Sorted device list alphabetically with (local device) being the exception 4.18.2 - Fixed multiple Chromecasts bug 4.18.1 - Fixed timer to work with improved performance - Changed "Stop Timing" to "Cancel Timer" 4.18.0 - Added "Locate File" feature to Controls tray menu - Added changelog 4.17.27 - Streamlined controls - New EXPERIMENTAL keyword in settings.json (set to true for main window access) 4.17.26 - Fixed bug to do with wanting to create/edit a playlist name with 'q' - Streamlined Play options (Play File, Play a File Next -> Play File Next, Play All) to be in a cascaded menu - Improved performance (theoretically) by not reading the tray if the program is not in use - Edited settings window to not have a copy button anymore, makes life harder than just clicking the email hyperlink - Updated GUI library to the latest - Fixed bug with main GUI - Main GUI is almost done, test it out by putting `"EXPERIMENTAL": True` in `settings.json` 4.17.25 - Fixed chromecast buffering bug that would screw up when the next song would start playing Wow you scrolled all the way to the end. Pre v4.17.25 changes found here https://github.com/elibroftw/music-caster/releases?after=v4.17.25 ================================================ FILE: Dockerfile ================================================ # this images allows building music caster into a folder that can be run # FROM fedora:latest ENV PY=python3.14 ENV PIP_ROOT_USER_ACTION=ignore # install required packages RUN dnf upgrade -y && dnf install -y \ $PY $PY-devel $PY-virtualenv python3-devel python3-tkinter python3-pyaudio \ dnf-plugins-core libappindicator-gtk3 binutils # install some dependencies here to reduce the dependencies installed at run time COPY requirements.txt build_files/ music-caster/ RUN $PY -m pip install --upgrade pip && $PY -m pip install pyaudio RUN cd music-caster && $PY -m pip install --upgrade -r requirements.txt RUN rm -rf ./music-caster # when running this image, need to mount the work directory to /var/music-caster CMD if [ ! -d /var/music-caster ] ; then git clone https://github.com/elibroftw/music-caster/ /var/music-caster ; fi && cd /var/music-caster && \ $PY -m pip install --upgrade pip && \ $PY -m pip install --upgrade -r requirements.txt && \ $PY -m pip install -r requirements-dev.txt && \ $PY -O -m PyInstaller build_files/onedir.spec && \ echo "done! check your dist folder" # TODO: create AppImage from dist/Music Caster OneDir ================================================ FILE: LICENSE ================================================ Copyright © Elijah Lopez You cannot sell Music Caster, unless there's an at least 50% royalty payment to the majority contributor (Elijah Lopez). Any modified version of Music Caster cannot be sold, unless again there's a 50%+ royalty to the majority contributor of the original version (Elijah Lopez). Any use of source code from Music Caster for the purpose of selling a competitor to Music Caster shall also have to meet a 50%+ royalty payment to the projects majority contributor (Elijah Lopez). (Obviously) No contributor of Music Caster is responsible for any damages caused by Music Caster, if any occur. This is not confirmation that damages are even remotely likely, it is simply a statement to protect contributors of this FREE and OPEN software. This license cannot restrict you from modifying the source code, compiling, and running it on your machine. This license also cannot restrict you from distributing PATCHES for Music Caster. This license is to be understood by spirit and motivation not by its letter. ================================================ FILE: README.md ================================================

[![GitHub Releases](https://img.shields.io/github/downloads/elibroftw/music-caster/latest/total?color=blue&label=github%20downloads%40latest&style=for-the-badge)](https://github.com/elibroftw/music-caster/releases/latest) [![Source Forge](https://img.shields.io/sourceforge/dt/music-caster?color=orange&label=SourceForge%20downloads&style=for-the-badge)](https://sourceforge.net/projects/music-caster/) Music Caster is a modern music player with the ability to cast audio files, system audio, and URLs to Google Chromecasts, Google Home/Nest Minis, etc. Display languages: English, German, Spanish, French, Italian, Dutch, Russian\*, and Ukrainian\* Unique users as of April 23rd 2023: 3,800 [Screenshots](https://elijahlopez.ca/music-caster/) ### Donate or Translate - monero:84PR6SkYd5zaFLKDjAFrQfbaAg2c7SV3q3XDZ15QCpEZUggrN4YzY7n8m9XC3deXjo41yWHTm1LrsUpPTYGnRQbD9Cwp8En - [PayPal](https://www.paypal.me/elibroftw) - [Translate](https://github.com/elibroftw/music-caster/issues/12#issuecomment-808658776) Music Caster to other languages ## Install ### [Windows Download Music.Caster.Setup.exe](https://github.com/elibroftw/music-caster/releases/latest) - **IMPORTANT INFORMATION:** The tray icon will be in the tray, so you will need to move it to your taskbar - Command line installation: `winget install "Music Caster"` - [VirusTotal scan](https://www.virustotal.com/gui/file/40a1c61e5cb2c5eed714eb70bb84f138e9fd9742076ea665b4ac85fc8f372abf) - If Music Caster is auto-removed, open "Virus & threat protection", then "protection history," and restore all files related to Music Caster ### Linux Not maintained, but I did get it to work on Ubuntu once. Music Caster is not straight forward to package, so you can invoke a sudo-free [install script](linux_install.sh). ```bash mkdir -p ~/bin && git clone --depth 1 https://github.com/elibroftw/music-caster.git ~/bin/music-caster cd ~/bin/music-caster ./linux_install.sh # use sudo for non-interactive install in case a dependency needs to be installed ``` ## Demo

Music Caster Video Demo Thumbnail

## Limitations - Chromecasts only support the AAC version of WMA files - Emojis might not work well. There's always settings.json + WEB GUI though - [Road Map](https://github.com/elibroftw/music-caster/projects/1) ## Power User Features - Global media hot-keys are supported - Web GUI (QR code in Settings window) - [Command Line Arguments](https://github.com/elibroftw/music-caster/wiki/Command-Line-Arguments) Here are Music Caster specific keyboard shortcuts aside from the global media hot-keys. | **Shortcut** | **Window** | **Behaviour** | |------------------------|------------|--------------------------------| | Ctrl + Shift + Alt + M | Global | Activate Main Window | | Ctrl + (Shift) + } | Main | Toggle mini-mode | | Esc | Main | Close Window | | Ctrl + Shift + Q | Main | Exit Program | | Scroll | Main | Volume and Progress Bar | | ⬆ / A | Main | Decrease Volume by 5% | | ⬇ / D | Main | Increase Volume by 5% | | # | Main | Set Volume to # * 10% | | K | Main | Pause / Resume / Start Playing | | Shift + N | Main | Next Track | | Shift + P / Shift + B | Main | Previous Track | | J | Main | Rewind 5 seconds | | L | Main | Fast-forward 5 seconds | | Ctrl + R | Main | Cycle Repeat | | Ctrl + M | Main | Mute | | Ctrl + 1 | Main | Go to Tab 1 (Queue) | | Ctrl + 2 | Main | Go to Tab 2 (URL) | | Ctrl + 3 | Main | Go to Tab 3 (Library) | | Ctrl + 4 | Main | Go to Tab 4 (Playlists) | | Ctrl + 5 | Main | Go to Tab 5 (Timer) | | Ctrl + 6 | Main | Go to Tab 6 (Metadata) | | Ctrl + 7 | Main | Go to Tab 7 (Settings) | ### Editing `settings.json` - I do not recommend editing unless you know what you are doing - Music Caster will detect changes within 10 seconds of editing `settings.json` - Some settings values are hidden from the GUI for good reason ## Data Collection / Privacy Policy Below is the reasonable data that is collected when errors are encountered. I'm sure other programs collect way more than necessary. ```python # in handle_exception, payload = {'VERSION': VERSION, 'FATAL': restart_program, 'EXCEPTION TYPE': exc_type.__name__, 'LINE': exc_tb.tb_lineno, 'TRACEBACK': trace_back_msg, 'LOG': log_lines, 'MQ[0]': playing_uri, 'PLAYING_STATUS': str(playing_status), 'DEVICE': device, 'CWD': os.getcwd(), 'PORTABLE': not os.path.exists(UNINSTALLER), 'MAC': hashlib.md5(get_mac().encode()).hexdigest(), 'OS': platform.platform(), 'TIME': current_time} ``` In addition, I collect MD5 hashed MAC addresses and IP addresses in a Google Excel Sheet. Only I have access to this data, I will NEVER give it to anyone else. Will stop collecting analytics once I stop caring about the number of users. - Hashed MAC so that I know how many users without knowing the actual MAC addresses - IP because I can map out the IPs to a visual map to see where my users are located [Developer Guide](https://github.com/elibroftw/music-caster/wiki/Developer-Guide) ### Linux Build Guide - Define correct PY variable (requires rebuilding the image) - Obtain the mc-builder Image - Option A: `docker pull elibroftw/mc-builder` - Option B: `docker build . -t elibroftw/mc-builder` - Remember to have Docker desktop/daemon running already - Build source code using: `docker run --rm --volume .:/var/music-caster elibroftw/mc-builder` ### Virtualenv You need to use the free threading version (python3.14). ```sh python3.14 -m venv .venv .venv\Scripts\activate # Windows source .venv/bin/activate # Non-Windows ``` ### Resources - [Generate sound waves for any audio file](https://gist.github.com/elibroftw/0fc6ed102dbe3f99863829a7e989dcc2) ## Upgrading Python Version Update the version found in the following files - .github/workflows/build.yml - Dockerfile - README#virtualenv - linux_install.sh - scripts/debian-install.sh 1. Next, in a another directory clone PyInstaller like so: `git clone git@github.com:pyinstaller/pyinstaller.git` 2. We need to [build the bootloader](https://pyinstaller.org/en/stable/bootloader-building.html) ourselves to avoid being flagged by Anti Virus. 3. Run `py -m build` (not in the bootloader directory) 4. Compile [pyaudio_portaudio](https://github.com/elibroftw/pyaudio_portaudio) 5. After this, we need to update the requirements-dev.txt. 6. Create venv From here, you can test the app in the newer version of Python (need to initialize a new virtualenv) Lastly, build the app and submit it to Microsoft for false positive. I'm not sure when I'll buy a cert to ensure we don't encounter this again. ================================================ FILE: build.cmd ================================================ @echo off set args=%1 shift :start if [%1] == [] goto done set args=%args% %1 shift goto start :done python -m venv .venv 2>NUL ".venv/Scripts/python.exe" build.py %args% ================================================ FILE: build.py ================================================ #!/usr/bin/env python3 import argparse import glob import math import os import platform import shutil import sys import threading import time import traceback import zipfile from contextlib import suppress from datetime import datetime from pathlib import Path from subprocess import DEVNULL, CalledProcessError, Popen, check_call, getoutput from multiprocessing import freeze_support if __name__ == '__main__': freeze_support() from src.meta import VERSION, USING_TAURI_FRONTEND # build constants script_dir = Path(__file__).parent SRC_DIR = script_dir / 'src' DIST_DIR = script_dir / 'dist' build_files = script_dir / 'build_files' SETUP_OUTPUT_NAME = 'Music Caster Setup' VERSION_FILE = build_files / 'mc_version_info.txt' INSTALLER_SCRIPT = build_files / 'setup_script.iss' PORTABLE_SPEC = build_files / 'portable.spec' DAEMON_SPEC = build_files / 'daemon.spec' ONEDIR_SPEC = build_files / 'onedir.spec' UPDATER_SPEC_FILE = build_files / 'updater.spec' CHANGELOG_FILE = script_dir / 'CHANGELOG.txt' TKDND_FILES = (build_files / 'tkdnd2.9.2', build_files / 'TkinterDnD2') UPDATER_MANIFEST_FILE = build_files / 'Updater.exe.MANIFEST' UPDATER_ICO = build_files / 'updater.ico' UPDATER_DIST = DIST_DIR / 'Updater.exe' REQUIREMENTS_FILE = script_dir / 'requirements.txt' REQUIREMENTS_DEV_FILE = script_dir / 'requirements-dev.txt' SRC_FRONTEND = script_dir / 'src-frontend' IS_VENV = sys.prefix != sys.base_prefix class ProgressUpload: # 1MB chunk size def __init__(self, filename, chunk_size=1_024_000): self.filename = filename self.chunk_size = chunk_size self.file_size = os.path.getsize(filename) self.size_read = 0 self.divisor = min(math.floor(math.log(self.file_size, 1000)) * 3, 9) # cap unit at a GB self.unit = {0: 'B', 3: 'KB', 6: 'MB', 9: 'GB'}[self.divisor] self.divisor = 10**self.divisor def __iter__(self): progress_str = f'0 / {self.file_size / self.divisor:.2f} {self.unit} (0 %)' sys.stderr.write(f'\rUploading {self.filename}: {progress_str}') with open(self.filename, 'rb') as file_to_upload: for chunk in iter(lambda: file_to_upload.read(self.chunk_size), b''): self.size_read += len(chunk) yield chunk sys.stderr.write('\b' * len(progress_str)) percentage = self.size_read / self.file_size * 100 completed_str = f'{self.size_read / self.divisor:.2f}' to_complete_str = f'{self.file_size / self.divisor:.2f} {self.unit}' progress_str = ( f'{completed_str} / {to_complete_str} ({percentage:.2f} %)' ) sys.stderr.write(progress_str) sys.stderr.flush() sys.stderr.write('\n') def __len__(self): return self.file_size def read_env(env_file='.env'): if not args.ci: with open(env_file, encoding='utf-8') as env_file: env_line = env_file.readline() while env_line: k, v = env_line.split('=', 1) os.environ[k] = v.strip() env_line = env_file.readline() return os.environ def add_new_changes(prev_changes: str): changes = set(prev_changes.split('\n')) with open(CHANGELOG_FILE, encoding='utf-8') as _file: add_changes = False line = _file.readline() while line: line = line.strip() if line == VERSION: add_changes = True elif add_changes: if line == '': break changes.add(line) line = _file.readline() if not add_changes: # TODO: generate changelog from commits # could use AI/LLM for summarizing subjects # see modern-desktop-app print(f'CHANGELOG does not contain changes for {VERSION}...') if args.ci: sys.exit(3) input('Press enter to try again...') return add_new_changes(prev_changes) return '\n'.join(sorted(changes, key=lambda item: item.casefold())) def set_spec_debug(debug_option): for file_name in (ONEDIR_SPEC, PORTABLE_SPEC, UPDATER_SPEC_FILE): with open(file_name, 'r+', encoding='utf-8', newline='\n') as _f: new_spec = _f.read().replace(f'debug={not debug_option}', f'debug={debug_option}') new_spec = new_spec.replace(f'console={not debug_option}', f'console={debug_option}') _f.seek(0) _f.write(new_spec) _f.truncate() def create_zip(zip_filename, files_to_zip, compression=zipfile.ZIP_BZIP2): with zipfile.ZipFile(zip_filename, 'w', compression=compression) as zf: for file_to_zip in files_to_zip: try: if type(file_to_zip) == tuple: zf.write(*file_to_zip) else: zf.write(file_to_zip) except FileNotFoundError: print(f'{file_to_zip} not found') def update_versions(version): """Update versions of version file and installer script""" with open(VERSION_FILE, 'r+', encoding='utf-8', newline='\n') as version_info_file: lines = version_info_file.readlines() for i, line in enumerate(lines): if line.startswith(' prodvers'): version_tuple = ', '.join(version.split('.')) lines[i] = f' prodvers=({version_tuple}, 0),\n' elif line.startswith(' filevers'): version_tuple = ', '.join(version.split('.')) lines[i] = f' filevers=({version_tuple}, 0),\n' elif line.startswith(" StringStruct('FileVersion"): lines[ i] = f" StringStruct('FileVersion', '{version}.0'),\n" elif line.startswith(" StringStruct('LegalCopyright'"): lines[ i] = f" StringStruct('LegalCopyright', 'Copyright (c) 2019 - {YEAR}, Elijah Lopez'),\n" elif line.startswith(" StringStruct('ProductVersion"): lines[ i] = f" StringStruct('ProductVersion', '{version}.0')])\n" break version_info_file.seek(0) version_info_file.writelines(lines) version_info_file.truncate() with open(INSTALLER_SCRIPT, 'r+', encoding='utf-8', newline='\n') as version_info_file: lines = version_info_file.readlines() for i, line in enumerate(lines): if line.startswith('#define MyAppVersion'): lines[i] = f'#define MyAppVersion "{version}"\n' elif line.startswith('OutputBaseFilename'): lines[i] = f'OutputBaseFilename={SETUP_OUTPUT_NAME}\n' break version_info_file.seek(0) version_info_file.writelines(lines) version_info_file.truncate() def local_install(): exe = os.getenv('LOCALAPPDATA') + '/Programs/Music Caster/Music Caster.exe' cmd = [ str(DIST_DIR / 'Music Caster Setup.exe'), '/FORCECLOSEAPPLICATIONS', '/VERYSILENT', '/MERGETASKS="!desktopicon"', ] cmd.extend(('&&', exe)) if not player_state.get('gui_open', False): cmd.append('--minimized') if player_state.get('status', 'NOT PLAYING') in ('PLAYING', 'PAUSED'): cmd.append('--start-playing') if player_state['status'] == 'PAUSED': cmd.append('--queue') if position := player_state.get('position', 0) > 0: cmd.append(f'--position={position}') Popen(cmd, shell=True) def test(title, fn, assert_statement=False): try: if assert_statement: assert fn(), title else: fn() except Exception as _e: print('---') print('TEST FAILED', title) print('TEST TRACEBACK', traceback.format_exc()) print('---') raise _e def upgrade_yt_dlp(): latest_ytdl = 'https://api.github.com/repos/yt-dlp/yt-dlp/commits/master' latest_mc = 'https://api.github.com/repos/elibroftw/music-caster/releases/latest' yt_dlp_master = requests.get(latest_ytdl).json() ytdl_publish = yt_dlp_master['commit']['author']['date'] yt_dlp_release_time = datetime.strptime(ytdl_publish, '%Y-%m-%dT%H:%M:%SZ') mc_publish = requests.get(latest_mc).json()['published_at'] mc_release_time = datetime.strptime(mc_publish, '%Y-%m-%dT%H:%M:%SZ') if mc_release_time < yt_dlp_release_time: # latest yt-dlp not used in latest MC print('New YouTube-dl found, updating Music Caster version') # if youtube-dl was released after the latest music-caster, update version and publish maj, _min, fix = VERSION.split('.') fix = int(fix) + 1 new_version = f'{maj}.{_min}.{fix}' with open('meta.py', 'r+', encoding='utf-8', newline='\n') as f: # VERSION = latest_version = '5.0.0' new_txt = f.read().replace( f"VERSION = latest_version = '{VERSION}'", f"VERSION = latest_version = '{new_version}'", ) f.seek(0) f.write(new_txt) with open(CHANGELOG_FILE, 'r+', encoding='utf-8', newline='\n') as f: content = ''.join( (f.readline(), f'\n{VERSION}\n- Upgrade dependencies\n', f.read())) f.seek(0) f.write(content) update_versions(new_version) # commit and push change from git import Repo repo = Repo('.git') repo.git.add(update=True) repo.index.commit('Upgraded yt-dlp') origin = repo.remote(name='origin') origin.push() if __name__ == '__main__': start_time = time.time() starting_dir = os.path.dirname(os.path.abspath(sys.argv[0])) os.chdir(starting_dir) # CONSTANTS YEAR = datetime.today().year parser = argparse.ArgumentParser(description='Music Caster Build Script') parser.add_argument( '--debug', '-d', default=False, action='store_true', help='build as console app + debug=True', ) parser.add_argument( '--ver_update', '-v', default=False, action='store_true', help="Only update build files' version", ) parser.add_argument( '--clean', '-c', default=False, action='store_true', help='Use pyinstaller --clean flag', ) parser.add_argument( '--upload', '-u', '--publish', default=False, action='store_true', help='Upload and Publish to GitHub after building', ) parser.add_argument( '--skip-build', '--no-build', default=False, action='store_true', help='Skip to testing / uploading', ) parser.add_argument('--skip-tests', '--no-tests', '--st', default=False, action='store_true', help='Skip testing') parser.add_argument( '--force-install', '-f', default=False, action='store_true', help='Force install after build', ) parser.add_argument( '--deps', '--dry', default=False, action='store_true', help='does not modify anything', ) parser.add_argument( '--test-auto-update', default=False, action='store_true', help='use if testing auto update', ) parser.add_argument( '--skip-deps', '--no-deps', '-i', default=False, action='store_true', help='skips installation of dependencies', ) parser.add_argument( '--no-install', default=False, action='store_true', help='do not install after building', ) parser.add_argument( '--ytdl', default=False, action='store_true', help='version++ if new youtube-dl available', ) parser.add_argument( '--ci', default=False, action='store_true', help='if running in a CI do not prompt just fail', ) args = parser.parse_args() if args.clean: shutil.rmtree(DIST_DIR, True) shutil.rmtree('build', True) for file in glob.iglob('*.log'): os.remove(file) sys.exit() if args.deps: print('Installing Music Caster dependencies') else: print('Building Music Caster') if args.ytdl: upgrade_yt_dlp() else: update_versions(VERSION) print('Updated versions of build files') if args.ver_update: sys.exit() install_to_user = '' if IS_VENV else '--user' pip_cmd = f'"{sys.executable}" -m pip install --upgrade {install_to_user} --upgrade-strategy eager -r "{REQUIREMENTS_FILE}" -r "{REQUIREMENTS_DEV_FILE}"' if args.deps or (not args.skip_build and not args.skip_deps): print('Installing and/or upgrading dependencies...') if platform.system() == 'Windows': # install tkdnd custom way sys_dir_name = Path(sys.executable).parent if IS_VENV: shutil.copytree( TKDND_FILES[0], f'{sys_dir_name}/tcl/tkdnd2.9.2', dirs_exist_ok=True, ) shutil.copytree( TKDND_FILES[1], f'{sys_dir_name.parent}/Lib/site-packages/TkinterDnD2', dirs_exist_ok=True, ) else: shutil.copytree(TKDND_FILES[0], f'{sys_dir_name}/tcl/tkdnd2.9.2', dirs_exist_ok=True) shutil.copytree( TKDND_FILES[1], f'{sys_dir_name}/Lib/site-packages/TkinterDnD2', dirs_exist_ok=True, ) install_time_start = time.time() p = Popen(pip_cmd, stdin=DEVNULL, stdout=None if args.deps else DEVNULL, text=True) if p.wait() > 0: print( 'ERROR: pip install failed\n', pip_cmd, ) sys.exit(1) if args.deps: print(f'Dependencies installed ({time.time() - install_time_start:.0} seconds)') sys.exit() assert IS_VENV # import third party libraries import requests from git import Repo sys.argv = sys.argv[:1] from src.shared import get_running_processes, is_already_running args.upload = args.upload and not args.test_auto_update try: player_state = requests.get('http://[::1]:2001/state').json() requests.get('http://[::1]:2001/exit') time.sleep(1) # wait for MC to exit except requests.exceptions.RequestException: player_state = {} for process in get_running_processes('Music Caster.exe'): # force close any other instances of MC pid = process['pid'] with suppress(PermissionError): os.kill(pid, 9) if args.debug: set_spec_debug(True) else: set_spec_debug(False) if args.upload: print('Will upload to GitHub after building') if args.test_auto_update: print("This test should test auto update and won't publish to GitHub") if not args.skip_build: # remove existing builds try: with suppress(FileNotFoundError): shutil.rmtree('dist/Music Caster OneDir', False) except PermissionError: print('Files in "dist/Music Caster OneDir" are in use somehow') sys.exit() main_file = 'Music Caster' if platform.system() == 'Windows': main_file += '.exe' for dist_file in (main_file, f'{SETUP_OUTPUT_NAME}.exe', 'Portable.zip'): with suppress(FileNotFoundError): dist_file = DIST_DIR / dist_file print(f'Removing {dist_file}') os.remove(dist_file) if not args.skip_build: print(f'building executables with debug={args.debug}') additional_args = '--log=DEBUG' if args.debug else '' if args.clean: additional_args += ' --clean' if USING_TAURI_FRONTEND: # Only build daemon for Tauri frontend print('Building daemon for Tauri frontend...') check_call( f'{sys.executable} -O -m PyInstaller -y {additional_args} {DAEMON_SPEC}', shell=True, ) s1 = None s4 = None else: # build frontend # check_call('yarn build', cwd=SRC_FRONTEND, shell=True) if platform.system() == 'Windows': s1 = Popen( f'{sys.executable} -O -m PyInstaller -y {additional_args} {PORTABLE_SPEC}', shell=True, ) else: s1 = None try: # build Updater # install go dependencies check_call('go install github.com/akavel/rsrc@latest') check_call( f'rsrc -manifest "{UPDATER_MANIFEST_FILE}" -ico "{UPDATER_ICO}"' ) check_call( f'go build -ldflags "-s -w -H windowsgui" -o "{UPDATER_DIST}"', cwd=SRC_DIR) except Exception as e: if args.upload: raise Exception('failed to build updater') from e print(f'WARNING: {e}') check_call( f'{sys.executable} -O -m PyInstaller -y {additional_args} {ONEDIR_SPEC}', shell=True, ) try: if platform.system() == 'Windows': s4 = Popen(f'iscc "{INSTALLER_SCRIPT}"') else: s4 = None except FileNotFoundError: s4 = None print( 'WARNING: could not create an installer because iscc is not installed or is not on PATH' ) try: portable_failed = s1.wait() except AttributeError: portable_failed = False if args.debug: set_spec_debug(False) if portable_failed: print('Portable installation failed') print(s1.communicate()[1]) sys.exit() # Portable if platform.system() == 'Windows': music_caster_portable = (DIST_DIR / 'Music Caster.exe', 'Music Caster.exe') updater_portable = (DIST_DIR / 'Updater.exe', 'Updater.exe') portable_files = [ music_caster_portable, (CHANGELOG_FILE, 'CHANGELOG.txt'), updater_portable, ] vlc_ext = 'dll' if platform.system() == 'Windows' else 'so' print('Creating dist/Portable.zip') create_zip( DIST_DIR / 'Portable.zip', portable_files, compression=zipfile.ZIP_DEFLATED, ) # zip directory for Linux or Darwin elif platform.system() == 'Darwin': pass else: shutil.rmtree(DIST_DIR / 'Music Caster OneDir/share/') linux_dist = DIST_DIR / 'Music Caster (Linux)' print(f'Creating {linux_dist}.zip') shutil.make_archive(linux_dist, 'zip', 'dist/Music Caster OneDir') with suppress(AttributeError): s4.wait() # Wait for InnoSetup script to finish print(f'v{VERSION} Build Time:', round(time.time() - start_time, 2), 'seconds') print('Last commit: ' + getoutput('git log --format="%H" -n 1')) if platform.system() == 'Windows': dist_files = ('Music Caster Setup.exe', 'Portable.zip') elif platform.system() == 'Darwin': dist_files = ('Music Caster (OSX).zip', ) else: dist_files = ('Music Caster (Linux).zip', ) # check if all files were built dist_files_exist = True for dist_file in dist_files: dist_file_path = DIST_DIR / dist_file if os.path.exists(dist_file_path): file_size = os.path.getsize(dist_file_path) // 1000 # KB file_exists_str = f'EXISTS {file_size:,} KB'.rjust(12) else: file_exists_str = 'DOES NOT EXIST!' dist_files_exist = False print((dist_file + ':').ljust(30) + file_exists_str) if dist_files_exist and platform.system() == 'Windows': with zipfile.ZipFile(DIST_DIR / 'Portable.zip') as portable_zip: if 'Updater.exe' in portable_zip.namelist(): print('Portable.zip/Updater.exe:'.ljust(30) + 'EXISTS') else: print('Portable.zip/Updater.exe:'.ljust(30) + 'DOES NOT EXIST!') dist_files_exist = False daemon_dist = DIST_DIR / 'Music Caster Daemon.exe' if daemon_dist.exists() and USING_TAURI_FRONTEND: shutil.copy2(daemon_dist, DIST_DIR / 'music-caster-daemon-x86_64-pc-windows-msvc.exe') if not args.skip_tests and dist_files_exist and not USING_TAURI_FRONTEND: try: sys.argv = sys.argv[:1] pytest_args = ['pytest'] if args.upload: pytest_args.append('--upload') if args.test_auto_update: pytest_args.append('--test-auto-update') if args.ci: pytest_args.append('--ci') pytest_args.append('--capture=no') check_call(pytest_args, cwd=SRC_DIR) except CalledProcessError: print('pytest: failed') sys.exit(1) # Test if executable can be run import appdirs user_data_dir = Path(appdirs.user_data_dir(roaming=True)) test( 'User data dir exists', lambda: user_data_dir.exists(), True, ) p = Popen( f'"{DIST_DIR}/Music Caster OneDir/Music Caster" -m --debug', shell=True) time.sleep(5) p.poll() if p.returncode is not None: print('got return code', p.returncode) test( 'No return code', lambda: p.returncode is None, True, ) test( 'Music Caster Should Be Running', lambda: is_already_running(threshold=1), True, ) time.sleep(2) test('Music Caster Should Accept Exit API', lambda: requests.post('http://[::1]:2001/exit')) time.sleep(2) test('Music Caster Should Have Exited', lambda: not is_already_running(), True) if args.debug or not dist_files_exist: print( 'Exiting early to avoid upload or installation of possibly broken build' ) sys.exit(0 if args.dist_files_exist else 2) print(f'Build v{VERSION} complete') print('Time taken:', round(time.time() - start_time, 2), 'seconds') print('Last commit: ' + getoutput('git log --format="%H" -n 1')) if args.upload: print('Will try to upload to GitHub') # upload to GitHub github = read_env()['github'] headers = { 'Authorization': f'token {github}', 'Accept': 'application/vnd.github.v3+json', } USERNAME = 'elibroftw' github_api = 'https://api.github.com' # check if tag vVERSION does not exist r = requests.get( f'{github_api}/repos/{USERNAME}/music-caster/releases/tags/v{VERSION}', headers=headers, ) if r.status_code != 404: if args.ci: print('INFO: not uploading build since tag already exists') sys.exit(0) print(f'ERROR: Release for tag "v{VERSION}" already exists') sys.exit(1) old_release = requests.get( f'{github_api}/repos/{USERNAME}/music-caster/releases/latest' ).json() try: old_release_id = old_release['id'] except KeyError: print( 'rate limit exceeded, upload manually at https://github.com/elibroftw/music-caster/releases' ) sys.exit() # keep changes of current major version if new version is a minor update body = '' if VERSION.endswith('.0') else old_release['body'] body = add_new_changes(body) if any(Repo('.git').index.diff(None)): # possible if build.cmd -v wasn't run before print('Warning: Changes detected.') print(Repo('.git').index.diff(None)) if not args.ci: input( 'Changed (not committed) files detected. Press enter to confirm upload.\n' ) print('Will upload and install at the same time!') t = threading.Thread(target=local_install) t.start() new_release = { 'tag_name': f'v{VERSION}', 'target_commitish': 'master', 'name': f'Music Caster v{VERSION}', 'body': body, 'draft': True, 'prerelease': False, } r = requests.post( f'{github_api}/repos/{USERNAME}/music-caster/releases', json=new_release, headers=headers, ) release = r.json() upload_url = release['upload_url'][:-13] release_id = release['id'] # upload assets for dist_file in dist_files: requests.post( upload_url, data=ProgressUpload(DIST_DIR / dist_file), params={'name': dist_file}, headers={ **headers, 'Content-Type': 'application/octet-stream' }, ) requests.post( f'{github_api}/repos/{USERNAME}/music-caster/releases/{release_id}', headers=headers, json={ 'body': body, 'draft': False }, ) # since winget is slower on the PR's, it's better to not delete anything # if not VERSION.endswith('.0'): # # delete old release if not a new major build # requests.delete(f'{github_api}/repos/{USERNAME}/music-caster/releases/{old_release_id}', headers=headers) print(f'Published Release v{VERSION}') print( f'v{VERSION} Total Time Taken:', round(time.time() - start_time, 2), 'seconds', ) t.join() elif not args.no_install and (not args.skip_tests or args.force_install): print( 'Installing Music Caster and it will be launched after installation.' ) local_install() ================================================ FILE: build_files/TkinterDnD2/TkinterDnD.py ================================================ # -*- coding: utf-8 -*- """Python wrapper for the tkdnd tk extension. The tkdnd extension provides an interface to native, platform specific drag and drop mechanisms. Under Unix the drag & drop protocol in use is the XDND protocol version 5 (also used by the Qt toolkit, and the KDE and GNOME desktops). Under Windows, the OLE2 drag & drop interfaces are used. Under Macintosh, the Cocoa drag and drop interfaces are used. Once the TkinterDnD2 package is installed, it is safe to do: from TkinterDnD2 import * This will add the classes TkinterDnD.Tk and TkinterDnD.TixTk to the global namespace, plus the following constants: PRIVATE, NONE, ASK, COPY, MOVE, LINK, REFUSE_DROP, DND_TEXT, DND_FILES, DND_ALL, CF_UNICODETEXT, CF_TEXT, CF_HDROP, FileGroupDescriptor, FileGroupDescriptorW Drag and drop for the application can then be enabled by using one of the classes TkinterDnD.Tk() or (in case the tix extension shall be used) TkinterDnD.TixTk() as application main window instead of a regular tkinter.Tk() window. This will add the drag-and-drop specific methods to the Tk window and all its descendants.""" try: import Tkinter as tkinter # type: ignore import tkinter.ttk as ttk # type: ignore except ImportError: import tkinter import tkinter.ttk as ttk TkdndVersion = None def _require(tkroot): """Internal function.""" global TkdndVersion try: TkdndVersion = tkroot.tk.call('package', 'require', 'tkdnd') except tkinter.TclError: raise RuntimeError('Unable to load tkdnd library.') return TkdndVersion class DnDEvent: """Internal class. Container for the properties of a drag-and-drop event, similar to a normal tkinter.Event. An instance of the DnDEvent class has the following attributes: action (string) actions (tuple) button (int) code (string) codes (tuple) commonsourcetypes (tuple) commontargettypes (tuple) data (string) name (string) types (tuple) modifiers (tuple) supportedsourcetypes (tuple) sourcetypes (tuple) type (string) supportedtargettypes (tuple) widget (widget instance) x_root (int) y_root (int) Depending on the type of DnD event however, not all attributes may be set. """ pass class DnDWrapper: """Internal class.""" # some of the percent substitutions need to be enclosed in braces # so we can use splitlist() to convert them into tuples _subst_format_dnd = ('%A', '%a', '%b', '%C', '%c', '{%CST}', '{%CTT}', '%D', '%e', '{%L}', '{%m}', '{%ST}', '%T', '{%t}', '{%TT}', '%W', '%X', '%Y') _subst_format_str_dnd = " ".join(_subst_format_dnd) tkinter.BaseWidget._subst_format_dnd = _subst_format_dnd tkinter.BaseWidget._subst_format_str_dnd = _subst_format_str_dnd def _substitute_dnd(self, *args): """Internal function.""" if len(args) != len(self._subst_format_dnd): return args def getint_event(s): try: return int(s) except ValueError: return s def splitlist_event(s): try: return self.tk.splitlist(s) except ValueError: return s # valid percent substitutions for DnD event types # (tested with tkdnd-2.8 on debian jessie): # <> : %W, %X, %Y %e, %t # <> : %A, %W, %e # <> : all except : %D (always empty) # <> : all except %D (always empty) # <> :all except %D (always empty) # <> : all A, a, b, C, c, CST, CTT, D, e, L, m, ST, T, t, TT, W, X, Y = args ev = DnDEvent() ev.action = A ev.actions = splitlist_event(a) ev.button = getint_event(b) ev.code = C ev.codes = splitlist_event(c) ev.commonsourcetypes = splitlist_event(CST) ev.commontargettypes = splitlist_event(CTT) ev.data = D ev.name = e ev.types = splitlist_event(L) ev.modifiers = splitlist_event(m) ev.supportedsourcetypes = splitlist_event(ST) ev.sourcetypes = splitlist_event(t) ev.type = T ev.supportedtargettypes = splitlist_event(TT) try: ev.widget = self.nametowidget(W) except KeyError: ev.widget = W ev.x_root = getint_event(X) ev.y_root = getint_event(Y) return ev, tkinter.BaseWidget._substitute_dnd = _substitute_dnd def _dnd_bind(self, what, sequence, func, add, needcleanup=True): """Internal function.""" if isinstance(func, str): self.tk.call(what + (sequence, func)) elif func: funcid = self._register(func, self._substitute_dnd, needcleanup) # FIXME: why doesn't the "return 'break'" mechanism work here?? # cmd = ('%sif {"[%s %s]" == "break"} break\n' % (add and '+' or '', # funcid, self._subst_format_str_dnd)) cmd = '%s%s %s' % (add and '+' or '', funcid, self._subst_format_str_dnd) self.tk.call(what + (sequence, cmd)) return funcid elif sequence: return self.tk.call(what + (sequence,)) else: return self.tk.splitlist(self.tk.call(what)) tkinter.BaseWidget._dnd_bind = _dnd_bind def dnd_bind(self, sequence=None, func=None, add=None): """Bind to this widget at drag and drop event SEQUENCE a call to function FUNC. SEQUENCE may be one of the following: <>, <>, <>, <>, <>, <>, <> . The callbacks for the > events, with the exception of <>, should always return an action (i.e. one of COPY, MOVE, LINK, ASK or PRIVATE). The callback for the <> event must return a tuple containing three elements: the drop action(s) supported by the drag source, the format type(s) that the data can be dropped as and finally the data that shall be dropped. Each of these three elements may be a tuple of strings or a single string.""" return self._dnd_bind(('bind', self._w), sequence, func, add) tkinter.BaseWidget.dnd_bind = dnd_bind def drag_source_register(self, button=None, *dndtypes): """This command will register SELF as a drag source. A drag source is a widget than can start a drag action. This command can be executed multiple times on a widget. When SELF is registered as a drag source, optional DNDTYPES can be provided. These DNDTYPES will be provided during a drag action, and it can contain platform independent or platform specific types. Platform independent are DND_Text for dropping text portions and DND_Files for dropping a list of files (which can contain one or multiple files) on SELF. However, these types are indicative/informative. SELF can initiate a drag action with even a different type list. Finally, button is the mouse button that will be used for starting the drag action. It can have any of the values 1 (left mouse button), 2 (middle mouse button - wheel) and 3 (right mouse button). If button is not specified, it defaults to 1.""" # hack to fix a design bug from the first version if button is None: button = 1 else: try: button = int(button) except ValueError: # no button defined, button is actually # something like DND_TEXT dndtypes = (button,) + dndtypes button = 1 self.tk.call( 'tkdnd::drag_source', 'register', self._w, dndtypes, button) tkinter.BaseWidget.drag_source_register = drag_source_register def drag_source_unregister(self): """This command will stop SELF from being a drag source. Thus, window will stop receiving events related to drag operations. It is an error to use this command for a window that has not been registered as a drag source with drag_source_register().""" self.tk.call('tkdnd::drag_source', 'unregister', self._w) tkinter.BaseWidget.drag_source_unregister = drag_source_unregister def drop_target_register(self, *dndtypes): """This command will register SELF as a drop target. A drop target is a widget than can accept a drop action. This command can be executed multiple times on a widget. When SELF is registered as a drop target, optional DNDTYPES can be provided. These types list can contain one or more types that SELF will accept during a drop action, and it can contain platform independent or platform specific types. Platform independent are DND_Text for dropping text portions and DND_Files for dropping a list of files (which can contain one or multiple files) on SELF.""" self.tk.call('tkdnd::drop_target', 'register', self._w, dndtypes) tkinter.BaseWidget.drop_target_register = drop_target_register def drop_target_unregister(self): """This command will stop SELF from being a drop target. Thus, SELF will stop receiving events related to drop operations. It is an error to use this command for a window that has not been registered as a drop target with drop_target_register().""" self.tk.call('tkdnd::drop_target', 'unregister', self._w) tkinter.BaseWidget.drop_target_unregister = drop_target_unregister def platform_independent_types(self, *dndtypes): """This command will accept a list of types that can contain platform independent or platform specific types. A new list will be returned, where each platform specific type in DNDTYPES will be substituted by one or more platform independent types. Thus, the returned list may have more elements than DNDTYPES.""" return self.tk.split(self.tk.call( 'tkdnd::platform_independent_types', dndtypes)) tkinter.BaseWidget.platform_independent_types = platform_independent_types def platform_specific_types(self, *dndtypes): """This command will accept a list of types that can contain platform independent or platform specific types. A new list will be returned, where each platform independent type in DNDTYPES will be substituted by one or more platform specific types. Thus, the returned list may have more elements than DNDTYPES.""" return self.tk.split(self.tk.call( 'tkdnd::platform_specific_types', dndtypes)) tkinter.BaseWidget.platform_specific_types = platform_specific_types def get_dropfile_tempdir(self): """This command will return the temporary directory used by TkDND for storing temporary files. When the package is loaded, this temporary directory will be initialised to a proper directory according to the operating system. This default initial value can be changed to be the value of the following environmental variables: TKDND_TEMP_DIR, TEMP, TMP.""" return self.tk.call('tkdnd::GetDropFileTempDirectory') tkinter.BaseWidget.get_dropfile_tempdir = get_dropfile_tempdir def set_dropfile_tempdir(self, tempdir): """This command will change the temporary directory used by TkDND for storing temporary files to TEMPDIR.""" self.tk.call('tkdnd::SetDropFileTempDirectory', tempdir) tkinter.BaseWidget.set_dropfile_tempdir = set_dropfile_tempdir # The main window classes that enable Drag & Drop for # themselves and all their descendant widgets: class Tk(tkinter.Tk, DnDWrapper): """Creates a new instance of a tkinter.Tk() window; all methods of the DnDWrapper class apply to this window and all its descendants.""" def __init__(self, *args, **kw): tkinter.Tk.__init__(self, *args, **kw) self.TkdndVersion = _require(self) class TixTk(tkinter.Tk, DnDWrapper): """Creates a new instance of a tkinter.Tk() window with ttk support; all methods of the DnDWrapper class apply to this window and all its descendants.""" def __init__(self, *args, **kw): tkinter.Tk.__init__(self, *args, **kw) self.TkdndVersion = _require(self) ================================================ FILE: build_files/TkinterDnD2/__init__.py ================================================ # dnd actions PRIVATE = 'private' NONE = 'none' ASK = 'ask' COPY = 'copy' MOVE = 'move' LINK = 'link' REFUSE_DROP = 'refuse_drop' # dnd types DND_TEXT = 'DND_Text' DND_FILES = 'DND_Files' DND_ALL = '*' CF_UNICODETEXT = 'CF_UNICODETEXT' CF_TEXT = 'CF_TEXT' CF_HDROP = 'CF_HDROP' FileGroupDescriptor = 'FileGroupDescriptor - FileContents'# ?? FileGroupDescriptorW = 'FileGroupDescriptorW - FileContents'# ?? from TkinterDnD2 import TkinterDnD ================================================ FILE: build_files/Updater.cs.txt ================================================ // NOTE: This was the old portable updater // the new updater is updater.go using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.IO.Compression; using System.Net; using System.Text.Json; /** old code in build.py that was used to build the updater def get_msbuild(): import re import winreg reg = winreg.ConnectRegistry(None, winreg.HKEY_LOCAL_MACHINE) root_key = winreg.OpenKey(reg, r'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall', 0, winreg.KEY_READ | winreg.KEY_WOW64_32KEY) num_sub_keys = winreg.QueryInfoKey(root_key)[0] vs = {} for i in range(num_sub_keys): with suppress(EnvironmentError): software: dict = {} software_key = winreg.EnumKey(root_key, i) software_key = winreg.OpenKey(root_key, software_key) info_key = winreg.QueryInfoKey(software_key) for value in range(info_key[1]): value = winreg.EnumValue(software_key, value) software[value[0]] = value[1] display_name = software.get('DisplayName', '') if re.search(r'Visual Studio (Community|Professional|Enterprise)', display_name): software['ver'] = int(software['DisplayName'].rsplit(maxsplit=1)[1]) vs_ver = vs.get('ver', 0) if software['ver'] > vs_ver: vs = software if vs is None: raise RuntimeWarning('No installation of Visual Studio could be found') ms_build_path = vs['InstallLocation'] + r'\MSBuild\Current\Bin\MSBuild.exe' return ms_build_path ... ms_build = get_msbuild() check_call(f'{ms_build} "{starting_dir}/Music Caster Updater/Music Caster Updater.sln"' f' /t:Build /p:Configuration=Release /p:PlatformTarget=x86') ... # portable_files.extend([(f, os.path.basename(f)) for f in glob.iglob(f'{glob.escape(UPDATER_DIST_PATH)}/*.*')]) */ namespace Music_Caster_Updater { class Program { private static void ExtractZip(string fileName) { /** * Extracts fileName (ends with .zip) to root directory * Deletes fileName after */ using (ZipArchive archive = ZipFile.OpenRead(fileName)) { foreach (ZipArchiveEntry entry in archive.Entries) { string dir = Path.GetDirectoryName(entry.FullName); if (dir != "" && !Directory.Exists(dir)) Directory.CreateDirectory(dir); try { if (File.Exists(entry.FullName)) File.Delete(entry.FullName); entry.ExtractToFile(entry.FullName); } catch (IOException) { } catch (System.UnauthorizedAccessException) { } } } File.Delete(fileName); } private static void Download(string url, string outfile) { // Downloads url to outfile // If outfile is a zip, extract it Debug.WriteLine($"Downloading {outfile}"); using WebClient myWebClient = new WebClient(); myWebClient.DownloadFile(url, outfile); if (outfile.EndsWith(".zip")) ExtractZip(outfile); } private static List DirectorySearch(string dir) { // returns all files in a dir and its subdirs recursively List files = new List(); try { foreach (string f in Directory.GetFiles(dir)) files.Add(Path.GetFileName(f)); foreach (string d in Directory.GetDirectories(dir)) files.AddRange(DirectorySearch(d)); } catch (Exception) { } return files; } static void Main() { // use @ for string literals const string releasesURL = @"https://api.github.com/repos/elibroftw/music-caster/releases/latest"; const string settingsFile = "settings.json"; Directory.SetCurrentDirectory(AppDomain.CurrentDomain.BaseDirectory); // Change working dir to dir of this program Dictionary loadedSettings = new Dictionary() { { "DEBUG", false } }; if (File.Exists(settingsFile)) { using StreamReader fs = new StreamReader(settingsFile); loadedSettings = JsonSerializer.Deserialize>(fs.ReadToEnd()); } bool debugSetting = false; try { debugSetting = ((JsonElement)loadedSettings.GetValueOrDefault("DEBUG")).GetBoolean(); } catch (InvalidCastException) { } Dictionary jsonResponse; HttpWebRequest request = (HttpWebRequest)WebRequest.Create(releasesURL); request.Method = "GET"; request.UserAgent = "MusicCasterUpdaterC#"; using (HttpWebResponse response = (HttpWebResponse)request.GetResponse()) using (Stream stream = response.GetResponseStream()) using (StreamReader reader = new StreamReader(stream)) { jsonResponse = JsonSerializer.Deserialize>((new StreamReader(response.GetResponseStream())).ReadToEnd()); } string setupDownloadURL = "", portableDownloadURL = ""; JsonElement assets = (JsonElement) jsonResponse.GetValueOrDefault("assets"); foreach (JsonElement asset in assets.EnumerateArray()) { if (asset.GetProperty("name").ToString().Contains("exe")) setupDownloadURL = asset.GetProperty("browser_download_url").ToString(); else if (asset.GetProperty("name").ToString().Tocasefold().Contains("portable")) portableDownloadURL = asset.GetProperty("browser_download_url").ToString(); } if (debugSetting) { string latestVersion = jsonResponse.GetValueOrDefault("tag_name").ToString(); Debug.WriteLine($"Latest Version: {latestVersion}"); Debug.WriteLine($"Portable: {portableDownloadURL}"); Debug.WriteLine($"Installer: {setupDownloadURL}"); } else if (File.Exists("unins000.exe")) { // Was installed using the Installer Download(setupDownloadURL, "MC_Installer.exe"); Process.Start("MC_Installer.exe", "/VERYSILENT /CLOSEAPPLICATIONS /FORCECLOSEAPPLICATIONS /MERGETASKS=\"!desktopicon\""); } else { // portable installation Download(portableDownloadURL, "Portable.zip"); Process.Start("\"Music Caster.exe\" --nupdate"); } } } } ================================================ FILE: build_files/Updater.exe.MANIFEST ================================================ ================================================ FILE: build_files/daemon.spec ================================================ # -*- mode: python ; coding: utf-8 -*- import os from PyInstaller.building.api import PYZ, EXE from PyInstaller.building.build_main import Analysis, Tree # type: ignore from PyInstaller.config import CONF import platform CONF['distpath'] = './dist' # type: ignore # CONF['workpath'] = './build' block_cipher = None a = Analysis([f'{os.getcwd()}/src/music_caster.py'], pathex=[os.getcwd()], binaries=[], datas=[('../CHANGELOG.TXT', '.')], hiddenimports=['pystray._win32', 'zeroconf._utils.ipaddress', 'zeroconf._handlers.answers'], hookspath=[], runtime_hooks=[], excludes=['crypto', 'cryptography', 'pycryptodome', 'pandas', 'gevent', 'numpy', 'simplejson', 'PySide2', 'PyQt5', 'greenlet'], win_no_prefer_redirects=False, win_private_assemblies=False, cipher=block_cipher, noarchive=False) a.datas.extend(Tree('src/templates', 'templates')) a.datas.extend(Tree('src/static', 'static')) VLC_EXCLUDES = ['*.dll', '*.so', '*.so*', '*.dylib*', '*.dylib'] if platform.system() == 'Windows': VLC_EXCLUDES.remove('*.dll') elif platform.system() == 'Darwin': VLC_EXCLUDES.remove('*.dylib*') VLC_EXCLUDES.remove('*.dylib') elif platform.system() == 'Linux': VLC_EXCLUDES.remove('*.so*') VLC_EXCLUDES.remove('*.so') a.datas.extend(Tree('src/vlc_lib', 'vlc_lib', excludes=VLC_EXCLUDES)) a.datas.extend(Tree('src/languages', 'languages')) a.datas.extend(Tree('build_files/tkdnd2.9.2', 'tkdnd2.9.2')) a.datas.extend(Tree('src/theme', 'theme')) # a.datas.extend(Tree('src-frontend/dist', 'frontend')) pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) exe = EXE(pyz, a.scripts, a.binaries, a.zipfiles, a.datas, [], name='Music Caster Daemon', debug=False, bootloader_ignore_signals=False, strip=False, upx=False, runtime_tmpdir=None, # TODO: use ENV variable console=True, version='mc_version_info.txt', icon=os.path.abspath('resources/Music Caster Icon.ico')) ================================================ FILE: build_files/flatpak-pip-generator.py ================================================ #!/usr/bin/env python3 __license__ = 'MIT' import argparse import json import hashlib import os import shutil import subprocess import sys import tempfile import urllib.request from collections import OrderedDict from typing import Dict import requirements parser = argparse.ArgumentParser() parser.add_argument('packages', nargs='*') parser.add_argument('--python2', action='store_true', help='Look for a Python 2 package') parser.add_argument('--cleanup', choices=['scripts', 'all'], help='Select what to clean up after build') parser.add_argument('--requirements-file', '-r', help='Specify requirements.txt file') parser.add_argument('--build-only', action='store_const', dest='cleanup', const='all', help='Clean up all files after build') parser.add_argument('--build-isolation', action='store_true', default=False, help=( 'Do not disable build isolation. ' 'Mostly useful on pip that does\'t ' 'support the feature.' )) parser.add_argument('--checker-data', action='store_true', help='Include x-checker-data in output for the "Flatpak External Data Checker"') parser.add_argument('--output', '-o', help='Specify output file name') parser.add_argument('--runtime', help='Specify a flatpak to run pip inside of a sandbox, ensures python version compatibility') parser.add_argument('--yaml', action='store_true', help='Use YAML as output format instead of JSON') opts = parser.parse_args() if opts.yaml: try: import yaml except ImportError: exit('PyYAML modules is not installed. Run "pip install PyYAML"') def get_pypi_url(name: str, filename: str) -> str: url = 'https://pypi.org/pypi/{}/json'.format(name) print('Extracting download url for', name) with urllib.request.urlopen(url) as response: body = json.loads(response.read().decode('utf-8')) for release in body['releases'].values(): for source in release: if source['filename'] == filename: return source['url'] raise Exception('Failed to extract url from {}'.format(url)) def get_tar_package_url_pypi(name: str, version: str) -> str: url = 'https://pypi.org/pypi/{}/{}/json'.format(name, version) with urllib.request.urlopen(url) as response: body = json.loads(response.read().decode('utf-8')) for ext in ['bz2', 'gz', 'xz', 'zip']: for source in body['urls']: if source['url'].endswith(ext): return source['url'] err = 'Failed to get {}-{} source from {}'.format(name, version, url) raise Exception(err) def get_package_name(filename: str) -> str: if filename.endswith(('bz2', 'gz', 'xz', 'zip')): segments = filename.split('-') if len(segments) == 2: return segments[0] return '-'.join(segments[:len(segments) - 1]) elif filename.endswith('whl'): segments = filename.split('-') if len(segments) == 5: return segments[0] candidate = segments[:len(segments) - 4] # Some packages list the version number twice # e.g. PyQt5-5.15.0-5.15.0-cp35.cp36.cp37.cp38-abi3-manylinux2014_x86_64.whl if candidate[-1] == segments[len(segments) - 4]: return '-'.join(candidate[:-1]) return '-'.join(candidate) else: raise Exception( 'Downloaded filename: {} does not end with bz2, gz, xz, zip, or whl'.format(filename) ) def get_file_version(filename: str) -> str: name = get_package_name(filename) segments = filename.split(name + '-') version = segments[1].split('-')[0] for ext in ['tar.gz', 'whl', 'tar.xz', 'tar.gz', 'tar.bz2', 'zip']: version = version.replace('.' + ext, '') return version def get_file_hash(filename: str) -> str: sha = hashlib.sha256() print('Generating hash for', filename.split('/')[-1]) with open(filename, 'rb') as f: while True: data = f.read(1024 * 1024 * 32) if not data: break sha.update(data) return sha.hexdigest() def download_tar_pypi(url: str, tempdir: str) -> None: with urllib.request.urlopen(url) as response: file_path = os.path.join(tempdir, url.split('/')[-1]) with open(file_path, 'x+b') as tar_file: shutil.copyfileobj(response, tar_file) def parse_continuation_lines(fin): for line in fin: line = line.rstrip('\n') while line.endswith('\\'): try: line = line[:-1] + next(fin).rstrip('\n') except StopIteration: exit('Requirements have a wrong number of line continuation characters "\\"') yield line def fprint(string: str) -> None: separator = '=' * 72 # Same as `flatpak-builder` print(separator) print(string) print(separator) packages = [] if opts.requirements_file: requirements_file = os.path.expanduser(opts.requirements_file) try: with open(requirements_file, 'r') as req_file: reqs = parse_continuation_lines(req_file) reqs_as_str = '\n'.join([r.split('--hash')[0] for r in reqs]) packages = list(requirements.parse(reqs_as_str)) except FileNotFoundError: pass elif opts.packages: packages = list(requirements.parse('\n'.join(opts.packages))) with tempfile.NamedTemporaryFile('w', delete=False, prefix='requirements.') as req_file: req_file.write('\n'.join(opts.packages)) requirements_file = req_file.name else: exit('Please specifiy either packages or requirements file argument') for i in packages: if i["name"].casefold().startswith("pyqt"): print("PyQt packages are not supported by flapak-pip-generator") print("However, there is a BaseApp for PyQt available, that you should use") print("Visit https://github.com/flathub/com.riverbankcomputing.PyQt.BaseApp for more information") sys.exit(0) with open(requirements_file, 'r') as req_file: use_hash = '--hash=' in req_file.read() python_version = '2' if opts.python2 else '3' if opts.python2: pip_executable = 'pip2' else: pip_executable = 'pip3' if opts.runtime: flatpak_cmd = [ 'flatpak', '--devel', '--share=network', '--filesystem=/tmp', '--command={}'.format(pip_executable), 'run', opts.runtime ] if opts.requirements_file: requirements_file = os.path.expanduser(opts.requirements_file) if os.path.exists(requirements_file): prefix = os.path.realpath(requirements_file) flag = '--filesystem={}'.format(prefix) flatpak_cmd.insert(1,flag) else: flatpak_cmd = [pip_executable] if opts.output: output_package = opts.output elif opts.requirements_file: output_package = 'python{}-{}'.format( python_version, os.path.basename(opts.requirements_file).replace('.txt', ''), ) elif len(packages) == 1: output_package = 'python{}-{}'.format( python_version, packages[0].name, ) else: output_package = 'python{}-modules'.format(python_version) if opts.yaml: output_filename = output_package + '.yaml' else: output_filename = output_package + '.json' modules = [] vcs_modules = [] sources = {} tempdir_prefix = 'pip-generator-{}'.format(os.path.basename(output_package)) with tempfile.TemporaryDirectory(prefix=tempdir_prefix) as tempdir: pip_download = flatpak_cmd + [ 'download', '--exists-action=i', '--dest', tempdir, '-r', requirements_file ] if use_hash: pip_download.append('--require-hashes') fprint('Downloading sources') cmd = ' '.join(pip_download) print('Running: "{}"'.format(cmd)) try: subprocess.run(pip_download, check=True) except subprocess.CalledProcessError: print('Failed to download') print('Please fix the module manually in the generated file') if not opts.requirements_file: try: os.remove(requirements_file) except FileNotFoundError: pass fprint('Downloading arch independent packages') for filename in os.listdir(tempdir): if not filename.endswith(('bz2', 'any.whl', 'gz', 'xz', 'zip')): version = get_file_version(filename) name = get_package_name(filename) url = get_tar_package_url_pypi(name, version) print('Deleting', filename) try: os.remove(os.path.join(tempdir, filename)) except FileNotFoundError: pass print('Downloading {}'.format(url)) download_tar_pypi(url, tempdir) files = {get_package_name(f): [] for f in os.listdir(tempdir)} for filename in os.listdir(tempdir): name = get_package_name(filename) files[name].append(filename) # Delete redundant sources, for vcs sources for name in files: if len(files[name]) > 1: zip_source = False for f in files[name]: if f.endswith('.zip'): zip_source = True if zip_source: for f in files[name]: if not f.endswith('.zip'): try: os.remove(os.path.join(tempdir, f)) except FileNotFoundError: pass vcs_packages = { x.name: {'vcs': x.vcs, 'revision': x.revision, 'uri': x.uri} for x in packages if x.vcs } fprint('Obtaining hashes and urls') for filename in os.listdir(tempdir): name = get_package_name(filename) sha256 = get_file_hash(os.path.join(tempdir, filename)) if name in vcs_packages: uri = vcs_packages[name]['uri'] revision = vcs_packages[name]['revision'] vcs = vcs_packages[name]['vcs'] url = 'https://' + uri.split('://', 1)[1] s = 'commit' if vcs == 'svn': s = 'revision' source = OrderedDict([ ('type', vcs), ('url', url), (s, revision), ]) is_vcs = True else: url = get_pypi_url(name, filename) source = OrderedDict([ ('type', 'file'), ('url', url), ('sha256', sha256)]) if opts.checker_data: source['x-checker-data'] = { 'type': 'pypi', 'name': name} if url.endswith(".whl"): source['x-checker-data']['packagetype'] = 'bdist_wheel' is_vcs = False sources[name] = {'source': source, 'vcs': is_vcs} # Python3 packages that come as part of org.freedesktop.Sdk. system_packages = ['cython', 'easy_install', 'mako', 'markdown', 'meson', 'pip', 'pygments', 'setuptools', 'six', 'wheel'] fprint('Generating dependencies') for package in packages: if package.name is None: print('Warning: skipping invalid requirement specification {} because it is missing a name'.format(package.line), file=sys.stderr) print('Append #egg= to the end of the requirement line to fix', file=sys.stderr) continue elif package.name.casefold() in system_packages: continue if len(package.extras) > 0: extras = '[' + ','.join(extra for extra in package.extras) + ']' else: extras = '' version_list = [x[0] + x[1] for x in package.specs] version = ','.join(version_list) if package.vcs: revision = '' if package.revision: revision = '@' + package.revision pkg = package.uri + revision + '#egg=' + package.name else: pkg = package.name + extras + version dependencies = [] # Downloads the package again to list dependencies tempdir_prefix = 'pip-generator-{}'.format(package.name) with tempfile.TemporaryDirectory(prefix='{}-{}'.format(tempdir_prefix, package.name)) as tempdir: pip_download = flatpak_cmd + [ 'download', '--exists-action=i', '--dest', tempdir, ] try: print('Generating dependencies for {}'.format(package.name)) subprocess.run(pip_download + [pkg], check=True, stdout=subprocess.DEVNULL) for filename in os.listdir(tempdir): dep_name = get_package_name(filename) if dep_name.casefold() in system_packages: continue dependencies.append(dep_name) except subprocess.CalledProcessError: print('Failed to download {}'.format(package.name)) is_vcs = True if package.vcs else False package_sources = [] for dependency in dependencies: if dependency in sources: source = sources[dependency] elif dependency.replace('_', '-') in sources: source = sources[dependency.replace('_', '-')] else: continue if not (not source['vcs'] or is_vcs): continue package_sources.append(source['source']) if package.vcs: name_for_pip = '.' else: name_for_pip = pkg module_name = 'python{}-{}'.format(python_version, package.name) pip_command = [ pip_executable, 'install', '--verbose', '--exists-action=i', '--no-index', '--find-links="file://${PWD}"', '--prefix=${FLATPAK_DEST}', '"{}"'.format(name_for_pip) ] if not opts.build_isolation: pip_command.append('--no-build-isolation') module = OrderedDict([ ('name', module_name), ('buildsystem', 'simple'), ('build-commands', [' '.join(pip_command)]), ('sources', package_sources), ]) if opts.cleanup == 'all': module['cleanup'] = ['*'] elif opts.cleanup == 'scripts': module['cleanup'] = ['/bin', '/share/man/man1'] if package.vcs: vcs_modules.append(module) else: modules.append(module) modules = vcs_modules + modules if len(modules) == 1: pypi_module = modules[0] else: pypi_module = { 'name': output_package, 'buildsystem': 'simple', 'build-commands': [], 'modules': modules, } print() with open(output_filename, 'w') as output: if opts.yaml: class OrderedDumper(yaml.Dumper): def increase_indent(self, flow=False, indentless=False): return super(OrderedDumper, self).increase_indent(flow, False) def dict_representer(dumper, data): return dumper.represent_dict(data.items()) OrderedDumper.add_representer(OrderedDict, dict_representer) yaml.dump(pypi_module, output, Dumper=OrderedDumper) else: output.write(json.dumps(pypi_module, indent=4)) print('Output saved to {}'.format(output_filename)) ================================================ FILE: build_files/mc_version_info.txt ================================================ # UTF-8 # For more details about fixed file info 'ffi' see: http://msdn.microsoft.com/en-us/library/ms646997.aspx VSVersionInfo( ffi=FixedFileInfo( prodvers=(5, 25, 1, 0), filevers=(5, 25, 1, 0), # Contains a bitmask that specifies the valid bits 'flags'r mask=0x17, # Contains a bitmask that specifies the Boolean attributes of the file. flags=0x0, # The operating system for which this file was designed. # 0x4 - NT and there is no need to change it. OS=0x4, # The general type of file. # 0x1 - the file is an application. fileType=0x1, # The function of the file. # 0x0 - the function is not defined for this fileType subtype=0x0, # Creation date and time stamp. date=(0, 0) ), kids=[ StringFileInfo( [ StringTable( '000004b0', [StringStruct('CompanyName', 'Elijah Lopez'), StringStruct('FileDescription', 'Music Caster'), StringStruct('FileVersion', '5.25.1.0'), StringStruct('InternalName', 'Music Caster'), StringStruct('LegalCopyright', 'Copyright (c) 2019 - 2025, Elijah Lopez'), StringStruct('OriginalFilename', 'Music Caster.exe'), StringStruct('ProductName', 'Music Caster'), StringStruct('ProductVersion', '5.25.1.0')]) ]), VarFileInfo([VarStruct('Translation', [0, 1200])]) ] ) ================================================ FILE: build_files/mcu_version_info.txt ================================================ # UTF-8 VSVersionInfo( ffi=FixedFileInfo( prodvers=(2, 3, 0, 0), filevers=(2, 3, 0, 0), mask=0x17, flags=0x0, OS=0x4, fileType=0x1, subtype=0x0, date=(0, 0)), kids=[StringFileInfo( [StringTable( '000004b0', [StringStruct('CompanyName', 'Elijah Lopez'), StringStruct('FileDescription', 'Updater for Music Caster'), StringStruct('FileVersion', '2.3.0.0'), StringStruct('InternalName', 'Music Caster Updater'), StringStruct('LegalCopyright', 'Copyright (c) 2019 - 2020, Elijah Lopez'), StringStruct('OriginalFilename', 'Updater.exe'), StringStruct('ProductName', 'Music Caster Updater'), StringStruct('ProductVersion', '2.3.0.0')])]), VarFileInfo([VarStruct('Translation', [0, 1200])])]) ================================================ FILE: build_files/onedir.spec ================================================ # -*- mode: python ; coding: utf-8 -*- import os from PyInstaller.building.api import PYZ, EXE, COLLECT from PyInstaller.building.build_main import Analysis, Tree # type: ignore from PyInstaller.config import CONF import platform CONF['distpath'] = './dist' # type: ignore block_cipher = None # CONF['workpath'] = './build' # TODO: test on MAC OSX data_files = [('../CHANGELOG.txt', '.')] a = Analysis([f'{os.getcwd()}/src/music_caster.py'], pathex=[os.getcwd()], binaries=[], datas=data_files, hiddenimports=['pystray._win32', 'zeroconf._utils.ipaddress', 'zeroconf._handlers.answers'], hookspath=[], runtime_hooks=[], excludes=['crypto', 'cryptography', 'pycryptodome', 'pandas', 'gevent', 'numpy', 'simplejson', 'PySide2', 'PyQt5', 'greenlet'], win_no_prefer_redirects=False, win_private_assemblies=False, cipher=block_cipher, noarchive=False) a.datas.extend(Tree('src/templates', 'templates')) a.datas.extend(Tree('src/static', 'static')) VLC_EXCLUDES = ['*.dll', '*.so', '*.so*', '*.dylib*', '*.dylib'] if platform.system() == 'Windows': VLC_EXCLUDES.remove('*.dll') elif platform.system() == 'Darwin': VLC_EXCLUDES.remove('*.dylib*') VLC_EXCLUDES.remove('*.dylib') elif platform.system() == 'Linux': VLC_EXCLUDES.remove('*.so*') VLC_EXCLUDES.remove('*.so') a.datas.extend(Tree('src/vlc_lib', 'vlc_lib', excludes=VLC_EXCLUDES)) a.datas.extend(Tree('src/languages', 'languages')) a.datas.extend(Tree('build_files/tkdnd2.9.2', 'tkdnd2.9.2')) a.datas.extend(Tree('src/theme', 'theme')) # a.datas.extend(Tree('src-frontend/dist', 'frontend')) pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) exe = EXE(pyz, a.scripts, [], exclude_binaries=True, name='Music Caster', debug=False, bootloader_ignore_signals=False, strip=False, upx=False, console=False, version='mc_version_info.txt', icon=os.path.abspath('resources/Music Caster Icon.ico')) coll = COLLECT(exe, a.binaries, a.zipfiles, a.datas, strip=False, upx=False, name='Music Caster OneDir') ================================================ FILE: build_files/portable.spec ================================================ # -*- mode: python ; coding: utf-8 -*- import os from PyInstaller.building.api import PYZ, EXE from PyInstaller.building.build_main import Analysis, Tree # type: ignore from PyInstaller.config import CONF import platform CONF['distpath'] = './dist' # type: ignore # CONF['workpath'] = './build' block_cipher = None a = Analysis([f'{os.getcwd()}/src/music_caster.py'], pathex=[os.getcwd()], binaries=[], datas=[('../CHANGELOG.TXT', '.')], hiddenimports=['pystray._win32', 'zeroconf._utils.ipaddress', 'zeroconf._handlers.answers'], hookspath=[], runtime_hooks=[], excludes=['crypto', 'cryptography', 'pycryptodome', 'pandas', 'gevent', 'numpy', 'simplejson', 'PySide2', 'PyQt5', 'greenlet'], win_no_prefer_redirects=False, win_private_assemblies=False, cipher=block_cipher, noarchive=False) a.datas.extend(Tree('src/templates', 'templates')) a.datas.extend(Tree('src/static', 'static')) VLC_EXCLUDES = ['*.dll', '*.so', '*.so*', '*.dylib*', '*.dylib'] if platform.system() == 'Windows': VLC_EXCLUDES.remove('*.dll') elif platform.system() == 'Darwin': VLC_EXCLUDES.remove('*.dylib*') VLC_EXCLUDES.remove('*.dylib') elif platform.system() == 'Linux': VLC_EXCLUDES.remove('*.so*') VLC_EXCLUDES.remove('*.so') a.datas.extend(Tree('src/vlc_lib', 'vlc_lib', excludes=VLC_EXCLUDES)) a.datas.extend(Tree('src/languages', 'languages')) a.datas.extend(Tree('build_files/tkdnd2.9.2', 'tkdnd2.9.2')) a.datas.extend(Tree('src/theme', 'theme')) # a.datas.extend(Tree('src-frontend/dist', 'frontend')) pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) exe = EXE(pyz, a.scripts, a.binaries, a.zipfiles, a.datas, [], name='Music Caster', debug=False, bootloader_ignore_signals=False, strip=False, upx=False, runtime_tmpdir=None, # TODO: use ENV variable console=False, version='mc_version_info.txt', icon=os.path.abspath('resources/Music Caster Icon.ico')) ================================================ FILE: build_files/setup_script.iss ================================================ #define MyAppName "Music Caster" #define MyAppVersion "5.25.1" #define MyAppPublisher "Elijah Lopez" #define MyAppURL "https://elijahlopez.ca/software#music-caster" #define MyAppExeName "Music Caster.exe" [Setup] ; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications. ; (To generate a new GUID, click Tools | Generate GUID inside the IDE.) AppId={{FBE8A652-58D6-482D-B6A9-B3D7931CC9C5} AppName={#MyAppName} AppVersion={#MyAppVersion} ;AppVerName={#MyAppName} {#MyAppVersion} AppPublisher={#MyAppPublisher} AppPublisherURL={#MyAppURL} AppSupportURL={#MyAppURL} AppUpdatesURL={#MyAppURL} DefaultDirName={autopf}\{#MyAppName} DisableProgramGroupPage=yes Compression=lzma SolidCompression=yes WizardStyle=modern MinVersion=0,6.1.7600 ; Minimum version is Windows 10 or later ; Remove the following line to run in administrative install mode (install for all users.) PrivilegesRequired=lowest OutputDir={#SourcePath}\..\dist OutputBaseFilename=Music Caster Setup UninstallDisplayName=Music Caster UninstallDisplayIcon={app}\{#MyAppExeName} UninstallLogMode=overwrite SetupIconFile="..\resources\Music Caster Icon.ico" [Languages] Name: "english"; MessagesFile: "compiler:Default.isl" [Files] Source: "{#SourcePath}\..\dist\Music Caster OneDir\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs Source: "{#SourcePath}..\CHANGELOG.txt"; DestDir: "{app}"; DestName: "CHANGELOG.txt"; Flags: ignoreversion ; NOTE: Don't use "Flags: ignoreversion" on any shared system files [InstallDelete] Type: files; Name: {app}\*.pyd Type: files; Name: {app}\*.dll ; delete previous version folders and those that may contain old files Type: filesandordirs; Name: {app}\_internal Type: filesandordirs; Name: {app}\Crypto Type: filesandordirs; Name: {app}\Cryptodome Type: filesandordirs; Name: {app}\google Type: filesandordirs; Name: {app}\numpy Type: filesandordirs; Name: {app}\PIL Type: filesandordirs; Name: {app}\psutil Type: filesandordirs; Name: {app}\gevent Type: filesandordirs; Name: {app}\greenlet Type: filesandordirs; Name: {app}\templates Type: filesandordirs; Name: {app}\setuptools* Type: filesandordirs; Name: {app}\images Type: filesandordirs; Name: {app}\lib2to3 Type: filesandordirs; Name: {app}\lxml Type: filesandordirs; Name: {app}\markupsafe Type: filesandordirs; Name: {app}\pygame Type: filesandordirs; Name: {app}\PyQt5 Type: filesandordirs; Name: {app}\wx Type: filesandordirs; Name: {app}\vlc Type: filesandordirs; Name: {app}\vlc_lib Type: filesandordirs; Name: {app}\importlib_metadata* Type: filesandordirs; Name: {app}\keyring* Type: filesandordirs; Name: {app}\lz4* Type: filesandordirs; Name: {app}\websockets* Type: filesandordirs; Name: {app}\wheel* [Icons] Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" [Run] Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent ================================================ FILE: build_files/tkdnd2.9.2/pkgIndex.tcl ================================================ package ifneeded tkdnd 2.9.2 \ "source \{$dir/tkdnd.tcl\} ; \ tkdnd::initialise \{$dir\} libtkdnd2.9.2[info sharedlibextension] tkdnd" package ifneeded tkdnd::utils 2.9.2 \ "source \{$dir/tkdnd_utils.tcl\} ; \ package provide tkdnd::utils 2.9.2" ================================================ FILE: build_files/tkdnd2.9.2/tkdnd.tcl ================================================ # # tkdnd.tcl -- # # This file implements some utility procedures that are used by the TkDND # package. # # This software is copyrighted by: # George Petasis, National Centre for Scientific Research "Demokritos", # Aghia Paraskevi, Athens, Greece. # e-mail: petasis@iit.demokritos.gr # # The following terms apply to all files associated # with the software unless explicitly disclaimed in individual files. # # The authors hereby grant permission to use, copy, modify, distribute, # and license this software and its documentation for any purpose, provided # that existing copyright notices are retained in all copies and that this # notice is included verbatim in any distributions. No written agreement, # license, or royalty fee is required for any of the authorized uses. # Modifications to this software may be copyrighted by their authors # and need not follow the licensing terms described here, provided that # the new terms are clearly indicated on the first page of each file where # they apply. # # IN NO EVENT SHALL THE AUTHORS OR DISTRIBUTORS BE LIABLE TO ANY PARTY # FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES # ARISING OUT OF THE USE OF THIS SOFTWARE, ITS DOCUMENTATION, OR ANY # DERIVATIVES THEREOF, EVEN IF THE AUTHORS HAVE BEEN ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # # THE AUTHORS AND DISTRIBUTORS SPECIFICALLY DISCLAIM ANY WARRANTIES, # INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. THIS SOFTWARE # IS PROVIDED ON AN "AS IS" BASIS, AND THE AUTHORS AND DISTRIBUTORS HAVE # NO OBLIGATION TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR # MODIFICATIONS. # package require Tk namespace eval ::tkdnd { variable _topw ".drag" variable _tabops variable _state variable _x0 variable _y0 variable _platform_namespace variable _drop_file_temp_dir variable _auto_update 1 variable _dx 3 ;# The difference in pixels before a drag is initiated. variable _dy 3 ;# The difference in pixels before a drag is initiated. variable _windowingsystem bind TkDND_Drag1 {tkdnd::_begin_drag press 1 %W %s %X %Y %x %y} bind TkDND_Drag1 {tkdnd::_begin_drag motion 1 %W %s %X %Y %x %y} bind TkDND_Drag2 {tkdnd::_begin_drag press 2 %W %s %X %Y %x %y} bind TkDND_Drag2 {tkdnd::_begin_drag motion 2 %W %s %X %Y %x %y} bind TkDND_Drag3 {tkdnd::_begin_drag press 3 %W %s %X %Y %x %y} bind TkDND_Drag3 {tkdnd::_begin_drag motion 3 %W %s %X %Y %x %y} # ---------------------------------------------------------------------------- # Command tkdnd::initialise: Initialise the TkDND package. # ---------------------------------------------------------------------------- proc initialise { dir PKG_LIB_FILE PACKAGE_NAME} { variable _platform_namespace variable _drop_file_temp_dir variable _windowingsystem global env switch [tk windowingsystem] { x11 { set _windowingsystem x11 } win32 - windows { set _windowingsystem windows } aqua { set _windowingsystem aqua } default { error "unknown Tk windowing system" } } ## Get User's home directory: We try to locate the proper path from a set of ## environmental variables... foreach var {HOME HOMEPATH USERPROFILE ALLUSERSPROFILE APPDATA} { if {[info exists env($var)]} { if {[file isdirectory $env($var)]} { set UserHomeDir $env($var) break } } } ## Should use [tk windowingsystem] instead of tcl platform array: ## OS X returns "unix," but that's not useful because it has its own ## windowing system, aqua ## Under windows we have to also combine HOMEDRIVE & HOMEPATH... if {![info exists UserHomeDir] && [string equal $_windowingsystem windows] && [info exists env(HOMEDRIVE)] && [info exists env(HOMEPATH)]} { if {[file isdirectory $env(HOMEDRIVE)$env(HOMEPATH)]} { set UserHomeDir $env(HOMEDRIVE)$env(HOMEPATH) } } ## Have we located the needed path? if {![info exists UserHomeDir]} { set UserHomeDir [pwd] } set UserHomeDir [file normalize $UserHomeDir] ## Try to locate a temporary directory... foreach var {TKDND_TEMP_DIR TEMP TMP} { if {[info exists env($var)]} { if {[file isdirectory $env($var)] && [file writable $env($var)]} { set _drop_file_temp_dir $env($var) break } } } if {![info exists _drop_file_temp_dir]} { foreach _dir [list "$UserHomeDir/Local Settings/Temp" \ "$UserHomeDir/AppData/Local/Temp" \ /tmp \ C:/WINDOWS/Temp C:/Temp C:/tmp \ D:/WINDOWS/Temp D:/Temp D:/tmp] { if {[file isdirectory $_dir] && [file writable $_dir]} { set _drop_file_temp_dir $_dir break } } } if {![info exists _drop_file_temp_dir]} { set _drop_file_temp_dir $UserHomeDir } set _drop_file_temp_dir [file native $_drop_file_temp_dir] source $dir/tkdnd_generic.tcl switch $_windowingsystem { x11 { source $dir/tkdnd_unix.tcl set _platform_namespace xdnd } win32 - windows { source $dir/tkdnd_windows.tcl set _platform_namespace olednd } aqua { source $dir/tkdnd_macosx.tcl set _platform_namespace macdnd } default { error "unknown Tk windowing system" } } load $dir/$PKG_LIB_FILE $PACKAGE_NAME source $dir/tkdnd_compat.tcl ${_platform_namespace}::initialise };# initialise proc GetDropFileTempDirectory { } { variable _drop_file_temp_dir return $_drop_file_temp_dir } proc SetDropFileTempDirectory { dir } { variable _drop_file_temp_dir set _drop_file_temp_dir $dir } };# namespace ::tkdnd # ---------------------------------------------------------------------------- # Command tkdnd::drag_source # ---------------------------------------------------------------------------- proc ::tkdnd::drag_source { mode path { types {} } { event 1 } { tagprefix TkDND_Drag } } { set tags [bindtags $path] set idx [lsearch $tags ${tagprefix}$event] switch -- $mode { register { if { $idx != -1 } { ## No need to do anything! # bindtags $path [lreplace $tags $idx $idx ${tagprefix}$event] } else { bindtags $path [linsert $tags 1 ${tagprefix}$event] } _drag_source_update_types $path $types } unregister { if { $idx != -1 } { bindtags $path [lreplace $tags $idx $idx] } } } };# tkdnd::drag_source proc ::tkdnd::_drag_source_update_types { path types } { set types [platform_specific_types $types] set old_types [bind $path <>] foreach type $types { if {[lsearch $old_types $type] < 0} {lappend old_types $type} } bind $path <> $old_types };# ::tkdnd::_drag_source_update_types # ---------------------------------------------------------------------------- # Command tkdnd::drop_target # ---------------------------------------------------------------------------- proc ::tkdnd::drop_target { mode path { types {} } } { variable _windowingsystem set types [platform_specific_types $types] switch -- $mode { register { switch $_windowingsystem { x11 { _register_types $path [winfo toplevel $path] $types } win32 - windows { _RegisterDragDrop $path bind $path {+ tkdnd::_RevokeDragDrop %W} } aqua { macdnd::registerdragwidget [winfo toplevel $path] $types } default { error "unknown Tk windowing system" } } set old_types [bind $path <>] set new_types {} foreach type $types { if {[lsearch -exact $old_types $type] < 0} {lappend new_types $type} } if {[llength $new_types]} { bind $path <> [concat $old_types $new_types] } } unregister { switch $_windowingsystem { x11 { } win32 - windows { _RevokeDragDrop $path } aqua { error todo } default { error "unknown Tk windowing system" } } bind $path <> {} } } };# tkdnd::drop_target # ---------------------------------------------------------------------------- # Command tkdnd::_begin_drag # ---------------------------------------------------------------------------- proc ::tkdnd::_begin_drag { event button source state X Y x y } { variable _x0 variable _y0 variable _state switch -- $event { press { set _x0 $X set _y0 $Y set _state "press" } motion { if { ![info exists _state] } { # This is just extra protection. There seem to be # rare cases where the motion comes before the press. return } if { [string equal $_state "press"] } { variable _dx variable _dy if { abs($_x0-$X) > ${_dx} || abs($_y0-$Y) > ${_dy} } { set _state "done" _init_drag $button $source $state $X $Y $x $y } } } } };# tkdnd::_begin_drag # ---------------------------------------------------------------------------- # Command tkdnd::_init_drag # ---------------------------------------------------------------------------- proc ::tkdnd::_init_drag { button source state rootX rootY X Y } { # Call the <> binding. set cmd [bind $source <>] # puts "CMD: $cmd" if {[string length $cmd]} { set cmd [string map [list %W $source %X $rootX %Y $rootY %x $X %y $Y \ %S $state %e <> %A \{\} %% % \ %t [bind $source <>]] $cmd] set code [catch {uplevel \#0 $cmd} info options] # puts "CODE: $code ---- $info" switch -exact -- $code { 0 {} 3 - 4 { # FRINK: nocheck return } default { return -options $options $info } } set len [llength $info] if {$len == 3} { foreach { actions types _data } $info { break } set types [platform_specific_types $types] set data [list] foreach type $types { lappend data $_data } unset _data } elseif {$len == 2} { foreach { actions _data } $info { break } set data [list]; set types [list] foreach {t d} $_data { foreach t [platform_specific_types $t] { lappend types $t; lappend data $d } } unset _data t d } else { if {$len == 1 && [string equal [lindex $actions 0] "refuse_drop"]} { return } error "not enough items in the result of the <>\ event binding. Either 2 or 3 items are expected. The command executed was: \"$cmd\"\nResult was: \"$info\"" } set action refuse_drop variable _windowingsystem # puts "Source: \"$source\"" # puts "Types: \"[join $types {", "}]\"" # puts "Actions: \"[join $actions {", "}]\"" # puts "Button: \"$button\"" # puts "Data: \"[string range $data 0 100]\"" switch $_windowingsystem { x11 { set action [xdnd::_dodragdrop $source $actions $types $data $button] } win32 - windows { set action [_DoDragDrop $source $actions $types $data $button] } aqua { set action [macdnd::dodragdrop $source $actions $types $data $button] } default { error "unknown Tk windowing system" } } ## Call _end_drag to notify the widget of the result of the drag ## operation... _end_drag $button $source {} $action {} $data {} $state $rootX $rootY $X $Y } };# tkdnd::_init_drag # ---------------------------------------------------------------------------- # Command tkdnd::_end_drag # ---------------------------------------------------------------------------- proc ::tkdnd::_end_drag { button source target action type data result state rootX rootY X Y } { set rootX 0 set rootY 0 # Call the <> binding. set cmd [bind $source <>] if {[string length $cmd]} { set cmd [string map [list %W $source %X $rootX %Y $rootY %x $X %y $Y %% % \ %S $state %e <> %A \{$action\}] $cmd] set info [uplevel \#0 $cmd] # if { $info != "" } { # variable _windowingsystem # foreach { actions types data } $info { break } # set types [platform_specific_types $types] # switch $_windowingsystem { # x11 { # error "dragging from Tk widgets not yet supported" # } # win32 - # windows { # set action [_DoDragDrop $source $actions $types $data $button] # } # aqua { # macdnd::dodragdrop $source $actions $types $data # } # default { # error "unknown Tk windowing system" # } # } # ## Call _end_drag to notify the widget of the result of the drag # ## operation... # _end_drag $button $source {} $action {} $data {} $state $rootX $rootY # } } };# tkdnd::_end_drag # ---------------------------------------------------------------------------- # Command tkdnd::platform_specific_types # ---------------------------------------------------------------------------- proc ::tkdnd::platform_specific_types { types } { variable _platform_namespace ${_platform_namespace}::platform_specific_types $types }; # tkdnd::platform_specific_types # ---------------------------------------------------------------------------- # Command tkdnd::platform_independent_types # ---------------------------------------------------------------------------- proc ::tkdnd::platform_independent_types { types } { variable _platform_namespace ${_platform_namespace}::platform_independent_types $types }; # tkdnd::platform_independent_types # ---------------------------------------------------------------------------- # Command tkdnd::platform_specific_type # ---------------------------------------------------------------------------- proc ::tkdnd::platform_specific_type { type } { variable _platform_namespace ${_platform_namespace}::platform_specific_type $type }; # tkdnd::platform_specific_type # ---------------------------------------------------------------------------- # Command tkdnd::platform_independent_type # ---------------------------------------------------------------------------- proc ::tkdnd::platform_independent_type { type } { variable _platform_namespace ${_platform_namespace}::platform_independent_type $type }; # tkdnd::platform_independent_type # ---------------------------------------------------------------------------- # Command tkdnd::bytes_to_string # ---------------------------------------------------------------------------- proc ::tkdnd::bytes_to_string { bytes } { set string {} foreach byte $bytes { append string [binary format c $byte] } return $string };# tkdnd::bytes_to_string # ---------------------------------------------------------------------------- # Command tkdnd::urn_unquote # ---------------------------------------------------------------------------- proc ::tkdnd::urn_unquote {url} { set result "" set start 0 while {[regexp -start $start -indices {%[0-9a-fA-F]{2}} $url match]} { foreach {first last} $match break append result [string range $url $start [expr {$first - 1}]] append result [format %c 0x[string range $url [incr first] $last]] set start [incr last] } append result [string range $url $start end] return [encoding convertfrom utf-8 $result] };# tkdnd::urn_unquote ================================================ FILE: build_files/tkdnd2.9.2/tkdnd_compat.tcl ================================================ # # tkdnd_compat.tcl -- # # This file implements some utility procedures, to support older versions # of the TkDND package. # # This software is copyrighted by: # George Petasis, National Centre for Scientific Research "Demokritos", # Aghia Paraskevi, Athens, Greece. # e-mail: petasis@iit.demokritos.gr # # The following terms apply to all files associated # with the software unless explicitly disclaimed in individual files. # # The authors hereby grant permission to use, copy, modify, distribute, # and license this software and its documentation for any purpose, provided # that existing copyright notices are retained in all copies and that this # notice is included verbatim in any distributions. No written agreement, # license, or royalty fee is required for any of the authorized uses. # Modifications to this software may be copyrighted by their authors # and need not follow the licensing terms described here, provided that # the new terms are clearly indicated on the first page of each file where # they apply. # # IN NO EVENT SHALL THE AUTHORS OR DISTRIBUTORS BE LIABLE TO ANY PARTY # FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES # ARISING OUT OF THE USE OF THIS SOFTWARE, ITS DOCUMENTATION, OR ANY # DERIVATIVES THEREOF, EVEN IF THE AUTHORS HAVE BEEN ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # # THE AUTHORS AND DISTRIBUTORS SPECIFICALLY DISCLAIM ANY WARRANTIES, # INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. THIS SOFTWARE # IS PROVIDED ON AN "AS IS" BASIS, AND THE AUTHORS AND DISTRIBUTORS HAVE # NO OBLIGATION TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR # MODIFICATIONS. # namespace eval compat { };# namespace compat # ---------------------------------------------------------------------------- # Command ::dnd # ---------------------------------------------------------------------------- proc ::dnd {method window args} { switch $method { bindtarget { switch [llength $args] { 0 {return [tkdnd::compat::bindtarget0 $window]} 1 {return [tkdnd::compat::bindtarget1 $window [lindex $args 0]]} 2 {return [tkdnd::compat::bindtarget2 $window [lindex $args 0] \ [lindex $args 1]]} 3 {return [tkdnd::compat::bindtarget3 $window [lindex $args 0] \ [lindex $args 1] [lindex $args 2]]} 4 {return [tkdnd::compat::bindtarget4 $window [lindex $args 0] \ [lindex $args 1] [lindex $args 2] [lindex $args 3]]} } } cleartarget { return [tkdnd::compat::cleartarget $window] } bindsource { switch [llength $args] { 0 {return [tkdnd::compat::bindsource0 $window]} 1 {return [tkdnd::compat::bindsource1 $window [lindex $args 0]]} 2 {return [tkdnd::compat::bindsource2 $window [lindex $args 0] \ [lindex $args 1]]} 3 {return [tkdnd::compat::bindsource3 $window [lindex $args 0] \ [lindex $args 1] [lindex $args 2]]} } } clearsource { return [tkdnd::compat::clearsource $window] } drag { return [tkdnd::_init_drag 1 $window "press" 0 0 0 0] } } error "invalid number of arguments!" };# ::dnd # ---------------------------------------------------------------------------- # Command compat::bindtarget # ---------------------------------------------------------------------------- proc compat::bindtarget0 {window} { return [bind $window <>] };# compat::bindtarget0 proc compat::bindtarget1 {window type} { return [bindtarget2 $window $type ] };# compat::bindtarget1 proc compat::bindtarget2 {window type event} { switch $event { {return [bind $window <>]} {return [bind $window <>]} {return [bind $window <>]} {return [bind $window <>]} } };# compat::bindtarget2 proc compat::bindtarget3 {window type event script} { set type [normalise_type $type] ::tkdnd::drop_target register $window [list $type] switch $event { {return [bind $window <> $script]} {return [bind $window <> $script]} {return [bind $window <> $script]} {return [bind $window <> $script]} } };# compat::bindtarget3 proc compat::bindtarget4 {window type event script priority} { return [bindtarget3 $window $type $event $script] };# compat::bindtarget4 proc compat::normalise_type { type } { switch $type { text/plain - {text/plain;charset=UTF-8} - Text {return DND_Text} text/uri-list - Files {return DND_Files} default {return $type} } };# compat::normalise_type # ---------------------------------------------------------------------------- # Command compat::bindsource # ---------------------------------------------------------------------------- proc compat::bindsource0 {window} { return [bind $window <>] };# compat::bindsource0 proc compat::bindsource1 {window type} { return [bindsource2 $window $type ] };# compat::bindsource1 proc compat::bindsource2 {window type script} { set type [normalise_type $type] ::tkdnd::drag_source register $window $type bind $window <> "list {copy} {%t} \[$script\]" };# compat::bindsource2 proc compat::bindsource3 {window type script priority} { return [bindsource2 $window $type $script] };# compat::bindsource3 # ---------------------------------------------------------------------------- # Command compat::cleartarget # ---------------------------------------------------------------------------- proc compat::cleartarget {window} { };# compat::cleartarget # ---------------------------------------------------------------------------- # Command compat::clearsource # ---------------------------------------------------------------------------- proc compat::clearsource {window} { };# compat::clearsource ================================================ FILE: build_files/tkdnd2.9.2/tkdnd_generic.tcl ================================================ # # tkdnd_generic.tcl -- # # This file implements some utility procedures that are used by the TkDND # package. # # This software is copyrighted by: # George Petasis, National Centre for Scientific Research "Demokritos", # Aghia Paraskevi, Athens, Greece. # e-mail: petasis@iit.demokritos.gr # # The following terms apply to all files associated # with the software unless explicitly disclaimed in individual files. # # The authors hereby grant permission to use, copy, modify, distribute, # and license this software and its documentation for any purpose, provided # that existing copyright notices are retained in all copies and that this # notice is included verbatim in any distributions. No written agreement, # license, or royalty fee is required for any of the authorized uses. # Modifications to this software may be copyrighted by their authors # and need not follow the licensing terms described here, provided that # the new terms are clearly indicated on the first page of each file where # they apply. # # IN NO EVENT SHALL THE AUTHORS OR DISTRIBUTORS BE LIABLE TO ANY PARTY # FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES # ARISING OUT OF THE USE OF THIS SOFTWARE, ITS DOCUMENTATION, OR ANY # DERIVATIVES THEREOF, EVEN IF THE AUTHORS HAVE BEEN ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # # THE AUTHORS AND DISTRIBUTORS SPECIFICALLY DISCLAIM ANY WARRANTIES, # INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. THIS SOFTWARE # IS PROVIDED ON AN "AS IS" BASIS, AND THE AUTHORS AND DISTRIBUTORS HAVE # NO OBLIGATION TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR # MODIFICATIONS. # namespace eval generic { variable _types {} variable _typelist {} variable _codelist {} variable _actionlist {} variable _pressedkeys {} variable _action {} variable _common_drag_source_types {} variable _common_drop_target_types {} variable _drag_source {} variable _drop_target {} variable _last_mouse_root_x 0 variable _last_mouse_root_y 0 variable _tkdnd2platform variable _platform2tkdnd proc debug {msg} { puts $msg };# debug proc initialise { } { };# initialise proc initialise_platform_to_tkdnd_types { types } { variable _platform2tkdnd variable _tkdnd2platform set _platform2tkdnd [dict create {*}$types] set _tkdnd2platform [dict create] foreach type [dict keys $_platform2tkdnd] { dict lappend _tkdnd2platform [dict get $_platform2tkdnd $type] $type } };# initialise_platform_to_tkdnd_types proc initialise_tkdnd_to_platform_types { types } { variable _tkdnd2platform set _tkdnd2platform [dict create {*}$types] };# initialise_tkdnd_to_platform_types };# namespace generic # ---------------------------------------------------------------------------- # Command generic::HandleEnter # ---------------------------------------------------------------------------- proc generic::HandleEnter { drop_target drag_source typelist codelist actionlist pressedkeys } { variable _typelist; set _typelist $typelist variable _pressedkeys; set _pressedkeys $pressedkeys variable _action; set _action refuse_drop variable _common_drag_source_types; set _common_drag_source_types {} variable _common_drop_target_types; set _common_drop_target_types {} variable _actionlist variable _drag_source; set _drag_source $drag_source variable _drop_target; set _drop_target {} variable _actionlist; set _actionlist $actionlist variable _codelist set _codelist $codelist variable _last_mouse_root_x; set _last_mouse_root_x 0 variable _last_mouse_root_y; set _last_mouse_root_y 0 # debug "\n===============================================================" # debug "generic::HandleEnter: drop_target=$drop_target,\ # drag_source=$drag_source,\ # typelist=$typelist" # debug "generic::HandleEnter: ACTION: default" return default };# generic::HandleEnter # ---------------------------------------------------------------------------- # Command generic::HandlePosition # ---------------------------------------------------------------------------- proc generic::HandlePosition { drop_target drag_source pressedkeys rootX rootY { time 0 } } { variable _types variable _typelist variable _codelist variable _actionlist variable _pressedkeys variable _action variable _common_drag_source_types variable _common_drop_target_types variable _drag_source variable _drop_target variable _last_mouse_root_x; set _last_mouse_root_x $rootX variable _last_mouse_root_y; set _last_mouse_root_y $rootY # debug "generic::HandlePosition: drop_target=$drop_target,\ # _drop_target=$_drop_target, rootX=$rootX, rootY=$rootY" if {![info exists _drag_source] && ![string length $_drag_source]} { # debug "generic::HandlePosition: no or empty _drag_source:\ # return refuse_drop" return refuse_drop } if {$drag_source ne "" && $drag_source ne $_drag_source} { debug "generic position event from unexpected source: $_drag_source\ != $drag_source" return refuse_drop } set _pressedkeys $pressedkeys ## Does the new drop target support any of our new types? # foreach {common_drag_source_types common_drop_target_types} \ # [GetWindowCommonTypes $drop_target $_typelist] {break} foreach {drop_target common_drag_source_types common_drop_target_types} \ [FindWindowWithCommonTypes $drop_target $_typelist] {break} set data [GetDroppedData $time] # debug "\t($_drop_target) -> ($drop_target)" if {$drop_target != $_drop_target} { if {[string length $_drop_target]} { ## Call the <> event. # debug "\t<> on $_drop_target" set cmd [bind $_drop_target <>] if {[string length $cmd]} { set cmd [string map [list %W $_drop_target %X $rootX %Y $rootY \ %CST \{$_common_drag_source_types\} \ %CTT \{$_common_drop_target_types\} \ %CPT \{[lindex [platform_independent_type [lindex $_common_drag_source_types 0]] 0]\} \ %ST \{$_typelist\} %TT \{$_types\} \ %A \{$_action\} %a \{$_actionlist\} \ %b \{$_pressedkeys\} %m \{$_pressedkeys\} \ %D \{\} %e <> \ %L \{$_typelist\} %% % \ %t \{$_typelist\} %T \{[lindex $_common_drag_source_types 0]\} \ %c \{$_codelist\} %C \{[lindex $_codelist 0]\} \ ] $cmd] uplevel \#0 $cmd } } set _drop_target $drop_target set _action refuse_drop if {[llength $common_drag_source_types]} { set _action [lindex $_actionlist 0] set _common_drag_source_types $common_drag_source_types set _common_drop_target_types $common_drop_target_types ## Drop target supports at least one type. Send a <>. # puts "<> -> $drop_target" set cmd [bind $drop_target <>] if {[string length $cmd]} { focus $drop_target set cmd [string map [list %W $drop_target %X $rootX %Y $rootY \ %CST \{$_common_drag_source_types\} \ %CTT \{$_common_drop_target_types\} \ %CPT \{[lindex [platform_independent_type [lindex $_common_drag_source_types 0]] 0]\} \ %ST \{$_typelist\} %TT \{$_types\} \ %A $_action %a \{$_actionlist\} \ %b \{$_pressedkeys\} %m \{$_pressedkeys\} \ %D [list $data] %e <> \ %L \{$_typelist\} %% % \ %t \{$_typelist\} %T \{[lindex $_common_drag_source_types 0]\} \ %c \{$_codelist\} %C \{[lindex $_codelist 0]\} \ ] $cmd] set _action [uplevel \#0 $cmd] switch -exact -- $_action { copy - move - link - ask - private - refuse_drop - default {} default {set _action copy} } } } } set _drop_target {} if {[llength $common_drag_source_types]} { set _common_drag_source_types $common_drag_source_types set _common_drop_target_types $common_drop_target_types set _drop_target $drop_target ## Drop target supports at least one type. Send a <>. set cmd [bind $drop_target <>] if {[string length $cmd]} { set cmd [string map [list %W $drop_target %X $rootX %Y $rootY \ %CST \{$_common_drag_source_types\} \ %CTT \{$_common_drop_target_types\} \ %CPT \{[lindex [platform_independent_type [lindex $_common_drag_source_types 0]] 0]\} \ %ST \{$_typelist\} %TT \{$_types\} \ %A $_action %a \{$_actionlist\} \ %b \{$_pressedkeys\} %m \{$_pressedkeys\} \ %D [list $data] %e <> \ %L \{$_typelist\} %% % \ %t \{$_typelist\} %T \{[lindex $_common_drag_source_types 0]\} \ %c \{$_codelist\} %C \{[lindex $_codelist 0]\} \ ] $cmd] set _action [uplevel \#0 $cmd] } } # Return values: copy, move, link, ask, private, refuse_drop, default # debug "generic::HandlePosition: ACTION: $_action" switch -exact -- $_action { copy - move - link - ask - private - refuse_drop - default {} default {set _action copy} } return $_action };# generic::HandlePosition # ---------------------------------------------------------------------------- # Command generic::HandleLeave # ---------------------------------------------------------------------------- proc generic::HandleLeave { } { variable _types variable _typelist variable _codelist variable _actionlist variable _pressedkeys variable _action variable _common_drag_source_types variable _common_drop_target_types variable _drag_source variable _drop_target variable _last_mouse_root_x variable _last_mouse_root_y if {![info exists _drop_target]} {set _drop_target {}} # debug "generic::HandleLeave: _drop_target=$_drop_target" if {[info exists _drop_target] && [string length $_drop_target]} { set cmd [bind $_drop_target <>] if {[string length $cmd]} { set cmd [string map [list %W $_drop_target \ %X $_last_mouse_root_x %Y $_last_mouse_root_y \ %CST \{$_common_drag_source_types\} \ %CTT \{$_common_drop_target_types\} \ %CPT \{[lindex [platform_independent_type [lindex $_common_drag_source_types 0]] 0]\} \ %ST \{$_typelist\} %TT \{$_types\} \ %A \{$_action\} %a \{$_actionlist\} \ %b \{$_pressedkeys\} %m \{$_pressedkeys\} \ %D \{\} %e <> \ %L \{$_typelist\} %% % \ %t \{$_typelist\} %T \{[lindex $_common_drag_source_types 0]\} \ %c \{$_codelist\} %C \{[lindex $_codelist 0]\} \ ] $cmd] set _action [uplevel \#0 $cmd] } } foreach var {_types _typelist _actionlist _pressedkeys _action _common_drag_source_types _common_drop_target_types _drag_source _drop_target} { set $var {} } };# generic::HandleLeave # ---------------------------------------------------------------------------- # Command generic::HandleDrop # ---------------------------------------------------------------------------- proc generic::HandleDrop {drop_target drag_source pressedkeys rootX rootY time } { variable _types variable _typelist variable _codelist variable _actionlist variable _pressedkeys variable _action variable _common_drag_source_types variable _common_drop_target_types variable _drag_source variable _drop_target variable _last_mouse_root_x variable _last_mouse_root_y variable _last_mouse_root_x; set _last_mouse_root_x $rootX variable _last_mouse_root_y; set _last_mouse_root_y $rootY set _pressedkeys $pressedkeys # puts "generic::HandleDrop: $time" if {![info exists _drag_source] && ![string length $_drag_source]} { return refuse_drop } if {![info exists _drop_target] && ![string length $_drop_target]} { return refuse_drop } if {![llength $_common_drag_source_types]} {return refuse_drop} ## Get the dropped data. set data [GetDroppedData $time] ## Try to select the most specific <> event. foreach type [concat $_common_drag_source_types $_common_drop_target_types] { set type [platform_independent_type $type] set cmd [bind $_drop_target <>] if {[string length $cmd]} { set cmd [string map [list %W $_drop_target %X $rootX %Y $rootY \ %CST \{$_common_drag_source_types\} \ %CTT \{$_common_drop_target_types\} \ %CPT \{[lindex [platform_independent_type [lindex $_common_drag_source_types 0]] 0]\} \ %ST \{$_typelist\} %TT \{$_types\} \ %A $_action %a \{$_actionlist\} \ %b \{$_pressedkeys\} %m \{$_pressedkeys\} \ %D [list $data] %e <> \ %L \{$_typelist\} %% % \ %t \{$_typelist\} %T \{[lindex $_common_drag_source_types 0]\} \ %c \{$_codelist\} %C \{[lindex $_codelist 0]\} \ ] $cmd] set _action [uplevel \#0 $cmd] # Return values: copy, move, link, ask, private, refuse_drop switch -exact -- $_action { copy - move - link - ask - private - refuse_drop - default {} default {set _action copy} } return $_action } } set cmd [bind $_drop_target <>] if {[string length $cmd]} { set cmd [string map [list %W $_drop_target %X $rootX %Y $rootY \ %CST \{$_common_drag_source_types\} \ %CTT \{$_common_drop_target_types\} \ %CPT \{[lindex [platform_independent_type [lindex $_common_drag_source_types 0]] 0]\} \ %ST \{$_typelist\} %TT \{$_types\} \ %A $_action %a \{$_actionlist\} \ %b \{$_pressedkeys\} %m \{$_pressedkeys\} \ %D [list $data] %e <> \ %L \{$_typelist\} %% % \ %t \{$_typelist\} %T \{[lindex $_common_drag_source_types 0]\} \ %c \{$_codelist\} %C \{[lindex $_codelist 0]\} \ ] $cmd] set _action [uplevel \#0 $cmd] } # Return values: copy, move, link, ask, private, refuse_drop switch -exact -- $_action { copy - move - link - ask - private - refuse_drop - default {} default {set _action copy} } return $_action };# generic::HandleDrop # ---------------------------------------------------------------------------- # Command generic::GetWindowCommonTypes # ---------------------------------------------------------------------------- proc generic::GetWindowCommonTypes { win typelist } { set types [bind $win <>] # debug ">> Accepted types: $win $_types" set common_drag_source_types {} set common_drop_target_types {} if {[llength $types]} { ## Examine the drop target types, to find at least one match with the drag ## source types... set supported_types [supported_types $typelist] foreach type $types { foreach matched [lsearch -glob -all -inline $supported_types $type] { ## Drop target supports this type. lappend common_drag_source_types $matched lappend common_drop_target_types $type } } } list $common_drag_source_types $common_drop_target_types };# generic::GetWindowCommonTypes # ---------------------------------------------------------------------------- # Command generic::FindWindowWithCommonTypes # ---------------------------------------------------------------------------- proc generic::FindWindowWithCommonTypes { win typelist } { set toplevel [winfo toplevel $win] while {![string equal $win $toplevel]} { foreach {common_drag_source_types common_drop_target_types} \ [GetWindowCommonTypes $win $typelist] {break} if {[llength $common_drag_source_types]} { return [list $win $common_drag_source_types $common_drop_target_types] } set win [winfo parent $win] } ## We have reached the toplevel, which may be also a target (SF Bug #30) foreach {common_drag_source_types common_drop_target_types} \ [GetWindowCommonTypes $win $typelist] {break} if {[llength $common_drag_source_types]} { return [list $win $common_drag_source_types $common_drop_target_types] } return { {} {} {} } };# generic::FindWindowWithCommonTypes # ---------------------------------------------------------------------------- # Command generic::GetDroppedData # ---------------------------------------------------------------------------- proc generic::GetDroppedData { time } { variable _dropped_data return $_dropped_data };# generic::GetDroppedData # ---------------------------------------------------------------------------- # Command generic::SetDroppedData # ---------------------------------------------------------------------------- proc generic::SetDroppedData { data } { variable _dropped_data set _dropped_data $data };# generic::SetDroppedData # ---------------------------------------------------------------------------- # Command generic::GetDragSource # ---------------------------------------------------------------------------- proc generic::GetDragSource { } { variable _drag_source return $_drag_source };# generic::GetDragSource # ---------------------------------------------------------------------------- # Command generic::GetDropTarget # ---------------------------------------------------------------------------- proc generic::GetDropTarget { } { variable _drop_target return $_drop_target };# generic::GetDropTarget # ---------------------------------------------------------------------------- # Command generic::GetDragSourceCommonTypes # ---------------------------------------------------------------------------- proc generic::GetDragSourceCommonTypes { } { variable _common_drag_source_types return $_common_drag_source_types };# generic::GetDragSourceCommonTypes # ---------------------------------------------------------------------------- # Command generic::GetDropTargetCommonTypes # ---------------------------------------------------------------------------- proc generic::GetDropTargetCommonTypes { } { variable _common_drag_source_types return $_common_drag_source_types };# generic::GetDropTargetCommonTypes # ---------------------------------------------------------------------------- # Command generic::platform_specific_types # ---------------------------------------------------------------------------- proc generic::platform_specific_types { types } { set new_types {} foreach type $types { set new_types [concat $new_types [platform_specific_type $type]] } return $new_types }; # generic::platform_specific_types # ---------------------------------------------------------------------------- # Command generic::platform_specific_type # ---------------------------------------------------------------------------- proc generic::platform_specific_type { type } { variable _tkdnd2platform if {[dict exists $_tkdnd2platform $type]} { return [dict get $_tkdnd2platform $type] } list $type }; # generic::platform_specific_type # ---------------------------------------------------------------------------- # Command tkdnd::platform_independent_types # ---------------------------------------------------------------------------- proc ::tkdnd::platform_independent_types { types } { set new_types {} foreach type $types { set new_types [concat $new_types [platform_independent_type $type]] } return $new_types }; # tkdnd::platform_independent_types # ---------------------------------------------------------------------------- # Command generic::platform_independent_type # ---------------------------------------------------------------------------- proc generic::platform_independent_type { type } { variable _platform2tkdnd if {[dict exists $_platform2tkdnd $type]} { return [dict get $_platform2tkdnd $type] } return $type }; # generic::platform_independent_type # ---------------------------------------------------------------------------- # Command generic::supported_types # ---------------------------------------------------------------------------- proc generic::supported_types { types } { set new_types {} foreach type $types { if {[supported_type $type]} {lappend new_types $type} } return $new_types }; # generic::supported_types # ---------------------------------------------------------------------------- # Command generic::supported_type # ---------------------------------------------------------------------------- proc generic::supported_type { type } { variable _platform2tkdnd if {[dict exists $_platform2tkdnd $type]} { return 1 } return 0 }; # generic::supported_type ================================================ FILE: build_files/tkdnd2.9.2/tkdnd_macosx.tcl ================================================ # # tkdnd_macosx.tcl -- # # This file implements some utility procedures that are used by the TkDND # package. # This software is copyrighted by: # Georgios Petasis, Athens, Greece. # e-mail: petasisg@yahoo.gr, petasis@iit.demokritos.gr # # Mac portions (c) 2009 Kevin Walzer/WordTech Communications LLC, # kw@codebykevin.com # # # The following terms apply to all files associated # with the software unless explicitly disclaimed in individual files. # # The authors hereby grant permission to use, copy, modify, distribute, # and license this software and its documentation for any purpose, provided # that existing copyright notices are retained in all copies and that this # notice is included verbatim in any distributions. No written agreement, # license, or royalty fee is required for any of the authorized uses. # Modifications to this software may be copyrighted by their authors # and need not follow the licensing terms described here, provided that # the new terms are clearly indicated on the first page of each file where # they apply. # # IN NO EVENT SHALL THE AUTHORS OR DISTRIBUTORS BE LIABLE TO ANY PARTY # FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES # ARISING OUT OF THE USE OF THIS SOFTWARE, ITS DOCUMENTATION, OR ANY # DERIVATIVES THEREOF, EVEN IF THE AUTHORS HAVE BEEN ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # # THE AUTHORS AND DISTRIBUTORS SPECIFICALLY DISCLAIM ANY WARRANTIES, # INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. THIS SOFTWARE # IS PROVIDED ON AN "AS IS" BASIS, AND THE AUTHORS AND DISTRIBUTORS HAVE # NO OBLIGATION TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR # MODIFICATIONS. # #basic API for Mac Drag and Drop #two data types supported: strings and file paths #two commands at C level: ::tkdnd::macdnd::registerdragwidget, ::tkdnd::macdnd::unregisterdragwidget #data retrieval mechanism: text or file paths are copied from drag clipboard to system clipboard and retrieved via [clipboard get]; array of file paths is converted to single tab-separated string, can be split into Tcl list if {[tk windowingsystem] eq "aqua" && "AppKit" ni [winfo server .]} { error {TkAqua Cocoa required} } namespace eval macdnd { proc initialise { } { ## Mapping from platform types to TkDND types... ::tkdnd::generic::initialise_platform_to_tkdnd_types [list \ NSPasteboardTypeString DND_Text \ NSFilenamesPboardType DND_Files \ NSPasteboardTypeHTML DND_HTML \ ] };# initialise };# namespace macdnd # ---------------------------------------------------------------------------- # Command macdnd::HandleEnter # ---------------------------------------------------------------------------- proc macdnd::HandleEnter { path drag_source typelist { data {} } } { variable _pressedkeys variable _actionlist set _pressedkeys 1 set _actionlist { copy move link ask private } ::tkdnd::generic::SetDroppedData $data ::tkdnd::generic::HandleEnter $path $drag_source $typelist $typelist \ $_actionlist $_pressedkeys };# macdnd::HandleEnter # ---------------------------------------------------------------------------- # Command macdnd::HandlePosition # ---------------------------------------------------------------------------- proc macdnd::HandlePosition { drop_target rootX rootY {drag_source {}} } { variable _pressedkeys variable _last_mouse_root_x; set _last_mouse_root_x $rootX variable _last_mouse_root_y; set _last_mouse_root_y $rootY ::tkdnd::generic::HandlePosition $drop_target $drag_source \ $_pressedkeys $rootX $rootY };# macdnd::HandlePosition # ---------------------------------------------------------------------------- # Command macdnd::HandleLeave # ---------------------------------------------------------------------------- proc macdnd::HandleLeave { args } { ::tkdnd::generic::HandleLeave };# macdnd::HandleLeave # ---------------------------------------------------------------------------- # Command macdnd::HandleDrop # ---------------------------------------------------------------------------- proc macdnd::HandleDrop { drop_target data args } { variable _pressedkeys variable _last_mouse_root_x variable _last_mouse_root_y ## Get the dropped data... ::tkdnd::generic::SetDroppedData $data ::tkdnd::generic::HandleDrop {} {} $_pressedkeys \ $_last_mouse_root_x $_last_mouse_root_y 0 };# macdnd::HandleDrop # ---------------------------------------------------------------------------- # Command macdnd::GetDragSourceCommonTypes # ---------------------------------------------------------------------------- proc macdnd::GetDragSourceCommonTypes { } { ::tkdnd::generic::GetDragSourceCommonTypes };# macdnd::GetDragSourceCommonTypes # ---------------------------------------------------------------------------- # Command macdnd::platform_specific_types # ---------------------------------------------------------------------------- proc macdnd::platform_specific_types { types } { ::tkdnd::generic::platform_specific_types $types }; # macdnd::platform_specific_types # ---------------------------------------------------------------------------- # Command macdnd::platform_specific_type # ---------------------------------------------------------------------------- proc macdnd::platform_specific_type { type } { ::tkdnd::generic::platform_specific_type $type }; # macdnd::platform_specific_type # ---------------------------------------------------------------------------- # Command tkdnd::platform_independent_types # ---------------------------------------------------------------------------- proc ::tkdnd::platform_independent_types { types } { ::tkdnd::generic::platform_independent_types $types }; # tkdnd::platform_independent_types # ---------------------------------------------------------------------------- # Command macdnd::platform_independent_type # ---------------------------------------------------------------------------- proc macdnd::platform_independent_type { type } { ::tkdnd::generic::platform_independent_type $type }; # macdnd::platform_independent_type ================================================ FILE: build_files/tkdnd2.9.2/tkdnd_unix.tcl ================================================ # # tkdnd_unix.tcl -- # # This file implements some utility procedures that are used by the TkDND # package. # # This software is copyrighted by: # George Petasis, National Centre for Scientific Research "Demokritos", # Aghia Paraskevi, Athens, Greece. # e-mail: petasis@iit.demokritos.gr # # The following terms apply to all files associated # with the software unless explicitly disclaimed in individual files. # # The authors hereby grant permission to use, copy, modify, distribute, # and license this software and its documentation for any purpose, provided # that existing copyright notices are retained in all copies and that this # notice is included verbatim in any distributions. No written agreement, # license, or royalty fee is required for any of the authorized uses. # Modifications to this software may be copyrighted by their authors # and need not follow the licensing terms described here, provided that # the new terms are clearly indicated on the first page of each file where # they apply. # # IN NO EVENT SHALL THE AUTHORS OR DISTRIBUTORS BE LIABLE TO ANY PARTY # FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES # ARISING OUT OF THE USE OF THIS SOFTWARE, ITS DOCUMENTATION, OR ANY # DERIVATIVES THEREOF, EVEN IF THE AUTHORS HAVE BEEN ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # # THE AUTHORS AND DISTRIBUTORS SPECIFICALLY DISCLAIM ANY WARRANTIES, # INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. THIS SOFTWARE # IS PROVIDED ON AN "AS IS" BASIS, AND THE AUTHORS AND DISTRIBUTORS HAVE # NO OBLIGATION TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR # MODIFICATIONS. # namespace eval xdnd { variable _dragging 0 proc initialise { } { ## Mapping from platform types to TkDND types... ::tkdnd::generic::initialise_platform_to_tkdnd_types [list \ text/plain\;charset=utf-8 DND_Text \ UTF8_STRING DND_Text \ text/plain DND_Text \ STRING DND_Text \ TEXT DND_Text \ COMPOUND_TEXT DND_Text \ text/uri-list DND_Files \ text/html\;charset=utf-8 DND_HTML \ text/html DND_HTML \ application/x-color DND_Color \ ] };# initialise };# namespace xdnd # ---------------------------------------------------------------------------- # Command xdnd::HandleXdndEnter # ---------------------------------------------------------------------------- proc xdnd::HandleXdndEnter { path drag_source typelist time { data {} } } { variable _pressedkeys variable _actionlist variable _typelist set _pressedkeys 1 set _actionlist { copy move link ask private } set _typelist $typelist # puts "xdnd::HandleXdndEnter: $time" ::tkdnd::generic::SetDroppedData $data ::tkdnd::generic::HandleEnter $path $drag_source $typelist $typelist \ $_actionlist $_pressedkeys };# xdnd::HandleXdndEnter # ---------------------------------------------------------------------------- # Command xdnd::HandleXdndPosition # ---------------------------------------------------------------------------- proc xdnd::HandleXdndPosition { drop_target rootX rootY time {drag_source {}} } { variable _pressedkeys variable _typelist variable _last_mouse_root_x; set _last_mouse_root_x $rootX variable _last_mouse_root_y; set _last_mouse_root_y $rootY # puts "xdnd::HandleXdndPosition: $time" ## Get the dropped data... catch { ::tkdnd::generic::SetDroppedData [GetPositionData $drop_target $_typelist $time] } ::tkdnd::generic::HandlePosition $drop_target $drag_source \ $_pressedkeys $rootX $rootY };# xdnd::HandleXdndPosition # ---------------------------------------------------------------------------- # Command xdnd::HandleXdndLeave # ---------------------------------------------------------------------------- proc xdnd::HandleXdndLeave { } { ::tkdnd::generic::HandleLeave };# xdnd::HandleXdndLeave # ---------------------------------------------------------------------------- # Command xdnd::_HandleXdndDrop # ---------------------------------------------------------------------------- proc xdnd::HandleXdndDrop { time } { variable _pressedkeys variable _last_mouse_root_x variable _last_mouse_root_y ## Get the dropped data... ::tkdnd::generic::SetDroppedData [GetDroppedData \ [::tkdnd::generic::GetDragSource] [::tkdnd::generic::GetDropTarget] \ [::tkdnd::generic::GetDragSourceCommonTypes] $time] ::tkdnd::generic::HandleDrop {} {} $_pressedkeys \ $_last_mouse_root_x $_last_mouse_root_y $time };# xdnd::HandleXdndDrop # ---------------------------------------------------------------------------- # Command xdnd::GetPositionData # ---------------------------------------------------------------------------- proc xdnd::GetPositionData { drop_target typelist time } { foreach {drop_target common_drag_source_types common_drop_target_types} \ [::tkdnd::generic::FindWindowWithCommonTypes $drop_target $typelist] {break} GetDroppedData [::tkdnd::generic::GetDragSource] $drop_target \ $common_drag_source_types $time };# xdnd::GetPositionData # ---------------------------------------------------------------------------- # Command xdnd::GetDroppedData # ---------------------------------------------------------------------------- proc xdnd::GetDroppedData { _drag_source _drop_target _common_drag_source_types time } { if {![llength $_common_drag_source_types]} { error "no common data types between the drag source and drop target widgets" } ## Is drag source in this application? if {[catch {winfo pathname -displayof $_drop_target $_drag_source} p]} { set _use_tk_selection 0 } else { set _use_tk_selection 1 } foreach type $_common_drag_source_types { # puts "TYPE: $type ($_drop_target)" # _get_selection $_drop_target $time $type if {$_use_tk_selection} { if {![catch { selection get -displayof $_drop_target -selection XdndSelection \ -type $type } result options]} { return [normalise_data $type $result] } } else { # puts "_selection_get -displayof $_drop_target -selection XdndSelection \ # -type $type -time $time" #after 100 [list focus -force $_drop_target] #after 50 [list raise [winfo toplevel $_drop_target]] if {![catch { _selection_get -displayof $_drop_target -selection XdndSelection \ -type $type -time $time } result options]} { return [normalise_data $type $result] } } } return -options $options $result };# xdnd::GetDroppedData # ---------------------------------------------------------------------------- # Command xdnd::platform_specific_types # ---------------------------------------------------------------------------- proc xdnd::platform_specific_types { types } { ::tkdnd::generic::platform_specific_types $types }; # xdnd::platform_specific_types # ---------------------------------------------------------------------------- # Command xdnd::platform_specific_type # ---------------------------------------------------------------------------- proc xdnd::platform_specific_type { type } { ::tkdnd::generic::platform_specific_type $type }; # xdnd::platform_specific_type # ---------------------------------------------------------------------------- # Command tkdnd::platform_independent_types # ---------------------------------------------------------------------------- proc ::tkdnd::platform_independent_types { types } { ::tkdnd::generic::platform_independent_types $types }; # tkdnd::platform_independent_types # ---------------------------------------------------------------------------- # Command xdnd::platform_independent_type # ---------------------------------------------------------------------------- proc xdnd::platform_independent_type { type } { ::tkdnd::generic::platform_independent_type $type }; # xdnd::platform_independent_type # ---------------------------------------------------------------------------- # Command xdnd::_normalise_data # ---------------------------------------------------------------------------- proc xdnd::normalise_data { type data } { # Tk knows how to interpret the following types: # STRING, TEXT, COMPOUND_TEXT # UTF8_STRING # Else, it returns a list of 8 or 32 bit numbers... switch -glob $type { STRING - UTF8_STRING - TEXT - COMPOUND_TEXT {return $data} text/html { if {[catch { encoding convertfrom unicode $data } string]} { set string $data } return [string map {\r\n \n} $string] } text/html\;charset=utf-8 - text/plain\;charset=utf-8 - text/plain { if {[catch { encoding convertfrom utf-8 [tkdnd::bytes_to_string $data] } string]} { set string $data } return [string map {\r\n \n} $string] } text/uri-list* { if {[catch { encoding convertfrom utf-8 [tkdnd::bytes_to_string $data] } string]} { set string $data } ## Get rid of \r\n set string [string trim [string map {\r\n \n} $string]] set files {} foreach quoted_file [split $string] { set file [tkdnd::urn_unquote $quoted_file] switch -glob $file { \#* {} file://* {lappend files [string range $file 7 end]} ftp://* - https://* - http://* {lappend files $quoted_file} default {lappend files $file} } } return $files } application/x-color { return $data } text/x-moz-url - application/q-iconlist - default {return $data} } }; # xdnd::normalise_data ############################################################################# ## ## XDND drag implementation ## ############################################################################# # ---------------------------------------------------------------------------- # Command xdnd::_selection_ownership_lost # ---------------------------------------------------------------------------- proc xdnd::_selection_ownership_lost {} { variable _dragging set _dragging 0 };# _selection_ownership_lost # ---------------------------------------------------------------------------- # Command xdnd::_dodragdrop # ---------------------------------------------------------------------------- proc xdnd::_dodragdrop { source actions types data button } { variable _dragging # puts "xdnd::_dodragdrop: source: $source, actions: $actions, types: $types,\ # data: \"$data\", button: $button" if {$_dragging} { ## We are in the middle of another drag operation... error "another drag operation in progress" } variable _dodragdrop_drag_source $source variable _dodragdrop_drop_target 0 variable _dodragdrop_drop_target_proxy 0 variable _dodragdrop_actions $actions variable _dodragdrop_action_descriptions $actions variable _dodragdrop_actions_len [llength $actions] variable _dodragdrop_types $types variable _dodragdrop_types_len [llength $types] variable _dodragdrop_data $data variable _dodragdrop_transfer_data {} variable _dodragdrop_button $button variable _dodragdrop_time 0 variable _dodragdrop_default_action refuse_drop variable _dodragdrop_waiting_status 0 variable _dodragdrop_drop_target_accepts_drop 0 variable _dodragdrop_drop_target_accepts_action refuse_drop variable _dodragdrop_current_cursor $_dodragdrop_default_action variable _dodragdrop_drop_occured 0 variable _dodragdrop_selection_requestor 0 ## ## If we have more than 3 types, the property XdndTypeList must be set on ## the drag source widget... ## if {$_dodragdrop_types_len > 3} { _announce_type_list $_dodragdrop_drag_source $_dodragdrop_types } ## ## Announce the actions & their descriptions on the XdndActionList & ## XdndActionDescription properties... ## _announce_action_list $_dodragdrop_drag_source $_dodragdrop_actions \ $_dodragdrop_action_descriptions ## ## Arrange selection handlers for our drag source, and all the supported types ## registerSelectionHandler $source $types ## ## Step 1: When a drag begins, the source takes ownership of XdndSelection. ## selection own -command ::tkdnd::xdnd::_selection_ownership_lost \ -selection XdndSelection $source set _dragging 1 ## Grab the mouse pointer... _grab_pointer $source $_dodragdrop_default_action ## Register our generic event handler... # The generic event callback will report events by modifying variable # ::xdnd::_dodragdrop_event: a dict with event information will be set as # the value of the variable... _register_generic_event_handler ## Set a timeout for debugging purposes... # after 60000 {set ::tkdnd::xdnd::_dragging 0} tkwait variable ::tkdnd::xdnd::_dragging _SendXdndLeave set _dragging 0 _ungrab_pointer $source _unregister_generic_event_handler catch {selection clear -selection XdndSelection} unregisterSelectionHandler $source $types return $_dodragdrop_drop_target_accepts_action };# xdnd::_dodragdrop # ---------------------------------------------------------------------------- # Command xdnd::_process_drag_events # ---------------------------------------------------------------------------- proc xdnd::_process_drag_events {event} { # The return value from proc is normally 0. A non-zero return value indicates # that the event is not to be handled further; that is, proc has done all # processing that is to be allowed for the event variable _dragging if {!$_dragging} {return 0} # puts $event variable _dodragdrop_time set time [dict get $event time] set type [dict get $event type] if {$time < $_dodragdrop_time && ![string equal $type SelectionRequest]} { return 0 } set _dodragdrop_time $time variable _dodragdrop_drag_source variable _dodragdrop_drop_target variable _dodragdrop_drop_target_proxy variable _dodragdrop_default_action switch $type { MotionNotify { set rootx [dict get $event x_root] set rooty [dict get $event y_root] set window [_find_drop_target_window $_dodragdrop_drag_source \ $rootx $rooty] if {[string length $window]} { ## Examine the modifiers to suggest an action... set _dodragdrop_default_action [_default_action $event] ## Is it a Tk widget? # set path [winfo containing $rootx $rooty] # puts "Window under mouse: $window ($path)" if {$_dodragdrop_drop_target != $window} { ## Send XdndLeave to $_dodragdrop_drop_target _SendXdndLeave ## Is there a proxy? If not, _find_drop_target_proxy returns the ## target window, so we always get a valid "proxy". set proxy [_find_drop_target_proxy $_dodragdrop_drag_source $window] ## Send XdndEnter to $window _SendXdndEnter $window $proxy ## Send XdndPosition to $_dodragdrop_drop_target _SendXdndPosition $rootx $rooty $_dodragdrop_default_action } else { ## Send XdndPosition to $_dodragdrop_drop_target _SendXdndPosition $rootx $rooty $_dodragdrop_default_action } } else { ## No window under the mouse. Send XdndLeave to $_dodragdrop_drop_target _SendXdndLeave } } ButtonPress { } ButtonRelease { variable _dodragdrop_button set button [dict get $event button] if {$button == $_dodragdrop_button} { ## The button that initiated the drag was released. Trigger drop... _SendXdndDrop } return 1 } KeyPress { } KeyRelease { set keysym [dict get $event keysym] switch $keysym { Escape { ## The user has pressed escape. Abort... if {$_dragging} {set _dragging 0} } } } SelectionRequest { variable _dodragdrop_selection_requestor variable _dodragdrop_selection_property variable _dodragdrop_selection_selection variable _dodragdrop_selection_target variable _dodragdrop_selection_time set _dodragdrop_selection_requestor [dict get $event requestor] set _dodragdrop_selection_property [dict get $event property] set _dodragdrop_selection_selection [dict get $event selection] set _dodragdrop_selection_target [dict get $event target] set _dodragdrop_selection_time $time return 0 } default { return 0 } } return 0 };# _process_drag_events # ---------------------------------------------------------------------------- # Command xdnd::_SendXdndEnter # ---------------------------------------------------------------------------- proc xdnd::_SendXdndEnter {window proxy} { variable _dodragdrop_drag_source variable _dodragdrop_drop_target variable _dodragdrop_drop_target_proxy variable _dodragdrop_types variable _dodragdrop_waiting_status variable _dodragdrop_drop_occured if {$_dodragdrop_drop_target > 0} _SendXdndLeave if {$_dodragdrop_drop_occured} return set _dodragdrop_drop_target $window set _dodragdrop_drop_target_proxy $proxy set _dodragdrop_waiting_status 0 if {$_dodragdrop_drop_target < 1} return # puts "XdndEnter: $_dodragdrop_drop_target $_dodragdrop_drop_target_proxy" _send_XdndEnter $_dodragdrop_drag_source $_dodragdrop_drop_target \ $_dodragdrop_drop_target_proxy $_dodragdrop_types };# xdnd::_SendXdndEnter # ---------------------------------------------------------------------------- # Command xdnd::_SendXdndPosition # ---------------------------------------------------------------------------- proc xdnd::_SendXdndPosition {rootx rooty action} { variable _dodragdrop_drag_source variable _dodragdrop_drop_target if {$_dodragdrop_drop_target < 1} return variable _dodragdrop_drop_occured if {$_dodragdrop_drop_occured} return variable _dodragdrop_drop_target_proxy variable _dodragdrop_waiting_status ## Arrange a new XdndPosition, to be send periodically... variable _dodragdrop_xdnd_position_heartbeat catch {after cancel $_dodragdrop_xdnd_position_heartbeat} set _dodragdrop_xdnd_position_heartbeat [after 200 \ [list ::tkdnd::xdnd::_SendXdndPosition $rootx $rooty $action]] if {$_dodragdrop_waiting_status} {return} # puts "XdndPosition: $_dodragdrop_drop_target $rootx $rooty $action" _send_XdndPosition $_dodragdrop_drag_source $_dodragdrop_drop_target \ $_dodragdrop_drop_target_proxy $rootx $rooty $action set _dodragdrop_waiting_status 1 };# xdnd::_SendXdndPosition # ---------------------------------------------------------------------------- # Command xdnd::_HandleXdndStatus # ---------------------------------------------------------------------------- proc xdnd::_HandleXdndStatus {event} { variable _dodragdrop_drop_target variable _dodragdrop_waiting_status variable _dodragdrop_drop_target_accepts_drop variable _dodragdrop_drop_target_accepts_action set _dodragdrop_waiting_status 0 foreach key {target accept want_position action x y w h} { set $key [dict get $event $key] } set _dodragdrop_drop_target_accepts_drop $accept set _dodragdrop_drop_target_accepts_action $action if {$_dodragdrop_drop_target < 1} return variable _dodragdrop_drop_occured if {$_dodragdrop_drop_occured} return _update_cursor # puts "XdndStatus: $event" };# xdnd::_HandleXdndStatus # ---------------------------------------------------------------------------- # Command xdnd::_HandleXdndFinished # ---------------------------------------------------------------------------- proc xdnd::_HandleXdndFinished {event} { variable _dodragdrop_xdnd_finished_event_after_id catch {after cancel $_dodragdrop_xdnd_finished_event_after_id} set _dodragdrop_xdnd_finished_event_after_id {} variable _dodragdrop_drop_target set _dodragdrop_drop_target 0 variable _dragging if {$_dragging} {set _dragging 0} variable _dodragdrop_drop_target_accepts_drop variable _dodragdrop_drop_target_accepts_action if {[dict size $event]} { foreach key {target accept action} { set $key [dict get $event $key] } set _dodragdrop_drop_target_accepts_drop $accept set _dodragdrop_drop_target_accepts_action $action } else { set _dodragdrop_drop_target_accepts_drop 0 } if {!$_dodragdrop_drop_target_accepts_drop} { set _dodragdrop_drop_target_accepts_action refuse_drop } # puts "XdndFinished: $event" };# xdnd::_HandleXdndFinished # ---------------------------------------------------------------------------- # Command xdnd::_SendXdndLeave # ---------------------------------------------------------------------------- proc xdnd::_SendXdndLeave {} { variable _dodragdrop_drag_source variable _dodragdrop_drop_target if {$_dodragdrop_drop_target < 1} return variable _dodragdrop_drop_target_proxy # puts "XdndLeave: $_dodragdrop_drop_target" _send_XdndLeave $_dodragdrop_drag_source $_dodragdrop_drop_target \ $_dodragdrop_drop_target_proxy set _dodragdrop_drop_target 0 variable _dodragdrop_drop_target_accepts_drop variable _dodragdrop_drop_target_accepts_action set _dodragdrop_drop_target_accepts_drop 0 set _dodragdrop_drop_target_accepts_action refuse_drop variable _dodragdrop_drop_occured if {$_dodragdrop_drop_occured} return _update_cursor };# xdnd::_SendXdndLeave # ---------------------------------------------------------------------------- # Command xdnd::_SendXdndDrop # ---------------------------------------------------------------------------- proc xdnd::_SendXdndDrop {} { variable _dodragdrop_drag_source variable _dodragdrop_drop_target if {$_dodragdrop_drop_target < 1} { ## The mouse has been released over a widget that does not accept drops. _HandleXdndFinished {} return } variable _dodragdrop_drop_occured if {$_dodragdrop_drop_occured} {return} variable _dodragdrop_drop_target_proxy variable _dodragdrop_drop_target_accepts_drop variable _dodragdrop_drop_target_accepts_action set _dodragdrop_drop_occured 1 _update_cursor clock if {!$_dodragdrop_drop_target_accepts_drop} { _SendXdndLeave _HandleXdndFinished {} return } # puts "XdndDrop: $_dodragdrop_drop_target" variable _dodragdrop_drop_timestamp set _dodragdrop_drop_timestamp [_send_XdndDrop \ $_dodragdrop_drag_source $_dodragdrop_drop_target \ $_dodragdrop_drop_target_proxy] set _dodragdrop_drop_target 0 # puts "XdndDrop: $_dodragdrop_drop_target" ## Arrange a timeout for receiving XdndFinished... variable _dodragdrop_xdnd_finished_event_after_id set _dodragdrop_xdnd_finished_event_after_id \ [after 10000 [list ::tkdnd::xdnd::_HandleXdndFinished {}]] };# xdnd::_SendXdndDrop # ---------------------------------------------------------------------------- # Command xdnd::_update_cursor # ---------------------------------------------------------------------------- proc xdnd::_update_cursor { {cursor {}}} { # puts "_update_cursor $cursor" variable _dodragdrop_current_cursor variable _dodragdrop_drag_source variable _dodragdrop_drop_target_accepts_drop variable _dodragdrop_drop_target_accepts_action if {![string length $cursor]} { set cursor refuse_drop if {$_dodragdrop_drop_target_accepts_drop} { set cursor $_dodragdrop_drop_target_accepts_action } } if {![string equal $cursor $_dodragdrop_current_cursor]} { _set_pointer_cursor $_dodragdrop_drag_source $cursor set _dodragdrop_current_cursor $cursor } };# xdnd::_update_cursor # ---------------------------------------------------------------------------- # Command xdnd::_default_action # ---------------------------------------------------------------------------- proc xdnd::_default_action {event} { variable _dodragdrop_actions variable _dodragdrop_actions_len if {$_dodragdrop_actions_len == 1} {return [lindex $_dodragdrop_actions 0]} set alt [dict get $event Alt] set shift [dict get $event Shift] set control [dict get $event Control] if {$shift && $control && [lsearch $_dodragdrop_actions link] != -1} { return link } elseif {$control && [lsearch $_dodragdrop_actions copy] != -1} { return copy } elseif {$shift && [lsearch $_dodragdrop_actions move] != -1} { return move } elseif {$alt && [lsearch $_dodragdrop_actions link] != -1} { return link } return default };# xdnd::_default_action # ---------------------------------------------------------------------------- # Command xdnd::getFormatForType # ---------------------------------------------------------------------------- proc xdnd::getFormatForType {type} { switch -glob [string tolower $type] { text/plain\;charset=utf-8 - text/html\;charset=utf-8 - utf8_string {set format UTF8_STRING} text/html - text/plain - string - text - compound_text {set format STRING} text/uri-list* {set format UTF8_STRING} application/x-color {set format $type} default {set format $type} } return $format };# xdnd::getFormatForType # ---------------------------------------------------------------------------- # Command xdnd::registerSelectionHandler # ---------------------------------------------------------------------------- proc xdnd::registerSelectionHandler {source types} { foreach type $types { selection handle -selection XdndSelection \ -type $type \ -format [getFormatForType $type] \ $source [list ::tkdnd::xdnd::_SendData $type] } };# xdnd::registerSelectionHandler # ---------------------------------------------------------------------------- # Command xdnd::unregisterSelectionHandler # ---------------------------------------------------------------------------- proc xdnd::unregisterSelectionHandler {source types} { foreach type $types { catch { selection handle -selection XdndSelection \ -type $type \ -format [getFormatForType $type] \ $source {} } } };# xdnd::unregisterSelectionHandler # ---------------------------------------------------------------------------- # Command xdnd::_convert_to_unsigned # ---------------------------------------------------------------------------- proc xdnd::_convert_to_unsigned {data format} { switch $format { 8 { set mask 0xff } 16 { set mask 0xffff } 32 { set mask 0xffffff } default {error "unsupported format $format"} } ## Convert signed integer into unsigned... set d [list] foreach num $data { lappend d [expr { $num & $mask }] } return $d };# xdnd::_convert_to_unsigned # ---------------------------------------------------------------------------- # Command xdnd::_SendData # ---------------------------------------------------------------------------- proc xdnd::_SendData {type offset bytes args} { variable _dodragdrop_drag_source variable _dodragdrop_types variable _dodragdrop_data variable _dodragdrop_transfer_data ## The variable _dodragdrop_data contains a list of data, one for each ## type in the _dodragdrop_types variable. We have to search types, and find ## the corresponding entry in the _dodragdrop_data list. set index [lsearch $_dodragdrop_types $type] if {$index < 0} { error "unable to locate data suitable for type \"$type\"" } set typed_data [lindex $_dodragdrop_data $index] set format 8 if {$offset == 0} { ## Prepare the data to be transferred... switch -glob $type { text/plain* - UTF8_STRING - STRING - TEXT - COMPOUND_TEXT { binary scan [encoding convertto utf-8 $typed_data] \ c* _dodragdrop_transfer_data set _dodragdrop_transfer_data \ [_convert_to_unsigned $_dodragdrop_transfer_data $format] } text/uri-list* { set files [list] foreach file $typed_data { switch -glob $file { *://* {lappend files $file} default {lappend files file://$file} } } binary scan [encoding convertto utf-8 "[join $files \r\n]\r\n"] \ c* _dodragdrop_transfer_data set _dodragdrop_transfer_data \ [_convert_to_unsigned $_dodragdrop_transfer_data $format] } application/x-color { set format 16 ## Try to understand the provided data: we accept a standard Tk colour, ## or a list of 3 values (red green blue) or a list of 4 values ## (red green blue opacity). switch [llength $typed_data] { 1 { set color [winfo rgb $_dodragdrop_drag_source $typed_data] lappend color 65535 } 3 { set color $typed_data; lappend color 65535 } 4 { set color $typed_data } default {error "unknown color data: \"$typed_data\""} } ## Convert the 4 elements into 16 bit values... set _dodragdrop_transfer_data [list] foreach c $color { lappend _dodragdrop_transfer_data [format 0x%04X $c] } } default { set format 32 binary scan $typed_data c* _dodragdrop_transfer_data } } } ## ## Data has been split into bytes. Count the bytes requested, and return them ## set data [lrange $_dodragdrop_transfer_data $offset [expr {$offset+$bytes-1}]] switch $format { 8 { set data [encoding convertfrom utf-8 [binary format c* $data]] } 16 { variable _dodragdrop_selection_requestor if {$_dodragdrop_selection_requestor} { ## Tk selection cannot process this format (only 8 & 32 supported). ## Call our XChangeProperty... set numItems [llength $data] variable _dodragdrop_selection_property variable _dodragdrop_selection_selection variable _dodragdrop_selection_target variable _dodragdrop_selection_time XChangeProperty $_dodragdrop_drag_source \ $_dodragdrop_selection_requestor \ $_dodragdrop_selection_property \ $_dodragdrop_selection_target \ $format \ $_dodragdrop_selection_time \ $data $numItems return -code break } } 32 { } default { error "unsupported format $format" } } # puts "SendData: $type $offset $bytes $args ($typed_data)" # puts " $data" return $data };# xdnd::_SendData ================================================ FILE: build_files/tkdnd2.9.2/tkdnd_utils.tcl ================================================ # # tkdnd_utils.tcl -- # # This file implements some utility procedures that are used by the TkDND # package. # # This software is copyrighted by: # George Petasis, National Centre for Scientific Research "Demokritos", # Aghia Paraskevi, Athens, Greece. # e-mail: petasis@iit.demokritos.gr # # The following terms apply to all files associated # with the software unless explicitly disclaimed in individual files. # # The authors hereby grant permission to use, copy, modify, distribute, # and license this software and its documentation for any purpose, provided # that existing copyright notices are retained in all copies and that this # notice is included verbatim in any distributions. No written agreement, # license, or royalty fee is required for any of the authorized uses. # Modifications to this software may be copyrighted by their authors # and need not follow the licensing terms described here, provided that # the new terms are clearly indicated on the first page of each file where # they apply. # # IN NO EVENT SHALL THE AUTHORS OR DISTRIBUTORS BE LIABLE TO ANY PARTY # FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES # ARISING OUT OF THE USE OF THIS SOFTWARE, ITS DOCUMENTATION, OR ANY # DERIVATIVES THEREOF, EVEN IF THE AUTHORS HAVE BEEN ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # # THE AUTHORS AND DISTRIBUTORS SPECIFICALLY DISCLAIM ANY WARRANTIES, # INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. THIS SOFTWARE # IS PROVIDED ON AN "AS IS" BASIS, AND THE AUTHORS AND DISTRIBUTORS HAVE # NO OBLIGATION TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR # MODIFICATIONS. # package require tkdnd namespace eval ::tkdnd { namespace eval utils { };# namespace ::tkdnd::utils namespace eval text { variable _drag_tag tkdnd::drag::selection::tag variable _state {} variable _drag_source_widget {} variable _drop_target_widget {} variable _now_dragging 0 };# namespace ::tkdnd::text };# namespace ::tkdnd bind TkDND_Drag_Text1 {tkdnd::text::_begin_drag clear 1 %W %s %X %Y %x %y} bind TkDND_Drag_Text1 {tkdnd::text::_begin_drag motion 1 %W %s %X %Y %x %y} bind TkDND_Drag_Text1 {tkdnd::text::_TextAutoScan %W %x %y} bind TkDND_Drag_Text1 {tkdnd::text::_begin_drag reset 1 %W %s %X %Y %x %y} bind TkDND_Drag_Text2 {tkdnd::text::_begin_drag clear 2 %W %s %X %Y %x %y} bind TkDND_Drag_Text2 {tkdnd::text::_begin_drag motion 2 %W %s %X %Y %x %y} bind TkDND_Drag_Text2 {tkdnd::text::_begin_drag reset 2 %W %s %X %Y %x %y} bind TkDND_Drag_Text3 {tkdnd::text::_begin_drag clear 3 %W %s %X %Y %x %y} bind TkDND_Drag_Text3 {tkdnd::text::_begin_drag motion 3 %W %s %X %Y %x %y} bind TkDND_Drag_Text3 {tkdnd::text::_begin_drag reset 3 %W %s %X %Y %x %y} # ---------------------------------------------------------------------------- # Command tkdnd::text::drag_source # ---------------------------------------------------------------------------- proc ::tkdnd::text::drag_source { mode path { types DND_Text } { event 1 } { tagprefix TkDND_Drag_Text } { tag sel } } { switch -exact -- $mode { register { $path tag bind $tag \ "tkdnd::text::_begin_drag press ${event} %W %s %X %Y %x %y" ## Set a binding to the widget, to put selection as data... bind $path <> "::tkdnd::text::DragInitCmd $path {%t} $tag" ## Set a binding to the widget, to remove selection if action is move... bind $path <> "::tkdnd::text::DragEndCmd $path %A $tag" } unregister { $path tag bind $tag {} bind $path <> {} bind $path <> {} } } ::tkdnd::drag_source $mode $path $types $event $tagprefix };# ::tkdnd::text::drag_source # ---------------------------------------------------------------------------- # Command tkdnd::text::drop_target # ---------------------------------------------------------------------------- proc ::tkdnd::text::drop_target { mode path { types DND_Text } } { switch -exact -- $mode { register { bind $path <> "::tkdnd::text::DropPosition $path %X %Y %A %a %m" bind $path <> "::tkdnd::text::Drop $path %D %X %Y %A %a %m" } unregister { bind $path <> {} bind $path <> {} bind $path <> {} bind $path <> {} } } ::tkdnd::drop_target $mode $path $types };# ::tkdnd::text::drop_target # ---------------------------------------------------------------------------- # Command tkdnd::text::DragInitCmd # ---------------------------------------------------------------------------- proc ::tkdnd::text::DragInitCmd { path { types DND_Text } { tag sel } { actions { copy move } } } { ## Save the selection indices... variable _drag_source_widget variable _drop_target_widget set _drag_source_widget $path set _drop_target_widget {} _save_selection $path $tag list $actions $types [$path get $tag.first $tag.last] };# ::tkdnd::text::DragInitCmd # ---------------------------------------------------------------------------- # Command tkdnd::text::DragEndCmd # ---------------------------------------------------------------------------- proc ::tkdnd::text::DragEndCmd { path action { tag sel } } { variable _drag_source_widget variable _drop_target_widget set _drag_source_widget {} set _drop_target_widget {} _restore_selection $path $tag switch -exact -- $action { move { ## Delete the original selected text... variable _selection_first variable _selection_last $path delete $_selection_first $_selection_last } } };# ::tkdnd::text::DragEndCmd # ---------------------------------------------------------------------------- # Command tkdnd::text::DropPosition # ---------------------------------------------------------------------------- proc ::tkdnd::text::DropPosition { path X Y action actions keys} { variable _drag_source_widget variable _drop_target_widget set _drop_target_widget $path ## This check is primitive, a more accurate one is needed! if {$path eq $_drag_source_widget} { ## This is a drag within the same widget! Set action to move... if {"move" in $actions} {set action move} } incr X -[winfo rootx $path] incr Y -[winfo rooty $path] $path mark set insert @$X,$Y; update return $action };# ::tkdnd::text::DropPosition # ---------------------------------------------------------------------------- # Command tkdnd::text::Drop # ---------------------------------------------------------------------------- proc ::tkdnd::text::Drop { path data X Y action actions keys } { incr X -[winfo rootx $path] incr Y -[winfo rooty $path] $path mark set insert @$X,$Y $path insert [$path index insert] $data return $action };# ::tkdnd::text::Drop # ---------------------------------------------------------------------------- # Command tkdnd::text::_save_selection # ---------------------------------------------------------------------------- proc ::tkdnd::text::_save_selection { path tag} { variable _drag_tag variable _selection_first variable _selection_last variable _selection_tag $tag set _selection_first [$path index $tag.first] set _selection_last [$path index $tag.last] $path tag add $_drag_tag $_selection_first $_selection_last $path tag configure $_drag_tag \ -background [$path tag cget $tag -background] \ -foreground [$path tag cget $tag -foreground] };# tkdnd::text::_save_selection # ---------------------------------------------------------------------------- # Command tkdnd::text::_restore_selection # ---------------------------------------------------------------------------- proc ::tkdnd::text::_restore_selection { path tag} { variable _drag_tag variable _selection_first variable _selection_last $path tag delete $_drag_tag $path tag remove $tag 0.0 end #$path tag add $tag $_selection_first $_selection_last };# tkdnd::text::_restore_selection # ---------------------------------------------------------------------------- # Command tkdnd::text::_begin_drag # ---------------------------------------------------------------------------- proc ::tkdnd::text::_begin_drag { event button source state X Y x y } { variable _drop_target_widget variable _state # puts "::tkdnd::text::_begin_drag $event $button $source $state $X $Y $x $y" switch -exact -- $event { clear { switch -exact -- $_state { press { ## Do not execute other bindings, as they will erase selection... return -code break } } set _state clear } motion { variable _now_dragging if {$_now_dragging} {return -code break} if { [string equal $_state "press"] } { variable _x0; variable _y0 if { abs($_x0-$X) > ${::tkdnd::_dx} || abs($_y0-$Y) > ${::tkdnd::_dy} } { set _state "done" set _drop_target_widget {} set _now_dragging 1 set code [catch { ::tkdnd::_init_drag $button $source $state $X $Y $x $y } info options] set _drop_target_widget {} set _now_dragging 0 if {$code != 0} { ## Something strange occurred... return -options $options $info } } return -code break } set _state clear } press { variable _x0; variable _y0 set _x0 $X set _y0 $Y set _state "press" } reset { set _state {} } } if {$source eq $_drop_target_widget} {return -code break} return -code continue };# tkdnd::text::_begin_drag proc tkdnd::text::_TextAutoScan {w x y} { variable _now_dragging if {$_now_dragging} {return -code break} return -code continue };# tkdnd::text::_TextAutoScan ================================================ FILE: build_files/tkdnd2.9.2/tkdnd_windows.tcl ================================================ # # tkdnd_windows.tcl -- # # This file implements some utility procedures that are used by the TkDND # package. # # This software is copyrighted by: # George Petasis, National Centre for Scientific Research "Demokritos", # Aghia Paraskevi, Athens, Greece. # e-mail: petasis@iit.demokritos.gr # # The following terms apply to all files associated # with the software unless explicitly disclaimed in individual files. # # The authors hereby grant permission to use, copy, modify, distribute, # and license this software and its documentation for any purpose, provided # that existing copyright notices are retained in all copies and that this # notice is included verbatim in any distributions. No written agreement, # license, or royalty fee is required for any of the authorized uses. # Modifications to this software may be copyrighted by their authors # and need not follow the licensing terms described here, provided that # the new terms are clearly indicated on the first page of each file where # they apply. # # IN NO EVENT SHALL THE AUTHORS OR DISTRIBUTORS BE LIABLE TO ANY PARTY # FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES # ARISING OUT OF THE USE OF THIS SOFTWARE, ITS DOCUMENTATION, OR ANY # DERIVATIVES THEREOF, EVEN IF THE AUTHORS HAVE BEEN ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # # THE AUTHORS AND DISTRIBUTORS SPECIFICALLY DISCLAIM ANY WARRANTIES, # INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. THIS SOFTWARE # IS PROVIDED ON AN "AS IS" BASIS, AND THE AUTHORS AND DISTRIBUTORS HAVE # NO OBLIGATION TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR # MODIFICATIONS. # namespace eval olednd { proc initialise { } { ## Mapping from platform types to TkDND types... ::tkdnd::generic::initialise_platform_to_tkdnd_types [list \ CF_UNICODETEXT DND_Text \ CF_TEXT DND_Text \ CF_HDROP DND_Files \ UniformResourceLocator DND_URL \ CF_HTML DND_HTML \ {HTML Format} DND_HTML \ CF_RTF DND_RTF \ CF_RTFTEXT DND_RTF \ {Rich Text Format} DND_RTF \ ] # FileGroupDescriptorW DND_Files \ # FileGroupDescriptor DND_Files \ ## Mapping from TkDND types to platform types... ::tkdnd::generic::initialise_tkdnd_to_platform_types [list \ DND_Text {CF_UNICODETEXT CF_TEXT} \ DND_Files {CF_HDROP} \ DND_URL {UniformResourceLocator UniformResourceLocatorW} \ DND_HTML {CF_HTML {HTML Format}} \ DND_RTF {CF_RTF CF_RTFTEXT {Rich Text Format}} \ ] };# initialise };# namespace olednd # ---------------------------------------------------------------------------- # Command olednd::HandleDragEnter # ---------------------------------------------------------------------------- proc olednd::HandleDragEnter { drop_target typelist actionlist pressedkeys rootX rootY codelist { data {} } } { ::tkdnd::generic::SetDroppedData $data focus $drop_target ::tkdnd::generic::HandleEnter $drop_target 0 $typelist \ $codelist $actionlist $pressedkeys set action [::tkdnd::generic::HandlePosition $drop_target {} \ $pressedkeys $rootX $rootY] if {$::tkdnd::_auto_update} {update idletasks} return $action };# olednd::HandleDragEnter # ---------------------------------------------------------------------------- # Command olednd::HandleDragOver # ---------------------------------------------------------------------------- proc olednd::HandleDragOver { drop_target pressedkeys rootX rootY } { set action [::tkdnd::generic::HandlePosition $drop_target {} \ $pressedkeys $rootX $rootY] if {$::tkdnd::_auto_update} {update idletasks} return $action };# olednd::HandleDragOver # ---------------------------------------------------------------------------- # Command olednd::HandleDragLeave # ---------------------------------------------------------------------------- proc olednd::HandleDragLeave { drop_target } { ::tkdnd::generic::HandleLeave if {$::tkdnd::_auto_update} {update idletasks} };# olednd::HandleDragLeave # ---------------------------------------------------------------------------- # Command olednd::HandleDrop # ---------------------------------------------------------------------------- proc olednd::HandleDrop { drop_target pressedkeys rootX rootY type data } { ::tkdnd::generic::SetDroppedData [normalise_data $type $data] set action [::tkdnd::generic::HandleDrop $drop_target {} \ $pressedkeys $rootX $rootY 0] if {$::tkdnd::_auto_update} {update idletasks} return $action };# olednd::HandleDrop # ---------------------------------------------------------------------------- # Command olednd::GetDataType # ---------------------------------------------------------------------------- proc olednd::GetDataType { drop_target typelist } { foreach {drop_target common_drag_source_types common_drop_target_types} \ [::tkdnd::generic::FindWindowWithCommonTypes $drop_target $typelist] {break} lindex $common_drag_source_types 0 };# olednd::GetDataType # ---------------------------------------------------------------------------- # Command olednd::GetDragSourceCommonTypes # ---------------------------------------------------------------------------- proc olednd::GetDragSourceCommonTypes { drop_target } { ::tkdnd::generic::GetDragSourceCommonTypes };# olednd::GetDragSourceCommonTypes # ---------------------------------------------------------------------------- # Command olednd::platform_specific_types # ---------------------------------------------------------------------------- proc olednd::platform_specific_types { types } { ::tkdnd::generic::platform_specific_types $types }; # olednd::platform_specific_types # ---------------------------------------------------------------------------- # Command olednd::platform_specific_type # ---------------------------------------------------------------------------- proc olednd::platform_specific_type { type } { ::tkdnd::generic::platform_specific_type $type }; # olednd::platform_specific_type # ---------------------------------------------------------------------------- # Command tkdnd::platform_independent_types # ---------------------------------------------------------------------------- proc ::tkdnd::platform_independent_types { types } { ::tkdnd::generic::platform_independent_types $types }; # tkdnd::platform_independent_types # ---------------------------------------------------------------------------- # Command olednd::platform_independent_type # ---------------------------------------------------------------------------- proc olednd::platform_independent_type { type } { ::tkdnd::generic::platform_independent_type $type }; # olednd::platform_independent_type # ---------------------------------------------------------------------------- # Command olednd::normalise_data # ---------------------------------------------------------------------------- proc olednd::normalise_data { type data } { switch [lindex [::tkdnd::generic::platform_independent_type $type] 0] { DND_Text {return $data} DND_Files {return $data} DND_HTML {return [encoding convertfrom utf-8 $data]} default {return $data} } }; # olednd::normalise_data ================================================ FILE: build_files/updater.spec ================================================ # -*- mode: python ; coding: utf-8 -*- import os import sys from PyInstaller.building.api import PYZ, EXE from PyInstaller.building.build_main import Analysis from PyInstaller.config import CONF CONF['distpath'] = './dist' # type: ignore CONF['workpath'] = './_build' # type: ignore block_cipher = None sys.modules['FixTk'] = None # type: ignore a = Analysis([f'{os.getcwd()}/updater.py'], pathex=[os.getcwd()], binaries=[], datas=[], hiddenimports=['pkg_resources.py2_warn'], hookspath=[], runtime_hooks=[], excludes=['pandas', 'numpy', 'cryptography', 'simplejson', 'PySide2', 'FixTk', 'tcl', 'tk', '_tkinter', 'tkinter', 'Tkinter'], win_no_prefer_redirects=False, win_private_assemblies=False, cipher=block_cipher, noarchive=False) pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) exe = EXE(pyz, a.scripts, a.binaries, a.zipfiles, a.datas, [], name='Updater', debug=False, manifest='build_files/Updater.exe.MANIFEST', bootloader_ignore_signals=False, strip=False, upx=False, upx_exclude=['vcruntime140.dll', 'msvcp140.dll', 'python36.dll', 'python37.dll', 'python38.dll'], runtime_tmpdir=None, console=False, icon=os.path.abspath('resources/Updater.ico'), version='mcu_version_info.txt') # ONLY USE FOR DEBUGGING # coll = COLLECT(exe, # a.binaries - TOC([('libcrypto-1_1.dll', None, None)]), # a.zipfiles, # a.datas, # strip=False, # upx=False, # upx_exclude=['vcruntime140.dll', 'msvcp140.dll', 'python36.dll', 'python37.dll', 'python38.dll'], # name='updater') ================================================ FILE: conftest.py ================================================ import pytest def pytest_addoption(parser): parser.addoption('--ci', action='store_true', default=False) parser.addoption('--upload', action='store_true', default=False) parser.addoption('--test-auto-update', action='store_true', default=False) @pytest.fixture(scope='session') def ci(pytestconfig): return pytestconfig.getoption('ci') def pytest_collection_modifyitems(config, items): if config.getoption('--ci'): skip_non_ci = pytest.mark.skip(reason='test not for CI') for item in items: if 'no_ci' in item.keywords: item.add_marker(skip_non_ci) else: skip_ci = pytest.mark.skip(reason='test only for CI') for item in items: if 'ci_only' in item.keywords: item.add_marker(skip_ci) ================================================ FILE: linux_install.py ================================================ #!/usr/bin/env python3 import os from pathlib import Path INSTALL_DIR = Path('$HOME/bin/music-caster').expanduser() os.chdir(INSTALL_DIR) # TODO raise NotImplemented ================================================ FILE: linux_install.sh ================================================ #!/usr/bin/env bash set -ex echo "(music-caster) Updating..." # git fetch # git reset --hard "@{u}" PYTHON=python3.14 ./scripts/pre-req.sh $PYTHON echo "(music-caster) Creating $PYTHON virtual environment" # if .venv DNE or has wrong Python version, delete old .venv and install new .venv if [ ! -d .venv ] || [ "$(.venv/bin/python -V)" != "$($PYTHON -V)" ]; then rm -rf .venv src/.venv src/venv venv $PYTHON -m venv .venv fi . .venv/bin/activate echo "(music-caster) Installing dependencies" python -m pip install --upgrade -r requirements.txt # restore cd ~/bin/music-caster # copy icons mkdir -p ~/Downloads/music-caster-tmp mkdir -p ~/.local/share/icons/hicolor/32x32/apps mkdir -p ~/.local/share/icons/hicolor/128x128/apps mkdir -p ~/.local/share/icons/hicolor/256x256/apps mkdir -p ~/.local/share/icons/hicolor/512x512/apps # 32x32 cp -rf resources/icons/32x32.png ~/Downloads/music-caster-tmp mv -f ~/Downloads/music-caster-tmp/32x32.png ~/.local/share/icons/hicolor/32x32/apps/music_caster.png # 128x128 cp -rf resources/icons/128x128.png ~/Downloads/music-caster-tmp mv -f ~/Downloads/music-caster-tmp/128x128.png ~/.local/share/icons/hicolor/128x128/apps/music_caster.png # 256x256 cp -rf resources/icons/128x128@2x.png ~/Downloads/music-caster-tmp mv -f ~/Downloads/music-caster-tmp/128x128@2x.png ~/.local/share/icons/hicolor/256x256/apps/music_caster.png # 512x512 cp -rf resources/icons/icon.png ~/Downloads/music-caster-tmp mv -f ~/Downloads/music-caster-tmp/icon.png ~/.local/share/icons/hicolor/512x512/apps/music_caster.png rm -rf ~/Downloads/music-caster-tmp # install .desktop file echo "(music-caster) Registering as desktop application" cp -rf music_caster.desktop ~/.local/share/applications # delete old files rm -rf ~/.icons/music_caster.png ================================================ FILE: music_caster.desktop ================================================ # See README.md for instructions on how to use [Desktop Entry] Type=Application Name=Music Caster Comment=A modern music player that can cast audio files and urls to Google Chromecasts, Home minis, etc. Exec=sh -c "$HOME/bin/music-caster/.venv/bin/python $HOME/bin/music-caster/src/music_caster.py" Icon=music_caster.png Terminal=false MimeType=application/x-ogg;application/ogg;audio/x-vorbis+ogg;audio/vorbis;audio/x-vorbis;audio/x-scpls;audio/x-mp3;audio/x-mpeg;audio/mpeg;audio/x-mpegurl;audio/x-flac;audio/mp4; Categories=Audio;Player; Keywords=Audio;Song;Track;MP3;Playlist;YouTube;Cast;URL; Actions=PlayPause;Next;Previous;StopQuit; X-GNOME-UsesNotifications=true # [Desktop Action PlayPause] # Name=Play/Pause # Exec=XXX --play-pause # [Desktop Action Next] # Name=Next # Exec=XXX --next # [Desktop Action Previous] # Name=Previous # Exec=XXX --previous # [Desktop Action StopQuit] # Name=Stop & Quit # Exec=XXX --quit ================================================ FILE: pyproject.toml ================================================ [tool.ruff.format] # Like Black, use double quotes for strings. quote-style = "single" # pyproject.toml [tool.pytest.ini_options] minversion = "8.0" addopts = "-s" testpaths = [ "src/test_harness.py", "tests", ] markers = [ "no_ci: marks tests as unable to run in CI environment", "ci_only: marks tests as only able to run in CI environment", ] ================================================ FILE: requirements-dev.txt ================================================ GitPython~=3.1 autopep8 requirements-parser # pyinstaller dependencies setuptools>=65.5.1 altgraph pyinstaller-hooks-contrib >= 2020.11 pefile; sys_platform == 'win32' pywin32-ctypes; sys_platform == 'win32' pypiwin32; sys_platform == 'win32' macholib; sys_platform == 'darwin' build_files/pyinstaller-6.16.0-py3-none-any.whl pipdeptree pyoxidizer pytest ruff ================================================ FILE: requirements.txt ================================================ audioop-lts; python_version>='3.13' comtypes; sys_platform == 'win32' wheel build_files/pyaudio-0.2.14-cp314-cp314-win_amd64.whl; sys_platform == 'win32' pyaudio; sys_platform == 'darwin' pywin32; sys_platform == 'win32' winrt-Windows.Media; sys_platform == 'win32' winrt-Windows.Media.Playback; sys_platform == 'win32' winrt-Windows.Media.Control; sys_platform == 'win32' winrt-Windows.Storage.Streams; sys_platform == 'win32' winrt-Windows.Foundation; sys_platform == 'win32' winrt-runtime; sys_platform == 'win32' pgi; sys_platform == 'linux' testresources; sys_platform == 'linux' ujson~=5.5 mutagen~=1.45 Pillow~=11.3.0 PyChromecast~=14.0 zeroconf~=0.146 pynput~=1.4.5 pypng~=0.0.20 # use zip for faster install than git https://github.com/elibroftw/pypresence/archive/master.zip # https://github.com/qwertyquerty/pypresence/archive/master.zip https://github.com/yt-dlp/yt-dlp/archive/master.zip https://github.com/elibroftw/youtube-comment-downloader/archive/master.zip # PySimpleGUI 4.60.5 no longer available on pypi, therefore use FreeSimpleGUI commit hash https://github.com/spyoungtech/FreeSimpleGUI/archive/a0634800bca051c824d879d31e14ae3b201e5667.zip pyqrcode~=1.2.1 pystray~=0.19.1 requests~=2.28 urllib3~=2.5 FreeSimpleGUI waitress~=3.0 wavinfo~=4.0 scrapetube pyperclip~=1.8 werkzeug~=3.0 python-vlc==3.0.21203 # why was this fixed to 3.13? lz4~=4.4.4; sys_platform == 'win32' lz4; sys_platform != 'win32' browser_cookie3 beautifulsoup4~=4.10 flask~=2.0 pycparser~=2.14 # used by deemix deezer-py~=1.2 deemix~=3.5 six~=1.16 portalocker~=2.4 python-tkdnd; sys_platform != 'win32' # 1.2.2+ was causing issues dateparser==1.2.1 # experiments # numpy~=1.21 # matplotlib~=3.6 # soundfile~=0.11 appdirs ================================================ FILE: resources/favicons/browserconfig.xml ================================================ #ededed ================================================ FILE: resources/favicons/site.webmanifest ================================================ { "name": "", "short_name": "", "icons": [ { "src": "https://raw.githubusercontent.com/elibroftw/music-caster/master/resources/favicons/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" }, { "src": "https://raw.githubusercontent.com/elibroftw/music-caster/master/resources/favicons/android-chrome-256x256.png", "sizes": "256x256", "type": "image/png" } ], "theme_color": "#eeeeee", "background_color": "#eeeeee", "display": "standalone" } ================================================ FILE: resources/gude-2023-11-11.log ================================================ 19:31:21:330 [ALWAYS] GUDE Logging Started 19:31:21:331 [INFO] SqliteResumeCache::SqliteResumeCache basedirectory is null/empty 19:31:21:331 [ERROR] SqliteResumeCache::CreateSqliteResumeCache creating resume cache pointer failure 19:31:21:331 [ERROR] Initialize resume cache pointer failure 19:31:21:331 [INFO] gude policy POLICY_USER limit (2,2,1) chunk 2097152, chunking (UL 0 DL 1), resXfer 1, adapt 0, NSURL 0, timeout 86400 19:31:21:331 [INFO] Initialized -- gude-lib version: v0.12.1 app: gude 19:31:24:337 [WARN] Ignoring attempt to reinit logging to \gude at level 4 retaining 3 days of logs 19:31:24:337 [INFO] SqliteResumeCache::SqliteResumeCache basedirectory is null/empty 19:31:24:337 [ERROR] SqliteResumeCache::CreateSqliteResumeCache creating resume cache pointer failure 19:31:24:337 [ERROR] Initialize resume cache pointer failure 19:31:24:338 [INFO] gude policy POLICY_USER limit (4,4,1) chunk 2097152, chunking (UL 0 DL 1), resXfer 1, adapt 0, NSURL 1, timeout 86400 19:31:24:338 [INFO] Initialized -- gude-lib version: v0.12.1 app: gude ================================================ FILE: scripts/arch-install.sh ================================================ #!/usr/bin/env bash PYTHON=$1 echo "Installing Arch system dependencies" sudo pacman -Sy --noconfirm python-pip $PYTHON "$PYTHON-dev" "$PYTHON-virtualenv" "$PYTHON-tk" python3-pyaudio ================================================ FILE: scripts/debian-install.sh ================================================ #!/usr/bin/env bash echo "debian-based distro detected" PYTHON=$1 # sudo apt install -y software-properties-common # Check for python3.14 if ! $PYTHON --version &> /dev/null; then echo "Python 3.14 not found. Installing..." sudo add-apt-repository -y ppa:deadsnakes/ppa sudo apt update sudo apt install -y python3.14 fi echo "Installing system dependencies" sudo apt update sudo apt install -y python3-pip "${PYTHON}-venv" "${PYTHON}-tk" python3-pyaudio "${PYTHON}-venv" ================================================ FILE: scripts/fedora-install.sh ================================================ #!/usr/bin/env bash PYTHON=$1 echo "Installing Fedora system dependencies" sudo dnf install -y "$PYTHON" python-pip "$PYTHON-devel" "$PYTHON-virtualenv" "$PYTHON-tkinter" portaudio-devel redhat-rpm-config ================================================ FILE: scripts/pre-req.sh ================================================ #!/usr/bin/env bash # Define script locations (change paths if needed) DEBIAN_INSTALL="./scripts/debian-install.sh" ARCH_INSTALL="./scripts/arch-install.sh" FEDORA_INSTALL="./scripts/fedora-install.sh" SUSE_INSTALL="./scripts/suse-install.sh" # Check for /etc/os-release (preferred method) if [ -f /etc/os-release ]; then . /etc/os-release case "$ID" in debian|ubuntu|mint|linuxmint) if [ -f "$DEBIAN_INSTALL" ]; then "$DEBIAN_INSTALL" "$1" exit 0 fi echo "Error: $DEBIAN_INSTALL not found!" exit 1 ;; arch) if [ -f "$ARCH_INSTALL" ]; then "$ARCH_INSTALL" "$1" exit 0 fi echo "Error: $ARCH_INSTALL not found!" exit 1 ;; fedora) if [ -f "$FEDORA_INSTALL" ]; then "$FEDORA_INSTALL" "$1" exit 0 fi echo "Error: $FEDORA_INSTALL not found!" exit 1 ;; opensuse|sles) if [ -f "$SUSE_INSTALL" ]; then "$SUSE_INSTALL" "$1" exit 0 fi echo "Error: $SUSE_INSTALL not found!" exit 1 ;; esac fi # No match, display error echo "Error: Unsupported distribution. Only Debian, Arch, Fedora, and SUSE are supported." exit 1 ================================================ FILE: scripts/suse-install.sh ================================================ #!/usr/bin/env bash PYTHON=$1 echo "Installing SUSE system dependencies" sudo zypper install -y "$PYTHON" "$PYTHON-devel" "$PYTHON-tk" "$PYTHON-virtualenv" python3-pyaudio ================================================ FILE: src/audio_player.py ================================================ """ AudioPlayer v2.3.7 Author: Elijah Lopez Ensure VLC shared library files (*.dll, *.so) are located in "vlc_lib/" """ import math import os import platform import sys import time from enum import IntEnum from pathlib import Path IS_FROZEN = getattr(sys, 'frozen', False) # pyinstaller generated executable try: app_path = sys._MEIPASS except AttributeError: app_path = os.path.dirname(sys.executable if IS_FROZEN else __file__) vlc_ext = 'dll' if platform.system() == 'Windows' else 'so' if platform.system() != 'Windows': os.environ['PYTHON_VLC_MODULE_PATH'] = f'{app_path}/vlc_lib/plugins' vlc_lib_path = Path(f'{app_path}/vlc_lib/libvlc.{vlc_ext}') os.environ['PYTHON_VLC_LIB_PATH'] = str(vlc_lib_path) cwd = os.getcwd() if platform.system() == 'Linux': os.chdir(f'{app_path}/vlc_lib') import vlc os.chdir(cwd) class AudioPlayerUnit(IntEnum): MILLI_SECOND = 1 SECOND = 1000 class AudioPlayer: __slots__ = 'vlc_instance', 'player', 'is_url' def __init__(self, skip_vlc=False): if not skip_vlc: self.vlc_instance = vlc.Instance() self.player: vlc.MediaPlayer = self.vlc_instance.media_player_new() self.is_url = False def has_media(self): return self.player.get_media() is not None def is_busy(self): """Returns whether player is playing or is paused""" return self.has_media() def play(self, media_path, start_playing=True, volume=None, start_from=0): """ :param media_path: str :param start_playing: bool :param volume: float[0, 1] :param start_from: time to start from in seconds """ self.is_url = media_path.startswith('http') self.player.set_mrl(media_path) self.player.play() if volume is not None: self.set_volume(volume) block_until = time.time() + 1 while not self.player.is_playing() and time.time() < block_until: pass self.set_pos(start_from) if not start_playing: self.pause() def load(self, file_path): self.play(file_path, start_playing=False) def pause(self): if self.is_playing(): self.player.pause() block_until = time.time() + 1 while self.player.is_playing() and time.time() < block_until: pass return True return False def resume(self): """ Resumes playback if paused and has media Also used to start playing audio after load was used """ if not self.is_playing() and self.has_media(): if self.player.get_length() - self.player.get_time() > 0.5: self.player.audio_set_volume(self.player.audio_get_volume()) self.player.pause() block_until = time.time() + 1 while not self.player.is_playing() and time.time() < block_until: pass return True return False def stop(self): """Stop the playback of any audio and return the current position in seconds""" if self.is_busy(): position = self.player.get_time() / 1000 self.player.stop() self.player.set_media(None) return position return 0 @staticmethod def percent_to_db_percent(percent: float): """ :param percent: float [0, 1] """ try: return round(20 * math.log(percent * 100, 10), 3) / 40 except ValueError: return 0 @staticmethod def db_percent_to_percent(db: float): """:param db: float [0, 40]""" return 0 if db == 0 else round((10 ** (2 * db)) / 120, 2) def set_volume(self, volume): """ Sets the output volume and not the program volume :param volume: float[0, 1] """ self.player.audio_set_volume(int(volume * 100)) def get_volume(self): """ get the volume of the output :return float [0, 1] """ return self.player.audio_get_volume() / 100 def set_pos(self, position, unit=AudioPlayerUnit.SECOND): """position is in seconds from start""" self.player.set_time(int(position * unit)) def get_pos(self, unit=AudioPlayerUnit.SECOND) -> float: """ returns the position of the audio playing position meaning the time in seconds from the start of the audio data/file """ return self.player.get_time() / unit def is_playing(self): """returns strictly whether the player is playing audio""" return self.player.is_playing() def is_paused(self): return not self.is_playing() and self.has_media() def is_idle(self): """Whether audio player is in stopped state: audio was never loaded, finished/stopped playing""" return not self.is_busy() def toggle_mute(self): self.player.audio_toggle_mute() def mute(self): self.player.audio_set_mute(True) def unmute(self): self.player.audio_set_mute(False) def get_length(self, unit=AudioPlayerUnit.SECOND) -> float: return self.player.get_length() / unit def get_sample_rate(self): return self.player.get_rate() ================================================ FILE: src/b64_images.py ================================================ WINDOW_ICON = b'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' UNFILLED_ICON = b'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAQAAADZc7J/AAACO0lEQVR4nKWVPUxUURCFv1keKiCo2GGU0FOqRKO2xhgoTCxIrCyNRinURmujhbHRmJjYktgYNBqJQTtj7IyNFCKJqNEABT/yt/s+i7eswC6wi/OKd9+9d86cM/fNXPhPi+xlbMHV0tC6rcTOvJYZNLANN9q+NjyLMZfxiNBrXKCJoFohIrM8iDsGYK9btV5IgG5S5hkkX4OEhJPsoJv+BAhyzMWZGtwBx2kkMgYF8qS2MkVUlchAWpbzlQAtJOylEHkjqgAwQktyE+ANC8ywWJuEctycuSp3BtjihNqfMQAgUjBHRKG2wAl4icMM84mPMQoGuRpBfFn8LSYcsBs2q43VEnLAT6ZZAlrp4ZlDdkXBXA31aYsdnvCyr82ruuAVWD+lqxmsXjruoJqqt8G6yizKADzoSdtLy30uuqTeWC8X5QBP1V++slgN9jhnXj1VWUg5wECpPF/YBuB5NfWLuyslsxzguFd97ryqw7Yb4GNVr4PJpgDF6S7fqfreRnMecNLUEZuMtRzKGTRlUWx0aDkueFfV09lpmKx46k1sXQnwyA+eBbDN3xYcc49hl6mp9yqJAHD8XzHt4hBPvBj344cPuck+jsVzP/OVDjoNCh6hjwIrDzWhgbTYfnyi6qz7DY+qegsM36ojNoHnNm6qM0yxwHY645ujjNFMM4R+Z4oC9cA0k+T/lX6prfcbATYWl+ZiyWAnQT7+lOanQ+tpWJOC0sXyH1a62pbPOWuo2Ve4clT58q2mAVdhfwGtyvSp1tmkCwAAAABJRU5ErkJggg==' FILLED_ICON = b'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAQAAADZc7J/AAACqElEQVR4nKWVS2hcZRTHf2f6TTWJjZ24S5XQpVBwoTa2WAVBikiyEKsEXXUpiu2idlPXoos2Gx8guHER6UZD8BHUuAyKC6WbFnwEbCtKq9CkTib3zv25uDOT6TQZkvTcxT33++75n/c5cIcU5cvYgagd1l070V1KtS0YYDf2+71XPWtRL+2I0Dd4hSGCrToicpP34h0DcMqd0hQkYIKCVebJt+FC4ih3M8FMAoIK9XhuG+KA1xgkoAI0ySmsmbYsnKy145WAYRL3EZH7GCeAfkltsouzsbiesAQs0GCFHHiQF7dgwGcsrucrQUwzDVat8COLjFMhg01SmpNodB9U2kxkUcQFnuRpvqRKlSBt8twCncDXOMglLvBzLEXGAgu+wDlGafaNxjr5RassrjvrZNlW7nNezW4rnUw9Bo54XZ0pXfiTZTJghElm+cZxiCtMMEPaUnE57H6f8HW/Nle14QmwYnJObfa34FaoI86rhfo2WHHYiz0QGwH4iEcd64CcdM1MPQPgYRs2LfoDfKr+5Ve2usFJ6+bqMwC+ry3XNgWY7Vx/7iiAx9XCX91rxTH/NXPNzMzMupnP9wIc8ZRzrqp6yTED/EjV0wB+3JPKl7oBOlXlOOc4BHzPU6xyPz+xlyUeYoWHOU3Rqtl2M9X4hRE+iSnAobKRHfTbLr1nVX12w8Qna90ufOgPHgNw1L9tetma4biFhdPg7g1BrpUACbiXRznvq/FuXPUD3mQfj8ecF/md/RwwyDzEyZ7OSAxQtOa451W96QOGh1V9Cwy/U39zCHz5tp4oqTVUV7hBg7s4EH+4xGX2sAdCr3CDJlVgmX/IWR957bE+YwQ42LqqR2ZwD0Ee/3XOl0OrDPSEoLNY7oA6q629WMtlWX6F3dzGyze2swo3p/8BMqdny1/eeZQAAAAASUVORK5CYII=' NEXT_BUTTON_IMG = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAASBJREFUSInt1L8rRWEcx/HXEamruKNY/BnKZJJBmQzKaCGLTHfBZDeSRRlMZLGalEFRCjODReRHBuUx3HvqdBzHuSe37nDfdTrf8z2f5/t5PvX00KHdiNKNEMIO1nDXxJxJTCDgI4qiWq461HkPIcyGEBR8tkOC9MyuDJ9nVLCHfQwWSPKQqF+KmCR7M7jGXAGjX8kySVPFLg4w3CqTmGncYr6VJtCHLRxhJNHv/k+TmCncYAk9eMsT5+7gD3qxiUfc5wnLJonZwDGG8kRlk5xhEeeN74E8cZkkqxhNGMBn3oJmkpxiAZfN7qpokhWMlTEgO8lXoj5RP6ZXZYbHZCWpNt7LGC9oUEnU/emfWUkOsY6LAsNjnvDaqH/cwh3aj2+H8WMIDwPNtQAAAABJRU5ErkJggg==' PREVIOUS_BUTTON_IMG = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAARJJREFUSInt1LtKA0EUgOFPsRMvIAjiCyg+iGhjHbAQCx/Azs7SyqSxCiiYFwiCIIKFj6GFVRo7DSJCxGORDSzL4u4GFy3yw8Awe+b8O7fDhH9PRPQiop+0ZkSo0BYj4rSMJE2ngmA7Ip4jIrI5p3M8/VT/rcTi59HGNZbxmg2YKZHkJ3ZwhtXU2FQ2KG8lZVjABboZQS7jSBp4wF7ZCUXblf6+hBZ2q/5VkeQ9iWmgmYgqUyTpGR7u5TjJRxSdyQrucISvuiRzeMEJ1nFbh+Qz1X/EJg4x+E1JHk2s4aZOCTxhCwf4qEsyoo0NXKXGii9Ipgp3K1Th/YgY5FXhvHfSwaxhoSu97zjHPY4rzJnwR3wDDhTHcFM0dNgAAAAASUVORK5CYII=' PLAY_BUTTON_IMG = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAAH9JREFUSIntlUEOgCAMBNE/6Mv0kfozXjGe5CBEW+weTNyEG2E6JWlT+hMRYAMmIHmPBwKQgUUNOeOy6oW4rN5AzFYRkEerKMitVTSkaaWAVFZKSLG6vjXaseYM5psdFjswq9qVgRXhx5fqFZCq+miIdKzIB6R01MuXln79fi4Hzn7a/EcS7E0AAAAASUVORK5CYII=' PAUSE_BUTTON_IMG = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAAD5JREFUSIljYBgFJABGbIL/////jyYkwMDA8BGbUhTDGBmxmsdEltNIBKOWjFoyasmoJaOWjFoyZCwZBSQBABSVBiYy06UHAAAAAElFTkSuQmCC' VOLUME_IMG = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAAV1JREFUSIntlL1LA0EQxX8RtU0i2ChIIGAhRAW/OkGxC2JpFbBSbLT3TxHE0los7NRKUAJG7NKIlbWIIoJ5FjeHx3ofeweCRR4MzOzuzJvdnRnoIwdK7oIkH78VoAYcJwYu/YQeyJ8XdeACWPJ1yEtSA9qm3/8FyThwC1TMHnb2V4EbYKIoSR24A0ZTznwAi8CZD0kFqJqUgQWCJ3IJwgrZBE6Aa9OngbXUlCU9S3ozeVUy9iQhadvsltmS1M4i8cW+BUXSo6RP00/l9EHcc72kZhGPI2DI9Et3s0ifxOErovd8SMoFSLYi+rK7ORjj8GRE4btWMwhawCSwY/YGTqPGkUwRzLSQZAa44nfzhegB58Ah0LSYB6lpRSomKrNW0knVhaR5W++6Q9b34zvAHPCecmYE6ALrmdESbhJKw/pBknbTzha5SYgHgvkE0PB1ivv4LHQIKmisgG8f/wTfaEggzd7YFiAAAAAASUVORK5CYII=' VOLUME_MUTED_IMG = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAAVhJREFUSIntlM0qRVEUx38XSZR8voBMTJSPkZGUjJQ8gChewMTQQ9w3uRkYKCnKABkzcUsZiG5Xt5j4GZwtx+nc7RwyUH61219rr397r7UX/PNd1CKtX91Wh2J2aSpZkQKcAWPAUMyoUvlw3VH0loE9YAa4LXOoq4TtPrAUxq+/IXIIzJdxnCb7XAMkbz0IdIb+LEfgPXiLwGRmbwWYaquo3pnQUp/UF/OpqeNhXFe7Q1athrXjmEizjdMsJ8FpNcwP1I3U/nJMpFFQ5NKPP7GeWq8b/k+asimcx0Nq3AIaWYOsSFHRl9DvADXgHNgFJoAmMNv2pHoTrv2o3pskQB7pwDfUkfB0W2HtKCbSq/aFHnVUvcoROQ/7a+qcn+vWproQE8lrPepFJPBfFsgiMXgGpoHTAra5lMmuOeD9k1Vihlm+U+qvSUrPcNTxD0o9JDeqktS5f/4ob0mm8DiCKHHOAAAAAElFTkSuQmCC' REPEAT_ALL_IMG = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAAatJREFUSIntlU1LV0EUh58RCUEh6F+goC0CtxGBiwiEtJYKtQoKWki7aNUX6DsEuo0WUmT4htiukt5sZ7UpoiAoCiOiTYv0afGfahqv9467Fv5guDN3zjnPzLln5sKudqCQDtQW8CWzOQKMAyeAQaADeAvcBxaBR1uChpC/+gdyRZ1Rie26zZpTBxKf+m2p56LjpPokC/Zdfag+UNcr5o6XQsYrVvpavai2ktX2xAWtJXYbal8J5HQGeJemYZs2ndg/3km6Uk2pexpAzxP74SbIqPoypmEt9r+pEw2Q4QRyowmyXdtfkLYPEfIpj9tRn8A/Wi+wuRefWw5JfhifAgeAVeBs4QJ+qxvYCxhC+FgHeQ/0x2EnsFETtBP4WTWRn/g8XdNJ/0wNAOAo7R0vAbOxzQMjtV7q4aRK3jR86C71WkXJX26CoC4nDrcKqmolg1wogexTfyROq+pYRfBT6l11M4OcL4GgDtm+9FK9Uu+ot9UX2dxX9WbsXyqFoB5UlypynmtFPeTfe+xqHjcv4Sr2SdqVdgzojT6fgWe0K2ohs2+FEPIf367+M/0Cfu4WXwXJNBQAAAAASUVORK5CYII=' REPEAT_ONE_IMG = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAAftJREFUSIntlD1rVEEUhp/VVdC4wUIFsfGjkhAEa4kJiI0ERFAWQSRptAn6D/IDQkBcLY1aWInE3kpwsVtYBCsJ+FGIokRzNzEgPhZ7b5hM5u5uWvHAgZl7znnfec/MufDftmGVcKMOAz+jnIPAZeACMALUgK9AC1gEngF/kuCVytaP6rT6VCX3WXXd3vZBvRrUbHjS1PN54Zz6JAH4LQddScTuDEoyVnLau+qoujsH2KeOq8+jvLlBSMajoo56PNWKwKejmjP9SM4lVMz3IUG9HeS/HaRd6+qauqr+ygvvq7v6EL0JiE73Iqmotbznw7nvt3sf1T4kNwKS2ZCkGvMAKwn+5fSxNtlSsB4JA/EwPqY7bD+AqQGAQ6sBYznmR6BdDGNM8hvYGRRlA4BfASaBPcBr4AG58rKJXwj6OtnnDo6onxKvcU292Oviz6aeYokvqTabTev1uvV6PSYbLXB3RDwvgXf5+iQwX9Kim8AxgEajQavVSuXcK1OCeio60SP1QKTiVZhQomStTAlAG5gO9teB98ACcAu4BpwoURhadcsisofAag68N/fkk+50OpvWQ0NDxfZzEjlxuYfs/sK/JF6RWZZJd4A3PMuyIjxT4MZzUiZ9BJgAjtKdh2XgMDAVKgEKJYvApbI52a5PqC/U72qmttWZIv7v2V9E6mWXr8iSBAAAAABJRU5ErkJggg==' REPEAT_OFF_IMG = b'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' DEFAULT_ART = b'/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAIBAQEBAQIBAQECAgICAgQDAgICAgUEBAMEBgUGBgYFBgYGBwkIBgcJBwYGCAsICQoKCgoKBggLDAsKDAkKCgr/2wBDAQICAgICAgUDAwUKBwYHCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgr/wAARCAQABAADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD9lKKKK7DywooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooozyB3PQetABTWY54NauneEPEGp4aKwMaH/lpOdg/Lr+lWLnQvBnh7LeK/F8KOOsETgEfhyx/IUroTkkYJcjktUtraahenFnZTTf8AXOIn+VW5/il8MtCJXRPDk95IOkjxBQf+BSHP6Vmal+0P4gfKaR4es7deg86RpCPwG0UuYnm7G1beC/FFyAf7M8sessir/XNXYfhtrUgzPe20fsCzf0Fedah8afiTeZC66kCntbWqLj8SCf1rGvfH/jy84uPGOpH2W7ZR+S4o5h+8z2iL4YMRmbWT/wAAt/8AE1KvwysF4k1ac/RFFfP1zrGtXGTca1eyZ677uQ/1qnNNcyffuZT7mVj/AFpXC0u59IL8NdJ6HUrk/wDfP+FI3wx07Hy6ncj6qv8AhXzWZblDlLiUEekrD+tKmsa3bHNvrV7H/wBc7yQY/JqLi5Zdz6Ok+GEWMxa04/34Qf61Xl+GWojm31WBv9+Mr/LNeCW3xE+IOn4Fn441VAOg+3Ow/Jia07H4+/FrTsBfFPngdVu7SN8/jtB/WncLT7nrtx4B8SwcpbxSj/pnMP64rPutD1myBN1pVwgH8Xlkj8xXIaZ+1j4ytSBrHhvTrpR1MLPC382H6V1GjftZeCrshNc0PULEnq6Ks6D8VIb/AMdouF5roMyM4zz6UV1+k/ED4TeOiIrHX9MuZW6QzMI5f++Xw35VZvvh1olzlrOSW3Y9Arbl/I/407hzrqjh6K3dR+Huu2eXtDHcqP7h2t+R/wAaxbi3uLSXyLuB4nH8EikH9aZSaewyiiigYUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUsccksgiijZ3Y4VVGST7CgBKfbW1xeTC2tIHlkPREXJq7Lpul6Iom8U3/AJTkZWwt8NM317IPrWdqXxB1IQGx8N2qaZbnr5PMre5c9/p+dJslyNeXQNM0SIXPi/XYrMHkW0Z3yt+A/oDVG5+J+jaMDH4P8MruHH2u+OWPvgc/qPpXIzmSaRpZpGdmOWdmyT9SetQkY4NS22Tq9y/rnjnxfr2U1DXZvLb/AJZQHy0/JcZ/HNYTxgEkDk9ferboCKidP/rUgKjp+VROn/1qtuntULp7UAVHj9qieOrbp/8AWqF09vqKBp2KkkftUTp7fhVt46heOgvcqPH/APrqJ09qtunt9ahdP/rUAVHT8qidP/11bdDnpULp+VAFR09qidParbp/9aoXT2+ooAqyxBuGUHHTIzW14a+J3xB8HMo8PeK7yGNeltJJ5sR/4A+R+WKynSopI6APYfCf7X19AUt/HHhhJl6Nd6a21h7mNzg/gw+leoeFviX8NfiZALbSNZtbmQjLWVwuyZf+ANz+IyPevkl0z9aiIZHEiEhlOVYHBU+oPandkOnFn17q3w4sbjMukXJgb/nm+WT/ABH61zGq6Dq2itjULQqueJV5Q/j/AI15P4E/aU+I3gwpaajdjWbFcDyL9z5qj/Zl+9/31uFe3fD747/Dz4kqunW96LS+kGDpuoYV39kP3ZPwOfYU0xe/HzOdorttb+Hun3u6fSXFtL/c6xn8P4fw/KuR1PSdQ0ef7PqNsYyfut1Vvoe9UVGSlsV6KKKCgooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAoo74A69BVow22m/NqEYln/htM8L7yEf+gjn1I6Um7CbS3EtNOaeE3t1OtvaqcNcSjgn0UdXPsPxxUdz4n+wxm18NQtbKRh7x8GeQfXog9h+dVtQvLvUZhPdy7mUYRQMKg9FA4A9hVV074/CpbuRzcxWlDOxd2JYnJYnJJ96hdParTp+VROn/wBakBVdPaoXT/61W3Q5qJ09qAKpGODTHQEVO6f/AFqjIxwaAK7p/wDrqJ09qtOgIqJ0/wDrUAVHT8qidP8A61W3T2qF0/KgCo8ftUTx1bdP/rVC6e31FA07FSSP2qJ09vwq28dQvHQXuVHj/wD11E6e1W3T2+tQulAFR0/KonT/APXVt0PpULp+VAFR09vqKidParbp/wDWqF09vqKAKrx//WNROntzVp0qJ4//AK1AFV09vqKidP0OQfQ1adM/WonT2+ooA9C+Gn7TXjXwQY9M8RM+s6auFCzyf6REv+xIfvf7rZ+or37wh488C/FbRmuNCv4ruMAfaLSZdssJ9HQ8r7Hp6Gvjp0qbRtb1nwzqkWtaBqc1ndwnMVxA+1h7H1B7g5B9KadiZQT1Pq3xF8P7i03XeibpYupgJy6/T+8P1+tc30JBHIOCD2qv8H/2pdK8StF4c+IZhsNQYhIb8fLb3B98/wCqY+/ynsR0r0bxJ4OsddDXNvthusf6wDh/94f161SZKk4u0jgaKmv9PvNLums7+AxyL2PQj1B7ioaZoFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABSojyuI40LMegFIAzMFUZJ6CrkcPkRlV5LD529fb6Um7EykkhqOLEf6MwMxHM4/g9k/wDiuvpjqarp2NWXTH0qN07HpUGTbbKzp271E6HPvVl07H8DUbp2PWgWxVdPQfWonTj2q06HPvUTp7fUUFp3Krp/9aonSrTp+VROn/1qBlV09vqKhdP/AK1W3T2qJ09qAKpBBwaY6Aip3T/61RkY4NAFd0//AF1E6VadARUTp/8AWoAqOn5VE6f/AFqtuhz0qF0/KgCpJH7VE8dW3T/61Qunt9RQNOxUeOonT2+oq28dQulBe5UkT/61ROhzVt0/+vULpQBUdPyqJ4//ANdW3T2qF0/KgCo6e1ROlW3T/wCtULp7fUUAVZI6idPzq06VE8f/ANagCq6e31FROnFWnTPbmonT2+ooAqumRg/r3r0/4K/tI6x4BaLw34wea/0YYWOTJaazH+z3dP8AZ6j+H0rzV0qJ0/8ArUCaTWp9rlfDfj3QYdQsbuK6tbiPzLW7t2B4PcH+YP0Iridc0G/0C6+z3a5Rv9VMo+Vx/Q+1eGfCL4zeIvhNqubctdaXPJm905nwG/20J+6/6N0PYj6k0HXvCnxP8LR6tpFyl3ZXI4PRo3HVSOqOvp/Q1SZnrTfkcDRWh4i8O3fh678qUl4XP7mbH3vY+hrPqjVNMKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAoJxgYJJOAB1J9BUGo6jZaTZvf6hN5cUeMkKSSScKqgcsxJACjkkgCtHQtMvI0GpavB5dxIPktiwP2ZT/AAkjguf4iOB90cAkpuwpOyJbWzNum6QAuRzjt7U5lx9KsMuPpTHXHIqDFttlZ09KjdMfSrDLjkdKjdO9Aiu6dj0qJ07GrLpj6VG6dqAKzp271E6c+9WXTsfwNRunagNiq6eg+tROnHtVp0596idPb6igtO5VdP8A61ROlWnT8qidP/rUDKrp7fUVC6f/AFqtuntUTp7UAVSCDg0x0BFTun/1qjIxwaAK7p/9aonSrToCKidKAKjp+VROn/1qtuntULpQBUkj9qieOrbp/wDWqF09vqKBp2KkkdROnt9RVt46hkjoL3Kkif8A1qidDmrbp/8AXqF0/wD10AVHT8qidP8A9dW3T2qF0/KgCo6e1ROlW5E/+tULp7fUUAVXj/8A11E6fnVp0GPaopI6AKrp7fUVE6VadM9uaidPb6igCq6dvyrpfhT8VfEHwo8Qf2npjGa0mIGoaez4SdR3H91x2b8DxXPugxUTp6/nQB9peH9f8K/E/wAKRavpFwLmyul7jDxOOqsP4XU9R/Q1x+v6FdeH742s+WRuYZccOP8AH1FeE/Bz4uax8JvEYu4t8+m3LBdSsQfvr0Dp6Ovb1HB7Y+qlfw/8QPDMOoafeJcWl3EJbW6i5xnow9COhB9wapO5lrTfkee0VPqWnXWk3r2F4mHQ9R0YdiPY1BVGoUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAVV1zXNJ8NaTca7rt/Ha2drGXnnlPCj+pPQAck8CjW9b0nw3pM+ua5fR21pbJvnnkPCj+ZJ6ADkk4FeaeArbVP2nPHf/AAkeu2TweDPD9yDbadL0vbkcqJOzEDDMOighRyxNJuw+l2d78NdO1XxtcQ/E7xRYyWltgt4Z0mYYaCNhj7XKP+ezg/KP+WaHjlia7hlxwRxU7Lu57/SmMueDUHPJ8zuQMuPpUbLj6VOy44NMZcfSgRAyY5FRMuPpVhlx9KY645FAFZk9OlRumPpVhlx9KjdO9AFd07HpUTp2P4GrLpj6VG6dj0oArOnbvUTpz71ZdOx/A1G6dqA2Krp7fUVE6ce1WnT25qJ09vqKC07lV0/+tUTpVp0/KonT/wCtQMqunt9RULp/9arbp7VE6e1AFUgg4NMdARU7p/8AWqMjHBoArun/ANaonSrToCKidKAKjp+VROn/ANarbp7VC6flQBUkj9qieOrbp/8AWqF09vqKBp2KkkdROnt9RVt46hkjoL3Kkif/AFqidParbp/9eoXj/wD10AVHT8qidP8A9dW3T2qF0/KgCo6e31FROlW3T/61Qunt9RQBVeP/APXUToc+9WnQY9qikT/61AFV09vqKidKtOme3NROnt9RQBVdP/rV6V+zn8aX+HetDwx4huj/AGJfy8u54s5jx5nsh4DenDdjnzp0qJ488GgTSasfani7w7H4g08TWwH2mIZhYH7w/u/j2rz8hlYqykEHBBHIPpVD9lf4wtrViPhp4kuibyzizpc0jczQL1jJPVkHT1X/AHTXbfEHw75Ev9vWcfyOQLlQOjdm/HoffHrVpkRfK+VnMUUUUzQKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACqut63pPhzSZ9c1y+S2tLZN888h4Uf1JPAA5JOBRret6T4c0qfXNcvktrS2TfNNIeFH9STwAOSeBXzD8ZPjHqvxP1YRRB7bSbaQmysi3JPTzZMdXI/BRwO5KbsVGPMzU8W+M/F37SPxCsPBPhuF4LOW626faP0QAHdczY6lVy3oo4HJyfqzwV4M0X4feFrLwf4fhK2tlDsVmHzSseWkb1Zmyx+uO1eX/ALGvwiHhjwi/xL1q2xqGuRYsQ68w2YOQfYyMN3+6qete0MueDWavuzOrJN8q2RCy45FMZc/WpmXHBpjLjkUzEhZc8Go2XHBqdlz0pjLng0AQMuPpUbLj6VOy44NMZcfSgCBlxyBUTLjp0qwy4+lMdccigCs6eg4qN0x9KsMuPpUbp37UAV3Tt2qJ07H8KsumPpUbp2PSgCq6dj1qN0/OrLp2P4Go3TtQGxVdPb6ionQfhVp09uaidPb6igtO5VdP/rVE6e1WnT8qidP/AK1Ayq6e31FQun/1qtuntUTp7UAVSCDg0x0BFTun/wBaoyMcGgCu6f8A1qidKtOgIqJ0/wD10AVHT2qJ0/8ArVbdPaoXT8qAKjp7fWonjq26f/WqF09vqKBp2KkkdROnt9RVt46hdKC9yo6f/WqJ09qtun/16heP/wDXQBUdPyqJ0q26e1Qun5UAVHT2+oqJ09qtun/1qhdPb6igCrJHUTp+dWnQY9qikT/61AFV09vqKidBVp0z9aidPb6igBumalqOhapb6zpN00F1azLLbzL1Rwcg+/uO4JHevr74aeOtK+LHgWHXEiQNMhh1C1Bz5MwHzp9Ocg+hBr4+dK7z9nT4mH4eeOUsdRuNul6sywXe4/LHJnEcvtgnaf8AZb2pp2InHmR69rekzaJq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CiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAay4GQaQjHWn0jLmk1cBlFGKKnYn4WFFFFUncoKKKKYBRRRQAUUUUAFIy56UtFAEZBBwaKewyOBTCMVOzAKKKKpAFNdc8inUUbgR9KKcy55FNqGrAnYKQjIxS0UjTcYQQaSnkAimkEdaCdhKay45FOooHoyOilZccikoEnbRjWXvTakp1vaPcPxwo6tT3HsNt7N7p+OFH3mrSiijhQRxrgCiONIkCIMAU6qSsQ9QooopgFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAjLmmGpKRlz0pNAMooPXg0VOxOwUUUVSdygooopgFFFFABRRRQAUjLmlooAjIwcE0U9lzTCCODU2sO4UUUVQgprr3FOoo3AjopzL3FNqGrAnYKQgEUtFI03GEEGkp5AIppBBoJ2EprLjmnU+GBpmwOnc0DdmhlvbtO2Bwo6mr8aLGgRBgChEWNQiDAFLVpWIuFFFFMAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooopJ3EncKKKKYwooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAEZc80wgjrUlIwyKTVwtcZRQRziip1RO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' RESTORE_WINDOW = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAAPlJREFUSIntk79OAkEQhz8QcheTayyxEQobExNrCuND3CtY8wzG1qcAWirfwc5En8FAcRZIgRZefhQM0az3Z1mj1f2SyczuzN232dmBRo0AJLk2krSSlJuvMkl6dv/Z8eCeA4nFSVWh6TQEstMH8AJ0S/I5MAAOQiAt8/dAWlP7CFz4QMbAFZDZ+sR87HGgQhVBUiACjp39digk+MPfQiJgCfSAPjCz/fdQSFnjP4GFxW/mh8CUr2ccA4cGX7N9fWdeVBuo7NswXkuay18PIZAyu7PaiZtz9S+NL+vJEXBr+R8TDLwClxbne1MlPe1x/5J0U3ddjRr9nTawKgnkexlHFAAAAABJRU5ErkJggg==' LOCATE_FILE = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAAXNJREFUSIntlbEuRFEQhr+Zg4qgoqD1BBpERKIlolN4BxIUtJ5BJR5AIdErNmwkCiLbSHgENhqJZu/5Fc7dIHd3ryURiT85yZl7Z+Y//5yZHPjHn4GkQ0l1tUZD0q2kWUnkqwjWhkTANXDawqUX2Ej7OeAMwKxlymISSWvvT/lpuaR9ScfJd6aVEu/ANdrm3zQwCKwAl0AVmOqGpImCmt8CM0AFeErfdotie8qSxBgBCCEgCTOrA5PACPACXADDXZPEGIkxEkIYAnbM7C7LsgMze3T3x+SWAeFbSkIIQ+5eS6ftd/eJGON2UgVvpc+KYkvdibvj7kvAuKQBYNXMtkIIpVq2lJJ04TUzw8x2gQWg9k7F90kS0Y2ZXQF7yZ4vG/vVFl5P5r2kStnYUkryksQYq+5+AhzlM/Oj5XL3vJWX8+Tu5QrRieQh35hZcxBz+xMaQF83JIu8TXMTbcozBtQ75PsISedt3pIiPEva/BLJP34Nr3F5AGzDIVXfAAAAAElFTkSuQmCC' CLEAR_QUEUE = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAATRJREFUSIntkk1uwjAQhd+MDUEISrbZlQu0PUJ7ELbsco3cgAUL1hyDTS/QK8ASRY3aSKDEHnfTIJzwE3VZ5ZOske2ZeX4aAx0dHfeg802SJAAAEUGe55jP55vpdPr6h77fRPRQbfT5jXPOi0VReLHVq4kgIt6ZJ1KWJQDAWgsAWK/Xb71er1F0CeccmJkHg8GEiNxVkSp5NBqNtdbB4XDIrLWGiOppDZgZ1lpJ0/STma87YWYYYzCbzd6jKHq627lGlmW7xWLxWJalZ92TrGbR5uXXuFTrOSmKAsYYLJfLZ6XUSfQWv7NQw+EwFBFbd9EQCcNwAoCPx+OXiNi2szDG2P1+n2qtoZS67SSO4x0zj9s4qOj3+9hutx+r1epFKQWtG3/JFwmCoHXzuhARnVZHxz/jB11ofrsXPqlsAAAAAElFTkSuQmCC' SAVE_IMG = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAATlJREFUSInt1T1KBEEQBeBvF38QDRQ8hEZeQK8ggicwMDYQRBADEQTRwEgDf/AEG65eQMETCGJg6BGUVcvAXVhne3ZmRNBgHzQz1d31Xr3u6R4G+G+oZTsiAqaxgUm898kPtLD2jbTWQ9sjMhMRx1EN6xGh08o4ucAKntBAvY+LVUy0403sl3Vy2q7usru6nPaQcDSV5UxV+dZ+TiTGshjOxIc4yE4aKiAYlljSLqSK7NmUlEjna1rESx+RyBF5y3b0cyKHpIOC3S1H8muoKnKOZexUSSparm7sYav93sAdmmUSqzjZzcRXeP5tkfFE3+hPRfKW8CwTb6PndKc4U4QjOSJLeMQ15jCfM6/4poiI2Yg4qXgLd3AbEQtlnNzjCK8Yw0fXWM3XPrR8/8/UfZ30Jm4KnQzw5/gEo7Tit/WlmmYAAAAASUVORK5CYII=' SHUFFLE_ON = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAAcFJREFUSIntlU+IjVEYh587SP6VBdMwUrLQELITsZFiVtjZ2NjZKNlJKcpCSkmTmo3NTJONu1VidiaLyWamZCWZmZK/GZJ6LO65eu/xnXvvcHf86tQ9v/Oe3/N995z3XvivP5H6N+Oi+jB6UX09esZ54Chwr2pxefh8AFgJfARmgG9dhK8ChoA9aX4G+AGcray2VfPqVXV14etZp15XF6xWvRtIU6/UnRlgt/q6UN/UQglyWb2pjmcbvqr7EmBI/Z6tj6r1MJ9R15cgcexSp8PGF+pGdS54z9Qdqf5Y8mbVNWa3qwRB7VOfp83n1bEAeJrVXrNxjmutuMK1CKnQduAUIHAjeR+AQWAx1G0FPqW1RnDtV3RHCMAAMBfmg8CbUnEVpFMz1oDpML8EHE/gqA3AslJIJ8jjEFgH3gGjwERWdw6Y7ZiXHeQWdTIc9Ft1m6398UjdnOqHkzel1trdrgvqFfWu+qW1Fdybwo5k/mf1ljoRvCl1RQlSpffq/uwtDya/nV52A1lU76j9/t4/qAPqiI1fgypNliDD6kn1cJvwfPSrh9TbAXDfJXT8UsbpBHjQ9KJ69ae1CXgCnOhR3r+sn8p5XnZIXHVMAAAAAElFTkSuQmCC' SHUFFLE_OFF = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAAfVJREFUSInt1U2ITmEUB/AfQ9GYokTNSojx/Vm+h8nCwmdWiixkdmOh3s0kZUVTr5VkMTZSxCRS2CiUfIRSlDFkkMJkYayMmrJ4nqvnve77MsZy/nW7z/mfc8//Puc5515GMASMyhalUmk4eTqwEcszolwu/3KOHk7mBP1YhqtFzjHJegPqMIAe9P1F8imYjVnR3oor2FFN5Fay/o5OHMPHguSNOII9GJ/zbccdrM+IauUahwN4ieacrxndaC0QyLA2NVKR4ziJawnXEN+qJdprot2QxJyLXIY+zKgmUkIbtmA13iW+81iMmwnXG0V3Rz98FhrgbTWRFPexCO+jfQEnhDLCayzFvWivxKDQwh/yyWq1cL9wiKeEBsnqPIB1+JrEdghd9psAld1VhKc4gwcJ14JPubjuWkn+NIwTcCOxT2MzZubiGjHxX0TqhR1MivZjoYSHcEnyScJhod3rhyIyPyadF+0fOCjMBizEkxgHz4Tpf1QklJ5Ju1Ce6diVi9uEu9iPi5FbEpN3Ylrk5sSXW4FvRSJHC3Y0iG24He0u7Iz3usi15p5pwitMzYhaZ9IVH7ie4y9jrjA71ZC2d8VO9mIsvuA53tRI0iOUtB0LsE+YKUKzVHy7UpGzNZJWQ2+8mqLIQ6zKB/2vn9ZkvCgSGMGQ8RPgUlzkKawrHQAAAABJRU5ErkJggg==' PLAY_NEXT_ICON = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAAOxJREFUSIntkzFqAkEUhn8WLUMKBcHGTi08Qa5hk2aLnMQLWAQXUsVKLLyDNxAiGEgbcoOQUovPwlkY13Gz485W7gePXXjwvjdv3kg1IQDyIgISTrzZuZCSNN6NKAkt6QI9oAM8AGsjei0rGQIz4IdLfq3/latWo4AvljSX1PRrswDmBGOryw/gGRiEHFfLEiyruviJKfDtKB5shXemyMsVyQh4zOZ8JSmDK6NyhovIT30beZJP832qzP7PxXuNK0/SLrDCpSXZx7jl/DH2OD3I0hIBMXDAzSbUdi0k9SVNJX1J2lu5P7/Wa+6KIxwRWqnKaEkqAAAAAElFTkSuQmCC' QUEUE_ICON = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAAllJREFUSIntlEFLG0EUx/+7k+xkJdtGsgnB5iJ6kRRlQy5+gl76BYSeBFv8BkJP4lGQnAQLgR56NpeceshBFomnFiqBePDgpoSGGpIYZ012NtNDW8Hd0agnD/5gD/ve2///zTzeAs88ecrlMmKxGABgbW3tneu6LSFE/z4P57xTLBa3gppKMFCr1XB8fIxms4mNjY0mpfTVQxtVFOWGbiRYMBwOMT09jfn5eWia1gMQMmGMgRACSqnMww0G1GDAsqwfpmlunp6egjE2kqlUq9X329vbeSGELM0nmhiG8TqbzVqUUnDOpSoLCwt2Op3+5vu+LB0iZAIAruv+FkIgcLUAACEECCHZeDwOz/Nwy2luEJqJDCEE9vf3356fnx8lk0nFcZzuyckJOOdpSinOzs7M1dXV78lkUnu0yXg8xszMTHR2djZmGIaq63qfMYZcLkcTiQQURdE4D43iYSaEECwvL5f/v09NTb25uLj4almWo6oq5ubm7vxeOpNJXF1d/ez3+/A871719zpJkE6nU9A0bRyNRh9v4vu+SCQSiMfjUpXFxcXP+Xweqiq9iJCmtErXdfXw8BC9Xu+lLE8pBSHktsb1ia4AkMlkYgcHB6jX6x/39va2TNN84XmedPP+7Q0IIYQxNiyVSqVgTWjbhBCi0Wh8qlQqH2q1GgqFApaWlnB5eSltW1VVjEYjpFIpnTHGV1ZWvMFgMNnk11++mKaJdrvd55zz4J/1+ioiEaXb7Q5arVbRMAysr6/DcRxpQ9fs7OxsCiF88UBs29Zt28bu7u7dBs88Cf4AP1JCGwb4bR0AAAAASUVORK5CYII=' NEW_PLAYLIST = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAASBJREFUSInt078uBFEYBfDfLPWqNBS20ognoBCFB1BJlArPoPQGKhWVUqLSrkInCgkaFZ1GIRLE30+xNzHGzs7YUNmTTGbunXPPd79z7mWAAf4KWX4QEUuYxVvFuiHs4KhUOMu6/4iI7aiPlYhQ9uQxXKjziPtih932g+cKzmdXhU7qrqsWztlV7GQKE+plcoJrzKOJfbxWVo+I4x9kspb8f0rjkbqZbGBGvUza6fsKk3gvI/9GJqeYLmr1ymQVC75n0sAZ1rGI5cQJtBJnDy9Js43NrluKiMMeGdwmv7dq5PXlkhbtamG0xJY7XGAM47n5XZ0TOYeHNHeTZdllWZES/Z441zn6pZk0+lEtYCy9m2WEYvD94EDHruqLOMD/wgcLDgX8PuKpagAAAABJRU5ErkJggg==' DELETE_ICON = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAANZJREFUSInt1D8uxEEYxvHP+HMBS0HlAEqNam+jcwQHEI5A7yAkIoqNQuMGIlQaibyKVVhm3p0NxRa/bzLFzPvO87x55w8Dy0ZpBSLi59IRtr7N33AyI1bqcmtJAQfYwwd2cVzJ2cY9VvGA60TvNxFxFYtx19LK2rWPTbwjvkZtf8E6Xkspt4uawCHGpi1rsYIJzlpn0iQiRMRNZ6seKxdlpoqM586anrLgPJN/YTAZTKr0vq40b57JRqfJ6C8mF50ml1kw++rh3PSb30lyXnDaWczAkvAJ+zaCCsNSzjYAAAAASUVORK5CYII=' PLAY_ICON = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAAHFJREFUSIntlMENgCAQBE970Mq0SO2MKsaXfAQhl9uHhk14wjBHWLORTwU4gAWwnuWFACRgU0PuNK0iIE2rKMirVTSkaKWAPKyUkGxVO2f24YuZXLs6LU5gVY0rATvCh8+3V0Dkn1FaK/KC1Ff9yH9zAR0obmDa9niBAAAAAElFTkSuQmCC' EXPORT_PL = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAAPNJREFUSIntlDFKxEAUhr9xI55CvYignY02YmejoM2CpbfwAF5CxDtsYeMdFC9gJQjJfhZ5yqhJzLoRFPeH4WXeG/4vw8wbWOhPSO0au+qorT4EZNNaBz8F2fC99oeGbIfxoTqJqHo0FGQnDI9jXqlJ3Yr8yRCQJ3WczafqanzvBajogqQ2SKYCqIDX5BRYBx6yevlmmD5bFj02Vs5ZZ6kHZG79b0jjZfkOZAxcAKOG2hnw2JA/B057k6OrVVe+eCzzoXozy06qD7GPyrb1bX1yH/EWeO74GaibNIXX3SyQa2AZWMuM2pSoz+4SuOpYt9Av0gvtRmhPGjsRlgAAAABJRU5ErkJggg==' X_ICON = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAAR9JREFUSIntlLFKA0EQhodUJp0IgYBoBEmZKLHxCfTtfIE8gF0KbRMjlikENaAQCFjepRc94bPILewNe5dZLWzuh7+4ZWa+nZ3dE6lVywkQYBcYA/38e5tHwAAwQ3rAKxulwOEWwCyPXQN9K2RMUesK0J2KXVkhx8CLSk4CoImK+QQurRABdoBnVSQFWiUdZMAJETNxbgY6egRuAoChy4uFSL5zDdJHdObn/AbifBsALIGOjtVq2LAiIvIRWMtEJImoUdmJHrKvBOj+9bimqug7MFdrhQcbC9EdfHkz0JchBQ5iIJ0SwCnFW/cUAJ1bIVcq+RvvHXhuAgsVe2+F7AMPeVJWAvA7cqA34MgKcb4GLioAzntsfvVt80xq/at+ANFMQcxleDtAAAAAAElFTkSuQmCC' UP_ICON = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAALxJREFUSIntk7ERwjAMRRUXbkmbEWADpggD0DIAx2IMAWyQFdKmTsE9GhlyieNznND53flsS7K+fmGRTOZfFKEk4Asf9d1r0qzwt0sRuet+ihUJAozXHnjrOozzc5iFujd9Y/S8ntGkFdDzo9fYpk6uImIHd6uxdQym3AEdUzqg3MrJRURKT7zUXDo6oQVajwtHqzWzfWKcnEWkCuQrrUkDMEATcOFogKXf4StSRwg46lSRxwKRZ5JIJrOKD5gaOr3a2gWhAAAAAElFTkSuQmCC' DOWN_ICON = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAALdJREFUSIntjzEOglAQBUdirG29gpfhXiZEe1tu4B08AkfQlhYTHZstiBGEj8aGSUhg3/7/GJiZ+Qvq2eGcU0vyESV5akmmVgMKKjXruqczCB5AMeB/itgdj4q6Ui89FpfY6bznkwnADTj05PvYSSNMUNdq/caijoypJgA1cHwzP0aWTssEdaM2LYsmZnzLBOAKlK3vMmbTeDFB3ar3eLaveRfLkb0VcGq9D2JsCcAOWCScm5n5AU8R/D5fdm9FrQAAAABJRU5ErkJggg==' PLUS_ICON = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAAMNJREFUSIntkzEKAjEURN8u2gh2YqEXsLK28TJexht4j+28hI1gI4iohdZ2IutY7F8IYmATSJcHn0lCkskkBDKZFkm+Wkt6Sqok9f/N+UcZ6D8HhsAiZG2oSW0qqyQmUYSayNFkSdrr+oSY9DzjG2AJPKwv4EXz4AAjYAu8nT3GwA5YdXKWtFccx5AkZ2AG3JyxGpgCA2tfTdsrnwCXTiksia8qO/E95WfUj3Yi1KR0tEhlEkVskoKESU6mB5oPmcn4+QIW0bX+FmU7ggAAAABJRU5ErkJggg==' COPY_ICON = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAARZJREFUSInt1LsuRFEUxvHfdpsgMlFpRESvoRYdLyBKjRfQ6lQeQTyBF1AoVPQegAbFSFwamonrVsyemHD2MUcUJPMlOznrrG+v/zo5WYue/ppC0csYY2e4gnXU8JqpM4RLbIYQGl2RY4ztMxJjPI/da+snXzKHE6ziKHVcpCb2MRlCmPqcHMhcWsI9rlN8hpuMt61bjBclcpA9NDCf4vo3AEr+WV/mwpVW57kmKikHeUvnV5SD/Kp6kP8HaW+Euy5qNTFYlCibg1cfw7WQvIVF8IgZDFeF1PGEY+yW+Dq1UQVSx0t6Xky+6OuABoxhWmvXXVSBnGIZh6nwg+KNXcMBdjJ1SiFr2MYsnjGa8fVjogzQU2W9A9hjZp3X4thrAAAAAElFTkSuQmCC' EDIT_ICON = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAAQRJREFUSIntk8FKAlEYhQ9SoZCIKx9BhN7AFylaFEkhlqu2rl34CK16nnDhKorCnqBN7Vz4ufCMjkNOc3VcCB4YmDPcOd9/z9yRDtoLAaHXJTCI/C4gZ8Avc13sClJnVf1kXiGkxoTKkl4k/UiqxZ4f57WTEjCJTV8BmsBjXnWVHDy2H9kv1mwLqTrw1b5lfw8U8oBUHPhufxUDrKzdFHLiwE+W/wXA3V8DbQI5deBboqL2ulpDIWUHfthf23fTDkcIJAJEpyiq6CENEAIpOvAr8ZFv/gOEQL6BoV9qG3CbBRACAegB577vZAWEQMYslamiNMjRGs6TpIakqaTnbKMdtE+aAQHeA941GTukAAAAAElFTkSuQmCC' # pencil ================================================ FILE: src/ca.elijahlopez.MusicCaster.yml ================================================ app-id: ca.elijahlopez.MusicCaster runtime: org.freedesktop.Platform runtime-version: '21.08' sdk: org.freedesktop.Sdk finish-args: - --share=network - --share=ipc - --socket=x11 - --socket=wayland - --device=all - --filesystem=host command: python3 music_caster.py modules: - name: music-caster buildsystem: simple build-commands: - pip3 install -r requirements.txt # flatpak-builder dist/flatpak ca.elijahlopez.MusicCaster.yml --force-clean ================================================ FILE: src/experiments.py ================================================ from base64 import b64decode from pathlib import Path import urllib.parse import soundfile as sf import numpy as np import matplotlib.pyplot as plt import io from PIL import Image import urllib from b64_images import DEFAULT_ART from utils import get_album_art, get_ipv4 def get_audio_wave(file): data, samplerate = sf.read(file) n = len(data) time_axis = np.linspace(0, n / samplerate, n, endpoint=False) ch1, ch2 = data.transpose() sound_axis = ch1 + ch2 accent_color = '#00bfff' bg = '#121212' buf = io.BytesIO() fig = plt.figure(figsize=(4.5 * 60, 0.75 * 60), dpi=5) plt.plot(time_axis, sound_axis, color=accent_color) plt.axis('off') plt.margins(x=0) fig.patch.set_facecolor(bg) plt.savefig(buf, format='png', bbox_inches='tight', transparent=True) im = Image.open(buf) # return im.resize((int(im.size[0] / 3), int(im.size[1] / 3))) # test_file = 'C:/Users/maste/MEGA/Music/No Mana - Memories of Nothing.flac' # get_audio_wave(test_file) img_file = r"C:\Users\maste\Documents\MEGA\Music\02 - Under Your Spell.flac" img_file = r"C:\Users\maste\Downloads\TheMagicFluteOverture.mp3" mime_type, img_data = get_album_art(img_file, False) img = Image.open(io.BytesIO(b64decode(img_data))) data = io.BytesIO() img.convert('RGB').save(data, format='JPEG') img.save('dist/test.jpg', format='JPEG') assert data.getvalue() url_args = urllib.parse.urlencode({'path': Path(img_file).as_posix()}) url = f'http://{get_ipv4()}:2001/file?{url_args}&thumbnail_only=true' print(url) img = Image.open(io.BytesIO(b64decode(DEFAULT_ART))) data = io.BytesIO() img.convert('RGB').save(data, format='JPEG') print(len(data.getvalue())) print(len(data.getvalue())) ================================================ FILE: src/go.mod ================================================ module Updater go 1.17 ================================================ FILE: src/go.sum ================================================ github.com/akavel/rsrc v0.10.2 h1:Zxm8V5eI1hW4gGaYsJQUhxpjkENuG91ki8B4zCrvEsw= github.com/akavel/rsrc v0.10.2/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c= ================================================ FILE: src/gui/__init__.py ================================================ import FreeSimpleGUI as Sg import platform from .views import * import ctypes import ctypes.wintypes import sys ALT_KEY, EXTENDED_KEY, KEY_UP = 0x12, 0x0001, 0x0002 def focus_window(window: Sg.Window, is_frozen=getattr(sys, 'frozen', False)): # raises TclError [window_is_foreground] # use bring_to_front when frozen and in Python use other method if platform.system() == 'Windows': keybd_event = ctypes.windll.user32.keybd_event if is_frozen and window_is_foreground(window): window.bring_to_front() else: keybd_event(ALT_KEY, 0, EXTENDED_KEY | 0, 0) ctypes.windll.user32.SetForegroundWindow.argtypes = (ctypes.wintypes.HWND,) ctypes.windll.user32.SetForegroundWindow(window.TKroot.winfo_id()) keybd_event(ALT_KEY, 0, EXTENDED_KEY | KEY_UP, 0) if window.TKroot.state() == 'iconic': window.normal() window.force_focus() else: window.force_focus() window.bring_to_front() def window_is_foreground(window: Sg.Window): # raises TclError width, height = window.TKroot.winfo_width(), window.TKroot.winfo_height() x, y = window.TKroot.winfo_rootx(), window.TKroot.winfo_rooty() if (width, height, x, y) != (1, 1, 0, 0): return window.TKroot.winfo_containing(x + (width // 2), y + (height // 2)) is not None return False ================================================ FILE: src/gui/components.py ================================================ import base64 import io import platform import pyqrcode import FreeSimpleGUI as Sg from meta import FONT_NORMAL, State from PIL import Image, ImageDraw def get_styled_button_font(): if platform.system() == 'Windows': return 'Segoe UI Variable', 12 def StyledButton(button_text, fill, text_color, tooltip=None, key=None, visible=True, pad=None, bind_return_key=False, button_width=None, blend_color=None, outline=None): if State.using_tcl_theme: return Sg.Button(button_text, use_ttk_buttons=True, key=key, visible=visible, bind_return_key=bind_return_key, size=(button_width, 1), pad=pad) multi = 4 btn_w = ((len(button_text) if button_width is None else button_width) * 5 + 20) * multi height = 18 * multi btn_img = Image.new('RGBA', (btn_w, height), (0, 0, 0, 0)) d = ImageDraw.Draw(btn_img) x0 = y0 = 0 if outline is None: outline = fill d.rounded_rectangle((x0, y0, btn_w, height), fill=fill, outline=outline, width=5, radius=10) data = io.BytesIO() btn_img.thumbnail((btn_w // 3, height // 3), resample=Image.Resampling.LANCZOS) btn_img.save(data, format='png', quality=100) btn_img = base64.b64encode(data.getvalue()) btn_color = (text_color, blend_color) if blend_color is None: blend_color = text_color mouseover_colors = (None, None) highlight_colors = None else: mouseover_colors = btn_color if platform.system() == 'Windows' else None highlight_colors = btn_color return Sg.Button(button_text=button_text, image_data=btn_img, button_color=(text_color, blend_color), tooltip=tooltip, key=key, pad=pad, enable_events=False, size=(button_width, 1), bind_return_key=bind_return_key, font=get_styled_button_font(), visible=visible, mouseover_colors=mouseover_colors, highlight_colors=highlight_colors) def IconButton(image_data, key, tooltip, bg): return Sg.Button(image_data=image_data, key=key, tooltip=tooltip, enable_events=True, button_color=(bg, bg)) def Checkbox(name, key, settings, on_right=False, tooltip=None): # fix for languages that are too long to fit into the UI if tooltip is None: tooltip = name bg = settings['theme']['background'] size = (23, 5) if on_right else (23, 5) checkbox = {'background_color': bg, 'font': FONT_NORMAL, 'enable_events': True, 'pad': ((0, 5), (5, 5))} return Sg.Checkbox(name, default=settings[key], key=key, tooltip=tooltip, size=size, **checkbox) def QRCode(text_to_encode): try: qr_code = pyqrcode.create(text_to_encode) return qr_code.png_as_base64_str(scale=3, module_color=(255, 255, 255, 255), background=(18, 18, 18, 255)) except OSError: # Failed? return None ================================================ FILE: src/gui/views.py ================================================ import platform import time from datetime import datetime from math import ceil, floor import FreeSimpleGUI as Sg from b64_images import ( CLEAR_QUEUE, COPY_ICON, DELETE_ICON, DOWN_ICON, EDIT_ICON, EXPORT_PL, LOCATE_FILE, NEXT_BUTTON_IMG, PAUSE_BUTTON_IMG, PLAY_BUTTON_IMG, PLAY_ICON, PLAY_NEXT_ICON, PLUS_ICON, PREVIOUS_BUTTON_IMG, QUEUE_ICON, RESTORE_WINDOW, SAVE_IMG, SHUFFLE_OFF, SHUFFLE_ON, UP_ICON, VOLUME_IMG, VOLUME_MUTED_IMG, X_ICON, ) from meta import ( CONTACT_INFO, COVER_NORMAL, EMAIL, FONT_LINK, FONT_MED, FONT_NORMAL, FONT_TAB, FONT_TITLE, LINK_COLOR, PL_COMBO_W, VERSION, State, ) from modules.playing_status import PlayingStatus from modules.resolution_switcher import fmt_res, get_all_resolutions from utils import ( Device, create_progress_bar_texts, get_first_artist, get_languages, repeat_img_tooltip, t, truncate_title, ) from gui.components import Checkbox, IconButton, QRCode, StyledButton class GuiContext: text_color = fg = None background_color = bg = None accent_color = None experimental = None @classmethod def update(cls, text_color, background_colour, accent_color, experimental): cls.text_color = cls.fg = text_color cls.background_color = cls.bg = background_colour cls.accent_color = accent_color cls.experimental = experimental def MiniPlayerWindow(playing_status, settings, title: str, artist: str, album_art_data: bytes, track_length: float | int, track_position: float | int): # album_art_data is 125 x 125 album_art = Sg.Column([[Sg.Image(data=album_art_data, key='artwork', pad=(0, 0))]], element_justification='left', pad=(0, 0)) music_controls = MusicControls(settings, playing_status, prev_button_pad=((10, 5, None))) progress_bar_layout = ProgressBar(settings, track_position, track_length, playing_status) title = truncate_title(title) right_side = Sg.Column([ [Sg.Text(title, font=FONT_TITLE, key='title', pad=((10, 0), 0), size=(28, 1))], [Sg.Text(artist, font=FONT_MED, key='artist', pad=((10, 0), 0), size=(28, 2))], music_controls, progress_bar_layout], pad=(0, 0)) return [[album_art, right_side] if settings['show_album_art'] else [right_side]] def MainWindow(playing_status, settings, title: str, artist: str, album: str, album_art_data: bytes, track_length: float | int, track_position: float | int, queue, listbox_selected, timer, music_lib, devices, web_ui_url: str): # devices: device_names list of (name, device_key) accent_color, text_color, background_color = settings['theme']['accent'], settings['theme']['text'], settings['theme']['background'] alternate_bg = settings['theme']['alternate_background'] vertical_gui, show_album_art = settings['vertical_gui'], settings['show_album_art'] music_controls = MusicControls(settings, playing_status) progress_bar_layout = ProgressBar(settings, track_position, track_length, playing_status) if not show_album_art: album_art_data = b'' info_top_pad = 10 + 60 * (not album_art_data) - 30 * (vertical_gui and not album_art_data) # 10, 110, or 0 info_bot_pad = 10 + 40 * (not album_art_data) - 20 * (vertical_gui and not album_art_data) # 10 or 30 # default_device = [] default_device = next(filter(lambda device: device.id == settings['device'], devices), Device()) combo_devices = [Sg.Combo(devices, key='devices', readonly=True, background_color=background_color, expand_x=True, default_value=default_device, enable_events=True, pad=((5, 10), 10))] left_pad = settings['vertical_gui'] * 95 + 5 playing_section = Sg.Column([ [Sg.Image(data=album_art_data, pad=(0, 0), size=COVER_NORMAL, key='artwork')] if album_art_data else [], [Sg.Text(album, font=FONT_MED, key='album', pad=((0, 0), (info_top_pad, 0)), enable_events=True, size=(30, 2), justification='center')], [Sg.Text(title, font=FONT_TITLE, key='title', pad=((0, 0), 4), enable_events=True, size=(30, 2), justification='center')], [Sg.Text(artist, font=FONT_MED, key='artist', pad=((0, 0), (0, info_bot_pad)), enable_events=True, size=(30, 0), justification='center')], music_controls, progress_bar_layout, combo_devices], element_justification='center', pad=((left_pad, 5), 5 * vertical_gui)) LISTBOX_HEIGHT = 21 - 7 * (vertical_gui or not show_album_art) # do not allow casting to a music device video_devices = list(filter(lambda device: device.id != settings['device'] or playing_status == PlayingStatus.NOT_PLAYING, devices)) tabs = [ QueueTab(queue, listbox_selected, LISTBOX_HEIGHT), URLTab(accent_color, background_color), ] if settings['experimental_features']: tabs.append(VideoTab(video_devices)) tabs.extend(( LibraryTab(music_lib, LISTBOX_HEIGHT, alternate_bg, vertical_gui, show_album_art), PlaylistsTab(settings['playlists'], vertical_gui, show_album_art), TimerTab(timer, settings['timer_shut_down'], settings['timer_hibernate'], settings['timer_sleep']), MetadataTab(), SettingsTab(settings, web_ui_url) )) tabs_section = Sg.TabGroup([tabs], font=FONT_TAB, border_width=0, title_color=text_color, key='tab_group', selected_background_color=accent_color, enable_events=True, tab_background_color=background_color, selected_title_color=background_color, background_color=background_color) if vertical_gui: return [[playing_section], [tabs_section]] return [[playing_section, tabs_section]] if settings['flip_main_window'] else [[tabs_section, playing_section]] def MusicControls(settings, playing_status: PlayingStatus, prev_button_pad=None): btn_color = (GuiContext.bg, GuiContext.bg) is_muted = settings['muted'] volume = 0 if is_muted else settings['volume'] v_slider_img = VOLUME_MUTED_IMG if is_muted else VOLUME_IMG p_r_img = PAUSE_BUTTON_IMG if playing_status.playing() else PLAY_BUTTON_IMG repeat_img, repeat_tooltip = repeat_img_tooltip(settings['repeat']) repeat_button = {'button_color': btn_color, 'tooltip': repeat_tooltip, 'metadata': settings['repeat']} shuffle_button = {'button_color': btn_color, 'image_data': SHUFFLE_ON if settings['shuffle'] else SHUFFLE_OFF} mute_tooltip = t('unmute') if is_muted else t('mute') return [Sg.Button(key='prev', image_data=PREVIOUS_BUTTON_IMG, button_color=btn_color, tooltip=t('previous track'), pad=prev_button_pad), Sg.Button(key='pause/resume', image_data=p_r_img, button_color=btn_color), Sg.Button(key='next', image_data=NEXT_BUTTON_IMG, button_color=btn_color, tooltip=t('next track')), Sg.Button(key='repeat', image_data=repeat_img, **repeat_button), Sg.Button(key='shuffle', **shuffle_button, tooltip=t('shuffle')), Sg.Button(key='mute', image_data=v_slider_img, button_color=btn_color, tooltip=mute_tooltip), Sg.Slider((0, 100), default_value=volume, orientation='h', key='volume_slider', disable_number_display=True, enable_events=True, background_color=GuiContext.accent_color, text_color='#000000', size=(10, 10), tooltip=t('scroll mousewheel'), resolution=1)] def ProgressBar(settings, track_position, track_length, playing_status: PlayingStatus): time_elapsed, time_left = create_progress_bar_texts(track_position, track_length) text_size = (5, 1) bot_pad = (settings['vertical_gui'] and not settings['show_album_art']) * 30 mini_mode = settings['mini_mode'] time_elapsed_pad = ((2, 0), (0, 0)) if mini_mode else ((0, 5), (10, bot_pad)) time_left_pad = ((0, 0), (0, 0)) if mini_mode else ((5, 0), (10, bot_pad)) progress_layout = [Sg.Text(time_elapsed, key='time_elapsed', pad=time_elapsed_pad, justification='center', size=text_size, font=FONT_NORMAL), Sg.Slider(range=(0, 1 if track_length is None else track_length), default_value=1 if track_length is None else floor(track_position), orientation='h', size=(20 if mini_mode else 30, 10), key='progress_bar', enable_events=True, relief=Sg.RELIEF_FLAT, background_color=GuiContext.accent_color, disable_number_display=True, disabled=playing_status.stopped() or track_length is None, tooltip=t('scroll mousewheel'), pad=((2, 10), (0, 0)) if mini_mode else ((8, 8), (10, bot_pad))), Sg.Text(time_left, key='time_left', pad=time_left_pad, justification='left', size=text_size, font=FONT_NORMAL)] if mini_mode: progress_layout.append(Sg.Button(key='mini_mode', image_data=RESTORE_WINDOW, size=(1, 1), enable_events=True, button_color=(GuiContext.bg, GuiContext.bg), tooltip=t('restore window'), pad=(0, 0))) return progress_layout def URLTab(accent_color, bg): layout = [[Sg.Text(t('Enter URL'), font=FONT_NORMAL)], [Sg.Radio(t('Play Immediately'), 'url_option', key='url_play', default=True), Sg.Radio(t('Queue'), 'url_option', key='url_queue'), Sg.Radio(t('Play Next'), 'url_option', key='url_play_next')], [Sg.Input(key='url_input', font=FONT_NORMAL, enable_events=True, border_width=1), StyledButton(t('Submit'), accent_color, bg, key='url_submit', bind_return_key=True)], [Sg.Text('', key='url_msg', size=(20, 1))]] return Sg.Tab(t('URL'), [[Sg.Column(layout, pad=(5, 20))]], key='tab_url') def QueueTab(queue, listbox_selected, listbox_height): select_file_values = [t('Play'), t('Queue'), t('Play Next')] select_files = t('Select Files') select_folder = t('Select Folder') install_update_text = t('Install Update') biggest_word = len(max(*select_file_values, select_files, select_folder, key=len)) combo_w = ceil(biggest_word * 0.95) btn_color = (GuiContext.bg, GuiContext.bg) queue_controls = [Sg.Column([[ # fs stands for file system here Sg.Combo(select_file_values, default_value=select_file_values[0], key='fs_action', size=(combo_w, 5), enable_events=False, pad=(5, (6, 4)), readonly=True), StyledButton(select_files, GuiContext.accent_color, GuiContext.bg, key='select_files', button_width=biggest_word, pad=(5, (7, 5))), StyledButton(select_folder, GuiContext.accent_color, GuiContext.bg, key='select_folders', button_width=biggest_word), StyledButton(install_update_text, '#1f3139', '#b3edc9', outline='#1b583b', blend_color=GuiContext.bg, key='install_update', button_width=biggest_word, visible=State.update_available and not State.installing_update), ]], justification='left')] move_to_next_up = {'image_data': PLAY_NEXT_ICON, 'button_color': btn_color, 'tooltip': t('Move to next up')} listbox_controls = [ [Sg.Button(key='mini_mode', image_data=RESTORE_WINDOW, button_color=btn_color, tooltip=t('Launch mini mode'))], [Sg.Button(key='queue_all', image_data=QUEUE_ICON, button_color=btn_color, tooltip=t('queue all'))], [Sg.Button(key='clear_queue', image_data=CLEAR_QUEUE, button_color=btn_color, tooltip=t('Clear the queue'))], [Sg.Button(key='save_to_pl', image_data=SAVE_IMG, button_color=btn_color, tooltip=t('Save to playlist'))], [Sg.Button(key='locate_uri', image_data=LOCATE_FILE, button_color=btn_color, tooltip=t('locate track'))], [Sg.Button(key='copy_uri', image_data=COPY_ICON, button_color=btn_color, tooltip=t('copy uris'))], [Sg.Button(key='edit_metadata', image_data=EDIT_ICON, button_color=btn_color, tooltip=t('edit metadata'))], [Sg.Button(key='move_to_next_up', **move_to_next_up)], [IconButton(UP_ICON, 'move_up', t('move up'), GuiContext.bg)], [IconButton(X_ICON, 'remove_track', t('remove'), GuiContext.bg)], [IconButton(DOWN_ICON, 'move_down', t('move down'), GuiContext.bg)] ] queue_tab_layout = [[ Sg.Column([[Sg.Listbox(queue, default_values=listbox_selected, size=(64, listbox_height), select_mode=Sg.SELECT_MODE_EXTENDED, text_color=GuiContext.fg, key='queue', font=FONT_NORMAL, bind_return_key=True)], queue_controls]), Sg.Column(listbox_controls, pad=(0, (5, 0)), vertical_alignment='top')]] return Sg.Tab(t('Queue'), queue_tab_layout, key='tab_queue') def LibraryTab(music_lib, listbox_height, alternate_bg, vertical_gui: bool, show_album_art: bool): try: lib_data = [[track['title'], get_first_artist(track['artist']), track['album'], uri] for uri, track in music_lib.items()] except RuntimeError: lib_data = [] lib_headings = ['title', 'artist', 'album'] if State.using_tcl_theme: library_height = listbox_height col_widths = [25, 12, 15] else: library_height = 15 - 4 * (vertical_gui or not show_album_art) col_widths = [20, 15, 15] library_layout = [[Sg.Table(values=lib_data, headings=lib_headings, row_height=30, auto_size_columns=False, col_widths=col_widths, bind_return_key=True, justification='right', size=(10, 1), selected_row_colors=(GuiContext.bg, GuiContext.accent_color), num_rows=library_height, right_click_menu=['', ['Play::library', 'Play Next::library', 'Queue::library', 'Locate::library']], header_text_color=GuiContext.fg, header_background_color=GuiContext.bg, alternating_row_color=alternate_bg, key='library')]] return Sg.Tab(t('Library'), library_layout, key='tab_library') def PlaylistsTab(playlists, vertical_gui: bool, show_album_art: bool): playlists_names = list(playlists.keys()) default_pl_name = playlists_names[0] if playlists_names else None btn_color = (GuiContext.bg, GuiContext.bg) playlist_selector = [ [IconButton(PLUS_ICON, 'new_pl', t('new playlist'), GuiContext.bg), Sg.Button(image_data=EXPORT_PL, key='export_pl', tooltip=t('export playlist'), button_color=btn_color), Sg.Button(image_data=DELETE_ICON, key='delete_pl', tooltip=t('delete playlist'), button_color=btn_color), Sg.Button(image_data=PLAY_ICON, key='play_pl', tooltip=t('play playlist'), button_color=btn_color), Sg.Button(image_data=QUEUE_ICON, key='queue_pl', tooltip=t('queue playlist'), button_color=btn_color), Sg.Button(image_data=PLAY_NEXT_ICON, key='add_next_pl', tooltip=t('add to next up'), button_color=btn_color), Sg.Combo(values=playlists_names, size=(PL_COMBO_W, 1), key='playlist_combo', font=FONT_NORMAL, enable_events=True, default_value=default_pl_name, readonly=True)]] playlist_name = playlists_names[0] if playlists_names else '' pl_length_txt = [Sg.Text('', font=FONT_NORMAL, key='pl_length')] add_tracks_btn = [StyledButton(t('Add files'), GuiContext.accent_color, GuiContext.bg, key='pl_add_tracks', button_width=14)] url_input_btn = [Sg.Input('', key='pl_url_input', size=(15, 1), font=FONT_NORMAL, border_width=1, enable_events=True, pad=(5, (20, 5)))] add_url_btn = [StyledButton(t('Add URL'), GuiContext.accent_color, GuiContext.bg, key='pl_add_url', button_width=14)] pl_saved_txt = [Sg.Text(t('Playlist saved'), key='pl_saved', font=FONT_NORMAL, visible=False, text_color='green')] lb_height = 17 - 6 * (vertical_gui or not show_album_art) pl_name_text = t('Playlist name') name_text_w = max(13, len(pl_name_text)) layout = [[Sg.Column(playlist_selector, pad=(5, 20))], [Sg.Text(pl_name_text, font=FONT_NORMAL, size=(name_text_w, 1), justification='center', pad=(4, (5, 10))), Sg.Input(playlist_name, key='pl_name', size=(60 - name_text_w, 1), font=FONT_NORMAL, pad=((22, 5), (5, 10)), border_width=1), Sg.Button(key='pl_save', image_data=SAVE_IMG, tooltip='Ctrl + S', button_color=btn_color)], [Sg.Column([pl_length_txt, add_tracks_btn, url_input_btn, add_url_btn, pl_saved_txt], vertical_alignment='top'), Sg.Listbox([], size=(45, lb_height), select_mode=Sg.SELECT_MODE_EXTENDED, text_color=GuiContext.fg, key='pl_tracks', background_color=GuiContext.bg, font=FONT_NORMAL, bind_return_key=True), Sg.Column( [[IconButton(UP_ICON, 'pl_move_up', t('move up'), GuiContext.bg)], [IconButton(X_ICON, 'pl_rm_items', t('remove'), GuiContext.bg)], [IconButton(DOWN_ICON, 'pl_move_down', t('move down'), GuiContext.bg)], [Sg.Button(image_data=PLAY_ICON, key='play_pl_selected', tooltip=t('play selected'), button_color=btn_color)], [Sg.Button(image_data=QUEUE_ICON, key='queue_pl_selected', tooltip=t('queue selected'), button_color=btn_color)], [Sg.Button(image_data=PLAY_NEXT_ICON, key='add_next_pl_selected', tooltip=t('add selected to next up'), button_color=btn_color)], [Sg.Button(image_data=LOCATE_FILE, key='pl_locate_selected', button_color=btn_color, tooltip=t('locate selected'), size=(2, 1))], [Sg.Button(image_data=COPY_ICON, key='pl_copy_selected', button_color=btn_color, tooltip=t('copy URIs'), size=(2, 1))] ], background_color=GuiContext.bg)]] return Sg.Tab(t('Playlists'), layout, key='tab_playlists') def TimerTab(timer, is_shut_down: bool, is_hibernate: bool, is_sleep: bool): do_nothing = not (is_shut_down or is_hibernate or is_sleep) # if timer is valid if time.time() < timer: timer_date = datetime.fromtimestamp(timer) timer_date = timer_date.strftime('%#I:%M %p') timer_text = t('Timer set for $TIME').replace('$TIME', timer_date) else: timer_text = t('No Timer Set') # wait for last track to finish setting cancel_button = StyledButton(t('Cancel Timer'), GuiContext.accent_color, GuiContext.bg, key='cancel_timer', visible=timer != 0) defaults = {'text_color': GuiContext.fg, 'background_color': GuiContext.bg, 'font': FONT_NORMAL, 'enable_events': True} layout = [ [Sg.Radio(t('Shut down when timer runs out'), 'TIMER', default=is_shut_down, key='shut_down', **defaults)], [Sg.Radio(t('Sleep when timer runs out'), 'TIMER', default=is_sleep, key='sleep', **defaults)], [Sg.Radio(t('Hibernate when timer runs out'), 'TIMER', default=is_hibernate, key='hibernate', **defaults)], [Sg.Radio(t('Only Stop Playback').capitalize(), 'TIMER', default=do_nothing, key='timer_stop', **defaults)], [Sg.Text(t('Enter minutes or HH:MM'), font=FONT_NORMAL), Sg.Input(key='timer_input', size=(11, 1), border_width=1), StyledButton(t('Submit'), GuiContext.accent_color, GuiContext.bg, key='timer_submit')], [Sg.Text(t('Invalid Input (enter minutes or HH:MM)'), font=FONT_NORMAL, visible=False, key='timer_error')], [Sg.Text(timer_text, font=FONT_NORMAL, key='timer_text', size=(20, 1), metadata=timer != 0), cancel_button] ] return Sg.Tab(t('Timer'), [[Sg.Column(layout, pad=(0, (50, 0)), justification='center')]], key='tab_timer') def MetadataTab(): layout = [[Sg.Column([ [StyledButton(t('Select File'), GuiContext.accent_color, GuiContext.bg, key='metadata_browse'), StyledButton(t('Save'), GuiContext.accent_color, GuiContext.bg, key='metadata_save'), Sg.Text('', size=(45, 1), key='metadata_file', border_width=1, relief='sunken', click_submits=True)]], pad=(0, (20, 10)))], [Sg.Column([[Sg.Text(t(text), size=(20, 1)), Sg.Input(key=f'metadata_{key}', border_width=1, size=(25, 1))] for (text, key) in (('Title', 'title'), ('Artist', 'artist'), ('Album', 'album'), ('Track Number', 'track_num'))]), Sg.Image(key='metadata_art')], [Sg.Checkbox(t('Explicit'), key='metadata_explicit', enable_events=True), StyledButton(t('Select artwork'), GuiContext.accent_color, GuiContext.bg, key='metadata_select_art', pad=(5, 10)), StyledButton(t('Search artwork'), GuiContext.accent_color, GuiContext.bg, key='metadata_search_art', pad=(5, 10)), StyledButton(t('Remove artwork'), GuiContext.accent_color, GuiContext.bg, key='metadata_remove_art', pad=(5, 10))], [Sg.Text('', key='metadata_msg', text_color='green', size=(60, 1))]] return Sg.Tab(t('Metadata'), [[Sg.Column(layout, pad=(5, 5))]], key='tab_metadata') def SettingsTab(settings, web_ui_url): qr_code = QRCode(web_ui_url) general_tab = Sg.Tab(t('General'), [ [Sg.Text('🌐' if platform.system() == 'Windows' else 'g', tooltip=t('language', True)), Sg.Combo(values=get_languages(), size=(3, 1), default_value=settings['lang'], key='lang', readonly=True, enable_events=True, tooltip=t('language'))], [Checkbox(t('Auto update'), 'auto_update', settings), Checkbox(t('Discord presence'), 'discord_rpc', settings, True)], [Checkbox(t('Notifications'), 'notifications', settings), Checkbox(t('Run on startup'), 'run_on_startup', settings, True)], [Checkbox(t('Folder context menu'), 'folder_context_menu', settings), Checkbox(t('Scan folders'), 'scan_folders', settings, True)], [Checkbox(t('Remember last folder'), 'use_last_folder', settings), Checkbox(t('Exit app on GUI close'), 'gui_exits_app', settings, True)], [Sg.Text(t('System Audio Delay:')), Sg.Input(settings['sys_audio_delay'], size=(10, 1), key='sys_audio_delay', tooltip=t('seconds'), border_width=1, pad=(70, 1), enable_events=True)] ], background_color=GuiContext.bg) queuing_tab = Sg.Tab(t('Queueing'), [ [Checkbox(t('Reversed play next'), 'reversed_play_next', settings), Checkbox(t('Always queue library'), 'queue_library', settings, True)], [Checkbox(t('Populate queue on startup'), 'populate_queue_startup', settings), Checkbox(t('Persistent queue'), 'persistent_queue', settings, True)], [Checkbox(t('Smart queue'), 'smart_queue', settings)] ]) ui_tab = Sg.Tab(t('UI'), [ [Checkbox(t('Save window positions'), 'save_window_positions', settings), Checkbox(t('Show track number'), 'show_track_number', settings, True)], [Checkbox(t('Left-side music controls'), 'flip_main_window', settings), Checkbox(t('Vertical GUI'), 'vertical_gui', settings, True)], [Checkbox(t('Show album art'), 'show_album_art', settings), Checkbox(t('Mini mode on top'), 'mini_on_top', settings, True)], [Checkbox(t('Use cover.* for album art'), 'folder_cover_override', settings), Checkbox(t('Show index in queue'), 'show_queue_index', settings, True)], [Sg.Text(t('Track Format:'), tooltip='&alb, &trck, &artist, &title'), Sg.Input(settings['track_format'], size=(30, 1), key='track_format', enable_events=True, border_width=1, pad=(70, 1), tooltip='&alb, &trck, &artist, &title')] ], background_color=GuiContext.bg) tabs = [general_tab, queuing_tab, ui_tab] if platform.system() == 'Windows': res_values = list(get_all_resolutions().keys()) on_battery_res = None if settings['on_battery_res'] is None else fmt_res(*settings['on_battery_res']) plugged_in_res = None if settings['plugged_in_res'] is None else fmt_res(*settings['plugged_in_res']) misc_tab = Sg.Tab(t('Misc'),[ [Sg.Text(t('On battery resolution')), Sg.Combo(values=res_values, size=(6, 1), default_value=on_battery_res, key='on_battery_res', readonly=True, enable_events=True)], [Sg.Text(t('Plugged in resolution')), Sg.Combo(values=res_values, size=(6, 1), default_value=plugged_in_res, key='plugged_in_res', readonly=True, enable_events=True)], [Checkbox(t('Experimental features'), 'experimental_features', settings)] ], background_color=GuiContext.bg) tabs.append(misc_tab) settings_tab_group = Sg.TabGroup([tabs], title_color=GuiContext.fg, border_width=0, selected_background_color=GuiContext.accent_color, font=FONT_TAB, tab_background_color=GuiContext.bg, selected_title_color=GuiContext.bg, background_color=GuiContext.bg) checkbox_col = Sg.Column([[settings_tab_group]], pad=((0, 0), (5, 0))) qr_code_params = {'tooltip': t('Open Web GUI'), 'button_color': (GuiContext.bg, GuiContext.bg)} right_settings_col = Sg.Column([ [Sg.Button(key='web_gui', image_data=qr_code, **qr_code_params)], [StyledButton('settings.json', GuiContext.accent_color, GuiContext.bg, key='settings_file', pad=((15, 0), 5), button_width=12)], [StyledButton('Changelog', GuiContext.accent_color, GuiContext.bg, key='changelog_file', pad=((15, 0), 5), button_width=12)] ], pad=(0, 0)) link_params = {'text_color': LINK_COLOR, 'font': FONT_LINK, 'click_submits': True} layout = [ [Sg.Text(f'Music Caster v{VERSION}', font=FONT_NORMAL), Sg.Text(CONTACT_INFO, tooltip=t('Send me an email'), key='open_email', **link_params), Sg.Text('GitHub', **link_params, key='open_github')], [checkbox_col, right_settings_col] if qr_code else [checkbox_col], [Sg.Listbox(settings['music_folders'], size=(62, 5), select_mode=Sg.SELECT_MODE_EXTENDED, text_color=GuiContext.fg, key='music_folders', background_color=GuiContext.bg, font=FONT_NORMAL, bind_return_key=True, no_scrollbar=True), Sg.Column([ [IconButton(X_ICON, 'remove_music_folder', t('remove selected folder'), GuiContext.bg)], [IconButton(PLUS_ICON, 'add_music_folder', t('add folder'), GuiContext.bg)]])]] return Sg.Tab(t('Settings'), layout, key='tab_settings') def VideoTab(devices): select_files = t('Select Files') layout = [ [Sg.Text('Warning this is highly experimental and might not even work')], [Sg.Combo(devices, key='video_cast_device', readonly=True, background_color=GuiContext.bg, expand_x=True, enable_events=True, pad=((5, 10), 10))], [StyledButton(select_files, GuiContext.accent_color, GuiContext.bg, key='video_select_file', button_width=len(select_files), pad=(5, (7, 5)))], [Sg.Text('To shorten the time I spent programming this feature: playback will begin immediately upon file selection, use the google home app for scrubbing and volume adjustment, and this text will not be translated')], ] return Sg.Tab(t('Video'), layout) ================================================ FILE: src/knownpaths.py ================================================ ''' The MIT License (MIT) Copyright (c) 2014 Michael Kropat Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ''' # [1] http://msdn.microsoft.com/en-us/library/windows/desktop/aa373931.aspx # [2] http://msdn.microsoft.com/en-us/library/windows/desktop/dd378457.aspx # [3] http://msdn.microsoft.com/en-us/library/windows/desktop/bb762188.aspx # [4] http://msdn.microsoft.com/en-us/library/windows/desktop/ms680722.aspx # [5] http://www.themacaque.com/?p=954 import ctypes, sys from ctypes import windll, wintypes from uuid import UUID from enum import Enum class KNOWN_FOLDER_FLAG: KF_FLAG_DEFAULT = 0x00000000 KF_FLAG_FORCE_APP_DATA_REDIRECTION = 0x00080000 KF_FLAG_RETURN_FILTER_REDIRECTION_TARGET = 0x00040000 KF_FLAG_FORCE_PACKAGE_REDIRECTION = 0x00020000 KF_FLAG_NO_PACKAGE_REDIRECTION = 0x00010000 KF_FLAG_FORCE_APPCONTAINER_REDIRECTION = 0x00020000 KF_FLAG_NO_APPCONTAINER_REDIRECTION = 0x00010000 KF_FLAG_CREATE = 0x00008000 KF_FLAG_DONT_VERIFY = 0x00004000 KF_FLAG_DONT_UNEXPAND = 0x00002000 KF_FLAG_NO_ALIAS = 0x00001000 KF_FLAG_INIT = 0x00000800 KF_FLAG_DEFAULT_PATH = 0x00000400 KF_FLAG_NOT_PARENT_RELATIVE = 0x00000200 KF_FLAG_SIMPLE_IDLIST = 0x00000100 KF_FLAG_ALIAS_ONLY = 0x80000000 class GUID(ctypes.Structure): # [1] _fields_ = [ ("Data1", wintypes.DWORD), ("Data2", wintypes.WORD), ("Data3", wintypes.WORD), ("Data4", wintypes.BYTE * 8), ] def __init__(self, uuid_): ctypes.Structure.__init__(self) ( self.Data1, self.Data2, self.Data3, self.Data4[0], self.Data4[1], rest, ) = uuid_.fields for i in range(2, 8): self.Data4[i] = rest >> (8 - i - 1) * 8 & 0xFF class FOLDERID: # [2] AccountPictures = UUID('{008ca0b1-55b4-4c56-b8a8-4de4b299d3be}') AdminTools = UUID('{724EF170-A42D-4FEF-9F26-B60E846FBA4F}') ApplicationShortcuts = UUID('{A3918781-E5F2-4890-B3D9-A7E54332328C}') CameraRoll = UUID('{AB5FB87B-7CE2-4F83-915D-550846C9537B}') CDBurning = UUID('{9E52AB10-F80D-49DF-ACB8-4330F5687855}') CommonAdminTools = UUID('{D0384E7D-BAC3-4797-8F14-CBA229B392B5}') CommonOEMLinks = UUID('{C1BAE2D0-10DF-4334-BEDD-7AA20B227A9D}') CommonPrograms = UUID('{0139D44E-6AFE-49F2-8690-3DAFCAE6FFB8}') CommonStartMenu = UUID('{A4115719-D62E-491D-AA7C-E74B8BE3B067}') CommonStartup = UUID('{82A5EA35-D9CD-47C5-9629-E15D2F714E6E}') CommonTemplates = UUID('{B94237E7-57AC-4347-9151-B08C6C32D1F7}') Contacts = UUID('{56784854-C6CB-462b-8169-88E350ACB882}') Cookies = UUID('{2B0F765D-C0E9-4171-908E-08A611B84FF6}') Desktop = UUID('{B4BFCC3A-DB2C-424C-B029-7FE99A87C641}') DeviceMetadataStore = UUID('{5CE4A5E9-E4EB-479D-B89F-130C02886155}') Documents = UUID('{FDD39AD0-238F-46AF-ADB4-6C85480369C7}') DocumentsLibrary = UUID('{7B0DB17D-9CD2-4A93-9733-46CC89022E7C}') Downloads = UUID('{374DE290-123F-4565-9164-39C4925E467B}') Favorites = UUID('{1777F761-68AD-4D8A-87BD-30B759FA33DD}') Fonts = UUID('{FD228CB7-AE11-4AE3-864C-16F3910AB8FE}') GameTasks = UUID('{054FAE61-4DD8-4787-80B6-090220C4B700}') History = UUID('{D9DC8A3B-B784-432E-A781-5A1130A75963}') ImplicitAppShortcuts = UUID('{BCB5256F-79F6-4CEE-B725-DC34E402FD46}') InternetCache = UUID('{352481E8-33BE-4251-BA85-6007CAEDCF9D}') Libraries = UUID('{1B3EA5DC-B587-4786-B4EF-BD1DC332AEAE}') Links = UUID('{bfb9d5e0-c6a9-404c-b2b2-ae6db6af4968}') LocalAppData = UUID('{F1B32785-6FBA-4FCF-9D55-7B8E7F157091}') LocalAppDataLow = UUID('{A520A1A4-1780-4FF6-BD18-167343C5AF16}') LocalizedResourcesDir = UUID('{2A00375E-224C-49DE-B8D1-440DF7EF3DDC}') Music = UUID('{4BD8D571-6D19-48D3-BE97-422220080E43}') MusicLibrary = UUID('{2112AB0A-C86A-4FFE-A368-0DE96E47012E}') NetHood = UUID('{C5ABBF53-E17F-4121-8900-86626FC2C973}') OriginalImages = UUID('{2C36C0AA-5812-4b87-BFD0-4CD0DFB19B39}') PhotoAlbums = UUID('{69D2CF90-FC33-4FB7-9A0C-EBB0F0FCB43C}') PicturesLibrary = UUID('{A990AE9F-A03B-4E80-94BC-9912D7504104}') Pictures = UUID('{33E28130-4E1E-4676-835A-98395C3BC3BB}') Playlists = UUID('{DE92C1C7-837F-4F69-A3BB-86E631204A23}') PrintHood = UUID('{9274BD8D-CFD1-41C3-B35E-B13F55A758F4}') Profile = UUID('{5E6C858F-0E22-4760-9AFE-EA3317B67173}') ProgramData = UUID('{62AB5D82-FDC1-4DC3-A9DD-070D1D495D97}') ProgramFiles = UUID('{905e63b6-c1bf-494e-b29c-65b732d3d21a}') ProgramFilesX64 = UUID('{6D809377-6AF0-444b-8957-A3773F02200E}') ProgramFilesX86 = UUID('{7C5A40EF-A0FB-4BFC-874A-C0F2E0B9FA8E}') ProgramFilesCommon = UUID('{F7F1ED05-9F6D-47A2-AAAE-29D317C6F066}') ProgramFilesCommonX64 = UUID('{6365D5A7-0F0D-45E5-87F6-0DA56B6A4F7D}') ProgramFilesCommonX86 = UUID('{DE974D24-D9C6-4D3E-BF91-F4455120B917}') Programs = UUID('{A77F5D77-2E2B-44C3-A6A2-ABA601054A51}') Public = UUID('{DFDF76A2-C82A-4D63-906A-5644AC457385}') PublicDesktop = UUID('{C4AA340D-F20F-4863-AFEF-F87EF2E6BA25}') PublicDocuments = UUID('{ED4824AF-DCE4-45A8-81E2-FC7965083634}') PublicDownloads = UUID('{3D644C9B-1FB8-4f30-9B45-F670235F79C0}') PublicGameTasks = UUID('{DEBF2536-E1A8-4c59-B6A2-414586476AEA}') PublicLibraries = UUID('{48DAF80B-E6CF-4F4E-B800-0E69D84EE384}') PublicMusic = UUID('{3214FAB5-9757-4298-BB61-92A9DEAA44FF}') PublicPictures = UUID('{B6EBFB86-6907-413C-9AF7-4FC2ABF07CC5}') PublicRingtones = UUID('{E555AB60-153B-4D17-9F04-A5FE99FC15EC}') PublicUserTiles = UUID('{0482af6c-08f1-4c34-8c90-e17ec98b1e17}') PublicVideos = UUID('{2400183A-6185-49FB-A2D8-4A392A602BA3}') QuickLaunch = UUID('{52a4f021-7b75-48a9-9f6b-4b87a210bc8f}') Recent = UUID('{AE50C081-EBD2-438A-8655-8A092E34987A}') RecordedTVLibrary = UUID('{1A6FDBA2-F42D-4358-A798-B74D745926C5}') ResourceDir = UUID('{8AD10C31-2ADB-4296-A8F7-E4701232C972}') Ringtones = UUID('{C870044B-F49E-4126-A9C3-B52A1FF411E8}') RoamingAppData = UUID('{3EB685DB-65F9-4CF6-A03A-E3EF65729F3D}') RoamedTileImages = UUID('{AAA8D5A5-F1D6-4259-BAA8-78E7EF60835E}') RoamingTiles = UUID('{00BCFC5A-ED94-4e48-96A1-3F6217F21990}') SampleMusic = UUID('{B250C668-F57D-4EE1-A63C-290EE7D1AA1F}') SamplePictures = UUID('{C4900540-2379-4C75-844B-64E6FAF8716B}') SamplePlaylists = UUID('{15CA69B3-30EE-49C1-ACE1-6B5EC372AFB5}') SampleVideos = UUID('{859EAD94-2E85-48AD-A71A-0969CB56A6CD}') SavedGames = UUID('{4C5C32FF-BB9D-43b0-B5B4-2D72E54EAAA4}') SavedSearches = UUID('{7d1d3a04-debb-4115-95cf-2f29da2920da}') Screenshots = UUID('{b7bede81-df94-4682-a7d8-57a52620b86f}') SearchHistory = UUID('{0D4C3DB6-03A3-462F-A0E6-08924C41B5D4}') SearchTemplates = UUID('{7E636BFE-DFA9-4D5E-B456-D7B39851D8A9}') SendTo = UUID('{8983036C-27C0-404B-8F08-102D10DCFD74}') SidebarDefaultParts = UUID('{7B396E54-9EC5-4300-BE0A-2482EBAE1A26}') SidebarParts = UUID('{A75D362E-50FC-4fb7-AC2C-A8BEAA314493}') SkyDrive = UUID('{A52BBA46-E9E1-435f-B3D9-28DAA648C0F6}') SkyDriveCameraRoll = UUID('{767E6811-49CB-4273-87C2-20F355E1085B}') SkyDriveDocuments = UUID('{24D89E24-2F19-4534-9DDE-6A6671FBB8FE}') SkyDrivePictures = UUID('{339719B5-8C47-4894-94C2-D8F77ADD44A6}') StartMenu = UUID('{625B53C3-AB48-4EC1-BA1F-A1EF4146FC19}') Startup = UUID('{B97D20BB-F46A-4C97-BA10-5E3608430854}') System = UUID('{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}') SystemX86 = UUID('{D65231B0-B2F1-4857-A4CE-A8E7C6EA7D27}') Templates = UUID('{A63293E8-664E-48DB-A079-DF759E0509F7}') UserPinned = UUID('{9E3995AB-1F9C-4F13-B827-48B24B6C7174}') UserProfiles = UUID('{0762D272-C50A-4BB0-A382-697DCD729B80}') UserProgramFiles = UUID('{5CD7AEE2-2219-4A67-B85D-6C9CE15660CB}') UserProgramFilesCommon = UUID('{BCBD3057-CA5C-4622-B42D-BC56DB0AE516}') Videos = UUID('{18989B1D-99B5-455B-841C-AB7C74E4DDFC}') VideosLibrary = UUID('{491E922F-5643-4AF4-A7EB-4E7A138D8174}') Windows = UUID('{F38BF404-1D43-42F2-9305-67DE0B28FC23}') class UserHandle: # [3] current = wintypes.HANDLE(0) common = wintypes.HANDLE(-1) _CoTaskMemFree = windll.ole32.CoTaskMemFree # [4] _CoTaskMemFree.restype = None _CoTaskMemFree.argtypes = [ctypes.c_void_p] _SHGetKnownFolderPath = windll.shell32.SHGetKnownFolderPath # [5] [3] _SHGetKnownFolderPath.argtypes = [ ctypes.POINTER(GUID), wintypes.DWORD, wintypes.HANDLE, ctypes.POINTER(ctypes.c_wchar_p), ] class PathNotFoundException(Exception): pass def sh_get_known_folder_path(folderid, user_handle=UserHandle.current, flags=0x0): fid = GUID(folderid) pPath = ctypes.c_wchar_p() S_OK = 0 if ( _SHGetKnownFolderPath( ctypes.byref(fid), flags, user_handle, ctypes.byref(pPath) ) != S_OK ): raise PathNotFoundException() path = pPath.value _CoTaskMemFree(pPath) return path if __name__ == '__main__': print(sh_get_known_folder_path(FOLDERID.Music)) ================================================ FILE: src/languages/da.txt ================================================ # Language: Danish # Credits: Frey Clante # Any line starting with a # is ignored # If a line contains $X do not translate the $X, please use it in context # e.g. "Update $VER is available" will dynamically become "Update v4.75.0 is available" # Try to keep the same capitalization or lack of as in the English file tilføj mappe Tilføj Music Caster til mappe kontekst menu Tilføj filer Tilføj URL Der er sket en fejl, genstarter nu Der opstod en intern Server Fejl Audio Selection Automatiske opdateringer Af Annuller Annuller Timer Tøm køen Knapper Kunne ikke forbinde til cast device Kunne ikke finde optagelseskilde Kunne ikke afspille $URL Kunne ikke oprette køen fordi mappe-scanning er slået fra Kunne ikke indstille timer Discord installeret? Downloader opdatering ver: $VER Skriv minutter eller HH:MM (Timer:Minutter) Skriv tid Skriv URL FEJL Afslut Mappe kontekst menu Mappen indeholder ikke lydfiler Mapper Generelt Gå i dvale når nedtælling løber ud Sæt computer i dvale INFO Fejl i det indtastede (Skriv minutter eller timer ( 24 timers format (HH:mm) )) Ugyldig URL. URL'er SKAL starte med http:// eller https:// Ugyldig lydfil valgt Ugyldig lydfil $FILE behold mini-tilstand i forgrunden Start mini-tilstand Musik-Knapper (venstre) Bibliotek Bibliotek indeksering blev afbrudt, kun scannede filer blev tilføjet Lytter System Lyd Denne Computer find valgte Forbindelsen til $DEVICE blev afbrudt, skifter til denne computer Mini-Mode i forgrunden Mere flyt ned sæt til at afspille som næste Flyt indhold til venstre Flyt op Music Caster kører i minimeret tilstand. mute Ny næste nummer Der er ikke nok plads på den valgte disk til at auto-opdatere Der er ikke nok plads på den valgte disk til at gemme indstillingerne Ingen timer sat Ikke forbundet til et 'cast device' Ikke logget ind på deezer.com Intet afspilles Notifikationer Stop kun afspilning Åben Pause Gemt kø Afspil Afspil Alle Afspil Filer Afspil Filer bagefter Afspil Øjeblikkeligt Afspil Næste Afspil URL Afspil URL bagefter Afspiller Playlist navn Playlists Playlists Tab Indlæs kø under opstart Indlæser kø fra mapper under opstart forrige nummer Kø Sæt alle i kø Sæt filer i kø Sæt URL i kø Opdater Devices Husk sidste mappe fjern den valgte mappe fjern Gentag Gentag Alle Gentag 'fra' Gentag nummer Gentag Muligheder Scan Bibliotek igen Scanner Bibliotek igen Genoptag Reverse play next behaviour Afspil næste baglæns-opførsel Næste spilles baglæns Kør ved opstart Gem kø på tværs a sessioner Gem i playlist Gem Vindues positioner Scan mapper scroll musehjul Søg efter musik... Vælg Device Vælg Mappe Vælg Mapper Vælg Filer Vælg Fil Vælg Lydfiler Send mig en mail sæt --> DEBUG = true i `settings.json` for at aktivere denne side Sæt Timer Set Indstillinger Vis Album Art Vis album art in GUI Vis plads-indeks i køen Vis indeks for nummer Vis indeks for nummer i køen bland Luk computeren ned når timeren løber ud Luk computeren Gå i dvale når timeren løber ud Gå i dvale Stop Send Tak fordi du installerer Music Caster. Timer Timer annulleret Timer indstillet til: $TIME Udfyld kunstner/titel Brugerflade unmute Opdatering ver: $VER er tilgængelig URL Brug cover.* til album art Brug cover.* til art i stedet for filens album art cover.* billede trumfer coverfil Lodret Brugerflade Vis Lydfilers kilde Links Åbn Brugerflade i browser ny playlist eksporter playlist slet playlist afspil playliste sæt playliste i kø Ukendt Titel Ukendt Kunstner Ukendt Album Sæt altid bibliotek i kø Afspil med Music Caster Tilføj til kø i Music Caster Afspil næste i Music Caster Sætter i kø Intelligent kø tilføj som næste Metadata Gemmer metadata Metadata gemt Gem Titel Kunstner Album Nummer indeks Eksplicit Vælg artwork Søg artwork fjern artwork Artwork fundet intet artwork found Søger efter artwork... Indlæser URL(s) Tilføjede URL(s) sprog afspil valgte sæt valgte i kø tilføj valgte som næste System Lyd Forsinkelse: sekunder find nummer Skifter til lokal afspilning Nummer Format: Kunne ikke hente lyd fra: $URL Afslut når den grafiske brugeflade lukkes 'På batteri' opløsning 'tilsluttet' opløsning Kunne ikke indstille opløsning Kopier URIs rediger metadata Eksperimentelle funktioner Playlist gemt Opdatering Downloadet $VER. Relancering... ================================================ FILE: src/languages/de.txt ================================================ # Sprache: Deutsch (German) # Credits: Bernd Miller # Jede Zeile, die mit einem # beginnt, wird ignoriert! # Wenn eine Zeile $X enthält, $X nicht übersetzen, sondern im Kontext behandeln. # z.B. "Update $VER ist verfügbar" wird dynamisch zu "Update v4.75.0 ist verfügbar" # Try to keep the same capitlization or lack of as in the English file Ordner hinzufügen Music Caster zum Ordner-Kontext-Menü hinzufügen Dateien hinzufügen URL hinzufügen Ein Fehler ist aufgetreten, Neustart wird durchgeführt. Ein interner Serverfehler ist aufgetreten. Audio Auswahl Auto Update von abbrechen Zeitsteuerung abbrechen Warteschlange löschen Bedienelemente konnte nicht mit dem Castgerät verbinden konnte kein Ausgangsgerät zum Aufnehmen finden konnte $URL nicht abspielen konnte die Warteschlange nicht befüllen, da der Bibliotheksscan deaktiviert ist. konnte Zeitsteuerung nicht setzten. Discord-Präsenz lade Update $VER herunter Minuten oder HH:MM eingeben Zeit eingeben URL eingeben FEHLER beenden Ordner-Kontext-Menü Ordner enthält keine Audiodateien Ordner allgemein Ruhezustand wenn die Zeitsteuerung abgelaufen ist. Computer in den Ruhezustand versetzen. INFO ungültige Eingabe (Minutes or HH:MM eingeben) ungültige URL. (URL's müssen mit http:// or https:// beginnen) ungültige Audio Datei ausgewählt ungültige Audio Datei $FILE Mini Mode immer sichtbar Mini Mode starten Musik-Bedienelemente links Bibliothek Indizierung der Bibliothek unvollständig, nur bereits analysierte Dateien wurden hinzugefügt. hören System Audio lokales Gerät lokalisiere Auswahl Verbindung zu $DEVICE verloren, schalte auf lokales Gerät um. Mini Mode immer oben mehr nach unten schieben weiter zum nächsten Titelinhalt nach links verschieben nach oben schieben Music Caster läuft in der Taskleiste. stummschalten neu nächster Titel nicht genügend Speicherplatz für automatisches Update auf dem Gerät verfügbar. Einstellungen können nicht gespeichert werden, da nicht genügend Speicherplatz auf dem Gerät verfügbar ist. keine Zeitsteuerung gesetzt nicht mit einem Castgerät verbunden nicht bei deezer.com angemeldet nichts wird abgespielt Benachrichtigungen Nur die Wiedergabe anhalten öffnen Pause dauerhafte Warteschlange abspielen alles abspielen Dateien abspielen Dateien als nächstes abspielen sofort abspielen nächstes abspielen URL abspielen URL als nächstes abspielen wird abgespielt Wiedergabelistenname Wiedergabelisten Wiedergabelisten Tab Warteschlange bei Start füllen Warteschlange mit Ordnern beim Start befüllen vorheriger Track Warteschlange alle Warteschlangen Datei Warteschlangen URL Warteschlangen Neueinlesen der Geräte letzten Ordner merken entferne selektierten Ordner entfernen wiederhole wiederhole alles wiederholen aus wiederhole eins Wiederholoptionen Bibliothek einlesen lese Bibliothek ein fortsetzen kehre das Verhalten von "spiele nächstes ab" um spiele vorheriges ab beim Start ausführen speichere die Warteschlange zwischen Sitzungen speichere die Warteschlange als Wiedergabeliste speichere Fensterposition lese Ordner ein Blättern mit Mausrad Suche nach Musik... wähle Gerät wähle Ordner wähle Ordner wähle Dateien wähle Datei wähle Audio Dateien sende mir eine Email setze DEBUG = true in `settings.json` um diese Seite zu aktivieren setze Zeitsteuerung setze Einstellungen zeige Albumcover zeige Albumcover im GUI zeige Index in Warteschlange zeige Titelnummer zeige Titelnummer in Warteschlange zufällige Reihenfolge Wenn die Zeitsteuerung abgelaufen ist herunterfahren. Computer herunterfahren Wenn die Zeitsteuerung abgelaufen ist Energie sparen. Computer Energie sparen aktivieren Stop übermitteln Vielen Dank, dass Sie Music Caster installiert haben. Zeitsteuerung Zeitsteuerung abgebrochen Zeitsteuerung auf $TIME setzen schreibe Artist/Titel Benutzerschnittstelle Stummschaltung aufheben Update $VER ist verfügbar URL benutze cover.* für Albumcover benutze cover.* für Bilder anstatt Albumcover der Datei cover.* Bild überschreibt Albumcover der Datei vertikales GUI zeige Audiodateiquellenverknüpfungen öffne Web GUI neue Wiedergabeliste exportiere Wiedergabeliste lösche Wiedergabeliste Wiedergabeliste abspielen Wiedergabeliste zur Warteschlange hinzufügen unbekannter Titel unbekannter Artist unbekanntes Album Bibliothek als Warteschlange mit Music Caster abspielen zur Music Caster Warteschlange hinzufügen als nächstes in Music Caster abspielen anstehend intelligente Warteschlange als nächtes oben hinzufügen Metadaten speichere Metadaten Metadaten gespeichert speichern Titel Artist Album Track Nummer explizit wähle Albumcover suche Albumcover entferne Albumcover Albumcover gefunden kein Albumcover gefunden suche nach Albumcover... lade URL(s) hinzugefügte URL(s) Sprache spiele markiertes ab markiertes in die Warteschlange füge markiertes als nächstes oben ein System Audio Verzögerung: Sekunden Titel lokalisieren schalte auf lokales Gerät um Titelformat: konnte bei $URL kein Audio finden beende zusammen mit GUI Auflösung bei Batteriebetrieb Auflösung bei Netzbetrieb Konnte Auflösung nicht setzen kopiere URIs Metadaten beatbeiten experimentelle Funktionen Warteschlange gespeichert installiere Update Heruntergeladen $VER. Relaunch... ================================================ FILE: src/languages/en.txt ================================================ # Language: English # Credits: Elijah Lopez # Any line starting with a # is ignored # If a line contains $X do not translate the $X, please use it in context # e.g. "Update $VER is available" will dynamically become "Update v4.75.0 is available" # Try to keep the same capitalization or lack of as in the English file add folder Add Music Caster to folder context menu Add files Add URL An error occurred, restarting now An Internal Server Error occurred Audio Selection Auto update By Cancel Cancel Timer Clear the queue Controls Could not connect to cast device Could not find an output device to record Could not play $URL Could not populate queue because library scan is disabled Could not set timer Discord presence Downloading update $VER Enter minutes or HH:MM Enter Time Enter URL ERROR Exit Folder context menu Folder does not contain audio files Folders General Hibernate when timer runs out Hibernate Computer INFO Invalid Input (enter minutes or HH:MM) Invalid URL. URL's need to start with http:// or https:// Invalid audio file selected Invalid audio file $FILE Keep mini mode on top Launch mini mode Left-side music controls Library Library indexing incomplete, only scanned files have been added Listening System Audio Local device locate selected Lost connection to $DEVICE, switching to local device Mini mode on top More move down Move to next up Move track content to the left move up Music Caster is running in the tray. mute New next track No space left on device to auto-update No space left on device to save settings No Timer Set Not connected to a cast device Not logged into deezer.com Nothing Playing Notifications Only Stop Playback Open Pause Persistent queue Play Play All Play Files Play Files Next Play Immediately Play Next Play URL Play URL Next Playing Playlist name Playlists Playlists Tab Populate queue on startup Populates queue from folders on startup previous track Queue queue all Queue Files Queue URL Refresh Devices Remember last folder remove selected folder remove Repeat Repeat All Repeat Off Repeat One Repeat Options Rescan Library Rescanning library Resume Reverse play next behaviour Reversed play next Run on startup Save queue between sessions Save to playlist Save window positions Scan folders scroll mousewheel Search for music... Select Device Select Folder Select Folders Select Files Select File Select Audio Files Send me an email set DEBUG = true in `settings.json` to enable this page Set Timer Set Settings Show album art Show album art in GUI Show index in queue Show track number Show track number in queue shuffle Shut down when timer runs out Shut Down Computer Sleep when timer runs out Sleep Computer Stop Submit Thanks for installing Music Caster. Timer Timer cancelled Timer set for $TIME Type in artist/tracks UI unmute Update $VER is available URL Use cover.* for album art Use cover.* for art instead of file's album art cover.* image overrides file cover Vertical GUI View Audio Files Source Links Open Web GUI new playlist export playlist delete playlist play playlist queue playlist Unknown Title Unknown Artist Unknown Album Always queue library Play with Music Caster Queue in Music Caster Play next in Music Caster Queueing Smart queue add to next up Metadata Saving metadata Metadata saved Save Title Artist Album Track Number Explicit Select artwork Search artwork Remove artwork Artwork found No artwork found Searching for artwork... Loading URL(s) Added URL(s) language play selected queue selected add selected to next up System Audio Delay: seconds locate track Switching to local device Track Format: Could not fetch audio for $URL Exit app on GUI close On battery resolution Plugged in resolution Could not set resolution copy URIs edit metadata Experimental features Playlist saved Install Update Downloaded $VER. Relaunching... ================================================ FILE: src/languages/es.txt ================================================ # Language: Spanish # Credits: Sergi github.com/Varguit # Any line starting with a # is ignored! # If a line contains $X do not translate the $X, please use it in context # e.g. "Update $VER is available" will dynamically become "Update v4.75.0 is available" # Try to keep the same capitalization or lack of as in the English file agregar carpeta Agregar Music Caster al menú contextual de la carpeta Agregar pistas Agregar URL Ocurrió un error, reiniciando ahora Se produjo un error interno del servidor Selección de audio Actualización auto Por Cancelar Cancelar Temporizador Limpiar la cola Controles No se pudo conectar al dispositivo de transmisión No se pudo encontrar un dispositivo de salida para grabar No se pudo reproducir $URL No se pudo completar la cola porque el escaneo de la biblioteca está deshabilitado No se pudo configurar el temporizador Discord activo Descargando la actualización $VER Ingrese minutos o HH:MM Ingrese la hora Ingrese la URL ERROR Salida Menú contextual de carpeta Carpeta no contiene archivos de audio Carpetas General Hibernar cuando se acabe el tiempo Hibernar computadora INFO Entrada no válida (ingrese minutos o HH: MM) URL invalida. La URL debe comenzar con http:// o https:// Se seleccionó un archivo de audio no válido Archivo de audio no válido $FILE Mantenga el modo mini en la parte superior Iniciar el modo mini Controles de música en lado izquierdo Biblioteca Indexación de la biblioteca incompleta, solo se han agregado archivos escaneados Escuchando Audio del Sistema Dispositivo local localizar seleccionado Modo Mini en la parte superior Conexión perdida a $DEVICE, cambio a dispositivo local Más mover hacia abajo Pasar al siguiente Mover el contenido de la pista a la izquierda mover hacia arriba Music Caster se está ejecutando en la bandeja. silencio Nuevo siguiente pista No queda espacio en el dispositivo para actualizar automáticamente No queda espacio en el dispositivo para guardar la configuración Sin temporizador configurado No conectado a un dispositivo de transmisión No ha iniciado sesión en deezer.com Nada Reproduciéndose Notificaciones Detener solo la Reproducción Abierta Pausa Cola persistente Reproducir Reproducir Todo Reproducir archivos Reproducir archivos siguiente Reproduce inmediatamente Reproduce siguiente Reproducir URL Reproducir URL Siguiente Reproduciendo Nombre lista reprod. Listas de Reproducción Pestaña Listas de Reproducción Rellenar cola al inicio Llena la cola de las carpetas al inicio pista anterior Cola Poner en cola todo Archivos de cola URL de cola Actualizar Dispositivos Recuerde última carpeta eliminar carpeta seleccionada quitar Repetir Repite todo Repetir apagado Repetir uno Opciones de repetición Volver a Explorar la Biblioteca Explorar comenzó Reanudar Invertir comportamiento de reproducir siguiente Reproducir siguiente invertido Reproduce al empezar Guardar cola entre sesiones Guardar en la lista de reproducción Guardar posiciones de ventana Escanear carpetas Rueda del ratón de desplazamiento Buscar música... Seleccione el Dispositivo Seleccione la carpeta Seleccionar Carpetas Seleccionar Archivos Seleccionar Archivo Seleccionar Archivos de Audio Envíame un correo electrónico establezca DEBUG = true en `settings.json` para habilitar esta página Configurar Temporizador Ajusta Ajustes Mostrar la carátula del álbum Mostrar la carátula del álbum en la GUI Mostrar índice en cola Mostrar número de pista Mostrar el número de pista en la cola Aleatorio Activar modo sleep cuando se acabe el tiempo Activar modo Sleep la computadora Hibernar cuando se acabe el tiempo Hibernar Detener Activar Gracias por instalar Music Caster. Temporizador Temporizador cancelado Temporizador establecido a las $TIME Escribe artista / pistas UI activar el sonido Actualización $VER está disponible URL Use cover.* Para la carátula del álbum Use cover.* Para la carátula en lugar de la carátula del álbum del archivo cover.* la imagen anula la portada del archivo GUI vertical Ver enlaces de origen de archivos de audio Interfaz GUI Web Abierta nueva lista de reproducción exportar lista de reproducción eliminar lista de reproducción reproducir lista de reproducción lista de reproducción en cola Título Desconocido Artista Desconocido Álbum Desconocido Biblioteca de cola siempre Jugar con Music Caster Cola en Music Caster Reproducir a continuación en Music Caster Hacer Cola Cola inteligente agregar al siguiente Metadatos Guardar metadatos Metadatos guardados Guardar Título Artista Álbum Número de Pista Explícito Seleccionar obra de arte Buscar obra de arte Quitar obra de arte Obra de arte encontrada No se encontraron obras de arte Buscando obras de arte... Cargando URL(s) URL agregadas idioma jugar seleccionado cola seleccionada agregar seleccionado al siguiente Retraso de audio del sistema: segundos localizar pista Cambiar a dispositivo local Formato de Pista: No se pudo obtener el audio para $URL Salir de la aplicación al cerrar la GUI Sobre la resolución de la batería Resolución enchufada No se pudo establecer la resolución copiar URIs editar metadatos caracteristicas experimentales Lista de reproducción guardada Actualizar Descargado $VER. Reiniciando... ================================================ FILE: src/languages/fr.txt ================================================ # Language: French # Credits: github.com/tasye24 # Any line starting with a # is ignored # If a line contains $X do not translate the $X, please use it in context # e.g. "Update $VER is available" will dynamically become "Update v4.75.0 is available" # Try to keep the same capitalization or lack of as in the English file ajouter à partir d’un dossier ajouter Music Caster au dossier du menu contextuel ajouter à partir des fichiers ajouter à partir d’une URL Une erreur s’est produite, redémarrage immédiat Une erreur interne du serveur s’est produite Sélection audio Mise à jour automatique Par Annuler Annuler le chronomètre Nettoyer la file d’attente Contrôles Impossible de se connecter au périphérique cast Impossible de trouver un appareil de sortie pour enregistrer Impossible de jouer $URL Impossible de remplir la file d’attente car l’analyse de bibliothèque est désactivée Impossible de régler le chronomètre présence Discord Mise à jour vers $VER Saisir les minutes ou HH:MM Saisir un temps Saisir l'URL ERREUR Sortir Dossier du menu contextuel Le dossier ne contient pas de fichiers audio Dossiers Générale Mettre en veille lorsque le chronomètre finit Mettre en veille l'ordinateur INFO Entrée invalide (saisir minutes ou HH:MM) URL. invalide l'URL doit commencer par http:// ou https:// Fichier audio sélectionné invalide Fichier audio invalide $FILE Garder le mode mini au premier plan Launcer le mode mini Commandes de musique du côté gauche Bibliothèque Indexation de la bibliothèque incomplète, seulement les fichiers scannés ont été ajoutés Écoute Audio système Périphérique local langue sélectionnée Connexion perdue avec $DEVICE, changement vers l’appareil local Mode mini au premier plan Plus descendre Déplacer vers le suivant Déplacer le contenu de la piste vers la gauche monter Music Caster est en cours d'exécution. silencieux Nouveau morceau suivant Il n'y a plus d'espace libre sur l'appareil de mise à jour automatisé Il n'y a plus d'espace libre dans l'appareil pour sauvegarder les paramètres Aucun chronomètre Non connecté à un appareil de Cast Non connecté à deezer.com Joue rien Notifications Arrêter uniquement la lecture Ouvrir Pause File d’attente permanente Jouer Tout jouer Jouer les fichiers Jouer les fichiers suivants Jouer immédiatement Jouer le prochain Jouer à partir d'une URL Jouer le prochain morceau de l'URL En cours d'écoute Nom de la playlist Listes de lectures Onglet listes de lectures Remplir la file d’attente au démarrage Remplir la file d’attente depuis les dossiers au démarrage morceau précédent File d'attente Tout mettre dans la file d'attente Fichiers de la file d'attente URL de la file d'attente Rafraichir les appareils Restaurer le dernier dossier supprimer le dossier sélectionné supprimer Répéter Tout répéter Désactiver le mode repeat Répéter une fois Options du mode répéter Réanalyse de la bibliothèque Réanalyse de la bibliothèque en cours Résumer Jouer à l'envers le prochain comportement Jouer à l'envers le prochain morceau Démarrer au lancement Sauvegarder la file d'attente entre les sessions Sauvegarder à la playlist Sauvegarder les position de la fenêtre Scanner les dossier scroll molette Rechercher de la musique... Sélectionner l'appareil Sélectionner un dossier Sélectionner des dossiers Sélectionner des fichiers Sélectionner un fichier Sélectionner des fichiers audio Envoyez-moi un email régler DEBUG = true dans settings.json pour activer cette page Régler le chronomètre Régler Paramètres Afficher la pochette d'album Afficher la pochette d'album dans l'interface Afficher l'index dans la file d'attente Afficher le numéro du morceau Afficher le numéro du morceau dans la file d'attente aléatoire Éteindre à la fin du chronomètre Éteindre l'ordinateur Mettre en veille à la fin du chronomètre Mettre en veille l'ordinateur Arrêter Envoyer Merci d'avoir installé Music Caster. Chronomètre Chronomètre annulé Chronomètre réglé pour $TIME Rechercher dans artistes/morceaux Interface utilisateur réactiver le son Nouvelle mise à jour $VER disponible URL Utiliser cover.* pour la pochette d'album Utiliser cover.* pour la pochette d'album au lieu de celui du fichier remplacer la bannière par cover.* Interface verticale Afficher les liens source des fichiers audio Ouvrir l'interface Web nouvelle playlist exporter la playlist supprimer la playlist jouer la playlist playlist de la file d'attente Titre inconnu Artiste inconnu Album inconnu Toujours mettre en attente la bibliothèque Jouer avec Music Caster Mise en attente dans Music Caster Jouer le prochain titre dans Music Caster Mise en file d'attente File d'attente intelligente Ajouter au prochain métadonnées Sauvegarde des métadonnées Métadonnées sauvegardées Sauvegarder Titre Artiste Album Morceau numéro Explicite Sélectionner un morceau Rechercher un morceau Supprimer le morceau Morceau trouvé Aucun morceau trouvé Recherche de morceau... Chargements des URL URL ajoutées langue Jouer les morceaux sélectionnés file d’attente sélectionnée Ajouter la sélection au prochain Délai du système audio: secondes Localiser le morceau Passage à l'appareil local Format du morceau: Impossible de trouver le fichier audio à partir de $URL Sortir de l'app quand le GUI se ferme Résolution sur batterie Résolution branchée Impossible de définir la résolution copier les URI modifier les métadonnées Fonctionnalités expérimentales Liste de lecture enregistrée Mise à jour Téléchargé $VER. Relance... ================================================ FILE: src/languages/it.txt ================================================ # Language: Italiano # Credits: Antonio Negrini github.com/antonio-negrini # Any line starting with a # is ignored! # If a line contains $X do not translate the $X, please use it in context # e.g. "Update $VER is available" will dynamically become "Update v4.75.0 is available" # Try to keep the same capitalization or lack of as in the English file aggiungi cartella Aggiungi Music Caster al menu contestuale della cartella Aggiungi tracce Aggiungi URL Si è verificato un errore, riavviare ora Si è verificato un errore interno al Server Selezione audio Aggiornamento automatico Da Annulla Annulla il Timer Cancella la coda Controlla Impossibile connettersi al dispositivo cast Impossibile trovare un dispositivo di output da registrare Impossibile riprodurre $URL Impossibile popolare la coda perché la scansione della libreria è disabilitata Impossibile impostare il timer Presenza su Discord Download dell'aggiornamento $VER Inserisci i minuti o HH:MM Inserisci il tempo Inserisci l'URL ERRORE Esci Menu contestuale cartella Cartella non contiene file audio Cartelle Generale Ibernazione allo scadere del timer Ibernazione del computer INFO Input non valido (inserire minuti o HH:MM) URL non valido. Gli URL devono iniziare con http:// o https:// Selezionato file audio non valido File audio $FILE non valido Mantenere il modo mini in alto Avvia il modo mini Controlli musicali a sinistra Libreria Indicizzazione della libreria incompleta, sono stati aggiunti solo i file scansionati Ascolto Audio di Sistema Dispositivo locale individuare selezionato Modalità mini in alto Connessione persa a $DEVICE, passaggio al dispositivo locale Più Sposta giù Sposta su come successivo Sposta il contenuto della traccia a sinistra Sposta su Music Caster è in esecuzione nel tray. Mute Nuovo brano successivo Non c'è spazio sul dispositivo per l'aggiornamento automatico Non c'è spazio sul dispositivo per salvare le impostazioni Timer non impostato Non collegato a un dispositivo cast Non connesso a deezer.com Niente in riproduzione Notifiche Ferma solo la Riproduzione Apire Pausa Coda persistente Riproduci Riproduci Tutto Riproduci Files Riproduci Files Dopo Riproduci Immediatamente Riproduci Prossimo Riproduci URL Riproduci URL Dopo In riproduzione Nome Playlist Playlist Scheda Playlist Riempi la coda all'avvio Riempi la coda dalle cartelle all'avvio traccia precedente Coda Metti tutti in coda Metti in coda Files Metti in coda URL Aggiornare i Dispositivi Ricorda ultima cartella rimuovere la cartella selezionata Rimuovi Ripeti Ripeti Tutto Ripeti Off Ripeti Uno Opzioni di ripetizione Scansione della Libreria Scansionando la libreria Riprendi Inverti modalità riproduzione successiva Riprod. successiva invertita Esegui all'avvio Salva la coda tra le sessioni Salva in playlist Salva posizioni finestre Scansione cartelle scorrere la rotella del mouse Cerca la musica... Seleziona Dispositivo Seleziona Cartella Seleziona Cartellae Seleziona Files Seleziona File Seleziona Files Audio Inviami un'e-mail imposta DEBUG = true in `settings.json` per abilitare questa pagina Imposta Timer Imposta Impostazioni Mostra cover album Mostra cover album in GUI Mostra l'indice nella coda Mostra il numero del brano Mostra il numero del brano in coda Random Spegni PC alla fine del timer Spegni il computer Metti in Sleep il PC alla fine del timer Metti in Sleep il computer Stop Invia Grazie per aver installato Music Caster. Timer Timer cancellato Timer impostato per $TIME Inserisci artista/brani UI togli il mute Aggiornamento $VER disponibile URL Usa cover.* per cover album Usa cover.* invece della cover inclusa nel file il file cover.* sovrascrive la cover inclusa nel file GUI verticale Visualizza i file audio Link alla fonte Web GUI QR Code (clicca o scansiona) nuova playlist esporta playlist elimina playlist riproduci playlist playlist in coda Brano Sconosciuto Artista Sconosciuto Album Sconosciuto Metti sempre in coda libreria Gioca con Music Caster Coda in Music Caster Riproduci successivo in Music Caster Coda Coda smart aggiungere al prossimo Metadati Salvataggio dei metadati Metadati salvati Salva Brano Artista Album Numero Brano Esplicito Seleziona immagine Cerca immagine Rimuovi immagine Immagine trovata Nessuna immagine trovata Ricerca immagini... Caricamento URL(s) URL(s) aggiunti/e linguaggio riproduzione selezionata coda selezionata aggiungi selezionato al successivo Ritardo audio di sistema: secondi localizzare traccia Passaggio al dispositivo locale Formato Traccia: Impossibile recuperare l'audio per $URL Esci dall'app alla chiusura della GUI Sulla risoluzione della batteria Risoluzione inserita Impossibile impostare la risoluzione copia URIs modificare i metadati caratteristiche sperimentali Playlist salvata Aggiornamento Scaricato $VER. Riavvio... ================================================ FILE: src/languages/nl.txt ================================================ # Language: Dutch # Credits: Jeffrey (PD3J) Jansen # Any line starting with a # is ignored! # If a line contains $X do not translate the $X, please use it in context # e.g. "Update $VER is available" will dynamically become "Update v4.75.0 is available" # Try to keep the same capitalization or lack of as in the English file map toevoegen Voeg Music Caster toe aan het contextmenu van de map Liedjes toevoegen URL toevoegen Er is een fout opgetreden, nu aan het herstarten Er heeft een interne server fout plaatsgevonden Audio Selectie Automatische update Door Annuleren Annuleer Timer Wis de wachtrij Bediening Kon niet verbinden met cast-apparaat Kon geen uitvoerapparaat vinden om op te nemen Kan $URL niet afspelen Kan de wachtrij niet vullen omdat bibliotheekscan is uitgeschakeld Kon de timer niet instellen Discord aanwezig Downloading update $VER Voer het aantal minuten in of UU:MM Voer tijd in Vul URL in FOUT Exit Contextmenu van de map Deze map bevat geen audio bestanden Mappen Algemeen Ga in slaapstand als de timer afloopt Slaapstand Computer INFO Verkeerde ingave (geef aantal minuten in of UU:MM) Ongeldige URL. URL's moeten beginnen met http:// of https:// Ongeldig audiobestand geselecteerd Ongeldig audiobestand $FILE Houdt mini modus zichtbaar Start mini modus Muziekbediening aan de linkerkant Bibliotheek Bibliotheekindexering onvolledig, alleen gescande bestanden zijn toegevoegd Aan het luisteren Systeemaudio Lokale apparaten lokaliseren geselecteerd Mini-modus bovenaan Verbinding met $DEVICE verbroken, overschakelen naar lokaal apparaat Meer omlaag verplaatsen naar volgende te spelen Nummerinhoud naar links verplaatsen omhoog Music Caster is geminimaliseerd in de taakbalk. mute Nieuw volgend nummer Geen ruimte over op het apparaat om automatisch te updaten Geen ruimte meer op het apparaat om instellingen op te slaan Geen timer ingesteld Niet verbonden met een cast-apparaat Niet ingelogd bij deezer.com Niets aan het spelen Meldingen Alleen afspelen stoppen Open Pause Aanhoudende wachtrij Afspelen Speel alles af Speel bestanden Speel volgende bestanden Speel meteen Speel volgende Speel URL Speel volgende URL Afspelen Naam afspeellijst Afspeellijsten Tabblad Afspeellijsten Wachtrij vullen bij opstarten Vult wachtrij uit mappen bij opstarten vorige nummer Wachtrij alles naar de wachtrij Bestanden naar wachtrij URL naar wachtrij Ververs apparaten Onthoud laatste map verwijder geselecteerde map verwijder Herhaal Herhaal alles Herhalen uit Herhaal een keer Herhaal opties Bibliotheek opnieuw scannen Bibliotheek aan het herscannen Hervatten Omgekeerd afspelen volgende gedrag Omgekeerd afspelen volgende Uitvoeren bij het starten Wachtrij tussen sessies opslaan opslaan in afspeellijst Vensterposities opslaan Mappen scannen scroll muiswiel Muziek aan het zoeken... Selecteer apparaat Selecteer map Selecteer mappen Selecteer bestanden Selecteer bestand Selecteer Audio bestanden Stuur een e-mail set DEBUG = true in `settings.json` om deze pagina in te schakelen Stel Timer in Instellen Instellingen Albumhoezen weergeven Albumhoezen weergeven in Grafische gebruikers-interface Toon index in wachtrij Toon tracknummer Toon tracknummer in wachtrij shuffle Afsluiten wanneer de timer afloopt Computer afsluiten Ga in slaapstand als de timer afloopt Computer slaapstand Stop Indienen Bedankt voor het installeren van Music Caster. Timer Timer geannuleerd Timer gezet voor $TIME Geef artiest/tracks in Gebruikersinterface mute opheffen Update $VER is beschikbaar URL Gebruik cover.* voor albumhoezen Gebruik cover.* voor albumhoed in plaats van de albumhoes van het bestand cover.* afbeelding heeft voorrang op de albumhoes van het bestand Verticale Grafische gebruikers-interface Bekijk de bronlinks van de audiobestanden QR Code voor de webgebaseerde gebruikers-interface (Klikken of scannen) nieuwe afspeellijst afspeellijst exporteren afspeellijst verwijderen afspeellijst afspelen afspeellijst in de wachtrij zetten Onbekende titel Onbekende artiest Onbekend album Bibliotheek altijd in wachtrij plaatsen Afspelen met Music Caster In de wachtrij van Music Caster zetten Volgende afspelen in Music Caster In de wachtrij zetten Slimme wachtrij toevoegen aan volgende te spelen Metagegevens Metagegevens opslaan Metagegevens opgeslagen Opslaan Titel Artiest Album Tracknummer Expliciet Selecteer afbeelding Zoek afbeelding Verwijder afbeelding Afbeelding gevonden Geen afbeelding gevonden Zoeken naar afbeelding... URL(s) laden Toegevoegde URL(s) Taal Speel selectie Zet selectie in de wachtrij voeg selectie to aan volgende te spelen Systeem Audio Vertraging: seconden track zoeken Overschakelen naar lokaal apparaat Trackformaat: Kan geen audio ophalen voor $URL Sluit app op GUI sluiten Op batterij resolutie Aangesloten resolutie Kan resolutie niet instellen kopieer URI metagegevens bewerken experimentele functies Afspeellijst opgeslagen Installeer update Gedownload $VER. Opnieuw starten... ================================================ FILE: src/languages/pt-br.txt ================================================ # Idioma: Português - Brazil # Créditos: Cleiton Salvagni # Qualquer linha que começar com # é ignorada # Se uma linha contém $X, não traduza o $X, por favor use-o no contexto # Exemplo: "Update $VER is available" se tornará dinamicamente "Atualização v4.75.0 está disponível" # Tente manter a mesma capitalização ou falta de, como no arquivo original em inglês adicionar pasta Adicionar Music Caster ao menu de contexto da pasta Adicionar arquivos Adicionar URL Ocorreu um erro, reiniciando agora Ocorreu um Erro Interno no Servidor Seleção de Áudio Atualização automática Por Cancelar Cancelar Timer Limpar a fila Controles Não foi possível conectar ao dispositivo de transmissão Não foi possível encontrar um dispositivo de saída para gravação Não foi possível reproduzir $URL Não foi possível preencher a fila porque a varredura da biblioteca está desativada Não foi possível definir o timer Presença no Discord Baixando atualização $VER Digite minutos ou HH:MM Digite o Horário Digite o URL ERRO Sair Menu de contexto da pasta A pasta não contém arquivos de áudio Pastas Geral Hibernar quando o timer acabar Hibernar o Computador INFO Entrada Inválida (insira minutos ou HH:MM) URL inválida. URLs devem começar com http:// ou https:// Arquivo de áudio inválido selecionado Arquivo de áudio inválido $FILE Manter modo mini no topo Iniciar modo mini Controles de música no lado esquerdo Biblioteca Indexação da biblioteca incompleta, apenas os arquivos escaneados foram adicionados Ouvindo Áudio do Sistema Dispositivo local localizar selecionado Conexão perdida com $DEVICE, alternando para dispositivo local Modo mini no topo Mais mover para baixo Mover para próximo Mover conteúdo da faixa para a esquerda mover para cima Music Caster está rodando na bandeja. mudo Novo próxima faixa Sem espaço no dispositivo para atualização automática Sem espaço no dispositivo para salvar configurações Nenhum Timer Definido Não conectado a um dispositivo de transmissão Não logado em deezer.com Nada Tocando Notificações Apenas Parar Reprodução Abrir Pausar Fila Persistente Reproduzir Reproduzir Tudo Reproduzir Arquivos Reproduzir Arquivos a Seguir Reproduzir Imediatamente Reproduzir Próximo Reproduzir URL Reproduzir URL a Seguir Tocando Nome da Playlist Playlists Aba de Playlists Preencher fila na inicialização Preencher fila com pastas na inicialização faixa anterior Fila enfileirar tudo Enfileirar Arquivos Enfileirar URL Atualizar Dispositivos Lembrar última pasta remover pasta selecionada remover Repetir Repetir Tudo Repetir Desativado Repetir Uma Opções de Repetição Reescanear Biblioteca Reescaneando biblioteca Continuar Inverter comportamento de próximo Próximo invertido Executar na inicialização Salvar fila entre sessões Salvar na playlist Salvar posições das janelas Escanear pastas rolar com a roda do mouse Buscar música... Selecionar Dispositivo Selecionar Pasta Selecionar Pastas Selecionar Arquivos Selecionar Arquivo Selecionar Arquivos de Áudio Envie-me um email defina DEBUG = true em `settings.json` para habilitar esta página Definir Timer Definir Configurações Mostrar capa do álbum Mostrar capa do álbum na GUI Mostrar índice na fila Mostrar número da faixa Mostrar número da faixa na fila aleatório Desligar quando o timer acabar Desligar o Computador Suspender quando o timer acabar Suspender o Computador Parar Enviar Obrigado por instalar Music Caster. Timer Timer cancelado Timer definido para $TIME Digite artista/faixas UI ativar som Atualização $VER disponível URL Usar cover.* para capa do álbum Usar cover.* para arte em vez da capa do arquivo imagem cover.* substitui capa do arquivo GUI Vertical Ver Links de Fonte dos Arquivos de Áudio Abrir GUI Web nova playlist exportar playlist excluir playlist reproduzir playlist enfileirar playlist Título Desconhecido Artista Desconhecido Álbum Desconhecido Sempre enfileirar biblioteca Reproduzir com Music Caster Enfileirar no Music Caster Reproduzir próximo no Music Caster Enfileirando Fila inteligente adicionar ao próximo Metadados Salvando metadados Metadados salvos Salvar Título Artista Álbum Número da Faixa Explícito Selecionar arte Buscar arte Remover arte Arte encontrada Nenhuma arte encontrada Buscando por arte... Carregando URL(s) URL(s) Adicionada(s) idioma reproduzir selecionado enfileirar selecionado adicionar selecionado ao próximo Atraso do Áudio do Sistema: segundos localizar faixa Alternando para dispositivo local Formato da Faixa: Não foi possível buscar áudio para $URL Sair do aplicativo ao fechar a GUI Resolução ao usar bateria Resolução ao usar energia Não foi possível definir resolução copiar URIs editar metadados Recursos experimentais Playlist salva Instalar Atualização Baixado $VER. Reiniciando... ================================================ FILE: src/languages/ru.txt ================================================ # Language: Russian # Credits: Kostiantyn Astakhov # Any line starting with a # is ignored # If a line contains $X do not translate the $X, please use it in context # e.g. "Update $VER is available" will dynamically become "Update v4.75.0 is available" # Try to keep the same capitalization or lack of as in the English file добавить папку Добавить Music Caster в контексное меню папки Добавить файлы Добавить ссылку Произошла ошибка, приложение перезагружается Произошла внутренняя ошибка сервера Выбор аудио Автоматическое обновление Автор Отменить Отменить таймер Очистить очередь Управление Не удалось подключиться к потоковому устройству Не удалось найти устройство вывода для записи Не удалось воспроизвести $URL Не удалось заполнить очередь, поскольку сканирование медиатеки отключено Не удалось установить таймер Обновить статус в Discord Загрузка обновления $VER Введите минуты или ГГ:ХХ Введите время Введите ссылку ОШИБКА Выход Контекстное меню папки Папка не содержит аудиофайлов Папки Общие Перевести компьютер в режим гибернации, когда таймер закончится Перевести компьютер в режим гибернации ИНФОРМАЦИЯ Неверный ввод (введите минуты или ЧЧ:ММ) Ссылка недействительна. Ссылки должны начинаться с http:// или https:// Выбран неверный аудиофайл Неверный аудиофайл $FILE Оставлять мини-режим вверху Запустить мини-режим Управление музыкой слева Медиатека Индексация медиатеки не завершена, добавлены только просканированные файлы Слушаю Системное аудио Локальное устройство показать выбранный файл Утрачено соединение с $DEVICE, перехожу на локальное устройство Мини-режим вверху Больше переместить вниз Перейти к следующему Переместить содержимое песни влево переместить вверх Music Caster продолжает работать в области уведомлений. выключить звук Новый следующая песня На устройстве недостаточно места для автоматического обновления На устройстве недостаточно места для сохранения настроек Не установлен ни один таймер Не подключен к потоковому устройству Не вошли в систему на deezer.com Ничего не воспроизводится Уведомления Только отключить воспроизведение Открыть Пауза Сохраненная очередь Воспроизвести Воспроизвести все Воспроизвести файлы Воспроизвести файлы следующими Воспроизвести немедленно Воспроизвести следующим Воспроизвести ссылку Воспроизвести ссылку следующим Воспроизведение Название плейлиста Плейлисты Вкладка плейлистов Заполнить очередь при запуске Заполнить очередь из папок при запуске предыдущая песня Очередь добавить все в очередь Очередь файлов Очередь ссылок Обновить устройства Запомнить последнюю папку удалить выбранную папку Удалить Повторение Повторить все Выключить повторение Повторить один раз Параметры повторения Пересканировать медиатеку Пересканирование медиатеке Продолжить Изменить следующий режим воспроизведения Играть следующие в обратном порядке Запускать при старте системы Сохранить очередь между сессиями Добавить в плейлист Сохранить положение окон Сканировать папки Прокрутка мыши Поиск музыки... Выбрать устройство Выбрать папку Выбрать папки Выбрать файлы Выбрать файл Выбрать аудиофайлы Отправить мне электронное письмо установите DEBUG = true в settings.json для включения этой страницы Установить таймер Установить Настройки Показывать обложку альбома Показывать обложку альбома в пользовательском интерфейсе Показывать индекс в очереди Показывать номер песни Показывать номер песни в очереди перемешать Выключить компьютер, когда таймер закончится Выключить компьютер Перевести компьютер в спящий режим, когда таймер закончится Перевести компьютер в спящий режим Остановить Установить Спасибо за установку Music Caster. Таймер Таймер отменен Таймер установлен на $TIME Введите исполнителя/песни Пользовательский интерфейс включить звук Доступное обновление до $VER Ссылки Использовать cover.* в качестве обложки альбома Использовать cover.* в качестве обложки альбома вместо встроенной в файл cover.* изображение замещает встроенную в файл обложку Вертикальный пользовательский интерфейс Просмотреть ссылку на источник аудиофайлов Открыть веб-пользовательский интерфейс создать новый плейлист экспортировать плейлист удалить плейлист воспроизвести плейлист добавить плейлист в очередь Неизвестное название Неизвестный исполнитель Неизвестный альбом Всегда добавлять медиатеку в очередь Воспроизвести с помощью Music Caster Добавить в очередь в Music Caster Воспроизвести следующим в Music Caster Очередь Умная очередь добавить в следующие Метаданные Сохранение метаданных Метаданные сохранены Сохранить Название Исполнитель Альбом Номер песни Точно Выбрать обложку Искать обложку Удалить обложку Найден обложку Обложка не найдена Поиск обложки... Загрузка ссылки(ок) Добавлена ссылка язык воспроизвести избранное добавить избранное в очередь добавить избранное в следующие Задержка системного аудио: секунды найти песню Переход на локальное устройство Формат названия песни: Не удалось получить аудио с $URL Выход из программы при закрытии пользовательского интерфейса Разрешение при работе от батареи Разрешение при работе от питания Не удалось установить разрешение скопировать путь редактировать метаданные Экспериментальные функции Плейлист сохранен Обновлять Загружено $VER. Перезапуск... ================================================ FILE: src/languages/sk.txt ================================================ # Language: Slovak # Credits: PalVac # Any line starting with a # is ignored # If a line contains $X do not translate the $X, please use it in context # e.g. "Update $VER is available" will dynamically become "Update v4.75.0 is available" # Try to keep the same capitalization or lack of as in the English file prida prieinok Prida Music Caster do kontextovej ponuky prieinka Prida sbory Prida URL Vyskytla sa chyba, aplikcia sa retartuje Vyskytla sa vntorn chyba servera Vber zvuku Automatick aktualizcia Autor Zrui Zrui asova Vymaza frontu Ovldanie Nepodarilo sa pripoji k streamovaciemu zariadeniu Nepodarilo sa njs vstupn zariadenie na nahrvanie Nepodarilo sa prehra $URL Nepodarilo sa naplni frontu, pretoe skenovanie kninice mdi je zakzan Nepodarilo sa nastavi asova Aktualizova stav v Discord Sahuje sa aktualizcia $VER Zadajte minty alebo HH:MM Zadajte as Zadajte URL CHYBA Ukoni Kontextov menu prieinka Prieinok neobsahuje zvukov sbory Prieinky Veobecn Prepn pota do reimu hiberncie po uplynut asovaa Prepn pota do reimu hiberncie INFORMCIA Neplatn vstup (zadajte minty alebo HH:MM) Neplatn URL. URL musia zana na http:// alebo https:// Vybrali ste neplatn zvukov sbor Neplatn zvukov sbor $FILE Ponecha mini reim navrchu Spusti mini reim Ovldanie hudby vavo Kninica Indexovanie kninice je nepln, boli pridan iba naskenovan sbory Povanie Systmov zvuk Miestne zariadenie njs vybran Stratilo sa spojenie s $DEVICE, prepn na loklne zariadenie Mini reim navrchu Viac posun nadol Prejs na alie Presun obsah skladby doava posun hore Music Caster je spusten v tray. stlmi Nov alia skladba Vo vaom zariaden nie je dostatok miesta na automatick aktualizcie Vo vaom zariaden nie je dostatok miesta na uloenie nastaven Nebol nastaven iadny asova Nie je pripojen k streamovaciemu zariadeniu Nie ste prihlsen/- na deezer.com Ni sa neprehrva Oznmenia Iba zastavi prehrvanie Otvori Pozastavi Uloen front Prehra Prehra vetky Prehra sbory Prehra sbory nasledovne Prehra okamite Prehra aliu Prehra URL Prehra aliu URL Prehrvanie Nzov playlistu Playlisty Karta playlistov Naplni front pri spusten Naplni frontu prieinkov pri spusten predchdzajca skladba Fronta zaradi vetky Sbory frontu Fronta prepojen Obnovi zariadenia Zapamta si posledn prieinok odstrni vybran prieinok odstrni Opakova Opakova vetky Vypn opakovanie Opakova jednu Monosti opakovania Preskenova kninicu Preskenovanie kninice Pokraova Zmena nasledujceho reimu prehrvania Prehra nasledovn v opanom porad Spusti pri tarte Uloi front medzi relciami Uloi do playlistu Uloi polohu okien Skenova prieinky Posvanie myou Hada hudbu... Vybra zariadenie Vybra prieinok Vybra prieinky Vybra sbory Vybra sbor Vybra zvukov sbory Poli mi email nastavte DEBUG = true v `settings.json` pre aktivciu tejto strnky Nastavi asova Nastavi Nastavenia Zobrazi obal albumu Zobrazi obalu albumu v grafickom rozhran Zobrazi index vo fronte Zobrazi slo skladby Zobrazi sla skladieb vo fronte nhodn vber Vypn po uplynut asovaa Vypn pota Uspa po vypran asovaa Uspa pota Zastavi Odosla akujeme za intalciu Music Caster. asova asova zruen asova nastaven na $TIME Zadajte interpreta/skladby UI zapn zvuk Dostupn aktualizcia na $VER URL Poui cover.* ako obal albumu Poui cover.* ako obal albumu namiesto vloenia do sboru cover.* obrzok nahrad obrzok obalu vloen v sbore Vertiklne pouvatesk rozhranie Zobrazi odkazy na zdroje zvukovch sborov Otvori webov grafick rozhranie nov playlist exportova playlist vymaza playlist prehra playlist fronta playlistu Neznmy nzov Neznmy umelec Neznmy album Vdy zaraova kninicu do frontu Prehra v Music Caster Poradie v Music Caster Prehra alie v Music Caster Zaraovanie do fronty Inteligentn fronta prida do alej Metadta Ukladaj sa metadta Metadta uloen Uloi Nzov Umelec Album slo skladby Explicitn Vybra obal Hada obal Odstrni obal Obal njden Obal nenjden Had sa obal... Natava URL Pridan URL jazyk prehra vybran vybran fronta prida vybran na alie Oneskorenie zvuku systmu: seknd njs skladbu Prepnanie na loklne zariadenie Formt nzvu piesne: Nepodarilo sa nata zvuk pre $URL Ukoni program pri zatvoren pouvateskho rozhrania Rozlenie pri napjan z batrie Rozlenie pri napjan zo zdroja Nepodarilo sa nastavi rozlenie koprova cestu upravi metadta Experimentlne funkcie Playlist uloen Naintalova aktualizciu Stiahnut $VER. Relaunching... ================================================ FILE: src/languages/uk.txt ================================================ # Language: Ukrainian # Credits: Kostiantyn Astakhov # Any line starting with a # is ignored # If a line contains $X do not translate the $X, please use it in context # e.g. "Update $VER is available" will dynamically become "Update v4.75.0 is available" # Try to keep the same capitalization or lack of as in the English file додати каталог Додати Music Caster до контекстного меню каталогу Додати файли Додати посилання Сталася помилка, додаток перезаватажується Сталася внутрішня помилка сервера Вибір аудіо Автоматичне оновлення Виконавець Скасувати Скасувати таймер Очистити чергу Управління Не вдалося підключитися до потокового пристрою Не вдалося знайти пристрій виведення для запису Не вдалося відтворити $URL Не вдалося заповнити чергу, оскільки сканування медіатеки вимкнено Не вдалося встановити таймер Оновити статус в Discord Завантаження оновлення $VER Введіть хвилини або ГГ:ХХ Введіть час Введіть посилання ПОМИЛКА Вихід Контекстне меню каталогу Каталог не містить аудіофайлів Каталоги Загальні Перевести комп'ютер в режим гібернації, коли таймер закінчиться Перевести комп'ютер в режим гібернації ІНФОРМАЦІЯ Невірний ввід (введіть хвилини або ГГ:ХХ) Посилання недійсне. Посилання повинні починатися з http:// або https:// Вибрано невірний аудіофайл Невірний аудіофайл $FILE Залишати міні-режим вгорі Запустити міні-режим Керування музикою зліва Медіатека Індексація медіатеки незавершена, додані лише проскановані файли Слухаю Системне аудіо Локальний пристрій показати обраний файл Втрачено з'єднання з $DEVICE, переходжу на локальний пристрій Міні-режим вгорі Більше перемістити вниз Перейти до наступного Перемістити вміст пісні вліво перемістити вгору Music Caster продовжує працювати в області сповіщень. вимкнути звук Новий наступна пісня На пристрої недостатньо місця для автоматичного оновлення На пристрої недостатньо місця для збереження налаштувань Не встановлено жодного таймера Не підключено до потокового пристрою Не ввійшли в систему на deezer.com Нічого не відтворюється Повідомлення Лише вимкнути відтворювання Відкрити Пауза Збереженна черга Відтворити Відтворити все Відтворити файли Відтворити файли наступними Відтворити негайно Відтворити наступним Відтворити посилання Відтворити посилання наступним Відтворення Назва плейлисту Плейлисти Вкладка плейлистів Заповнити чергу при запуску Заповнити чергу з папок при запуску попередня пісня Черга додати все до черги Черга файлів Черга посилань Оновити пристрої Запам'ятати останній каталог видалити обраний каталог видалити Повторення Повторити все Вимкнути повторення Повторити один раз Параметри повторення Пересканувати медіатеку Пересканування медіатеке Продовжити Змінити наступний режим відтворення Грати наступні в зворотньому порядку Запускати при старті системи Зберігати чергу між сесіями Додати до плейлисту Зберегти положення вікон Сканувати каталоги Прокручування миші Пошук музики... Вибрати пристрій Вибрати каталог Вибрати каталоги Вибрати файли Вибрати файл Вибрати аудіофайли Надіслати мені електронного листа встановіть DEBUG = true в settings.json, щоб увімкнути цю сторінку Встановити таймер Встановити Налаштування Показувати обкладинку альбому Показувати обкладинку альбому в інтерфейсі користувача Показувати індекс у черзі Показувати номер пісні Показувати номер пісні в черзі перемішати Вимкнути комп'ютер, коли таймер закінчиться Вимкнути комп'ютер Перевести комп'ютер в режим сну, коли таймер закінчиться Перевести комп'ютер в режим сну Зупинити Встановити Дякуємо за встановлення Music Caster. Таймер Таймер скасовано Таймер встановлено на $TIME Введіть виконавця/пісні Користувацький інтерфейс увімкнути звук Доступне оновлення до $VER Посилання Використовувати cover.* як обкладинку альбома Використовувати cover.* як обкладинку альбома замість вбудованної в файл cover.* зображення заміщає вбудованну в файл обкладику Вертикальний інтерфейс користувача Переглянути посилання на джерело аудіофайлів Відкрити веб-інтерфейс користувача створити новий плейлист експортувати плейлист видалити плейлист відтворити плейлист додати плейлист до черги Невідома назва Невідомий виконавець Невідомий альбом Завжди додавати медіатеку до черги Відтворити за допомогою Music Caster Додати до черги в Music Caster Відтворити наступним в Music Caster Черга Розумна черга додати до наступних Метадані Збереження метаданих Метадані збережено Зберегти Назва Виконавець Альбом Номер пісні Винятково Вибрати обкладинку Шукати обкладинку Видалити обкладинку Знайдено обкладинку Обкладинка не знайдена Пошук обкладинки... Завантаження посилання(ь) Додано посилання мова відтворити вибране додати вибране до черги додати вибране до наступних Затримка системного аудіо: секунди знайти пісню Переходжу на локальний пристрій Формат назви пісні: Не вдалося отримати аудіо з $URL Вихід з програми при закритті інтерфейс користувача Роздільна здатність при роботі від батареї Роздільна здатність при роботі від живлення Не вдалось встановити роздільну здатність скопіювати шлях редагувати метадані Експериментальні функції Список відтворення збережено Оновлення Завантажено $VER. Перезапуск... ================================================ FILE: src/meta.py ================================================ VERSION = latest_version = '5.25.2' UPDATE_MESSAGE = """ [NEW] Support "System Audio" in CLI [MSG] Language translators wanted """.strip() IMPORTANT_INFORMATION = """ """.strip() # Constants DEFAULT_THEME = { 'accent': '#00bfff', 'background': '#121212', 'text': '#d7d7d7', 'alternate_background': '#222222', } TOGGLEABLE_SETTINGS = { 'auto_update', 'notifications', 'discord_rpc', 'run_on_startup', 'folder_cover_override', 'folder_context_menu', 'save_window_positions', 'populate_queue_startup', 'lang', 'smart_queue', 'show_track_number', 'persistent_queue', 'flip_main_window', 'vertical_gui', 'use_last_folder', 'show_album_art', 'reversed_play_next', 'scan_folders', 'show_queue_index', 'queue_library', 'show_queue_length', 'show_queue_time', 'gui_exits_app', 'experimental_features', } PID_FILENAME = 'music_caster.pid' LOCK_FILENAME = 'music_caster.lock' UNINSTALLER = 'unins000.exe' WAIT_TIMEOUT = 5 STREAM_CHUNK = 1024 EMAIL = 'elijahllopezz@gmail.com' CONTACT_INFO = f'Elijah Lopez <{EMAIL}>' SUBMIT_EVENTS = {'\r', 'special 16777220', 'special 16777221', 'timer_submit'} AUDIO_EXTS = ('mp3', 'mp4', 'mpeg', 'm4a', 'flac', 'aac', 'ogg', 'opus', 'wma', 'wav', 'aiff') IMG_FILE_TYPES = ( ('Image', '*.gif *.pdf *.png *jpg *jpeg *.tiff *.webp *.' + ' *.'.join(AUDIO_EXTS)), ) AUDIO_FILE_TYPES = (('Audio File', '*.' + ' *.'.join(AUDIO_EXTS) + ' *.m3u *.m3u8'),) VIDEO_FILE_TYPES = (('Media Container File', '*.' + ' *.'.join(('mp2t', 'mp3', 'mp4', 'ogg', 'wav', 'webm')))) # re-define AUDIO_EXTS AUDIO_EXTS = {f'.{ext}' for ext in AUDIO_EXTS} AUDIO_EXTS.add('.m3u') AUDIO_HANDLER_EXTS = ('mp3', 'flac', 'm4a', 'aac', 'ogg', 'opus', 'aiff', 'wma', 'wav', 'mpeg', 'm3u', 'm3u8') FONT_NORMAL = 'Segoe UI', 11 FONT_SMALL = 'Segoe UI', 10 FONT_LINK = 'Segoe UI', 11, 'underline' FONT_TITLE = 'Segoe UI', 14 FONT_MED = 'Segoe UI', 12 FONT_TAB = 'Meiryo UI', 10 LINK_COLOR = '#3ea6ff' COVER_MINI = (127, 127) COVER_NORMAL = (255, 255) PL_COMBO_W = 37 USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64; rv:12.0) Gecko/20100101 Firefox/591' SUN_VALLEY_TCL = 'theme/sun-valley.tcl' TKDND_ENABLED = False USING_TAURI_FRONTEND = False BUNDLE_IDENTIFIER = 'ca.elijahlopez.music-caster' class State: """ attributes in State are modified by music_caster.py """ lang = '' track_format = '&title - &artist' PORT = 2001 # experimental setting using_tcl_theme = False theme_sourced = False settings = {} update_available = False installing_update = True ================================================ FILE: src/modules/db.py ================================================ import sqlite3 from pathlib import Path import appdirs from meta import BUNDLE_IDENTIFIER user_data_dir = Path(appdirs.user_data_dir(roaming=True)) if not user_data_dir.exists(): print('warning: roaming app dir does not exist!') user_data_dir = Path.home() class DatabaseConnection: OLD_DATABASE_FILE = Path('music_caster.db').absolute() DEFAULT_DATABASE_FILE = (Path(user_data_dir) / BUNDLE_IDENTIFIER / 'music_caster.db').absolute() DATABASE_FILE = OLD_DATABASE_FILE @staticmethod def create_connection(): conn = sqlite3.connect(DatabaseConnection.DATABASE_FILE) conn.row_factory = sqlite3.Row return conn def __init__(self, db_override=None): if db_override is not None: self.DATABASE_FILE = db_override def __enter__(self): self.conn = self.create_connection() return self.conn def __exit__(self, exc_type, exc_val, exc_tb): self.conn.close() SCHEMA_2 = """ DROP TABLE IF EXISTS concert_events; DROP TABLE IF EXISTS url_metadata; CREATE TABLE IF NOT EXISTS url_metadata ( src TEXT PRIMARY KEY NOT NULL, title TEXT, artist TEXT, album TEXT, length REAL, url TEXT, audio_url TEXT, ext TEXT, album_cover_url TEXT, expiry REAL, id TEXT, type TEXT, playlist_url TEXT, live BOOLEAN DEFAULT 0 NOT NULL CHECK (live IN (0, 1)), timestamps TEXT ); """ SCHEMA_1 = """ CREATE TABLE IF NOT EXISTS file_metadata ( file_path TEXT PRIMARY KEY NOT NULL, title TEXT, artist TEXT, album TEXT, length INTEGER UNSIGNED, explicit BOOLEAN DEFAULT 0 NOT NULL CHECK (explicit IN (0, 1)), track_number INTEGER UNSIGNED DEFAULT 1 NOT NULL, sort_key TEXT DEFAULT file_path NOT NULL, time_modified REAL ); CREATE TABLE IF NOT EXISTS url_metadata ( src TEXT PRIMARY KEY NOT NULL, title TEXT, artist TEXT, album TEXT, length REAL, url TEXT, audio_url TEXT, ext TEXT, art TEXT, expiry REAL, id TEXT, pl_src TEXT, live BOOLEAN DEFAULT 0 NOT NULL CHECK (live IN (0, 1)) ); """ MIGRATIONS = [SCHEMA_1, SCHEMA_2] def init_db(): RESET_DB = False with DatabaseConnection() as connection: current_version = connection.execute('PRAGMA user_version').fetchone()[0] if RESET_DB: connection.executescript( 'DROP TABLE IF EXISTS file_metadata;DROP TABLE IF EXISTS url_metadata;DROP TABLE IF EXISTS concert_events;' ) connection.executescript('PRAGMA user_version = 0;') current_version = 0 for i, schema_migration in enumerate(MIGRATIONS): version = i + 1 if current_version < version: connection.executescript(schema_migration) connection.execute(f'PRAGMA user_version = {version};') connection.commit() ================================================ FILE: src/modules/error_reporting.py ================================================ # TODO: move the following # app:report_album_art_buffer_error # app:handle_exception # utils:log_translation_error ================================================ FILE: src/modules/iph1papi.py ================================================ # https://gist.github.com/NyaMisty/6c69c8f5681859b3b9ceb87737fabef7 import ctypes from ctypes import Structure, POINTER, c_char, c_void_p, c_ulong from ctypes.wintypes import DWORD, UINT, BYTE, BOOL, ULONG, WCHAR, WORD, USHORT, BOOLEAN from winerror import NO_ERROR, ERROR_INSUFFICIENT_BUFFER from comtypes import GUID ULONGLONG = ctypes.c_ulonglong ULONG64 = ctypes.c_uint64 UCHAR = ctypes.c_ubyte ########################################################################### #region GetIfTable2 class NET_LUID(Structure): _fields_ = [("Value", ULONGLONG)] NET_IFINDEX = ULONG IFTYPE = ULONG TUNNEL_TYPE = ctypes.c_int NDIS_MEDIUM = ctypes.c_int NDIS_PHYSICAL_MEDIUM = ctypes.c_int NET_IF_ACCESS_TYPE = ctypes.c_int NET_IF_DIRECTION_TYPE = ctypes.c_int IF_OPER_STATUS = ctypes.c_int NET_IF_ADMIN_STATUS = ctypes.c_int NET_IF_MEDIA_CONNECT_STATE = ctypes.c_int NET_IF_NETWORK_GUID = GUID NET_IF_CONNECTION_TYPE = ctypes.c_int IF_MAX_STRING_SIZE = 256 IF_MAX_PHYS_ADDRESS_LENGTH = 32 class _MIB_IF_ROW2(Structure): pass _MIB_IF_ROW2._fields_ = [ ("InterfaceLuid", NET_LUID), ("InterfaceIndex", NET_IFINDEX), ("InterfaceGuid", GUID), ("Alias", WCHAR * (IF_MAX_STRING_SIZE + 1)), ("Description", WCHAR * (IF_MAX_STRING_SIZE + 1)), ("PhysicalAddressLength", ULONG), ("PhysicalAddress", UCHAR * IF_MAX_PHYS_ADDRESS_LENGTH), ("PermanentPhysicalAddress", UCHAR * IF_MAX_PHYS_ADDRESS_LENGTH), ("Mtu", ULONG), ("Type", IFTYPE), ("TunnelType", TUNNEL_TYPE), ("MediaType", NDIS_MEDIUM), ("PhysicalMediumType", NDIS_PHYSICAL_MEDIUM), ("AccessType", NET_IF_ACCESS_TYPE), ("DirectionType", NET_IF_DIRECTION_TYPE), ("InterfaceAndOperStatusFlags", BYTE), ("OperStatus", IF_OPER_STATUS), ("AdminStatus", NET_IF_ADMIN_STATUS), ("MediaConnectState", NET_IF_MEDIA_CONNECT_STATE), ("NetworkGuid", NET_IF_NETWORK_GUID), ("ConnectionType", NET_IF_CONNECTION_TYPE), ("TransmitLinkSpeed", ULONG64), ("ReceiveLinkSpeed", ULONG64), ("InOctets", ULONG64), ("InUcastPkts", ULONG64), ("InNUcastPkts", ULONG64), ("InDiscards", ULONG64), ("InErrors", ULONG64), ("InUnknownProtos", ULONG64), ("InUcastOctets", ULONG64), ("InMulticastOctets", ULONG64), ("InBroadcastOctets", ULONG64), ("OutOctets", ULONG64), ("OutUcastPkts", ULONG64), ("OutNUcastPkts", ULONG64), ("OutDiscards", ULONG64), ("OutErrors", ULONG64), ("OutUcastOctets", ULONG64), ("OutMulticastOctets", ULONG64), ("OutBroadcastOctets", ULONG64), ("OutQLen", ULONG64), ] MIB_IF_ROW2 = _MIB_IF_ROW2 PMIB_IF_ROW2 = POINTER(_MIB_IF_ROW2) class _MIB_IF_TABLE2(Structure): pass _MIB_IF_TABLE2._fields_ = [ ("NumEntries", ULONG), ("Table", MIB_IF_ROW2 * 1) ] MIB_IF_TABLE2 = _MIB_IF_TABLE2 PMIB_IF_TABLE2 = POINTER(_MIB_IF_TABLE2) def get_if_table2(): pIfTable = PMIB_IF_TABLE2() if not ctypes.windll.iphlpapi.GetIfTable2(ctypes.byref(pIfTable)) == NO_ERROR: logging.error('Failed calling GetAdaptersInfo') IfTableMib = pIfTable.contents Table = ctypes.cast(ctypes.pointer(IfTableMib.Table), POINTER(MIB_IF_ROW2 * IfTableMib.NumEntries)).contents for i in range(IfTableMib.NumEntries): tempstruc = MIB_IF_ROW2() ctypes.pointer(tempstruc).contents = Table[i] yield tempstruc ctypes.windll.iphlpapi.FreeMibTable(pIfTable) #endregion ############################################################################### #region GetIpInterfaceTable ScopeLevelCount = 16 ADDRESS_FAMILY = USHORT NL_ROUTER_DISCOVERY_BEHAVIOR = ctypes.c_int NL_LINK_LOCAL_ADDRESS_BEHAVIOR = ctypes.c_int NL_INTERFACE_OFFLOAD_ROD = BYTE class MIB_IPINTERFACE_ROW(Structure): pass MIB_IPINTERFACE_ROW._fields_ = [ ("Family", ADDRESS_FAMILY), ("InterfaceLuid", NET_LUID), ("InterfaceIndex", NET_IFINDEX), ("MaxReassemblySize", ULONG), ("InterfaceIdentifier", ULONG64), ("MinRouterAdvertisementInterval", ULONG), ("MaxRouterAdvertisementInterval", ULONG), ("AdvertisingEnabled", BOOLEAN), ("ForwardingEnabled", BOOLEAN), ("WeakHostSend", BOOLEAN), ("WeakHostReceive", BOOLEAN), ("UseAutomaticMetric", BOOLEAN), ("UseNeighborUnreachabilityDetection", BOOLEAN), ("ManagedAddressConfigurationSupported", BOOLEAN), ("OtherStatefulConfigurationSupported", BOOLEAN), ("AdvertiseDefaultRoute", BOOLEAN), ("RouterDiscoveryBehavior", NL_ROUTER_DISCOVERY_BEHAVIOR), ("DadTransmits", ULONG), ("BaseReachableTime", ULONG), ("RetransmitTime", ULONG), ("PathMtuDiscoveryTimeout", ULONG), ("LinkLocalAddressBehavior", NL_LINK_LOCAL_ADDRESS_BEHAVIOR), ("LinkLocalAddressTimeout", ULONG), ("ZoneIndices", ULONG * ScopeLevelCount), ("SitePrefixLength", ULONG), ("Metric", ULONG), ("NlMtu", ULONG), ("Connected", BOOLEAN), ("SupportsWakeUpPatterns", BOOLEAN), ("SupportsNeighborDiscovery", BOOLEAN), ("SupportsRouterDiscovery", BOOLEAN), ("ReachableTime", ULONG), ("TransmitOffload", NL_INTERFACE_OFFLOAD_ROD), ("ReceiveOffload", NL_INTERFACE_OFFLOAD_ROD), ("DisableDefaultRoutes", BOOLEAN) ] class _MIB_IPINTERFACE_TABLE(Structure): pass _MIB_IPINTERFACE_TABLE._fields_ = [ ("NumEntries", ULONG), ("Table", MIB_IPINTERFACE_ROW * 1), ] MIB_IPINTERFACE_TABLE = _MIB_IPINTERFACE_TABLE PMIB_IPINTERFACE_TABLE = POINTER(MIB_IPINTERFACE_TABLE) def get_ip_interface_table(): pIfTable = PMIB_IPINTERFACE_TABLE() if not ctypes.windll.iphlpapi.GetIpInterfaceTable(socket.AF_INET, ctypes.byref(pIfTable)) == NO_ERROR: logging.error('Failed calling GetIpInterfaceTable') IfTableMib = pIfTable.contents Table = ctypes.cast(ctypes.pointer(IfTableMib.Table), POINTER(MIB_IPINTERFACE_ROW * IfTableMib.NumEntries)).contents for i in range(IfTableMib.NumEntries): tempstruc = MIB_IPINTERFACE_ROW() ctypes.pointer(tempstruc)[0] = Table[i] yield tempstruc ctypes.windll.iphlpapi.FreeMibTable(pIfTable) #endregion ############################################################################### #region GetAdaptersInfo MAX_ADAPTER_NAME_LENGTH = 256 MAX_ADAPTER_DESCRIPTION_LENGTH = 128 MAX_ADAPTER_LENGTH = 8 MIB_IF_TYPE_ETHERNET = 6 MIB_IF_TYPE_LOOPBACK = 28 IF_TYPE_IEEE80211 = 71 class IP_ADDRESS_STRING(Structure): _fields_ = [ ("String", c_char * 16), ] class IP_MASK_STRING(Structure): _fields_ = [ ("String", c_char * 16), ] class IP_ADDR_STRING(Structure): pass IP_ADDR_STRING._fields_ = [ ("Next", POINTER(IP_ADDR_STRING)), ("IpAddress", IP_ADDRESS_STRING), ("IpMask", IP_MASK_STRING), ("Context", DWORD), ] class IP_ADAPTER_INFO(Structure): pass IP_ADAPTER_INFO._fields_ = [ ("Next", POINTER(IP_ADAPTER_INFO)), ("ComboIndex", DWORD), ("AdapterName", c_char * (MAX_ADAPTER_NAME_LENGTH + 4)), ("Description", c_char * (MAX_ADAPTER_DESCRIPTION_LENGTH + 4)), ("AddressLength", UINT), ("Address", BYTE * MAX_ADAPTER_LENGTH), ("Index", DWORD), ("Type", UINT), ("DhcpEnabled", UINT), ("CurrentIpAddress", c_void_p), # Not used ("IpAddressList", IP_ADDR_STRING), ("GatewayList", IP_ADDR_STRING), ("DhcpServer", IP_ADDR_STRING), ("HaveWins", BOOL), ("PrimaryWinsServer", IP_ADDR_STRING), ("SecondaryWinsServer", IP_ADDR_STRING), ("LeaseObtained", c_ulong), ("LeaseExpires", c_ulong), ] ########################################################################### # The GetAdaptersInfo function retrieves adapter information for the local computer. # # On Windows XP and later: Use the GetAdaptersAddresses function instead of GetAdaptersInfo. # # DWORD GetAdaptersInfo( # _Out_ PIP_ADAPTER_INFO pAdapterInfo, # _Inout_ PULONG pOutBufLen # ); def get_adapters_info(): OutBufLen = DWORD(0) ctypes.windll.iphlpapi.GetAdaptersInfo(None, ctypes.byref(OutBufLen)) AdapterInfo = ctypes.create_string_buffer(OutBufLen.value) pAdapterInfo = ctypes.cast(AdapterInfo, POINTER(IP_ADAPTER_INFO)) if not ctypes.windll.iphlpapi.GetAdaptersInfo(ctypes.byref(AdapterInfo), ctypes.byref(OutBufLen)) == NO_ERROR: logging.error('Failed calling GetAdaptersInfo') return while pAdapterInfo: yield pAdapterInfo.contents pAdapterInfo = pAdapterInfo.contents.Next #endregion ########################################################################### ================================================ FILE: src/modules/playing_status.py ================================================ import time class PlayingStatus: __slots__ = ( 'NOT_PLAYING', 'PLAYING', 'PAUSED', 'BUSY', 'state', 'timer', 'track_position', 'track_start', 'track_end', 'track_length', 'device_is_local', ) def __init__(self): self.NOT_PLAYING = 0 self.PLAYING = 1 self.PAUSED = 2 self.BUSY = {self.PLAYING, self.PAUSED} self.state = self.NOT_PLAYING # @property def busy(self): return self.state in self.BUSY # @property def stopped(self): return self.state == self.NOT_PLAYING # @property def playing(self): return self.state == self.PLAYING # @property def paused(self): return self.state == self.PAUSED def stop(self): self.state = self.NOT_PLAYING def play(self, device_is_local: bool = True): self.state = self.PLAYING def play_uri(self, position, track_length, device_is_local: bool): self.track_position = position self.track_length = track_length self.device_is_local = device_is_local self.track_start = (time.monotonic() if device_is_local else time.time()) - position if self.track_length is not None: self.track_end = self.track_start + self.track_length def pause(self): self.state = self.PAUSED def play_system_audio(self): self.track_length = None self.track_position = 0 self.track_start = time.monotonic() def __repr__(self): return ['NOT PLAYING', 'PLAYING', 'PAUSED'][self.state] def __eq__(self, other): if isinstance(other, int): return self.state == other if not isinstance(other, PlayingStatus): return str(other) == str(self) return other.state == self.state ================================================ FILE: src/modules/resolution_switcher.py ================================================ import ctypes import multiprocessing as mp import platform from contextlib import suppress from functools import lru_cache import pystray from PIL import Image from pystray import MenuItem as item # for cross platform https://stackoverflow.com/a/20996948/7732434? CHANGE_DPI_SCALE = True MENU_SHOW_HEIGHT = False if platform.system() == 'Windows': from ctypes import wintypes import pywintypes import win32api import win32con class SYSTEM_POWER_STATUS(ctypes.Structure): _fields_ = [ ('ACLineStatus', ctypes.c_ubyte), ('BatteryFlag', ctypes.c_ubyte), ('BatteryLifePercent', ctypes.c_ubyte), ('SystemStatusFlag', ctypes.c_ubyte), ('BatteryLifeTime', wintypes.DWORD), ('BatteryFullLifeTime', wintypes.DWORD), ] SYSTEM_POWER_STATUS_P = ctypes.POINTER(SYSTEM_POWER_STATUS) GetSystemPowerStatus = ctypes.windll.kernel32.GetSystemPowerStatus GetSystemPowerStatus.argtypes = [SYSTEM_POWER_STATUS_P] GetSystemPowerStatus.restype = wintypes.BOOL powerStatus = SYSTEM_POWER_STATUS() def is_plugged_in(throw_error=True): """ Returns True if laptop or PC is plugged in throws RuntimeError """ if platform.system() == 'Windows': if not GetSystemPowerStatus(ctypes.pointer(powerStatus)): if throw_error: raise RuntimeError('could not get power status') return False return powerStatus.ACLineStatus == 1 # TODO: Linux implementation return True def get_aspect_ratio(width, height): return round(width / height, 2) def get_current_res(w=None, h=None): with suppress(Exception): user32 = ctypes.windll.user32 user32.SetProcessDPIAware() res = (user32.GetSystemMetrics(0), user32.GetSystemMetrics(1)) if w is not None: w.value = res[0] if h is not None: h.value = res[1] return res # TODO: Linux @lru_cache(maxsize=1) def get_initial_res(): w = mp.Value(ctypes.c_int, 0) h = mp.Value(ctypes.c_int, 0) # use setProcessDPIAware in only child process p = mp.Process(target=get_current_res, args=[w, h]) p.start() p.join() return w.value, h.value @lru_cache(maxsize=1) def get_initial_dpi_scale(): if platform.system() == 'Windows': transformed_res = (win32api.GetSystemMetrics(0), win32api.GetSystemMetrics(1)) raw_res = get_initial_res() return raw_res[0] / transformed_res[0] # 125% is 1.25 # TODO: Linux return 1 @lru_cache(maxsize=1) def get_all_refresh_rates(): i = 0 refresh_rates = set() with suppress(Exception): if platform.system() == 'Windows': while True: ds = win32api.EnumDisplaySettings(None, i) refresh_rates.add(ds.DisplayFrequency) i += 1 return refresh_rates @lru_cache(maxsize=1) def get_all_resolutions(): i = 0 resolutions = [] seen = set() max_width = 0 max_height = 0 with suppress(Exception): if platform.system() == 'Windows': while True: ds = win32api.EnumDisplaySettings(None, i) res = (ds.PelsWidth, ds.PelsHeight) if res not in seen: seen.add(res) if ds.PelsWidth > max_width: max_width = ds.PelsWidth if ds.PelsHeight > max_height: max_height = ds.PelsHeight resolutions.append((ds.PelsWidth, ds.PelsHeight)) i += 1 try: aspect_ratio = get_aspect_ratio(max_width, max_height) except ZeroDivisionError: # no resolutions found return {} # return resolutions with same aspect ratio as max resolution lst = sorted( filter(lambda res: get_aspect_ratio(*res) == aspect_ratio, resolutions) ) return { fmt_res(*res): {'w': res[0], 'h': res[1], 'dpi_scale': calc_dpi_scale(*res)} for res in lst } dpi_vals = [1.00, 1.25, 1.50, 1.75, 2.00, 2.25, 2.50, 3.00, 3.50, 4.00, 4.50, 5.00] dpi_vals_map = {dpi: i for i, dpi in enumerate(dpi_vals)} def get_recommended_dpi_idx(): dpi = ctypes.c_int(0) if ctypes.windll.user32.SystemParametersInfoA(0x009E, 0, ctypes.byref(dpi), 1) != 0: return -1 * dpi.value raise IndexError def calc_dpi_scale(new_w, _): # assume constant aspect ratios dpi_scale = get_initial_dpi_scale() initial_w = get_initial_res()[0] res_change = 1 - min(new_w, initial_w) / max(new_w, initial_w) dpi_scale += res_change if new_w > initial_w else -res_change return dpi_scale def set_resolution(width: int, height: int, dpi_scale: int, refresh_rate: int = None): if platform.system() == 'Windows': # adapted from Peter Wood: https://stackoverflow.com/a/54262365 devmode = pywintypes.DEVMODEType() devmode.PelsWidth = width devmode.PelsHeight = height devmode.Fields = win32con.DM_PELSWIDTH | win32con.DM_PELSHEIGHT if refresh_rate: devmode.DisplayFrequency = refresh_rate devmode.Fields |= win32con.DM_DISPLAYFREQUENCY win32api.ChangeDisplaySettings(devmode, 0) if CHANGE_DPI_SCALE: # https://stackoverflow.com/a/62916586/7732434 # dpi_scale = calc_dpi_scale(width, height) with suppress(KeyError, IndexError): ref_idx = get_recommended_dpi_idx() # dpi of 1.5 -> 2 - 1 = rel index of 1 # dpi of 1 -> 0 - 1 = rel index of -1 rel_idx = dpi_vals_map[dpi_scale] - ref_idx ctypes.windll.user32.SystemParametersInfoA(0x009F, rel_idx, 0, 1) def set_res_curry(width, height, dpi_scale): # ensure correct values are used when lambda executes return lambda: set_resolution(width, height, dpi_scale) def fmt_res(width, height, show_width=False): # formats either W x H or Wp return f'{width} x {height}' if show_width else f'{height}p' def on_exit(): icon.visible = False icon.stop() if __name__ == '__main__': mp.freeze_support() # save cache get_initial_dpi_scale() image = Image.open('icon.png') menu = [ item(k, set_res_curry(v['w'], v['h'], v['dpi_scale'])) for k, v in get_all_resolutions().items() ] menu.append(item('Exit', on_exit)) icon = pystray.Icon('Resolution Switcher', image, 'Resolution Switcher', menu) icon.run() ================================================ FILE: src/modules/url_metadata.py ================================================ import time from pathlib import Path from typing import Self from audio_player import AudioPlayer import requests import hashlib import appdirs from base64 import b64encode, b64decode from utils import custom_art import ujson as json from utils import get_yt_id from meta import BUNDLE_IDENTIFIER from PIL import Image from io import BytesIO def tbr_audio_key(item): return (item.get('tbr', 0) or 0) * (item.get('vcodec', 'none') == 'none') def tbr_video_key(item): return (item.get('height', 0) or 0), (item.get('tbr', 0) or 0) def ydl_get_metadata(item, duration_helper=True): if 'formats' in item: audio_url = max(item['formats'], key=tbr_audio_key)['url'] try: formats = [_f for _f in item['formats'] if _f.get('acodec') != 'none' and _f.get('vcodec') != 'none'] selected_format = max(formats, key=tbr_video_key) ext, _url = selected_format['ext'], selected_format['url'] except ValueError: # url is audio only ext, _url = item['ext'] if item['ext'] != 'unknown_video' else item['format_id'], audio_url else: ext = item['ext'] _url = audio_url = item['url'] if item.get('is_live', False) and 'duration' not in item and duration_helper: helper_ap = AudioPlayer() helper_ap.play(audio_url, False) item['duration'] = helper_ap.get_length() expiry_time = time.time() + max(1800, item.get('duration', 0)) length = item['duration'] if item.get('duration', 0) else None src_url = item['webpage_url'] split_url = src_url.rsplit('/', 2) backup_artist = split_url[-1] if split_url[-1] != '' else split_url[-2] artist = item.get('artist', item.get('uploader', backup_artist)) album = item.get('album', item.get('playlist')) if album is None: album = item['extractor_key'] album_cover_url = item.get('thumbnail') url_type = item.get('extractor_key', 'unknown') return URLMetadata( src=src_url, url=_url, title=item.get('track', item['title']), artist=artist, album=album, live=item.get('is_live', False), length=length, audio_url=audio_url, ext=ext, url_type=url_type, expiry=expiry_time, id=item['id'], album_cover_url=album_cover_url, ) class URLMetadata: __slots__ = ('title', 'artist', 'album', 'length', 'src', 'url', 'audio_url', 'ext', 'album_cover_url', 'expiry', 'id', 'playlist_url', 'type', 'live', 'timestamps') DB_COLUMNS = {'title', 'artist', 'album', 'length', 'url', 'audio_url', 'ext', 'art', 'expiry', 'id', 'pl_src', 'live', 'type'} MAPPED_FIELDS = {'url': 'src', 'ytid': 'id', 'is_live': 'live', 'art': 'album_cover_url'} FIELDS_TO_IGNORE = set() ALBUM_COVER_CACHE_DIR = Path(appdirs.user_cache_dir()) / BUNDLE_IDENTIFIER / 'Cache' / 'Album Covers' def __init__(self, src: str, url_type: str, title: str, artist: str, album: str, live: bool | None = None, length: float | None = None, url: str | None = None, audio_url: str | None = None, ext: str | None = None, expiry=None, id=None, album_cover_url=None, timestamps: None | list = None, playlist_url=None): self.src = src # for displays self.url = url # for speakers self.audio_url = audio_url self.title = title self.artist = artist self.album = album self.length = length self.ext = ext self.album_cover_url = album_cover_url self.expiry = expiry self.id = id self.playlist_url = playlist_url self.type = url_type.lower() self.live = live self.timestamps = [] if timestamps is None else timestamps def __hash__(self) -> int: return int(self.hash(), 16) def __getitem__(self, key): if key == 'art_data': return self.get_cover_image() if key == 'ytid' and self.type == 'youtube': return self.id attr = self.MAPPED_FIELDS.get(key, key) if attr not in self.__slots__: raise KeyError(key) return getattr(self, attr, None) def __setitem__(self, key, value): if key == 'art_data': self.image_cache_path.parent.mkdir(parents=True, exist_ok=True) with open(self.image_cache_path, 'wb') as f: f.write(b64decode(value)) return attr = self.MAPPED_FIELDS.get(key, key) if attr not in self.__slots__: raise KeyError(key) setattr(self, attr, value) def __delitem__(self, key): attr = self.MAPPED_FIELDS.get(key, key) if attr not in self.__slots__: raise KeyError(key) setattr(self, attr, None) def __iter__(self): return iter(self.__slots__) def __len__(self): return len(self.__slots__) def keys(self): return self.__slots__ def values(self): return (getattr(self, attr, None) for attr in self.__slots__) def items(self): return ((attr, getattr(self, attr, None)) for attr in self.__slots__) def get(self, key, default=None): if key not in self.__slots__: return default return getattr(self, key, default) def save_to_db(self, cur): """Return SQL statement and values for database insertion.""" sql = '''INSERT OR REPLACE INTO url_metadata (src, title, artist, album, length, url, audio_url, ext, album_cover_url, expiry, id, type, playlist_url, live, timestamps) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)''' values = ( self.src, self.title, self.artist, self.album, self.length, self.url, self.audio_url, self.ext, self.album_cover_url, self.expiry, self.id, self.type, self.playlist_url, int(self.live) if isinstance(self.live, bool) else self.live, json.dumps(self.timestamps, escape_forward_slashes=False) ) cur.execute(sql, values) @classmethod def from_db(cls, conn, url) -> Self | None: cur = conn.cursor() ytid = get_yt_id(url) if ytid is not None and not ytid.startswith('PL'): url = f"https://www.youtube.com/watch?v={ytid}" result = cur.execute('SELECT * FROM url_metadata WHERE src = ?', (url,)).fetchone() if not result: return None row = dict(result) return cls( url=row['url'], title=row['title'], artist=row['artist'], album=row['album'], live=bool(row['live']), length=row['length'], audio_url=row['audio_url'], ext=row['ext'], url_type=row['type'], expiry=row['expiry'], id=row['id'], album_cover_url=row.get('art'), playlist_url=row.get('pl_src'), src=url, timestamps=json.loads(row.get('timestamps', '[]')) ) @classmethod def from_dict(cls, data): """Create URLMetadata instance from dictionary.""" metadata = data.copy() if 'live' in metadata: metadata['is_live'] = bool(metadata.pop('live')) return cls(**metadata) def hash(self) -> str: return hashlib.md5(self.src.encode('utf-8')).hexdigest() @property def image_cache_path(self): return self.ALBUM_COVER_CACHE_DIR / f'{self.hash()}.jpg' @property def is_expired(self): if self.expiry is None: return False return self.expiry < time.time() def get_cover_image(self) -> bytes: if not self.image_cache_path.exists(): if not self.album_cover_url: return custom_art('URL') Image.open(BytesIO(requests.get(self.album_cover_url).content)).convert('RGB').save(self.image_cache_path, 'JPEG', quality=95) with open(self.image_cache_path, 'rb') as f: return b64encode(f.read()) # only run once to reduce OS calls URLMetadata.ALBUM_COVER_CACHE_DIR.mkdir(parents=True, exist_ok=True) ================================================ FILE: src/modules/win32_media_controls.py ================================================ # https://learn.microsoft.com/windows/uwp/audio-video-camera/system-media-transport-controls # https://github.com/microsoft/WindowsAppSDK/issues/127 import enum import platform from datetime import timedelta from typing import cast class SystemMediaTransportControlsButton(enum.IntEnum): PLAY = 0 PAUSE = 1 STOP = 2 RECORD = 3 FAST_FORWARD = 4 REWIND = 5 NEXT = 6 PREVIOUS = 7 CHANNEL_UP = 8 CHANNEL_DOWN = 9 class SystemMediaControls: def __init__(self, on_event): if platform.system() != 'Windows': return import winrt.windows.media as media import winrt.windows.media.playback as playback self.media_player = playback.MediaPlayer() self.system_media_transport_controls = cast(media.SystemMediaTransportControls, self.media_player.system_media_transport_controls) assert self.system_media_transport_controls is not None assert self.media_player.command_manager is not None self.media_player.command_manager.is_enabled = False self.system_media_transport_controls.is_play_enabled = True self.system_media_transport_controls.is_pause_enabled = True self.system_media_transport_controls.is_next_enabled = True self.system_media_transport_controls.is_previous_enabled = True self.on_event = on_event self.system_media_transport_controls.add_button_pressed(self._on_btn_press) if platform.system() == 'Windows': import winrt.windows.media as media def _on_btn_press(self, sender, args: media.SystemMediaTransportControlsButtonPressedEventArgs): self.on_event(args.button) def set_source(self, source): if platform.system() == 'Windows': from winrt.windows.foundation import Uri if source.startswith('htt'): self.media_player.set_uri_source(Uri(source)) else: self.media_player.set_uri_source(Uri(f'file://{source}')) def set_playing(self): if platform.system() == 'Windows': import winrt.windows.media as media self.system_media_transport_controls.playback_status = media.MediaPlaybackStatus.PLAYING def set_paused(self): if platform.system() == 'Windows': import winrt.windows.media as media self.system_media_transport_controls.playback_status = media.MediaPlaybackStatus.PAUSED def set_stopped(self): self.set_closed() def set_closed(self): if platform.system() == 'Windows': import winrt.windows.media as media self.system_media_transport_controls.playback_status = media.MediaPlaybackStatus.CLOSED def set_metadata(self, title, artist, album, thumb_uri: str): if platform.system() == 'Windows': import winrt.windows.media as media from winrt.windows.foundation import Uri _updater = cast(media.SystemMediaTransportControlsDisplayUpdater, self.system_media_transport_controls.display_updater) _updater.type = media.MediaPlaybackType.MUSIC _updater.music_properties.artist = artist _updater.music_properties.title = title if album is not None: _updater.music_properties.album_title = album import winrt.windows.storage.streams as streams assert isinstance(thumb_uri, str) assert thumb_uri.count('://', 1) uri = Uri(thumb_uri) _updater.thumbnail = streams.RandomAccessStreamReference.create_from_uri(uri) _updater.update() def update_time(self): # TODO: add arguments if platform.system() == 'windows': import winrt.windows.media as media timeline_properties = media.SystemMediaTransportControlsTimelineProperties() timeline_properties.start_time = timedelta(0) timeline_properties.min_seek_time = timedelta(0) timeline_properties.position = timedelta(0) timeline_properties.max_seek_time = timedelta(0) timeline_properties.end_time = timedelta(100) self.system_media_transport_controls.update_timeline_properties(timeline_properties) ================================================ FILE: src/music_caster.bat ================================================ pythonw "music_caster.py" -m ================================================ FILE: src/music_caster.py ================================================ from gui.views import GuiContext from meta import ( State, SUN_VALLEY_TCL, PID_FILENAME, LOCK_FILENAME, VERSION, UNINSTALLER, DEFAULT_THEME, EMAIL, FONT_NORMAL, WAIT_TIMEOUT, COVER_MINI, COVER_NORMAL, UPDATE_MESSAGE, IMPORTANT_INFORMATION, AUDIO_EXTS, AUDIO_FILE_TYPES, IMG_FILE_TYPES, SUBMIT_EVENTS, TOGGLEABLE_SETTINGS, TKDND_ENABLED, USING_TAURI_FRONTEND, BUNDLE_IDENTIFIER ) import time start_time = time.monotonic() from contextlib import suppress from itertools import islice, chain import io import multiprocessing as mp import os import platform import threading from subprocess import Popen, PIPE, DEVNULL # noqa import re import sys from shutil import copy2 from shared import is_already_running def create_pid_file(port=None): with open(PID_FILENAME, 'w', encoding='utf-8') as f: f.write(str(os.getpid())) if port is not None: f.write(f'\n{port}') def parse_pid_file(): with suppress(FileNotFoundError): with open(PID_FILENAME, encoding='utf-8') as f: pid = int(f.readline().strip()) try: port = int(f.readline().strip()) except ValueError: port = 2001 return pid, port return None, 2001 def ensure_single_instance(debugging=False): file = open(LOCK_FILENAME, 'w+', encoding='utf-8') if USING_TAURI_FRONTEND: return file # no old running instances found, try locking file try: # exclusively locked portalocker.lock(file, portalocker.LockFlags.EXCLUSIVE | portalocker.LockFlags.NON_BLOCKING) create_pid_file() if debugging: print(f'Locked {LOCK_FILENAME} pid = {os.getpid()}') except LockException as e: # another instance is probably running # wait a bit for pid to be written to file time.sleep(0.1) pid, port = parse_pid_file() look_for = 'Music Caster' if IS_FROZEN else Path(sys.executable).name # double check if it's already running # if more than one instance, there's definitely >3 processes threshold = 3 if pid is None else 0 if is_already_running(threshold=threshold, look_for=look_for, pid=pid): if debugging: print('not exiting because we are DEBUGGING') else: try: activate_instance(port=port, default_timeout=5) except Exception as activation_e: app_log.error('Failed to activate existing instance', exc_info=True) handle_exception(activation_e, restart_program=False) sys.exit() else: app_log.error('Instance was not found. Is the lock broken?', exc_info=True) handle_exception(e, restart_program=False) return file if __name__ == '__main__': mp.freeze_support() import argparse from inspect import currentframe from pathlib import Path from urllib.request import pathname2url, urlopen, Request from urllib.error import URLError import appdirs import portalocker from portalocker.exceptions import LockException import ujson as json from sys_tray import system_tray parser = argparse.ArgumentParser(description='Music Caster') parser.add_argument('--debug', '-d', default=False, action='store_true', help='allows more than one music caster instance and no telemetry') parser.add_argument('--start-playing', default=False, action='store_true', help='resume or shuffle play all') parser.add_argument('--queue', '-q', default=False, action='store_true', help='uris are queued rather than immediately played') parser.add_argument('--playnext', '-n', default=False, action='store_true', help='paths are added to "next up"') parser.add_argument('--urlprotocol', default=False, action='store_true', help='launched using uri protocol') parser.add_argument('--update', '-u', default=False, action='store_true', help='allow music caster to update when other CLI args are provided') parser.add_argument('--nupdate', default=False, action='store_true', help='start without auto-update') parser.add_argument('--exit', '-x', default=False, action='store_true', help='exits any existing instance (including self)') parser.add_argument('--minimized', '-m', default=False, action='store_true', help='start minimized to tray') parser.add_argument('--version', '-v', default=False, action='store_true', help='returns the version') parser.add_argument('uris', nargs='*', default=[], help='list of files/dirs/playlists/urls/"System Audio" to play/queue') parser.add_argument('--position', default=0, help='position to start at if resume_playing') parser.add_argument('--shell', default=False, action='store_true', help='if from shell/explorer') parser.add_argument('--device', action='store', help='select device to use (cast UUID or "local")', default=None) parser.add_argument('--db-path', action='store', help='path to sqlite database file', default=None) parser.add_argument('--settings-path', action='store', help='path to settings.json file', default=None) # freeze_support() adds the following parser.add_argument('--multiprocessing-fork', default=False, action='store_true', help=argparse.SUPPRESS) args = parser.parse_args() # if from url protocol, re-parse arguments if args.urlprotocol: new_args = args.uris[0].replace('music-caster://', '', 1).replace('music-caster:', '').replace('music-caster', '') if new_args: new_args = new_args.split(';') args = parser.parse_args(new_args) if args.version: print(VERSION) sys.exit() DEBUG = args.debug print(f'DEBUG: {DEBUG}') IS_FROZEN = getattr(sys, 'frozen', False) working_dir = Path(sys.argv[0]).absolute().parent os.chdir(working_dir) OLD_SETTINGS_FILE = Path('settings.json').absolute() DEFAULT_SETTINGS_FILE = Path(appdirs.user_data_dir(roaming=True)) / BUNDLE_IDENTIFIER / 'settings.json' SETTINGS_FILE = OLD_SETTINGS_FILE if IS_FROZEN: SETTINGS_FILE = Path(args.settings_path).absolute() if args.settings_path and USING_TAURI_FRONTEND else DEFAULT_SETTINGS_FILE if OLD_SETTINGS_FILE.exists(): SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True) try: os.rename(OLD_SETTINGS_FILE, SETTINGS_FILE) except OSError as e: if e.winerror == 17: copy2(OLD_SETTINGS_FILE, SETTINGS_FILE) os.remove(OLD_SETTINGS_FILE) else: raise e PHANTOMJS_DIR = Path('phantomjs') # c:\Users\maste\AppData\Local\Programs\Music Caster\settings.json def json_dumps(d): return json.dumps(d).encode('utf-8') def activate_instance(port=2001, default_timeout=0.5, to_port=2004): # by default activates if running already response, local_ipv6, local_ipv4 = '', 'http://[::1]:', 'http://127.0.0.1:' try: with open(SETTINGS_FILE, encoding='utf-8') as json_file: api_key = json.load(json_file).get('api_key', '') except (FileNotFoundError, ValueError): api_key = '' headers = { 'Content-Type': 'application/json', 'Accept': 'application/json', } data = {'api_key': api_key} while port < to_port and response == '': for localhost in (local_ipv4, local_ipv6): timeout = default_timeout with suppress(URLError): if args.exit: # --exit argument req = Request(f'{localhost}{port}/exit/', data=json_dumps(data)) elif args.uris: # MC was supplied at least one path to a folder/file uri_data = json_dumps({**data, 'uris': args.uris, 'queue': args.queue, 'play_next': args.playnext, 'device': args.device}) req = Request(f'{localhost}{port}/play/', data=uri_data, headers=headers) timeout += 0.5 else: # neither --exit nor paths was supplied req = Request(f'{localhost}{port}/action/activate', data=json_dumps(data)) response = urlopen(req, timeout=timeout).read() if response: return True port += 1 return False lock_file = ensure_single_instance(debugging=DEBUG) daemon_commands, tray_process_queue = mp.Queue(), mp.Queue() if args.exit: sys.exit() import asyncio from base64 import b64encode, b64decode import concurrent.futures from collections import deque from collections.abc import Iterable import ctypes import encodings.idna # noqa # DO NOT REMOVE from functools import cmp_to_key import glob import hashlib from copy import deepcopy from datetime import datetime, timedelta import errno from functools import lru_cache import logging from logging.handlers import RotatingFileHandler from math import log10, floor import pprint from random import shuffle from shutil import copyfileobj, rmtree from queue import Queue import secrets import socket from threading import Thread import tkinter from tkinter import filedialog as fd from tkinter import TclError import traceback import urllib.parse from urllib.parse import urlsplit from uuid import UUID import zipfile from b64_images import PAUSE_BUTTON_IMG, PLAY_BUTTON_IMG, SHUFFLE_OFF, SHUFFLE_ON, VOLUME_IMG, VOLUME_MUTED_IMG, WINDOW_ICON, DEFAULT_ART from audio_player import AudioPlayer from modules.win32_media_controls import SystemMediaTransportControlsButton # SystemMediaControls from mutagen._util import MutagenError from modules.playing_status import PlayingStatus from modules.url_metadata import ydl_get_metadata, URLMetadata from utils import ( get_first_artist, t, SystemAudioRecorder, startfile, custom_art, get_album_art, get_lan_ip, get_metadata, Unknown, get_file_name, parse_m3u, valid_audio_file, valid_color_code, get_mac, Device, natural_key_file, better_shuffle, truncate_title, resize_img, repeat_img_tooltip, DiscordPresence, get_ipv4, ydl_extract_info, parse_qs, urlparse, get_yt_id, get_yt_urls, install_phantomjs, add_to_path, open_in_browser, get_video_timestamps, get_deezer_tracks, get_ipv6, cmd_exists, add_reg_handlers, get_latest_release, rm_old_startup_shortcuts, start_on_login_win32, create_progress_bar_texts, set_metadata, get_spotify_headers, get_cut_text, export_playlist, fix_path, drop_target_register, dnd_bind, InvalidAudioFile, get_audio_length, get_spotify_tracks, ) from modules.resolution_switcher import fmt_res, get_all_resolutions, set_resolution, get_all_refresh_rates, get_initial_res, is_plugged_in, get_initial_dpi_scale get_initial_dpi_scale() from gui import MainWindow, MiniPlayerWindow, focus_window import FreeSimpleGUI as Sg from modules.db import DatabaseConnection, init_db if IS_FROZEN: try: with DatabaseConnection() as conn: pass except Exception: sys.exit(66) DatabaseConnection.DATABASE_FILE = Path(args.db_path).absolute() if args.db_path and USING_TAURI_FRONTEND else DatabaseConnection.DEFAULT_DATABASE_FILE if DatabaseConnection.OLD_DATABASE_FILE.exists(): DatabaseConnection.DATABASE_FILE.parent.mkdir(parents=True, exist_ok=True) if DatabaseConnection.DATABASE_FILE.exists(): print('not moving database because file already exists') else: try: os.rename(DatabaseConnection.OLD_DATABASE_FILE, DatabaseConnection.DATABASE_FILE) except OSError as e: if e.winerror == 17: copy2(DatabaseConnection.OLD_DATABASE_FILE, DatabaseConnection.DATABASE_FILE) os.remove(DatabaseConnection.OLD_DATABASE_FILE) else: raise e try: with DatabaseConnection() as conn: pass except Exception: sys.exit(67) else: try: with DatabaseConnection() as conn: pass except Exception: sys.exit(68) # 0.5 seconds gone to 3rd party imports from flask import Flask, jsonify, render_template, request, redirect, send_file, Response, make_response import waitress from jinja2.exceptions import TemplateNotFound from werkzeug.exceptions import InternalServerError, BadRequest, UnsupportedMediaType from PIL import Image import pychromecast from pychromecast.controllers.media import MediaStatusListener from pychromecast.controllers.receiver import CastStatusListener from pychromecast.error import PyChromecastError, UnsupportedNamespace, NotConnected, RequestTimeout, RequestFailed from pychromecast.config import APP_MEDIA_RECEIVER from pychromecast import Chromecast from pychromecast.models import CastInfo import pyperclip import requests from tempfile import NamedTemporaryFile try: import fcntl except ImportError: pass import scrapetube try: from TkinterDnD2 import DND_FILES, DND_ALL except ImportError: # what about tkinterdnd2 import tkinterDnD import zeroconf TIME_TO_IMPORT = time.monotonic() - start_time try: sun_valley_tcl_path = f'{sys._MEIPASS}/{SUN_VALLEY_TCL}' except AttributeError: sun_valley_tcl_path = SUN_VALLEY_TCL sun_valley_tcl_path = os.path.abspath(sun_valley_tcl_path) # LOGS log_format = logging.Formatter('%(asctime)s %(levelname)s (%(lineno)d) %(funcName)s(): %(message)s') # max 1 MB log file log_handler = RotatingFileHandler('music_caster.log', maxBytes=1000000, backupCount=1, encoding='UTF-8') log_handler.setFormatter(log_format) app_log = logging.getLogger('music_caster') app_log.propagate = False # disable console output app_log.setLevel(logging.INFO) app_log.addHandler(log_handler) # LOGGING logging.getLogger('pychromecast.socket_client').addHandler(log_handler) logging.getLogger('pychromecast').addHandler(log_handler) logging.getLogger('pychromecast').setLevel(logging.INFO) logging.getLogger('werkzeug').setLevel(logging.ERROR) logging.getLogger('werkzeug').addHandler(log_handler) app_log.debug(f'Time to import is {TIME_TO_IMPORT:.2f} seconds') gui_window = Sg.Window('', metadata={}) gui_window.close() WELCOME_MSG = t('Thanks for installing Music Caster.') + '\n' + t('Music Caster is running in the tray.') uris_to_scan = Queue() PRESSED_KEYS = set() settings_file_lock = threading.Lock() last_play_command = settings_last_modified = 0 update_last_checked = time.time() # check every hour cast: Chromecast = None # type: ignore all_tracks, all_tracks_sorted = {}, [] url_metadata: dict(URLMetadata) = {} tray_playlists = [t('Playlists Tab')] CHECK_MARK = '✓' music_folders, device_names = [], [(f'{CHECK_MARK} ' + t('Local device'), 'device:0')] music_queue, done_queue, next_queue = deque(), deque(), deque() # usage: background_thread sleep(1) if seek_queue, seek_queue.pop(), seek_queue.clear(), call set_pos seek_queue = [] playing_url = deezer_opened = attribute_error_reported = False recent_api_plays = {'play': 0, 'queue': 0, 'play_next': 0} # seconds but using time() playing_status = PlayingStatus() track_position = timer = track_end = track_length = track_start = 0 def get_downloads_folder(): if platform.system() == 'Windows': from knownpaths import sh_get_known_folder_path, FOLDERID possible_path = sh_get_known_folder_path(FOLDERID.Downloads) if possible_path is not None: return Path(possible_path) return Path.home() / 'Downloads' def get_installer_path(): downloads_dir = get_downloads_folder() if downloads_dir.exists(): return str(downloads_dir / 'music_caster_installer.exe') return 'music_caster_installer.exe' def get_default_music_folder(): if platform.system() == 'Windows': from knownpaths import sh_get_known_folder_path, FOLDERID return sh_get_known_folder_path(FOLDERID.Music) return str(Path.home() / 'Music') print('Installer path:', get_installer_path()) default_auto_update = os.path.exists(UNINSTALLER) or os.path.exists('Updater.exe') settings: dict = { # default settings 'device': None, 'window_locations': {}, 'smart_queue': False, 'skips': {}, 'theme': DEFAULT_THEME.copy(), 'auto_update': default_auto_update, 'run_on_startup': os.path.exists(UNINSTALLER), 'notifications': True, 'shuffle': False, 'repeat': None, 'discord_rpc': False, 'save_window_positions': True, 'mini_on_top': True, 'populate_queue_startup': False, 'persistent_queue': False, 'volume': 20, 'muted': False, 'volume_delta': 5, 'scrubbing_delta': 5, 'flip_main_window': False, 'show_track_number': False, 'folder_cover_override': True, 'show_album_art': True, 'folder_context_menu': True, 'vertical_gui': False, 'mini_mode': False, 'gui_exits_app': False, 'update_check_hours': 1, 'timer_shut_down': False, 'timer_hibernate': False, 'timer_sleep': False, 'show_queue_index': True, 'queue_library': False, 'lang': '', 'sys_audio_delay': 0, 'use_last_folder': False, 'upload_pw': '', 'last_folder': get_default_music_folder(), 'scan_folders': True, 'track_format': '&artist - &title', 'reversed_play_next': False, 'update_message': '', 'important_message': '', 'music_folders': [get_default_music_folder()], 'playlists': {}, 'queues': {'done': [], 'music': [], 'next': []}, 'position': 0, 'plugged_in_res': None, 'on_battery_res': None, 'experimental_features': False, 'api_key': secrets.token_urlsafe(16), 'concert_location': 'New York'} default_settings = deepcopy(settings) indexing_tracks_thread = save_queue_thread = Thread() sar = SystemAudioRecorder() app = Flask(__name__) app.jinja_env.lstrip_blocks = app.jinja_env.trim_blocks = True os.environ['WERKZEUG_RUN_MAIN'] = 'true' os.environ['FLASK_SKIP_DOTENV'] = '1' # if time.time() > SYNC_WITH_CHROMECAST good to sync from chromecast SYNC_WITH_CHROMECAST = 0 CAST_LOCK = threading.Lock() OLD_CAST_VOLUME = 0 OLD_CAST_POS = 0 LAST_PLAYED = time.time() init_db() def get_line_number(): cf = currentframe() return cf.f_back.f_lineno def tray_notify(message, title='Music Caster', context=''): """ A wrapper for tray_process_queue.put({ notify: {message: msg, title: title} }) """ if message == 'update_available': message = t('Update $VER is available').replace('$VER', f'v{context}') tray_process_queue.put({'notify': {'message': message, 'title': title}}) def close_tray(): tray_process_queue.put({'close': None}) tray_process.join() def save_settings(): global settings_last_modified # avoid corrupting settings file if the system crashes mid-write by using temporary file + sync + atomic rename with settings_file_lock: try: tmp_file = NamedTemporaryFile(mode='w', encoding='utf-8', prefix=SETTINGS_FILE.name, dir=SETTINGS_FILE.parent, suffix='.tmp', delete=False) json.dump(settings, tmp_file, indent=2, escape_forward_slashes=False) # send to kernel buffer tmp_file.flush() # inform OS to write to disk to avoid a situation where the file is replaced but not written to if platform.system() == 'Darwin': fcntl.fcntl(tmp_file.fileno(), fcntl.F_FULLFSYNC) else: os.fsync(tmp_file.fileno()) tmp_file.close() # this atomic operation ensures that a settings.file will exist if the system crashes before/after the system call os.replace(tmp_file.name, SETTINGS_FILE) settings_last_modified = os.path.getmtime(SETTINGS_FILE) except Exception as e: handle_exception(e) tray_notify(t('ERROR') + f': {e}') except OSError as e: if e.errno == errno.ENOSPC: tray_notify(t('ERROR') + ': ' + t('No space left on device to save settings')) else: tray_notify(t('ERROR') + f': {e}') def is_debug(): return settings.get('DEBUG', DEBUG) def refresh_tray(refresh_devices=False): if refresh_devices: device_names.clear() # account for case where user is connected to device not detectable if cast is not None and cast.uuid not in cast_browser.devices: cast_browser.devices[cast.uuid] = cast.cast_info for device in get_devices(): device_names.append(device.as_tray_item(settings['device'])) daemon_commands.put('__UPDATE_GUI__') tray_folders = [t('Select Folder')] for i, folder in enumerate(music_folders): folder = Path(folder) folder = ('../' + '/'.join(folder.parts[-2:])) if len(folder.parts) > 2 else folder.as_posix() tray_folders.append((folder, f'PF:{i}')) repeat_menu = [t('Repeat All') + f' {CHECK_MARK}' * (settings['repeat'] is False), t('Repeat One') + f' {CHECK_MARK}' * (settings['repeat'] is True), t('Repeat Off') + f' {CHECK_MARK}' * (settings['repeat'] is None)] tray_menu_default = [t('Settings'), t('Rescan Library'), t('Refresh Devices'), [t('Select Device'), *device_names], [t('Timer'), t('Set Timer'), t('Cancel Timer')], [t('Play'), t('System Audio'), [t('URL'), t('Play URL'), t('Queue URL'), t('Play URL Next')], [t('Folders'), *tray_folders], [t('Playlists'), *tray_playlists], [t('Select Files'), t('Play Files'), t('Queue Files'), t('Play Files Next')], t('Play All')], (t('Exit'), '__EXIT__')] tray_menu_playing = [t('Settings'), t('Rescan Library'), t('Refresh Devices'), [t('Select Device'), *device_names], [t('Timer'), t('Set Timer'), t('Cancel Timer')], [t('Controls'), t('locate track', 1), [t('Repeat Options'), *repeat_menu], t('Stop'), t('previous track', 1), t('next track', 1), t('Pause')], [t('Play'), t('System Audio'), [t('URL'), t('Play URL'), t('Queue URL'), t('Play URL Next')], [t('Folders'), *tray_folders], [t('Playlists'), *tray_playlists], [t('Select Files'), t('Play Files'), t('Queue Files'), t('Play Files Next')], t('Play All')], (t('Exit'), '__EXIT__')] tray_menu_paused = [t('Settings'), t('Rescan Library'), t('Refresh Devices'), [t('Select Device'), *device_names], [t('Timer'), t('Set Timer'), t('Cancel Timer')], [t('Controls'), t('locate track', 1), [t('Repeat Options'), *repeat_menu], t('Stop'), t('previous track', 1), t('next track', 1), t('Resume')], [t('Play'), t('System Audio'), [t('URL'), t('Play URL'), t('Queue URL'), t('Play URL Next')], [t('Folders'), *tray_folders], [t('Playlists'), *tray_playlists], [t('Select Files'), t('Play Files'), t('Queue Files'), t('Play Files Next')], t('Play All')], (t('Exit'), '__EXIT__')] if platform.system() == 'Linux': # more so for applicationindicator for menu in tray_menu_default, tray_menu_paused, tray_menu_playing: menu.append((t('Open'), '__ACTIVATED__')) # refresh playlists tray_playlists.clear() tray_playlists.append(t('Playlists Tab')) tray_playlists.extend([(pl.replace('&', '&&&'), f'PL:{pl}') for pl in settings['playlists']]) # tell tray process to update # icon = FILLED_ICON if playing_status.playing() else UNFILLED_ICON icon = {'filled': None} if playing_status.playing() else {'unfilled': None} if playing_status.busy(): menu = tray_menu_playing if playing_status.playing() else tray_menu_paused metadata = get_current_metadata() title, artists = metadata['title'], metadata['artist'] _tooltip = f'{get_first_artist(artists)} - {title}' else: menu, _tooltip = tray_menu_default, 'Music Caster' if is_debug(): _tooltip += ' [DEBUG]' tray_process_queue.put({'menu': menu, 'tooltip': _tooltip, **icon}) def refresh_tray_icon(): icon = {'filled': None} if playing_status.playing() else {'unfilled': None} tray_process_queue.put(icon) def update_settings(settings_key, new_value): """ returns new value and can be called from non-main thread """ if settings[settings_key] != new_value: settings[settings_key] = new_value save_settings() if settings_key == 'repeat': daemon_commands.put('__UPDATE_GUI__') refresh_tray() elif settings_key == 'shuffle': if not gui_window.is_closed(): daemon_commands.put('__UPDATE_GUI__') shuffle_queue() if new_value else un_shuffle_queue() return new_value def save_queues(): global save_queue_thread def _save_queue(): settings['queues']['done'] = tuple(done_queue) settings['queues']['music'] = tuple(music_queue) settings['queues']['next'] = tuple(next_queue) save_settings() if settings['persistent_queue'] and not save_queue_thread.is_alive() and not State.installing_update: save_queue_thread = Thread(target=_save_queue, name='SaveQueue') save_queue_thread.start() def update_volume(new_vol, _from=''): """ new_vol: float[0, 100] AKA set_volume """ app_log.info(f'set to {new_vol} from {_from}') gui_window.metadata['update_volume_slider'] = True if not isinstance(new_vol, (float, int)): new_vol = update_settings('volume', 20) new_vol = new_vol / 100 with suppress(NameError): audio_player.set_volume(new_vol) if cast is not None: # this was threaded because otherwise it would block for over 0.2 seconds # exceptions: NotConnected, RequestTimeout, RequestFailed set_volume_Thread = Thread(target=cast.set_volume, args=(new_vol,), name='CastSetVolume', daemon=True) set_volume_Thread.start() def cycle_repeat(): """ :return: new repeat value """ # Repeat Off (None) becomes All (False) becomes One (True) becomes Off new_repeat_setting = {None: False, True: None, False: True}[settings['repeat']] return update_settings('repeat', new_repeat_setting) def create_support_email_url(): try: with open('music_caster.log', encoding='utf-8') as f: log_lines = f.read().splitlines()[-10:] # get last 10 lines of the log except FileNotFoundError: log_lines = [] log_lines = '%0D%0A'.join(log_lines) email_body = f'body=%0D%0A%23%20Tail%20of%20Log%0D%0A%0D%0A{log_lines}' mail_to = f'mailto:{EMAIL}?subject=Regarding%20Music%20Caster%20v{VERSION}&{email_body}' return mail_to def handle_exception(e: Exception, restart_program=False) -> bool: current_time = str(datetime.now()) trace_back_msg = traceback.format_exc().replace('\\', '/') exc_type, exc_tb = sys.exc_info()[0], sys.exc_info()[2] playing_uri = 'N/A' if music_queue: if playing_url: playing_uri = music_queue[0] elif sar.alive: playing_uri = 'system audio' elif playing_status.busy(): playing_uri = music_queue[0] try: with open('music_caster.log', encoding='utf-8') as f: log_lines = f.read().splitlines(keepends=False)[-10:] # get last 10 lines of the log except FileNotFoundError: log_lines = [] device = 'local' if cast is None else 'cast' payload = {'VERSION': VERSION, 'FATAL': restart_program, 'EXCEPTION TYPE': exc_type.__name__, 'LINE': exc_tb.tb_lineno, 'TRACEBACK': trace_back_msg, 'LOG': log_lines, 'MQ[0]': playing_uri, 'PLAYING_STATUS': str(playing_status), 'DEVICE': device, 'CWD': os.getcwd(), 'PORTABLE': not os.path.exists(UNINSTALLER), 'MAC': hashlib.md5(get_mac().encode()).hexdigest(), 'OS': platform.platform(), 'TIME': current_time} if IS_FROZEN: with suppress(requests.RequestException): requests.post('https://lenerva.com/telemetry/music-caster/error/', json=payload, timeout=1) try: with open('error.log', 'r', encoding='utf-8') as _f: content = _f.read() except (FileNotFoundError, ValueError): content = '' with open('error.log', 'w', encoding='utf-8') as _f: _f.write(pprint.pformat(payload)) _f.write('\n') _f.write(content) if restart_program: close_tray() with suppress(Exception): stop('error handling') tray_notify(t('An error occurred, restarting now')) # minimized = main_window.was_closed() if IS_FROZEN: startfile('Music Caster') else: raise e # raise exception if running in script rather than executable sys.exit() return False def get_current_art() -> bytes: if sar.alive: return custom_art('SYS') if playing_status.busy() and music_queue: uri = music_queue[0] if uri.startswith('http'): with DatabaseConnection() as conn: maybe_url_metadata = URLMetadata.from_db(conn, uri) if isinstance(maybe_url_metadata, URLMetadata): return maybe_url_metadata.get_cover_image() if isinstance(url_metadata.get(uri), URLMetadata): return url_metadata[uri].get_cover_image() if url_metadata.get(uri, {}).get('art') in ('None', None): return custom_art('URL') if 'art_data' in url_metadata[uri]: return url_metadata[uri]['art_data'] # use 'art_data' else download 'art' link and cache to 'art_data' url_metadata[uri]['art_data'] = b64encode(requests.get(url_metadata[uri]['art']).content) return url_metadata[uri]['art_data'] return get_album_art(uri, settings['folder_cover_override'])[1] return DEFAULT_ART def get_metadata_wrapped(file_path: str) -> dict: # keys: title, artist, album, sort_key try: if file_path.startswith('http'): raise ValueError('expected file not http...') m = get_metadata(file_path) return m except (MutagenError, ValueError): try: return all_tracks[Path(file_path).as_posix()] except KeyError: # i forget the reason why we have the time_modified so high return {'title': Unknown('Title'), 'artist': Unknown('Artist'), 'explicit': False, 'time_modified': os.path.getmtime(file_path), 'album': Unknown('Title'), 'sort_key': get_file_name(file_path), 'track_number': '1'} def get_uri_metadata(uri, read_file=True): """ Uses cache to get metadata """ # raises KeyError uri = uri.replace('\\', '/') if uri.startswith('http'): with DatabaseConnection() as conn: maybe_url_metadata = URLMetadata.from_db(conn, uri) if maybe_url_metadata is not None: return maybe_url_metadata if uri in url_metadata: return url_metadata[uri] return {'title': Unknown('Title'), 'artist': Unknown('Artist'), 'explicit': False, 'album': Unknown('Album'), 'sort_key': uri, 'track_number': '1'} if uri in all_tracks: try: ignore_cache = os.path.getmtime(uri) != all_tracks[uri]['time_modified'] if read_file else False except FileNotFoundError: ignore_cache = False if not ignore_cache: return all_tracks[uri] # uri is probably a file that has not been cached yet if read_file: metadata = get_metadata_wrapped(uri) all_tracks[uri] = metadata return metadata raise KeyError def get_current_metadata() -> dict | URLMetadata: if sar.alive: return url_metadata['SYSTEM_AUDIO'] if music_queue and playing_status.busy(): return get_uri_metadata(music_queue[0]) return {'artist': '', 'title': t('Nothing Playing'), 'album': ''} def get_audio_uris(uris: Iterable, scan_uris=True, ignore_m3u=False, parsed_m3us=None, ignore_dir=False): """ :param uris: A list of URIs (urls, folders, m3u files, files) :param scan_uris: whether to add to uris_to_scan :param ignore_m3u: whether to ignore .m3u(8) files :param parsed_m3us: m3u files that have already been parsed. This is to avoid recursive parsing :param ignore_dir: whether to scan uri if it is a dir :return: generator of valid audio files """ if parsed_m3us is None: parsed_m3us = set() if isinstance(uris, str): uris = (uris,) for uri in uris: if isinstance(uri, Iterable) and not isinstance(uri, str): yield from get_audio_uris(uri, scan_uris, ignore_m3u, parsed_m3us, ignore_dir) elif uri in settings['playlists']: yield from get_audio_uris(settings['playlists'][uri], scan_uris=scan_uris, ignore_m3u=ignore_m3u, parsed_m3us=parsed_m3us) elif os.path.isdir(uri) and not ignore_dir: # if scanning a folder, # ignore playlist files and folders that are named as files as they aren't audio files yield from get_audio_uris(glob.iglob(f'{glob.escape(uri)}/**/*.*', recursive=True), ignore_dir=True, scan_uris=scan_uris, ignore_m3u=True, parsed_m3us=parsed_m3us) elif os.path.isfile(uri): uri = Path(uri).absolute().as_posix() if not ignore_m3u and (uri.endswith('.m3u') or uri.endswith('.m3u8')) and uri not in parsed_m3us: parsed_m3us.add(uri) yield from get_audio_uris(parse_m3u(uri), parsed_m3us=parsed_m3us) elif valid_audio_file(uri): if scan_uris and uri not in all_tracks: uris_to_scan.put(uri) yield uri elif uri.startswith('http'): if scan_uris and uri not in url_metadata: uris_to_scan.put(uri) yield uri def index_all_tracks(update_global=True, ignore_files: set | None = None) -> dict: """ returns the music library dict if update_global is False starts scanning and building the music library/database if update_global is True ignore_files is a list (converted to set) of files to not include in the return value / scan usually used with update_global=False (think about it) """ global indexing_tracks_thread, all_tracks # make sure ignore_files is a set if ignore_files is None: ignore_files = set() def _index_library(): """ Scans folders provided in settings and adds them to a dictionary Does not ignore the files that in ignore_files by design """ global all_tracks, all_tracks_sorted use_temp = len(all_tracks) # use temp if all_tracks is not empty all_tracks_temp = {} dict_to_use = all_tracks_temp if use_temp else all_tracks # scan items in queue and library file_metadata_list = [] urls_to_fetch = [] with DatabaseConnection() as conn: for uri in get_audio_uris((settings['queues'].values(), music_folders), scan_uris=False, ignore_m3u=True): if uri.startswith('http'): if not URLMetadata.from_db(conn, uri): urls_to_fetch.append(uri) else: m = get_metadata_wrapped(uri) dict_to_use[uri] = m file_metadata_list.append((uri, m)) cur = conn.cursor() # save_metadata_batch(file_metadata_list, 'file_metadata', 'file_path') gui_window.metadata['update_listboxes'] = True for url in urls_to_fetch: url_metadata_list = get_url_metadata(url) batch_to_save = [] for m in url_metadata_list: batch_to_save.append((url, m)) if isinstance(m, URLMetadata): m.save_to_db(cur) conn.commit() if use_temp: all_tracks = all_tracks_temp gui_window.metadata['update_listboxes'] = True # TODO # tracks = cur.execute('SELECT * FROM file_metadata ORDER BY sort_key').fetchall() all_tracks_sorted = sorted(all_tracks.items(), key=lambda item: item[1]['sort_key']) # scan items in playlists for _ in get_audio_uris(settings['playlists'].values(), ignore_m3u=True): # the function scans for us pass if not update_global: temp_tracks = all_tracks.copy() for ignore_file in ignore_files: temp_tracks.pop(ignore_file, None) return temp_tracks if indexing_tracks_thread is None: indexing_tracks_thread = Thread(target=_index_library, daemon=True, name='IndexLibrary') indexing_tracks_thread.start() elif not indexing_tracks_thread.is_alive(): # force reindex indexing_tracks_thread = Thread(target=_index_library, daemon=True, name='IndexLibrary') indexing_tracks_thread.start() def download(url, outfile): # throws ConnectionAbortedError r = requests.get(url, stream=True) if outfile.endswith('.zip'): outfile = outfile.replace('.zip', '') z = zipfile.ZipFile(io.BytesIO(r.content)) z.extractall(outfile) else: with open(outfile, 'wb') as _f: copyfileobj(r.raw, _f) def load_settings(first_load=False): # up to 0.4 seconds """ load (and fix if needed) the settings file calls refresh_tray(), index_all_tracks(), save_setting() first_load: if true, start indexing all tracks """ global settings, music_folders, settings_last_modified _save_settings = False with settings_file_lock: try: attempt = 0 while True: try: with open(SETTINGS_FILE, encoding='utf-8') as json_file: loaded_settings = json.load(json_file) break except PermissionError: attempt += 1 if attempt == 10: raise except (FileNotFoundError, ValueError): # if file does not exist _save_settings = True loaded_settings = {} for setting_name, setting_value in tuple(loaded_settings.items()): loaded_settings[setting_name.replace(' ', '_')] = loaded_settings.pop(setting_name) for setting_name, setting_value in settings.items(): does_not_exist = setting_name not in loaded_settings # setting DNE # use default settings if key/value does not exist if does_not_exist and setting_name in default_settings: loaded_settings[setting_name] = setting_value _save_settings = True elif setting_name in {'theme', 'queues'}: # for theme key for k, v in setting_value.items(): if k not in loaded_settings[setting_name]: loaded_settings[setting_name][k] = v _save_settings = True settings = loaded_settings # sort playlists by name settings['playlists'] = {k: settings['playlists'][k] for k in sorted(settings['playlists'].keys())} # if music folders were modified, re-index library if music_folders != settings['music_folders'] or first_load: music_folders = settings['music_folders'] if settings['scan_folders']: index_all_tracks() refresh_tray() theme = settings['theme'] for k, v in theme.copy().items(): # validate settings file color codes if not valid_color_code(v): _save_settings = True theme[k] = DEFAULT_THEME[k] # validate radio settings temp = (settings['timer_shut_down'], settings['timer_hibernate'], settings['timer_sleep']) if temp.count(True) > 1: # Only one of the below can be True if settings['timer_shut_down']: settings['timer_hibernate'] = False settings['timer_sleep'] = False _save_settings = True if settings['persistent_queue'] and settings['populate_queue_startup']: # mutually exclusive settings['populate_queue_startup'] = False _save_settings = True # backwards compatible 'previous_device' -> 'device' if 'previous_device' in settings: settings['device'] = settings.pop('previous_device') State.lang = settings['lang'] State.track_format = settings['track_format'] fg, bg, accent = theme['text'], theme['background'], theme['accent'] GuiContext.update(fg, bg, accent, settings['experimental_features']) Sg.set_options(text_color=fg, element_text_color=fg, input_text_color=fg, button_color=(bg, accent), element_background_color=bg, scrollbar_color=bg, text_element_background_color=bg, background_color=bg, input_elements_background_color=bg, progress_meter_color=accent, titlebar_background_color=bg, titlebar_text_color=fg, # progress_meter_style= border_width=0, slider_border_width=1, progress_meter_border_depth=0, font=FONT_NORMAL) if _save_settings: save_settings() settings_last_modified = os.path.getmtime(SETTINGS_FILE) @app.errorhandler(404) def page_not_found(_): return redirect('/') @app.post('/upload/') def upload_files(): # web GUI if 'files' not in request.files or not request.values.get('password'): return redirect('/#more') if request.values['password'] == settings['upload_pw']: # only save if upload_pw is set uploaded_files = request.files.getlist('files') for file in uploaded_files: if file.filename is not None: file.save(Path.home() / 'Downloads' / file.filename) return redirect('/#more') def get_request_data(): try: return request.json except (BadRequest, UnsupportedMediaType): return request.values @app.route('/action/', methods=['GET', 'POST']) def web_action(command): request_data = get_request_data() # if request_data.get('api_key') != settings['api_key']: # return {'error': 'Unauthorized, api_key=not-provided'}, 401 match command: case 'play': if resume('web'): api_msg = 'resumed playback' else: if music_queue: play() api_msg = 'started playing first track in queue' else: play_all() api_msg = 'shuffled all and started playing' case 'pause': pause() # resume == play api_msg = 'pause called' case 'next': ignore_timestamps = False times_to_skip = 1 if request_data is not None: ignore_timestamps = 'ignore_timestamps' in request_data times_to_skip = int(request_data.get('times', 1)) next_track(times=times_to_skip, forced=True, ignore_timestamps=ignore_timestamps) api_msg = 'next track called' case 'prev': times_to_skip = 1 if request_data is not None: times_to_skip = int(request_data.get('times', 1)) prev_track(times=times_to_skip, forced=True) api_msg = 'prev track called' case 'repeat': cycle_repeat() api_msg = 'cycled repeat to ' + {None: 'off', True: 'one', False: 'all'}[settings['repeat']] case 'shuffle': shuffle_enabled = update_settings('shuffle', not settings['shuffle']) api_msg = f'shuffle set to {shuffle_enabled}' case 'activate': daemon_commands.put('__ACTIVATED__') # tell main thread to show GUI api_msg = 'activated main window' case _: return f'unknown command: {command}' return {'message': api_msg} if ('is_api' in request.args or request.method == 'POST') else redirect('/') @app.route('/', methods=['GET', 'POST']) def web_index(): # web GUI request_data = get_request_data() if request_data is not None: for command in ('play', 'pause', 'next', 'prev', 'repeat', 'shuffle', 'activate'): if command in request_data: return web_action(command) api_key = settings['api_key'] # if request_data.get('api_key') != api_key: # return jsonify({'error': 'Unauthorized, api_key=not-provided'}), 401 metadata = get_current_metadata() art = get_current_art() if isinstance(art, bytes): art = art.decode() art = f'data:image/png;base64,{art}' repeat_option = settings['repeat'] repeat_enabled = 'repeat-enabled' if settings['repeat'] is not None else '' shuffle_enabled = 'shuffle-enabled' if settings['shuffle'] else '' # sort by the formatted title if all_tracks_sorted: sorted_tracks = all_tracks_sorted else: sorted_tracks = sorted( all_tracks.items(), key=lambda item: item[1]['sort_key'] ) list_of_tracks = [{'text': format_uri(filename), 'filename': pathname2url(filename).strip('/')} for filename, _ in sorted_tracks] _queue = create_track_list() device_index = 0 for i, devices in enumerate(device_names): if devices[0].startswith(CHECK_MARK): device_index = i break formatted_devices = [('Local device', '0')] stream_url, stream_time = None, track_position if playing_status.playing() and music_queue: metadata = get_current_metadata() uri = music_queue[0] if os.path.exists(uri): file_path = pathname2url(uri).strip('/') stream_url = f"/file?path={file_path}&api_key={api_key}" else: stream_url = metadata.get('audio_url', metadata.get('url')) for cast_info in sorted(cast_browser.devices.values(), key=cast_info_sorter): formatted_devices.append((cast_info.friendly_name, str(cast_info.uuid))) try: return render_template('index.html', device_name=platform.node(), shuffle=shuffle_enabled, version=VERSION, repeat_enabled=repeat_enabled, playing_status=playing_status, metadata=metadata, settings=settings, list_of_tracks=list_of_tracks, repeat_option=repeat_option, gt=t, queue=_queue, playing_index=len(done_queue), device_index=device_index, art=art, devices=formatted_devices, stream_url=stream_url, stream_time=stream_time) except TemplateNotFound: return redirect('https://github.com/elibroftw/music-caster/releases/latest') @app.route('/status/') @app.route('/state/') def api_state(): _metadata = get_current_metadata() now_playing = {'status': str(playing_status), 'volume': settings['volume'], 'lang': settings['lang'], 'title': str(_metadata['title']), 'artist': str(_metadata['artist']), 'album': str(_metadata['album']), 'gui_open': not gui_window.is_closed(), 'track_position': get_track_position(), 'track_length': track_end - track_start, 'queue_length': len(done_queue) + len(music_queue) + len(next_queue)} return jsonify(now_playing) @app.route('/play/', methods=['GET', 'POST']) def api_play(): global last_play_command merge_plays = time.monotonic() - last_play_command < 0.5 last_play_command = time.monotonic() request_data = get_request_data() if request_data is not None: queue_only = request_data.get('queue', False) if isinstance(queue_only, str): queue_only = queue_only.casefold() == 'true' play_next = request_data.get('play_next', False) if isinstance(play_next, str): play_next = play_next.casefold() == 'true' device_id = request_data.get('device', None) if device_id is not None: change_device(device_id) # reset recent_api_plays if not merge_plays: for opt in ('play', 'queue', 'play_next'): recent_api_plays[opt] = 0 if queue_only: opt = 'queue' elif play_next: opt = 'play_next' else: opt = 'play' merge_plays = recent_api_plays[opt] recent_api_plays[opt] += 1 if 'uris' in request_data: uris = request_data['uris'] if isinstance(request_data, dict) else request_data.getlist('uris') if uris and uris[0].lower().replace(' ', '').replace('_', '') == 'systemaudio': play_system_audio() else: play_uris(uris, queue_uris=queue_only, play_next=play_next, merge_tracks=merge_plays) if not queue_only and not play_next and settings['queue_library'] and merge_plays == 0: queue_all() elif 'uri' in request_data: if request_data['uri'].lower().replace(' ', '').replace('_', '') == 'systemaudio': play_system_audio() else: play_uris([request_data['uri']], queue_uris=queue_only, play_next=play_next, merge_tracks=merge_plays) if settings['queue_library']: queue_all() else: recent_api_plays['play'] += 1 return redirect('/') if request.method == 'GET' else api_state() @app.errorhandler(InternalServerError) def handle_500(_e): original = getattr(_e, 'original_exception', None) if original is None: # direct 500 error, such as abort(500) handle_exception(_e) return t('An Internal Server Error occurred') + f': {_e}' # wrapped unhandled error handle_exception(original) return t('An Internal Server Error occurred') + f': {original}' @app.route('/debug/') def api_get_debug_info(): threads = [(thread.name, thread.is_alive()) for thread in threading.enumerate()] if is_debug(): return jsonify({'pressed_keys': list(PRESSED_KEYS), 'last_traceback': sys.exc_info(), 'threads': threads, 'mac': get_mac()}) return t('set DEBUG = true in `settings.json` to enable this page') @app.route('/running/', methods=['GET', 'POST', 'OPTIONS']) def api_running(): response = make_response('true') http_origins = ('https://elijahlopez.herokuapp.com', 'http://elijahlopez.herokuapp.com', 'https://elijahlopez.ca', 'http://elijahlopez.ca') if request.environ.get('HTTP_ORIGIN') in http_origins: response.headers.add('Access-Control-Allow-Origin', request.environ['HTTP_ORIGIN']) return response @app.route('/exit/', methods=['GET', 'POST']) def api_exit(): daemon_commands.put('__EXIT__') return api_state() @app.route('/change-setting/', methods=['POST']) def api_change_setting(): with suppress(KeyError, TypeError): json_data = request.get_json(force=True, silent=True) if json_data is None: return 'false' setting_key = json_data['setting_name'] if setting_key in settings or setting_key == 'timer_stop': val = json_data['value'] update_settings(setting_key, val) timer_settings = {'timer_hibernate', 'timer_sleep', 'timer_shut_down', 'timer_stop'} if val and setting_key in timer_settings: for timer_setting in timer_settings.difference({setting_key, 'timer_stop'}): update_settings(timer_setting, False) if setting_key == 'volume': update_volume(0 if settings['muted'] else val, 'api') return 'true' return 'false' @app.route('/refresh-devices/') def api_refresh_devices(): refresh_tray(True) return 'true' @app.route('/rescan-library/') def api_rescan_library(): index_all_tracks() return 'true' @app.get('/devices/') def api_get_devices(): request_data = get_request_data() if request_data is None or ('friendly' not in request_data): devices: dict | list = {'0': 'Local device'} for _uuid, cast_info in cast_browser.devices.items(): devices[str(_uuid)] = cast_info.friendly_name else: devices: dict | list = ['Local device::0'] for cast_info in sorted(cast_browser.devices.values(), key=cast_info_sorter): devices.append(f'{cast_info.friendly_name}::{cast_info.uuid}') return jsonify(devices) @app.post('/change-device/<_uuid>') def api_change_device(_uuid): return str(change_device(_uuid)) def cancel_timer(): global timer timer = 0 if settings['notifications']: tray_notify(t('Timer cancelled')) def set_timer(val): # TIMER PARSER global timer if val == 'cancel': cancel_timer() return 'timer cancelled' elif val.isdigit(): seconds = abs(float(val)) * 60 elif val.count(':') == 1: # parse out any PM and AM's timer_value = val.strip().upper().replace(' ', '').replace('PM', '').replace('AM', '') to_stop = datetime.strptime(timer_value + time.strftime(',%Y,%m,%d,%p'), '%H:%M,%Y,%m,%d,%p') current_time = datetime.now() current_time = current_time.replace(second=0) seconds_delta = (to_stop - current_time).total_seconds() seconds_delta = seconds_delta % 43200 # add 12 hours seconds = seconds_delta else: raise ValueError('Timer input is invalid') timer = time.time() + seconds timer_set_to = datetime.now().replace(second=0) + timedelta(seconds=seconds) if platform.system() == 'Windows': timer_set_to = timer_set_to.strftime('%#I:%M %p') else: timer_set_to = timer_set_to.strftime('%-I:%M %p') # Linux return timer_set_to @app.route('/timer/', methods=['GET', 'POST']) def api_set_timer(): global timer if request.method == 'POST': val = request.data.decode() try: return set_timer(val.casefold()) except ValueError as e: return str(e) else: # GET request return str(timer) @lru_cache(maxsize=12) def get_cover_jpg_data(file_path) -> io.BytesIO: new_img_data = io.BytesIO() mime, img_data = get_album_art(file_path, settings['folder_cover_override']) img_data = io.BytesIO(b64decode(img_data)) if mime.lower().endswith('jpeg'): return img_data Image.open(img_data).convert('RGB').save(new_img_data, format='JPEG') return new_img_data @lru_cache() def report_album_art_buffer_error(file_path: str): msg_1 = f'{Path(file_path).name} has img_data with size 0; returning DEFUALT_ART instead' app_log.info(msg_1) _raw_album_art_mime, _raw_album_art_data = get_album_art(file_path, settings['folder_cover_override']) msg_2 = f'{Path(file_path).name} has album art with mime {_raw_album_art_mime} and data of size {len(_raw_album_art_data)}' app_log.info(msg_2) handle_exception(ValueError('\n'.join((msg_1, msg_2)))) @app.route('/file/') def api_get_file(): if 'path' in request.args: file_path = request.args['path'] if os.path.isfile(file_path) and valid_audio_file(file_path) or file_path == 'DEFAULT_ART': if request.args.get('thumbnail_only', False) or file_path == 'DEFAULT_ART': jpeg_buffer = get_cover_jpg_data(file_path) jpeg_buffer.seek(0) if (len(jpeg_buffer.getvalue()) == 0): report_album_art_buffer_error(file_path) return send_file(io.BytesIO(DEFAULT_ART), download_name='cover.jpeg', mimetype='image/jpeg', as_attachment=True, max_age=360000, conditional=True) return send_file(jpeg_buffer, download_name='cover.jpeg', mimetype='image/jpeg', as_attachment=True, max_age=360000, conditional=True) return send_file(file_path, conditional=True, as_attachment=True, max_age=360000) return '400' @app.route('/dz/') def api_get_dz(): from Cryptodome.Cipher import Blowfish if 'url' in request.args: # TODO: cache content to prevent extra requests url = request.args['url'] metadata = url_metadata[url] file_url = metadata['file_url'] range_header = {'Range': request.headers.get('Range', 'bytes=0-')} r = requests.get(file_url, headers=range_header, stream=True) start_bytes = int(range_header['Range'].split('=', 1)[1].split('-', 1)[0]) blowfish_key = metadata['bf_key'] iv = b'\x00\x01\x02\x03\x04\x05\x06\x07' def generate(): nonlocal start_bytes # if start_bytes is not a multiple of 2048, first yield will be < 2048 to fix the chunks extra_bytes = start_bytes % 2048 if extra_bytes != 0: extra_bytes = 2048 - extra_bytes chunk = next(r.iter_content(extra_bytes)) if start_bytes // 2048 == 0: chunk = Blowfish.new(blowfish_key, Blowfish.MODE_CBC, iv).decrypt(chunk) yield chunk start_bytes += extra_bytes for i, chunk in enumerate(r.iter_content(2048), start_bytes // 2048): if (i % 3) == 0 and len(chunk) == 2048: chunk = Blowfish.new(blowfish_key, Blowfish.MODE_CBC, iv).decrypt(chunk) yield chunk content_type = r.headers['Content-Type'] rv = Response(generate(), 206, mimetype=content_type, content_type=content_type) rv.headers['Content-Range'] = r.headers['Content-Range'] return rv return '400' @app.route('/system-audio/') @app.route('/system-audio/') def api_system_audio(get_thumb=''): """ send system audio to chromecast """ if get_thumb: return send_file(io.BytesIO(b64decode(custom_art('SYS'))), download_name='thumbnail.png', mimetype='image/png', as_attachment=True, max_age=360000, conditional=True) return Response(sar.get_audio_data(settings['sys_audio_delay'])) def cast_try_reconnect(switch_twice=False): global cast_browser, zconf if switch_twice and cast is not None: app_log.info('try changing devices to local and then back to cast') cast_uuid = cast.uuid if not playing_status.playing(): change_device() change_device(cast_uuid) app_log.info('try changing devices to local and then back to cast') app_log.info('stop discovery') cast_browser.stop_discovery() zconf = zeroconf.Zeroconf() cast_browser = pychromecast.discovery.CastBrowser(MyCastListener(), zconf) cast_browser.start_discovery() wait_until = time.monotonic() + WAIT_TIMEOUT while cast is None and time.monotonic() < wait_until: time.sleep(0.2) if cast is None: app_log.error('could not reconnect to cast') return cast is not None @cmp_to_key def cast_info_sorter(ci1: CastInfo, ci2: CastInfo): # sort by groups, then by name, then by UUID if ci1.cast_type == 'group' and ci2.cast_type != 'group': return -1 if ci1.cast_type != 'group' and ci2.cast_type == 'group': return 1 if ci1.friendly_name < ci2.friendly_name: return -1 if ci1.friendly_name > ci2.friendly_name: return 1 if str(ci1.uuid) > str(ci2.uuid): return 1 return -1 def get_devices(): lo_cis = sorted(cast_browser.devices.values(), key=cast_info_sorter) lo_devices = [Device()] lo_devices.extend((Device(cast_info) for cast_info in lo_cis)) return lo_devices class UpdateFailed(Exception): pass class StatusCastListener(CastStatusListener): """Cast status listener""" def __init__(self, _cast): self.cast = _cast self.name = _cast.name def new_cast_status(self, status): pass class MediaCastListener(MediaStatusListener): def __init__(self, _cast): self.cast = _cast self.name = _cast.name def new_media_status(self, status): pass def load_media_failed(self, item, error_code): pass class MyCastListener(pychromecast.discovery.AbstractCastListener): def add_cast(self, uuid, _service: str): """Called when a new cast has been discovered.""" global cast cast_info = cast_browser.devices[uuid] if str(cast_info.uuid) == settings['device']: # if currently connected to local device or another cast, change device if cast is None or cast.uuid != cast_info.uuid: change_device(cast_info.uuid) else: # otherwise, update the cast variable cast = pychromecast.get_chromecast_from_cast_info(cast_info, zconf=zconf) try: if cast.is_idle: cast.wait(30) except Exception as e: app_log.error('could not wait on cast', exc_info=True) handle_exception(e) # cast.register_status_listener(StatusCastListener(cast)) # cast.media_controller.register_status_listener(MediaCastListener(cast)) refresh_tray(True) def remove_cast(self, uuid, _service: str, cast_info): """Called when a cast has been lost (MDNS info expired or host down).""" global cast if cast is not None and cast.uuid == uuid: # lost connection to connected device app_log.info(f'Lost connection to {cast.name} ({uuid}), switching to local device') refresh_tray(True) def update_cast(self, uuid, _service: str): """Called when a cast has been updated (MDNS info renewed or changed).""" global cast # not entirely sure what to do if this function is called # due to recent connection errors, let's experiment # if we should update the cast variable? if cast is not None and cast.uuid == uuid: cast_info = cast_browser.devices[uuid] cast_2 = pychromecast.get_chromecast_from_cast_info(cast_info, zconf=zconf) try: assert cast_2 == cast except AssertionError as e: handle_exception(e) cast = cast_2 refresh_tray(True) def get_device(device_uuid): # UnboundLocalError is possible return pychromecast.get_chromecast_from_cast_info(cast_browser.devices[device_uuid], zconf) def change_device(new_uuid='local', unresponsive_cast=False): """switch_device if new_uuid is invalid, then the local device is selected """ global cast app_log.info(f'change_device({new_uuid})') try: if not isinstance(new_uuid, UUID): new_uuid = UUID(hex=new_uuid) try: if cast.uuid == new_uuid: app_log.info('noop because we are already connected to device wanting to change to') return True app_log.info(f'changing device from {cast.cast_info.friendly_name} ({cast.uuid})') except AttributeError: app_log.info('changing device from local') if new_uuid not in cast_browser.devices: return False new_device = get_device(new_uuid) app_log.info(f'new device name: {new_device.cast_info.friendly_name}') except (ValueError, TypeError): # local device selected (any non uuid string) new_device = None except UnboundLocalError: app_log.error('Could not connect to cast device', exc_info=True) tray_notify(t('ERROR') + ': ' + t('Could not connect to cast device')) return False if cast == new_device: # do not change device if local device is selected again return True # cache information current_pos = 0 if cast is not None and cast.app_id == APP_MEDIA_RECEIVER: if not unresponsive_cast and playing_status.busy(): mc = cast.media_controller with suppress(PyChromecastError, AssertionError): mc.update_status() # Switch device without playback loss current_pos = mc.status.adjusted_current_time if mc.status.player_is_playing or mc.status.player_is_paused: mc.stop() with suppress(PyChromecastError, AssertionError): cast.quit_app(10) elif cast is None and 'audio_player' in globals() and audio_player.is_busy(): current_pos = audio_player.stop() autoplay = playing_status.playing() was_busy = playing_status.busy() playing_status.stop() cast = new_device update_settings('device', None if cast is None else str(cast.uuid)) refresh_tray(True) if was_busy and (music_queue or sar.alive): app_log.info('continuing playback on new device') if sar.alive: play_system_audio(switching_device=True) else: play(position=current_pos, autoplay=autoplay, switching_device=True, show_error=True) else: if cast is not None: with suppress(PyChromecastError): cast.quit_app(30) try: cast.wait(timeout=WAIT_TIMEOUT) except RequestTimeout: tray_notify(t('ERROR') + ': ' + t('Could not connect to cast device')) update_volume(0 if settings['muted'] else settings['volume'], 'change_device') return True def un_shuffle_queue(): """ To be called when shuffle is toggled off sorts files by natural key... splits at current playing Does not affect next_queue Keeps currently playing the same """ global music_queue, done_queue if music_queue: # keep current playing track the same track = music_queue[0] temp_list = list(music_queue) + list(done_queue) temp_list.sort(key=natural_key_file) split_queue_at = temp_list.index(track) done_queue = deque(temp_list[:split_queue_at]) music_queue = deque(temp_list[split_queue_at:]) elif done_queue: # sort and set queue to first item music_queue = deque(sorted(done_queue, key=natural_key_file)) done_queue.clear() gui_window.metadata['update_listboxes'] = True def shuffle_queue(): """ To be called when shuffle is toggled on extends the music_queue with done_queue and then shuffles it Does not affect next_queue Keeps currently playing the same """ global music_queue # keep track the same if in the process of playing something first_index = 1 if playing_status.busy() and music_queue else 0 music_queue.extend(done_queue) done_queue.clear() # shuffle is slow for a deque so use a list temp_list = list(music_queue) better_shuffle(temp_list, first=first_index) music_queue = deque(temp_list) gui_window.metadata['update_listboxes'] = True def format_pl_lb(tracks): """Return (list of formatted tracks, readable playlist time length) for playlist listbox""" formatted_tracks = [] pl_length = 0 for i, track in enumerate(tracks): formatted_tracks.append(f"{i + 1}. {format_uri(track, _for='pl')}") with suppress(KeyError): metadata = get_uri_metadata(track, read_file=False) length = metadata.get('length') if length is not None: pl_length += length friendly_length = '' if pl_length > 3600: hours = pl_length // 3600 friendly_length = f'{hours:.0f}h ' pl_length -= hours * 3600 if pl_length > 60: minutes = pl_length // 60 friendly_length += f'{minutes:.0f}m ' pl_length -= minutes * 60 friendly_length += f'{pl_length:.0f}s' if friendly_length == '0s': friendly_length = '' return formatted_tracks, friendly_length def format_uri(uri: str, use_basename=False, _for=''): try: if use_basename: raise TypeError metadata = get_uri_metadata(uri, read_file=False) title, artist, album = metadata['title'], metadata['artist'], metadata['album'] if title == Unknown('Title'): title = os.path.splitext(os.path.basename(uri))[0] if '-' in title: artist, title = title.split('-', maxsplit=1) artist, title = artist.strip(), title.strip() else: assert not isinstance(title, Unknown) if uri in url_metadata and '-' in title: artist, title = title.split('-', maxsplit=1) artist, title = artist.strip(), title.strip() formatted = settings['track_format'].replace('&artist', str(artist)).replace('&title', title) formatted = formatted.replace('&alb', str(album)) number = metadata.get('track_number', '0').zfill(2) if '&trck' in formatted: formatted = formatted.replace('&trck', str(number)) elif settings['show_track_number'] and number != '': formatted = f'[{number}] {formatted}' if not _for: return formatted # at > ?, we need to cut characters if (cut_out := len(formatted) - {'queue': 70, 'pl': 50}[_for]) > 0: cut_out = (cut_out + 3) // 2 # for 3 dots middle = len(formatted) // 2 ro = middle + cut_out lo = middle - cut_out formatted = formatted[:lo] + '...' + formatted[ro:] return formatted except (TypeError, KeyError): if uri.startswith('http'): return uri return os.path.splitext(os.path.basename(uri))[0] def create_track_list(): """Return usable list for queue listbox """ try: max_digits = int(log10(max(len(music_queue) - 1 + len(next_queue), len(done_queue) * 10))) + 2 except ValueError: max_digits = 0 i = -len(done_queue) tracks = [] # format: Index | Artists - Title try: for items in (done_queue, islice(music_queue, 0, 1), next_queue, islice(music_queue, 1, None)): for uri in items: formatted_track = format_uri(uri, _for='queue') if settings['show_queue_index']: if i < 0: pre = f'\u2012{abs(i)} '.center(max_digits, '\u2000') else: pre = f'{i} '.center(max_digits, '\u2000') formatted_track = f'\u2004{pre}|\u2000{formatted_track}' i += 1 tracks.append(formatted_track) return tracks except RuntimeError: # deque mutated during iteration return create_track_list() def update_gui(): if gui_window.is_closed(): return try: if playing_status.stopped(): gui_window['progress_bar'].update(0, disabled=True) else: value, range_max = (1, 1) if track_length is None else (floor(track_position), track_length) gui_window['progress_bar'].update(value, range=(0, range_max), disabled=track_length is None) metadata = get_current_metadata() title, artist, album = metadata['title'], get_first_artist(metadata['artist']), metadata['album'] if playing_status.busy() and music_queue and not sar.alive: if settings['show_track_number']: with suppress(KeyError): track_number = metadata['track_number'] title = f'{track_number}. {title}' if settings['mini_mode']: title = truncate_title(title) else: default_device = None if cast is None else cast.cast_info gui_window['devices'].update(value=Device(default_device), values=get_devices()) gui_window['album'].update(album) gui_window['title'].update(title) gui_window['artist'].update(artist) image_data = PAUSE_BUTTON_IMG if playing_status.playing() else PLAY_BUTTON_IMG gui_window['pause/resume'].update(image_data=image_data) if settings['show_album_art']: size = COVER_MINI if settings['mini_mode'] else COVER_NORMAL bg = settings['theme']['background'] try: album_art_data = resize_img(get_current_art(), bg, size, default_art=DEFAULT_ART) except OSError as e: handle_exception(e) album_art_data = resize_img(DEFAULT_ART, bg, size) gui_window['artwork'].update(data=album_art_data) repeat_button: Sg.Button = gui_window['repeat'] repeat_img, new_tooltip = repeat_img_tooltip(settings['repeat']) repeat_button.metadata = settings['repeat'] repeat_button.update(image_data=repeat_img) repeat_button.set_tooltip(new_tooltip) shuffle_image_data = SHUFFLE_ON if settings['shuffle'] else SHUFFLE_OFF gui_window['shuffle'].update(image_data=shuffle_image_data) except TclError as e: app_log.info(f'gui_window.is_closed() = {gui_window.is_closed()}') handle_exception(e) def after_play(title, artists: str, album, autoplay, switching_device): app_log.info(f'autoplay={autoplay}, switching_device={switching_device}') # prevent Windows from going to sleep if autoplay: if platform.system() == 'Windows': ctypes.windll.kernel32.SetThreadExecutionState(0x80000000 | 0x00000001) if settings['notifications'] and not switching_device and gui_window.is_closed(): # artists is comma separated string tray_notify(t('Playing') + f': {get_first_artist(artists)} - {title}') playing_status.play() # system_media_controls.set_playing() else: playing_status.pause() # system_media_controls.set_paused() refresh_tray() save_queues() DiscordPresence.update(t('By') + f': {artists}', title, t('Listening'), confirm_connect=settings['discord_rpc']) # update metadata of the player # if platform.system() == 'Windows': # bg = settings['theme']['background'] # # base64 # try: # album_art_data = resize_img(get_current_art(), bg, COVER_NORMAL, default_art=DEFAULT_ART) # except OSError as e: # handle_exception(e) # album_art_data = resize_img(DEFAULT_ART, bg, COVER_NORMAL) # img_data = io.BytesIO(b64decode(album_art_data)) # album_art: Image.Image = Image.open(img_data) # thumb_path = Path('thumb.jpg').absolute() # TODO: convert to mode RGB in case RGBA # album_art.save(thumb_path) # system_media_controls.set_metadata(title, artists, album, thumb_path.as_uri()) # system_media_controls.update_time() if not gui_window.is_closed(): gui_window.metadata['update_listboxes'] = True daemon_commands.put('__UPDATE_GUI__') return True def play_system_audio(switching_device=False, show_error=False): global track_position, track_start, track_end, track_length if cast is None: tray_notify(t('ERROR') + ': ' + t('Not connected to a cast device')) sar.alive = False return False try: cast.wait(timeout=WAIT_TIMEOUT) cast.set_volume(0 if settings['muted'] else settings['volume'] / 100) mc = cast.media_controller if mc.status.player_is_playing or mc.status.player_is_paused: mc.stop() mc.block_until_active(WAIT_TIMEOUT) title = 'System Audio' artist = platform.node() album = 'Music Caster' metadata = {'metadataType': 3, 'albumName': album, 'title': title, 'artist': artist} url_metadata['SYSTEM_AUDIO'] = {'artist': artist, 'title': title, 'album': album} sar.start() # start recording system audio BEFORE the first request for data api_key = settings['api_key'] url = f'http://{get_ipv4()}:{State.PORT}/system-audio/?api_key={api_key}' mc.play_media(url, 'audio/wav', metadata=metadata, thumb=f'{url}/thumb', stream_type='LIVE') mc.block_until_active(WAIT_TIMEOUT + 1) stream_start_time = time.monotonic() block_until = time.monotonic() + WAIT_TIMEOUT while not mc.status.player_is_playing and time.monotonic() < block_until: time.sleep(0.05) mc.play() sar.lag = time.monotonic() - stream_start_time # ~1 second playing_status.play_system_audio() track_length = None track_position = 0 track_start = time.monotonic() after_play(title, artist, album, True, switching_device) return True except OSError: tray_notify(t('ERROR') + ': ' + t('Could not find an output device to record')) except PyChromecastError as e: app_log.error(f'play_sys_audio failed to cast {repr(e)}') if show_error: tray_notify(t('ERROR') + ': ' + t('Could not connect to cast device') + ' (psa)') change_device(unresponsive_cast=True) return handle_exception(e) cast_try_reconnect() return play_system_audio(switching_device=switching_device, show_error=True) except Exception as e: handle_exception(e) tray_notify('ERROR: Something went wrong') return False def url_expired(uri): """ Returns if URI is a URL that has expired """ expiry_time = url_metadata.get(uri, {}).get('expiry', 0) # if expiry_time is None, url does not have an expiry if expiry_time is None: return False return expiry_time < time.time() def get_url_metadata(url, fetch_art=True) -> list[dict | URLMetadata]: # TODO: cache in the database for persistence # TODO: move to utils.py and add parameter url_metadata_cache """ Tries to parse url and set url_metadata[url] to parsed metadata Supports: YouTube, Soundcloud, any url ending with a valid audio extension """ from yt_dlp.utils import YoutubeDLError global deezer_opened, attribute_error_reported ytsearch = 'ytsearch1' metadata_list = [] app_log.info('get_url_metadata: ' + url) with DatabaseConnection() as conn: maybe_metadata = URLMetadata.from_db(conn, url) if maybe_metadata and not maybe_metadata.is_expired: return [maybe_metadata] if url in url_metadata and not url_expired(url): return [url_metadata[url]] if url.startswith('www'): url = f'http://{url}' # short-circuit if not url.startswith('http') and not url.startswith(ytsearch): return metadata_list if url.startswith('http') and valid_audio_file(url): # source url e.g. http://...radio.mp3 ext = url[::-1].split('.', 1)[0][::-1] url_frags = urlsplit(url) title, artist, album = url_frags.path.split('/')[-1], url_frags.netloc, url_frags.path[1:] url_metadata[url] = metadata = {'title': title, 'artist': artist, 'length': None, 'album': album, 'src': url, 'url': url, 'ext': ext, 'expiry': None} # never expires metadata_list.append(metadata) elif 'twitch.tv' in url: with suppress(StopIteration, IOError): r = ydl_extract_info(url, quiet=not is_debug()) audio_url = max(r['formats'], key=lambda item: item['tbr'] * (item['vcodec'] == 'none'))['url'] # for now, expire immediately metadata = {'title': r['description'], 'artist': r['uploader'], 'ext': r['ext'], 'expiry': 0, 'album': 'Twitch', 'length': None, 'art': r['thumbnail'], 'url': r['url'], 'audio_url': audio_url, 'src': url} url_metadata[url] = metadata metadata_list.append(metadata) elif 'soundcloud.com' in url: with suppress(StopIteration, IOError): r = ydl_extract_info(url, quiet=not is_debug()) if 'entries' in r: for entry in r['entries']: parsed_url = parse_qs(urlparse(entry['url']).query)['Policy'][0].replace('_', '=') policy = b64decode(parsed_url).decode() expiry_time = json.loads(policy)['Statement'][0]['Condition']['DateLessThan']['AWS:EpochTime'] album = entry.get('album', r.get('title', 'SoundCloud')) metadata = {'title': entry['title'], 'artist': entry['uploader'], 'album': album, 'length': entry['duration'], 'art': entry['thumbnail'], 'src': entry['webpage_url'], 'url': entry['url'], 'ext': entry['ext'], 'expiry': expiry_time} url_metadata[entry['webpage_url']] = metadata metadata_list.append(metadata) else: url_policy_b64 = parse_qs(urlparse(r['url']).query)['Policy'][0].replace('_', '=') policy = b64decode(url_policy_b64).decode() expiry_time = json.loads(policy)['Statement'][0]['Condition']['DateLessThan']['AWS:EpochTime'] url_metadata[url] = metadata = {'title': r['title'], 'artist': r['uploader'], 'album': 'SoundCloud', 'src': url, 'ext': r['ext'], 'expiry': expiry_time, 'length': r['duration'], 'art': r['thumbnail'], 'url': r['url']} metadata_list.append(metadata) # youtube elif (ytid := get_yt_id(url)) is not None or url.startswith(f'{ytsearch}:'): # lazily get videos in the playlist if ytid is not None and ytid.startswith('PL'): videos = scrapetube.get_playlist(ytid) for i, video in enumerate(videos): _url = f'https://www.youtube.com/watch?v={video["videoId"]}' src_url = f'{_url}&list={ytid}' # fetch first most URL of playlist so that play_url does not break if not metadata_list: if m_lst := get_url_metadata(_url): m = m_lst[0] m['pl_src'] = src_url metadata_list.extend(m_lst) else: metadata = URLMetadata( src=_url, url_type='YouTube', title=video['title']['runs'][0]['text'], artist=video['shortBylineText']['runs'][0]['text'], album='YouTube', id= video['videoId'], playlist_url=src_url, expiry=0, album_cover_url=f'https://img.youtube.com/vi/{ytid}/maxresdefault.jpg' ) url_metadata[_url] = metadata metadata_list.append(metadata) else: # type error in case video was deleted or unavailable try: r = ydl_extract_info(url, quiet=not is_debug()) if 'entries' in r: for entry in r['entries']: metadata = ydl_get_metadata(entry, duration_helper=False) metadata['ytid'] = entry['id'] # if duration > 10 minutes, try to parse out timestamps for track from comment section if entry.get('duration', 0) > 600: metadata['timestamps'] = get_video_timestamps(entry) for webpage_url in get_yt_urls(entry['id']): url_metadata[webpage_url] = metadata metadata_list.append(metadata) else: # single video metadata = ydl_get_metadata(r, duration_helper=False) metadata['ytid'] = r['id'] # if duration > 10 minutes, try to parse out timestamps for track from comment section if r.get('duration', 0) > 600: metadata['timestamps'] = get_video_timestamps(r) for webpage_url in get_yt_urls(r['id']): url_metadata[webpage_url] = metadata url_metadata[url] = metadata metadata_list.append(metadata) except (IOError, TypeError) as e: print('error', e) except AttributeError as e: app_log.error(f'yt-dlp failed to extract {url}') trace_back_msg = traceback.format_exc().replace('\\', '/') if not attribute_error_reported: if 'PhantomJS' in trace_back_msg: try: install_phantomjs(PHANTOMJS_DIR) add_to_path(PHANTOMJS_DIR / 'bin') except Exception: open_in_browser('https://phantomjs.org/download.html') if 'blocked it on copyright grounds' not in trace_back_msg: attribute_error_reported = True handle_exception(e) # Spotify restricted web API access elif url.startswith('https://open.spotify.com') and False: # spotify metadata has already been fetched, so just get youtube metadata if url in url_metadata and isinstance(url_metadata[url], dict): metadata = url_metadata[url] if 'ytid' in metadata: youtube_metadata = get_url_metadata(f"https://www.youtube.com/watch?v={metadata['ytid']}", False) else: query = f"{get_first_artist(metadata['artist'])} - {metadata['title']}" youtube_metadata = get_url_metadata(f'{ytsearch}:{query}', False) if metadata['src'] == '': metadata['src'] = youtube_metadata['src'] if youtube_metadata: youtube_metadata = youtube_metadata[0] # these are the only fields we need to update since they actually expire for key in ('expiry', 'url', 'audio_url', 'ext', 'ytid', 'length'): metadata[key] = youtube_metadata[key] url_metadata[metadata['src']] = url_metadata[youtube_metadata['src']] = metadata metadata_list.append(metadata) else: error_msg = t('ERROR') + ': ' + t('Could not fetch audio for $URL').replace('$URL', url) + ' :(' tray_notify(error_msg) else: # get a list of spotify tracks from the track/album/playlist Spotify URL try: spotify_tracks = get_spotify_tracks(url) except AttributeError: spotify_tracks = [] except Exception as e: handle_exception(e) spotify_tracks = [] if spotify_tracks: metadata = spotify_tracks[0] query = f"{get_first_artist(metadata['artist'])} - {metadata['title']}" youtube_metadata = get_url_metadata(f'{ytsearch}:{query}', False) if youtube_metadata: youtube_metadata = youtube_metadata[0] # expiry, url, and audio_url are not overwritten here metadata = {**youtube_metadata, **metadata} if metadata['src'] == '': metadata['src'] = youtube_metadata['src'] url_metadata[metadata['src']] = url_metadata[youtube_metadata['src']] = metadata # if url is a spotify track, set its metadata if len(spotify_tracks) == 1: url_metadata[url] = metadata metadata_list.append(metadata) for spotify_track in islice(spotify_tracks, 1, None): url_metadata[spotify_track['src']] = spotify_track uris_to_scan.put(spotify_track['src']) metadata_list.append(spotify_track) elif url.startswith('https://deezer.page.link') or url.startswith('https://www.deezer.com'): try: for metadata in get_deezer_tracks(url): url_metadata[metadata['src']] = metadata metadata_list.append(metadata) except LookupError: # login cookie not found # first time open the browser if not deezer_opened: open_in_browser('https://www.deezer.com/login') tray_notify(t('ERROR') + ': ' + t('Not logged into deezer.com')) deezer_opened = True # fallback to deezer -> youtube if url in url_metadata: metadata = url_metadata[url] query = f"{get_first_artist(metadata['artist'])} - {metadata['title']}" youtube_metadata = get_url_metadata(f'{ytsearch}:{query}', False)[0] metadata = {**youtube_metadata, **metadata} url_metadata[metadata['src']] = url_metadata[youtube_metadata['src']] = metadata metadata_list.append(metadata) else: deezer_tracks = get_deezer_tracks(url, login=False) if deezer_tracks: metadata = deezer_tracks[0] query = f"{get_first_artist(metadata['artist'])} - {metadata['title']}" youtube_metadata = get_url_metadata(f'{ytsearch}:{query}', False)[0] metadata = {**youtube_metadata, **metadata} url_metadata[metadata['src']] = url_metadata[youtube_metadata['src']] = metadata metadata_list.append(metadata) for deezer_track in islice(deezer_tracks, 1, None): url_metadata[deezer_track['src']] = deezer_track uris_to_scan.put(deezer_track['src']) metadata_list.append(deezer_track) else: with suppress(IOError, TypeError, AttributeError, YoutubeDLError): r = ydl_extract_info(url, quiet=not is_debug()) if 'entries' in r: for entry in r['entries']: url_metadata[entry['webpage_url']] = metadata = ydl_get_metadata(entry) metadata_list.append(metadata) else: url_metadata[url] = url_metadata[r['webpage_url']] = metadata = ydl_get_metadata(r) metadata_list.append(metadata) if metadata_list and fetch_art: # fetch and cache artwork for first url metadata = metadata_list[0] if metadata.get('art') is not None and 'art_data' not in metadata: art_url = metadata['art'] try: url_metadata[metadata['src']]['art_data'] = b64encode(requests.get(art_url).content) except requests.RequestException as e: app_log.info(f'Could not fetch art url {art_url}') handle_exception(e) return metadata_list def play_url(position=0, autoplay=True, switching_device=False, show_error=False) -> bool: global cast, playing_url, track_length, track_start, track_end, track_position url = music_queue[0] if not url.startswith('http') and not url.startswith('www') and not url.startswith('//'): return False metadata_list = get_url_metadata(url) if not metadata_list: if settings['notifications']: tray_notify( t('ERROR') + ': ' + t('Could not play $URL').replace('$URL', url) ) return False if len(metadata_list) > 1: # url was for multiple sources with suppress(IndexError): music_queue.popleft() music_queue.extendleft((metadata['src'] for metadata in reversed(metadata_list))) metadata = metadata_list[0] title, artist, album = metadata['title'], metadata['artist'], metadata['album'] ext = metadata['ext'] url = metadata['audio_url'] if cast is None and 'audio_url' in metadata else metadata['url'] api_key = settings['api_key'] thumbnail = metadata['art'] if 'art' in metadata else f'{get_ipv4()}/file?path=DEFAULT_ART&api_key={api_key}' # can be None track_length = metadata['length'] try: app_log.info(f'cast.socket_client.is_alive(): {cast.socket_client.is_alive()}') cast.wait(timeout=WAIT_TIMEOUT) cast.set_volume(0 if settings['muted'] else settings['volume'] / 100) mc = cast.media_controller _metadata = {'metadataType': 3, 'albumName': album, 'title': title, 'artist': artist} stream_type = 'LIVE' if track_length is None else 'BUFFERED' mc.play_media(url, f'video/{ext}', metadata=_metadata, thumb=thumbnail, current_time=position, autoplay=autoplay, stream_type=stream_type) mc.block_until_active(WAIT_TIMEOUT) if track_length is None: mc.play() except AttributeError: # cast is None, so play on local volume = 0 if settings['muted'] else settings['volume'] / 100 if autoplay or not metadata.get('is_live', False): audio_player.play( url, start_playing=autoplay, start_from=position, volume=volume ) except NotConnected: app_log.error('play_url failed to cast because cast was not connected') tray_notify( t('ERROR') + ': ' + t('Could not connect to cast device') + ' (play_url)' ) change_device(unresponsive_cast=True) return False except (PyChromecastError, OSError) as e: app_log.error(f'play_url failed to cast {repr(e)}') if show_error: tray_notify( t('ERROR') + ': ' + t('Could not connect to cast device') + ' (play_url)' ) return handle_exception(e) cast_try_reconnect() return play_url(position, autoplay, switching_device, show_error=True) playing_status.play_uri(position, track_length, cast is None) track_position = position track_start = time.monotonic() - track_position if track_length is not None: track_end = track_start + track_length playing_url = True after_play(title, artist, album, autoplay, switching_device) return True # up to 4 seconds! def play(position=0, autoplay=True, switching_device=False, show_error=False, from_set_pos=False): global cast, track_start, track_end, track_length, track_position, music_queue, playing_url, cast_browser, zconf, LAST_PLAYED uri = music_queue[0] while not os.path.exists(uri): if play_url(position, autoplay, switching_device): return app_log.info(f'{uri} does not exist or is unplayable') # it's possible that these queues are empty with suppress(IndexError): done_queue.append(music_queue.popleft()) with suppress(IndexError): music_queue.appendleft(next_queue.popleft()) try: uri, position = music_queue[0], 0 except IndexError: return uri_path = Path(uri) uri = uri_path.as_posix() playing_url = sar.alive = False app_log.info(f'{uri_path.name} @{position}, autoplay={autoplay}, switching_device={switching_device}') try: track_length = get_audio_length(uri) except InvalidAudioFile: done_queue.append(music_queue.popleft()) msg = t('ERROR') + ': ' + t('Invalid audio file $FILE').replace('$FILE', uri) tray_notify(msg) if music_queue: play() return metadata = get_metadata_wrapped(uri) # update metadata of track in case something changed all_tracks[uri] = metadata volume = 0 if settings['muted'] else settings['volume'] / 100 if cast is None: # play locally audio_player.play(uri, volume=volume, start_playing=autoplay, start_from=position) playing_status.play_uri(position, track_length, True) else: # track_end = time.monotonic() + WAIT_TIMEOUT * 2 + 1 try: url_args = urllib.parse.urlencode({'path': uri, 'api_key': settings['api_key']}) url = f'http://{get_ipv4()}:{State.PORT}/file?{url_args}' app_log.info(f'calling cast.wait on device {cast.cast_info.friendly_name} / {cast.uuid}') app_log.info(f'cast.media_controller player state: {cast.media_controller.status.player_state}') cast.wait(timeout=15 if show_error else WAIT_TIMEOUT) if not from_set_pos: app_log.info(f'try: cast.set_volume({volume})') with suppress(RequestTimeout): cast.set_volume(volume) mc = cast.media_controller # https://developers.google.com/cast/docs/reference/web_receiver/cast.framework.messages#.MetadataType metadata = {'title': str(metadata['title']), 'artist': str(metadata['artist']), 'albumName': str(metadata['album']), 'metadataType': 3} ext = uri.split('.')[-1] # pychromecast.error.NotConnected: Chromecast unknown:8009 is connecting.. mc.play_media(url, f'audio/{ext}', current_time=position, metadata=metadata, thumb=f'{url}&thumbnail_only=true', autoplay=autoplay) mc.block_until_active(WAIT_TIMEOUT) playing_status.play_uri(position, track_length, False) app_log.info(f'mc.status.player_state={mc.status.player_state}') except (NotConnected, AttributeError) as e: app_log.error('cast device is not connected', exc_info=True) app_log.info(f'cast.media_controller player state: {cast.media_controller.status.player_state}') r""" 2022-03-09 10:52:40,920 ERROR (396): [Computer room(192.168.1.9):8009] Failed to connect to service HostServiceInfo(type='mdns', data='Google-Home-Mini-$HASH._googlecast._tcp.local.'), retrying in 5.0s Traceback (most recent call last): File "music_caster.py", line 1733, in play File "pychromecast/controllers/receiver.py", line 181, in set_volume File "pychromecast/controllers/__init__.py", line 95, in send_message File "pychromecast/controllers/__init__.py", line 99, in send_message_nocheck File "pychromecast/socket_client.py", line 930, in send_platform_message File "pychromecast/socket_client.py", line 924, in send_message pychromecast.error.NotConnected: Chromecast 192.168.1.9:8009 is connecting... """ if not IS_FROZEN or is_debug(): print(e) if show_error: tray_notify(t('ERROR') + ': ' + t('Could not connect to cast device') + ' (play)') change_device(unresponsive_cast=True) return False return play(position=position, autoplay=autoplay, switching_device=switching_device, show_error=True) except (PyChromecastError, OSError, RuntimeError, AssertionError) as e: r""" Traceback (most recent call last): File "music_caster.py", line 2137, in play File "pychromecast\__init__.py", line 505, in wait pychromecast.error.RequestTimeout: Execution of wait timed out after 5 s. """ app_log.error('play failed to cast', exc_info=True) app_log.info(f'cast.media_controller player state: {cast.media_controller.status.player_state}') app_log.info('falling back to playing on local device') if not show_error: try_reconnecting = True if cast.media_controller.status.player_state == 'UNKNOWN': try: cast.media_controller.stop() cast.quit_app(15) cast.wait(15) try_reconnecting = False except PyChromecastError as e: app_log.error('failed to stop, quit, or wait on cast device', exc_info=True) handle_exception(e) if try_reconnecting and not cast_try_reconnect(): show_error = True if show_error: tray_notify(t('ERROR') + ': ' + t('Could not connect to cast device') + ' (play)') change_device(unresponsive_cast=True) handle_exception(e) switching_device=True return play(position=position, autoplay=autoplay, switching_device=switching_device, show_error=True) track_position = position track_start = time.monotonic() - track_position track_end = track_start + track_length app_log.info(f'track_end = {track_end:.2f}, track_start = {track_start:.2f}, track_length = {track_length:.2f}') LAST_PLAYED = time.time() return after_play(metadata['title'], metadata['artist'], metadata.get('album'), autoplay, switching_device) def metadata_key(filename, album_sort=True): """ Sort by (artist, album, track number, title) """ m = get_uri_metadata(filename) try: tn = int(m.get('track_number')) except (ValueError, TypeError): tn = 1 return (m['album'].casefold() if album_sort else ''), tn, m['artist'].casefold(), m['title'].casefold() def play_uris(uris: Iterable, return_if_empty=True, queue_uris=False, play_next=False, merge_tracks=0, natural_sort=True): """ TODO: make thread safe Appends all music files in the provided uris (playlist names, folders, files, urls) to a temp list, which is shuffled if shuffled is enabled in settings, and then extends music_queue. Note: valid filesystem paths take precedence over playlist names If queue_only is false, the music queue and done queue are cleared, before files are added to the music_queue play_next has priority over queue_uris merge_tracks indicates the number of tracks that were already propogated but need to be merged If sort is False, shuffle being off does not sort items """ temp_queue, albums_found = [], set() for track in get_audio_uris(uris): album_name = get_uri_metadata(track)['album'] if not isinstance(album_name, Unknown): albums_found.add(album_name) elif album_name != Unknown('Album'): # NOTE: debugging purpose # TODO: remove condition handle_exception(Exception(f'found incorrect {album_name} instead of Unknown("Album")')) temp_queue.append(track) if not temp_queue and return_if_empty: return False # fresh play condition if not queue_uris and not play_next and merge_tracks == 0: music_queue.clear() done_queue.clear() # handle merge_tracks case if merge_tracks > 0: with suppress(IndexError): if play_next: if settings['reversed_play_next']: for _ in range(merge_tracks): temp_queue.append(next_queue.popleft()) else: for _ in range(merge_tracks): temp_queue.append(next_queue.pop()) elif queue_uris: for _ in range(merge_tracks): temp_queue.append(music_queue.pop()) else: # to play for _ in range(merge_tracks): temp_queue.append(music_queue.popleft()) # shuffle or sort if settings['shuffle']: shuffle(temp_queue) elif natural_sort: temp_queue.sort(key=natural_key_file) else: # do custom sort only if possible album was queued try: temp_queue.sort(key=lambda filename: metadata_key(filename, album_sort=len(albums_found) > 1)) except Exception as e: app_log.error('could not sort temp_queue', exc_info=True) handle_exception(e) # add to next queue condition if play_next: if settings['reversed_play_next']: next_queue.extendleft(reversed(temp_queue)) else: next_queue.extend(temp_queue) gui_window.metadata['update_listboxes'] = True return True # extend only if merge_tracks == 0 or we are queueing the tracks if queue_uris or merge_tracks == 0: music_queue.extend(temp_queue) else: # API play command with history (merge_tracks > 0) music_queue.extendleft(reversed(temp_queue)) if not queue_uris: if music_queue: play() return True elif next_queue: playing_status.play() next_track() return True gui_window.metadata['update_listboxes'] = True save_queues() return True def play_all(starting_files: Iterable = None, queue_only=False): """ Clears done queue, music queue, adds starting files to music queue. Shuffles and queues files in the library without duplication """ if starting_files is None: starting_files = [] if not queue_only: music_queue.clear() done_queue.clear() music_queue.extend(starting_files) ignore_files = set(starting_files).union(music_queue).union(done_queue).union(next_queue) if indexing_tracks_thread is not None and indexing_tracks_thread.is_alive() and settings['notifications']: info = t('INFO') tray_notify(f'{info}: ' + t('Library indexing incomplete, only scanned files have been added')) start_shuffle_from = len(music_queue) music_queue.extend(index_all_tracks(False, ignore_files).keys()) better_shuffle(music_queue, start_shuffle_from) if not queue_only: if not music_queue and next_queue: music_queue.append(next_queue.popleft()) if music_queue: play() gui_window.metadata['update_listboxes'] = True def queue_all(): if not any(filter(lambda thread: thread.name == 'PlayAll', threading.enumerate())): Thread(target=play_all, kwargs={'queue_only': True}, daemon=True, name='PlayAll').start() def open_dialog(title, for_dir=False, filetypes=None, single_file=False): if settings['use_last_folder']: prev_folder = initial_folder = settings['last_folder'] while not os.path.exists(initial_folder): initial_folder = Path(initial_folder).parent.absolute() if prev_folder == initial_folder: # prevent infinite loop initial_folder = get_default_music_folder() break else: initial_folder = get_default_music_folder() _root = tkinter.Tk() _root.withdraw() if platform.system() != 'Linux': _root.iconbitmap(WINDOW_ICON) if for_dir: paths = fd.askdirectory(title=title, initialdir=initial_folder, parent=_root) elif single_file: paths = [fd.askopenfilename(title=title, parent=_root, initialdir=initial_folder, filetypes=filetypes)] else: paths = fd.askopenfilenames(title=title, parent=_root, initialdir=initial_folder, filetypes=filetypes) _root.destroy() return paths def file_action(action='pf'): """ action = {'pf': 'Play Files', 'pfn': 'Play Files Next', 'qf': 'Queue Files'} :param action: one of {'pf': 'Play Files', 'pfn': 'Play Files Next', 'qf': 'Queue Files'} :return: """ paths = open_dialog(t('Select Audio Files'), filetypes=AUDIO_FILE_TYPES) if paths: natural_sort = len(paths) > 20 update_settings('last_folder', os.path.dirname(paths[-1])) app_log.info(f'file_action(action={action}), len(lst) is {len(paths)}') if action in {t('Play'), 'pf'}: if settings['queue_library']: return play_all(starting_files=paths) return play_uris(paths, natural_sort=natural_sort) if action in {t('Queue'), 'qf'}: return play_uris(paths, queue_uris=True, natural_sort=natural_sort) if action in {t('Play Next'), 'pfn'}: return play_uris(paths, play_next=True, natural_sort=natural_sort) gui_window.metadata['last_event'] = 'file_action' def video_file_action(): path = open_dialog(t('Select Video Files'), filetypes=AUDIO_FILE_TYPES, single_file=True)[0] device_id = gui_window['devices'].get().id url_args = urllib.parse.urlencode({'path': path, 'api_key': settings['api_key']}) url = f'http://{get_ipv4()}:{State.PORT}/file?{url_args}' video_device: Chromecast = get_device(device_id) video_device.wait(timeout=WAIT_TIMEOUT) mc = cast.media_controller # https://developers.google.com/cast/docs/reference/web_receiver/cast.framework.messages.MovieMediaMetadata metadata = {'title': Path(path).stem, 'metadataType': 1} ext = path.split('.')[-1] mc.play_media(url, f'video/{ext}', metadata=metadata, autoplay=True) mc.block_until_active(WAIT_TIMEOUT) def folder_action(action='pf'): """ :param action: one of {'pf': 'Play Folder', 'qf': 'Queue Folder', 'pfn': 'Play Folder Next'} """ directory = open_dialog(t('Select Folder'), for_dir=True) if directory: gui_window.metadata['last_event'] = Sg.TIMEOUT_KEY update_settings('last_folder', directory) app_log.info(f'folder_action: action={action}') if action in {t('Play'), 'pf'}: res = play_uris(directory, natural_sort=False) elif action in {t('Play Next'), 'pfn'}: res = play_uris(directory, play_next=True, natural_sort=False) elif action in {t('Queue'), 'qf'}: res = play_uris(directory, queue_uris=True, natural_sort=False) else: res = False if res: gui_window.metadata['update_listboxes'] = True save_queues() elif settings['notifications']: tray_notify(t('ERROR') + ': ' + t('Folder does not contain audio files')) else: gui_window.metadata['last_event'] = 'folder_action' def get_track_position(): global track_position if playing_status.busy(): if cast is not None: if playing_status.playing(): track_position = time.monotonic() - track_start else: track_position = audio_player.get_pos() return track_position def pause(source=''): """ Returns true if player was playing Returns false if player was not playing can be called from a non-main thread """ global track_position, LAST_PLAYED app_log.info(f'pause({source}), playing status = {playing_status}') if playing_status.playing(): if platform.system() == 'Windows': ctypes.windll.kernel32.SetThreadExecutionState(0x80000000) try: if cast is None: track_position = time.monotonic() - track_start if get_current_metadata().get('is_live', False): audio_player.stop() else: audio_player.pause() app_log.info('paused local audio player') else: mc = cast.media_controller try: mc.pause() except (RequestTimeout, RequestFailed): try: cast.wait(30) cast.media_controller.pause() except (RequestTimeout, RequestFailed): app_log.error('failed to pause cast device', exc_info=True) return False block_until = time.monotonic() + 5 while not mc.status.player_is_paused and time.monotonic() < block_until: time.sleep(0.1) if mc.status.adjusted_current_time is not None: track_position = mc.status.adjusted_current_time app_log.info('paused cast device') playing_status.pause() if music_queue or sar.alive: metadata = get_current_metadata() title, artist = metadata['title'], metadata['artist'] DiscordPresence.update(t('By') + f': {artist}', title, 'Paused', confirm_connect=settings['discord_rpc']) except UnsupportedNamespace: stop('pause') if not gui_window.is_closed(): daemon_commands.put('__UPDATE_GUI__') refresh_tray() LAST_PLAYED = time.time() return True return False def resume(source=''): global track_end, track_position, track_start app_log.info(f'resume(source = {source}), playing status = {playing_status}') if playing_status.paused(): if music_queue and not os.path.exists(music_queue[0]) and url_expired(music_queue[0]): app_log.info('url expired, hard playing') # check if the url has expired before resuming in case it has been a long time play(position=track_position, autoplay=False) try: if cast is None: if get_current_metadata().get('is_live', False): play() else: audio_player.resume() app_log.info('resumed local audio player') else: mc = cast.media_controller mc.update_status() mc.play() mc.block_until_active(WAIT_TIMEOUT) if mc.status.adjusted_current_time is not None: track_position = mc.status.adjusted_current_time track_start = time.monotonic() - track_position if track_length is not None: track_end = track_start + track_length playing_status.play() metadata = get_current_metadata() title, artist = metadata['title'], get_first_artist(metadata['artist']) DiscordPresence.update(t('By') + f': {artist}',title, t('Listening'), confirm_connect=settings['discord_rpc']) if platform.system() == 'Windows': ctypes.windll.kernel32.SetThreadExecutionState(0x80000000 | 0x00000001) if not gui_window.is_closed(): daemon_commands.put('__UPDATE_GUI__') refresh_tray() except (PyChromecastError, AssertionError) as e: print('error', e) if music_queue: return play(position=track_position) return True return False def stop(stopped_from: str, stop_cast=True): """ can be called from a non-main thread does not check if playing_status is busy """ global track_start, track_end, track_position, track_length, playing_url app_log.info(f'stopped from {stopped_from}, stop_cast={stop_cast}') # allow Windows to go to sleep if platform.system() == 'Windows': # system_media_controls.set_stopped() ctypes.windll.kernel32.SetThreadExecutionState(0x80000000) playing_status.stop() sar.alive = playing_url = False DiscordPresence.clear(settings['discord_rpc']) if cast is None: audio_player.stop() elif cast.app_id == APP_MEDIA_RECEIVER and stop_cast: mc = cast.media_controller with suppress(PyChromecastError): mc.stop() block_until = time.monotonic() + 5 # 5 seconds status = mc.status while ( status.player_is_playing or status.player_is_paused ) and time.monotonic() > block_until: time.sleep(0.1) if status.player_is_playing or status.player_is_paused: try: cast.quit_app(30) except PyChromecastError as e: app_log.error('cast.quit_app failed', exc_info=True) handle_exception(e) track_start = track_position = track_end = track_length = 0 if not gui_window.is_closed(): daemon_commands.put('__UPDATE_GUI__') refresh_tray() def set_pos(new_position: int): """ AKA: seeking sets position of audio player or cast to new_position """ global track_position, track_start, track_end, SYNC_WITH_CHROMECAST app_log.info('acquiring CAST_LOCK') with CAST_LOCK: t1 = time.time() app_log.info('trying to set playback position') if cast is not None: SYNC_WITH_CHROMECAST = time.time() + 1 try: pass # cast.media_controller.update_status() except (PyChromecastError, AssertionError): # File "C:\Users\maste\Documents\GitHub\music-caster\.venv\Lib\site-packages\pychromecast\socket_client.py", line 891, in send_message # assert self.socket is not None # AssertionError app_log.info('trying to wait on cast') cast.wait(WAIT_TIMEOUT) app_log.info(f'cast.wait took {time.time() - t1:.2f} seconds') if cast.media_controller.status.player_is_idle and music_queue: app_log.info('called play instead') return play(position=new_position, autoplay=playing_status.playing(), from_set_pos=True) else: for _ in range(2): try: # seek is unstable. use play instead app_log.info('call play with new position') return play(position=new_position, autoplay=playing_status.playing(), from_set_pos=True) # cast.media_controller.seek(new_position) if playing_status.paused(): cast.media_controller.pause() break except (RequestFailed, RequestTimeout) as e: app_log.exception('seek "failed"') if not IS_FROZEN or is_debug(): print(f'encountered error while seeking: {type(e)} {e}') # seeking is broken, prefer play instead return play(position=new_position, autoplay=playing_status.playing(), from_set_pos=True) break except (NotConnected): app_log.exception('seek failed') cast.wait(WAIT_TIMEOUT) SYNC_WITH_CHROMECAST = time.time() + 0.5 else: audio_player.set_pos(new_position) track_position = new_position track_start = time.monotonic() - track_position track_end = track_start + track_length def next_track(from_timeout=False, times=1, forced=False, ignore_timestamps=False): """ :param from_timeout: whether next track is due to the currently playing audio ending :param times: number of tracks ahead :param forced: if True, ignore current playing status :param ignore_timestamps: whether to ignore timestamps for a track :return: """ app_log.info(f'from_timeout={from_timeout}') if music_queue: app_log.info(f'current track = {Path(music_queue[0]).name}') if cast is not None and cast.app_id != APP_MEDIA_RECEIVER and cast.app_id is not None and not forced: # clicked next track when connected to cast and the app is not the media receiver app if cast is None: app_log.info('stopping internal playing_status because cast is None') if cast.app_id != APP_MEDIA_RECEIVER and not forced: app_log.info(f'stopping internal playing_status because cast present app id ({cast.app_id}) does not equal to APP_MEDIA_RECEIVER ({APP_MEDIA_RECEIVER})') playing_status.stop() elif (next_queue or music_queue) and (forced or playing_status.busy() and not sar.alive): # 1. there is something to play next # 2. we are already playing a track (or are forcing) with suppress(IndexError): if track_length is not None and track_length > 600 and not ignore_timestamps: if url_metadata.get(music_queue[0], {}).get('timestamps'): # smart next track if playing a long URL with multiple tracks timestamps = url_metadata[music_queue[0]]['timestamps'] new_position = next(filter(lambda seconds: seconds > get_track_position(), timestamps), 0) if new_position: return set_pos(new_position) # keep track of skips (used by smart queue feature) if music_queue and track_position < 5 and not from_timeout and playing_status.busy() and not forced: settings['skips'][music_queue[0]] = settings['skips'].get(music_queue[0], 0) + 1 # save queue... save_settings() # if repeat all or repeat is off or empty queue or manual next if settings['repeat'] in {False, None} or not music_queue or not from_timeout: if settings['repeat']: update_settings('repeat', False) app_log.info(f'will move the next {times} tracks from music_queue and then next_queue into the done queue') for _ in range(times): if music_queue: done_queue.append(music_queue.popleft()) if next_queue: music_queue.appendleft(next_queue.popleft()) # if queue is empty but repeat is all AND there are tracks in the done_queue # move tracks from done_queue to music_queue if not music_queue and settings['repeat'] is False and done_queue: music_queue.extend(done_queue) done_queue.clear() if music_queue: if settings['smart_queue'] and from_timeout: # in the rare case all tracks will be skipped, avoid infinite loop max_skips = len(music_queue) + len(done_queue) + len(next_queue) # auto skip tracks that have been skipped a lot previously while music_queue and settings['skips'].get(music_queue[0], 0) > 5 and max_skips > 0: done_queue.append(music_queue.popleft()) if next_queue: music_queue.appendleft(next_queue.popleft()) # if queue is empty but repeat is all, move tracks from done_queue to music_queue if not music_queue and settings['repeat'] is False: music_queue.extend(done_queue) done_queue.clear() max_skips -= 1 elif times > 1: # reset skip counter because user explicitly selected the track to play settings['skips'].pop(music_queue[0], None) save_settings() return play() # repeat is off (from timeout) or skip resulted in exhaustion of queue stop('next track queue exhaustion', stop_cast=not from_timeout) def prev_track(times=1, forced=False, ignore_timestamps=False): app_log.info('') if not forced and cast is not None and cast.app_id != APP_MEDIA_RECEIVER: playing_status.stop() elif forced or playing_status.busy() and not sar.alive: with suppress(IndexError, TypeError): # TypeError: if track_length is None timestamps = url_metadata.get(music_queue[0], {}).get('timestamps', []) if track_length > 600 and timestamps and not ignore_timestamps: # smart next track if playing a long URL with multiple tracks _track_position = get_track_position() new_position = next(filter(lambda secs: secs < _track_position - 5, reversed(timestamps)), -1) if new_position != -1: return set_pos(new_position) if done_queue: for _ in range(times): if settings['repeat']: update_settings('repeat', False) track = done_queue.pop() # if there's a next queue, move mq[0] to top of next_queue if music_queue and next_queue: next_queue.appendleft(music_queue.popleft()) music_queue.appendleft(track) with suppress(IndexError): settings['skips'].pop(music_queue[0], None) # reset skip counter play() class UpdateChecker(threading.Timer): latest_release = None latest_version = VERSION check_immediately = False def __init__(self): # check for an update every 30 minutes super().__init__(1800, self.check_for_updates) self.daemon = True self.start() def run(self): while not self.finished.wait(self.interval): self.function(*self.args, **self.kwargs) def check_for_updates(self): # avoid showing a notification for the same latest version release = get_latest_release(self.latest_version, VERSION) if release: self.latest_release = release self.latest_version = release['version'] State.update_available = True if not gui_window.is_closed(): gui_window['install_update'].update(visible=True) if settings['notifications']: tray_notify('update_available', context=self.latest_version) def auto_update(self, install_update=True, from_gui=False): """ auto_start should be True when checking for updates at startup up, false when checking for updates before exiting """ with suppress(requests.RequestException, UpdateFailed): State.installing_update = True app_log.info(f'IS_FROZEN={IS_FROZEN}') release = self.latest_release if release is None: # since the Linux version is script, we want to force only in debug release = get_latest_release(VERSION, VERSION, force=is_debug()) if not release: app_log.info('no update found, or no internet, or API rate limited') raise UpdateFailed State.update_available = True if not install_update: State.installing_update = False return release latest_ver = release['version'] setup_dl_link = release['setup'] app_log.info(f'Update found: v{latest_ver}') print('Installer Link:', setup_dl_link) if is_debug() or not setup_dl_link: app_log.info(f'not updating because: DEBUG={DEBUG} or not setup_dl_link={setup_dl_link}') State.update_available = False raise UpdateFailed if IS_FROZEN: if platform.system() in {'Linux', 'Darwin'}: tray_notify('update_available', context=latest_ver) elif os.path.exists(UNINSTALLER): installer_path = get_installer_path() # only show message on startup to not confuse the user cmd = [installer_path, '/VERYSILENT', '/FORCECLOSEAPPLICATIONS', '/MERGETASKS="!desktopicon"', '&&', 'Music Caster.exe'] if not from_gui: cmd.extend( filter( lambda arg: arg not in {'-m', '--minimized'}, sys.argv[1:], ) ) if gui_window.is_closed(quick_check=True): cmd.append('-m') download_update = t('Downloading update $VER').replace('$VER', latest_ver) tray_notify(download_update) tray_tooltip = download_update tray_process_queue.put({'tooltip': tray_tooltip}) try: # download setup, close tray, run setup, and exit download(setup_dl_link, installer_path) tray_notify(t('Downloaded $VER. Relaunching...').replace('$VER', latest_ver)) time.sleep(0.3) Popen(cmd, shell=True) daemon_commands.put('__EXIT__') # tell main thread to exit except OSError as e: if e.errno == errno.ENOSPC: tray_notify(t('ERROR') + ': ' + t('No space left on device to auto-update')) except Exception: tray_notify('update_available', context=latest_ver) elif os.path.exists('Updater.exe'): # portable installation try: startfile('Updater') daemon_commands.put('__EXIT__') # tell main thread to exit except OSError as e: if e == errno.ECANCELED: # user cancelled update, don't try auto-updating again # inform user what we were trying to do though update_settings('auto_update', False) tray_notify('update_available', context=latest_ver) else: # unins000.exe or updater.exe was deleted; better to inform user there is an update available tray_notify('update_available', context=latest_ver) State.installing_update = False def background_thread(): """ Startup tasks: - try to auto update - sends info - creates/removes shortcut - starts keyboard listener - connect Discord presence While True tasks: - scans files """ global SYNC_WITH_CHROMECAST import pynput.keyboard global track_position, track_start, track_end app_log.info('start') # check if update needs to be installed # check for update and update if no must-run arguments were provided or if the update flag was used limited_args = len(sys.argv) == 1 or ['-m'] == sys.argv[1:] install_update = ( limited_args and settings['auto_update'] or args.update ) and not args.nupdate update_checker.auto_update(install_update=install_update) State.installing_update = False start_on_login_modifications() p = pynput.keyboard.Listener(on_press=on_press, on_release=lambda key: PRESSED_KEYS.discard(str(key))) p.name = 'pynputListener' p.start() while True: scanned = 0 while not uris_to_scan.empty(): uri = uris_to_scan.get() if uri.startswith('http'): get_url_metadata(uri) else: uri = Path(uri).as_posix() all_tracks[uri] = get_metadata_wrapped(uri) uris_to_scan.task_done() scanned += 1 if scanned >= 50: scanned = 0 gui_window.metadata['update_listboxes'] = True if scanned: gui_window.metadata['update_listboxes'] = True if seek_queue and time.time() > SYNC_WITH_CHROMECAST: time_to_seek = seek_queue.pop() seek_queue.clear() set_pos_thread = Thread(target=set_pos, args=(time_to_seek,), name='SetPos', daemon=False) set_pos_thread.start() time.sleep(0.1) # SystemMediaTransportControls.ButtonPressed def on_smtc_btn_press(event: SystemMediaTransportControlsButton): match event: case SystemMediaTransportControlsButton.PLAY: print('play') case SystemMediaTransportControlsButton.PAUSE: print('pause') case SystemMediaTransportControlsButton.NEXT: print('next') case SystemMediaTransportControlsButton.PREVIOUS: print('previous') def on_press(key): key = str(key) PRESSED_KEYS.add(key) valid_shortcut = len(PRESSED_KEYS) == 4 and "'m'" in PRESSED_KEYS ctrl_clicked = 'Key.ctrl_l' in PRESSED_KEYS or 'Key.ctrl_r' in PRESSED_KEYS shift_clicked = 'Key.shift' in PRESSED_KEYS or 'Key.shift_r' in PRESSED_KEYS alt_clicked = 'Key.alt_l' in PRESSED_KEYS or 'Key.alt_r' in PRESSED_KEYS # Ctrl + Alt + Shift + M open up main window if valid_shortcut and ctrl_clicked and shift_clicked and alt_clicked: daemon_commands.put('__ACTIVATED__') if key not in {'<179>', '<176>', '<177>', '<178>'}: return app_log.info(f'valid key press: {key}') if key == '<179>' and not pause(): resume('keyboard') elif key == '<176>' and playing_status.busy(): next_track() elif key == '<177>' and playing_status.busy(): prev_track() elif key == '<178>': stop('keyboard shortcut') def get_window_location(): if not settings['save_window_positions']: return None, None if settings['mini_mode']: return settings['window_locations'].get('main_mini_mode', (None, None)) key = 'main_vertical' if settings['vertical_gui'] else 'main' w, h = settings['window_locations'].get(key, (None, None)) if w is None or h is None: return None, None # clamp window size to screen size if platform.system() == 'Windows': from win32api import GetSystemMetrics w = max(0, min(w, GetSystemMetrics(78) - 500)) h = max(0, min(h, GetSystemMetrics(79) - 500)) return w, h def metadata_process_file(file: None | os.PathLike, callback_source): if file is None: handle_exception(TypeError(f'metadata_process_file recevied file = None. Called from {callback_source}')) return False if not os.path.isfile(file): return False try: file_metadata = get_metadata_wrapped(file) gui_window['metadata_file'].update(value=file) gui_window['metadata_file'].set_tooltip(file) gui_window['metadata_title'].update(value=file_metadata['title']) gui_window['metadata_artist'].update(value=file_metadata['artist']) gui_window['metadata_album'].update(value=file_metadata['album']) gui_window['metadata_track_num'].update(value=file_metadata['track_number']) gui_window['metadata_explicit'].update(value=file_metadata['explicit']) mime, artwork = get_album_art(file) artwork = None if artwork == DEFAULT_ART else artwork if artwork is not None: gui_window['metadata_art'].metadata = (mime, artwork) with suppress(OSError): display_art = resize_img(artwork, settings['theme']['background'], COVER_MINI) gui_window['metadata_art'].update(data=display_art) return True except InvalidAudioFile: error = t('ERROR') + ': ' + t('Invalid audio file selected') gui_window['metadata_msg'].update(value=error, text_color='red') gui_window.TKroot.after(2000, lambda: gui_window['metadata_msg'].update(value='')) return False def add_music_folder(folders): added_folders = set(music_folders) for folder in folders: folder = folder.replace('\\', '/') if os.path.isdir(folder) and folder not in added_folders: music_folders.append(folder) added_folders.add(folder) gui_window['music_folders'].update(music_folders) refresh_tray() save_settings() if settings['scan_folders']: index_all_tracks() def set_callbacks(): """ Set callbacks for the main window """ def save_window_position(event): if event.widget is gui_window.TKroot: if settings['mini_mode']: key = 'main_mini_mode' else: key = 'main_vertical' if settings['vertical_gui'] else 'main' settings['window_locations'][key] = gui_window.CurrentLocation() save_settings() def library_events(event): library_tree_view = gui_window['library'].TKTreeview region = library_tree_view.identify('region', event.x, event.y) column_index = library_tree_view.identify_column(event.x).replace('#', '') gui_window.metadata['library']['region'] = region gui_window.metadata['library']['column'] = int(column_index) def dnd_pl_tracks(event): # pl: playlist file_paths = gui_window.TKroot.tk.splitlist(event.data) pl_tracks = gui_window.metadata['pl_tracks'] pl_tracks.extend(get_audio_uris(file_paths)) update_settings('last_folder', os.path.dirname(file_paths[-1])) new_values, new_length = format_pl_lb(pl_tracks) new_i = len(new_values) - 1 gui_window['pl_length'].update(value=new_length) gui_window['pl_tracks'].update(new_values, set_to_index=new_i, scroll_to_index=max(new_i - 3, 0)) def dnd_queue(event): items = tk_lb.tk.splitlist(event.data) files = list(filter(os.path.isfile, items)) dirs = filter(os.path.isdir, items) # ASSUMPTION: if there are more than 20 files being queued, that's not an album play_uris(files, queue_uris=True, natural_sort=len(files) > 20) for directory in dirs: # assume album play_uris(directory, queue_uris=True, natural_sort=False) def report_callback_exception(exc, _, __): if exc == KeyboardInterrupt: raise KeyboardInterrupt gui_window.hidden_master_root.report_callback_exception = report_callback_exception # tkdnd stopped working for some reason in python 3.14 if platform.system() == 'Windows' and TKDND_ENABLED: gui_window.TKroot.tk.call('package', 'require', 'tkdnd') if not settings['mini_mode']: gui_window['url_input'].bind('<>', '_cut') gui_window['url_input'].bind('<>', '_copy') gui_window['pl_url_input'].bind('<>', '_cut') gui_window['pl_url_input'].bind('<>', '_copy') gui_window['library'].TKTreeview.bind('', library_events, add='+') gui_window['library'].TKTreeview.bind('', library_events, add='+') scroll_areas = ['queue', 'pl_tracks', 'library'] for scroll_area in scroll_areas: gui_window[scroll_area].bind('', '_mouse_enter') gui_window[scroll_area].bind('', '_mouse_leave') for input_key in {'url_input', 'pl_url_input', 'pl_name', 'timer_input', 'metadata_title', 'metadata_artist', 'metadata_album', 'metadata_track_num'}: gui_window[input_key].Widget.config(insertbackground=settings['theme']['text']) if TKDND_ENABLED: try: # drag and drop callbacks tk_lb = gui_window['queue'].TKListbox drop_target_register(tk_lb, DND_ALL) dnd_bind(tk_lb, '<>', dnd_queue) tk_lb = gui_window['pl_tracks'].TKListbox drop_target_register(tk_lb, DND_ALL) dnd_bind(tk_lb, '<>', dnd_pl_tracks) tk_frame = gui_window['tab_metadata'].TKFrame drop_target_register(tk_frame, DND_FILES) dnd_bind(tk_frame, '<>', lambda event: metadata_process_file(tk_lb.tk.splitlist(event.data)[0], 'tkdnd')) tk_lb = gui_window['music_folders'].TKListbox drop_target_register(tk_lb, DND_FILES) dnd_bind(tk_lb, '<>', lambda event: add_music_folder(tk_lb.tk.splitlist(event.data))) except NameError: # https://github.com/rdbende/tkinterDnD print('TODO: DND Not Implemented') elif TKDND_ENABLED: try: root = gui_window.TKroot drop_target_register(root, DND_ALL) dnd_bind(root, '<>', lambda event: play_uris(root.tk.splitlist(event.data), queue_uris=True)) except NameError: print('TODO: DND Not Implemented') gui_window['volume_slider'].bind('', '_mouse_enter') gui_window['volume_slider'].bind('', '_mouse_leave') gui_window['progress_bar'].bind('', '_mouse_enter') gui_window['progress_bar'].bind('', '_mouse_leave') gui_window.TKroot.bind(' ', save_window_position, add='+') gui_window.bind('', 'mini_mode') gui_window.bind('', 'exit_program') gui_window.bind('', 'repeat') gui_window.bind('', 's:83') gui_window.bind('', 'mute') gui_window.bind('', 'locate_uri') gui_window.bind('', '<>') gui_window.bind('', 'KeyPress') for i in range(1, 10): gui_window.bind(f'', f'{i}:{48 + i}') gui_window.TKroot.bind("", lambda _: None) def activate_gui(selected_tab=None, url_option='url_play'): global gui_window # selected_tab can be 'tab_queue', ['tab_library'], 'tab_playlists', 'tab_timer', or 'tab_settings' app_log.info(f'selected_tab={selected_tab}') if gui_window.is_closed(): State.using_tcl_theme = settings['experimental_features'] and os.path.exists(sun_valley_tcl_path) # create window if window not alive lb_tracks = create_track_list() selected_value = lb_tracks[len(done_queue)] if lb_tracks and len(done_queue) < len(lb_tracks) else None mini_mode = settings['mini_mode'] window_location = get_window_location() if settings['show_album_art']: size = COVER_MINI if mini_mode else COVER_NORMAL bg = settings['theme']['background'] try: album_art_data = resize_img(get_current_art(), bg, size, default_art=DEFAULT_ART) except OSError as e: handle_exception(e) album_art_data = resize_img(DEFAULT_ART, bg, size) else: album_art_data = None metadata = get_current_metadata() title, artist, album = metadata['title'], get_first_artist(metadata['artist']), metadata['album'] _track_position = get_track_position() if settings['mini_mode']: window_layout = MiniPlayerWindow(playing_status, settings, title, artist, album_art_data, track_length, _track_position) else: window_layout = MainWindow(playing_status, settings, title, artist, album, album_art_data, track_length, _track_position, lb_tracks, selected_value, timer, all_tracks, get_devices(), f"http://{get_ipv4()}:{State.PORT}?api_key={settings['api_key']}") window_metadata: dict = {'last_event': None, 'update_listboxes': False, 'update_volume_slider': False, 'library': {'sort_by': 0, 'ascending': True, 'region': 'cell', 'column': 1}, 'mouse_hover': '', 'url_input': '', 'pl_url_input': ''} pl_name = window_metadata['pl_name'] = next(iter(settings['playlists']), '') pl_tracks = window_metadata['pl_tracks'] = settings['playlists'].get(pl_name, []).copy() gui_window = Sg.Window('Music Caster', window_layout, grab_anywhere=mini_mode, no_titlebar=mini_mode, margins=(0, 0), finalize=True, icon=WINDOW_ICON, return_keyboard_events=True, use_default_focus=False, keep_on_top=mini_mode and settings['mini_on_top'], location=window_location, metadata=window_metadata, debugger_enabled=is_debug()) if State.using_tcl_theme: Sg.TOOLTIP_BACKGROUND_COLOR = settings['theme']['background'] try: if not State.theme_sourced: # as per State.using_tcl_theme, sun_valley_tcl_path exists # source errors out if called more than once gui_window.TKroot.tk.call('source', sun_valley_tcl_path) State.theme_sourced = True # this needs to be called every time the GUI is constructed gui_window.TKroot.tk.call('set_theme', 'dark') except TclError as e: # _tkinter.TclError: Theme sun-valley-light already exists if IS_FROZEN: handle_exception(e) else: raise e if not settings['mini_mode']: gui_window['queue'].update(set_to_index=len(done_queue), scroll_to_index=len(done_queue)) pl_tracks_values, pl_length = format_pl_lb(pl_tracks) gui_window['pl_length'].update(value=pl_length) gui_window['pl_tracks'].update(values=pl_tracks_values) set_callbacks() elif settings['mini_mode']: if selected_tab: update_settings('mini_mode', not settings['mini_mode']) gui_window.close() return activate_gui(selected_tab) else: # flash border if already in mini mode accent = settings['theme']['accent'] for _ in range(2): gui_window.TKroot.config(background=accent, bd=1) gui_window.read(50) gui_window.TKroot.config(background=accent, bd=0) gui_window.read(50) if not settings['mini_mode'] and selected_tab is not None: gui_window[selected_tab].select() if selected_tab == 'tab_timer': gui_window['timer_input'].set_focus() elif selected_tab == 'tab_url': gui_window[url_option].update(True) gui_window['url_input'].set_focus() with suppress(pyperclip.PyperclipException): default_text: str = pyperclip.paste() if default_text.startswith('http'): gui_window['url_input'].update(default_text) gui_window.metadata['url_input'] = default_text elif selected_tab == 'tab_playlists': with suppress(pyperclip.PyperclipException): default_text: str = pyperclip.paste() if default_text.startswith('http'): gui_window['pl_url_input'].update(default_text) gui_window.metadata['pl_url_input'] = default_text with suppress(TclError): focus_window(gui_window) def uri_at_idx(idx=0, offset=None): # converts listbox idx to uri # raises IndexError if idx < len(done_queue): uri = done_queue[idx] elif idx == len(done_queue): uri = music_queue[0] elif idx <= len(next_queue) + len(done_queue): uri = next_queue[idx - 1 - len(done_queue)] else: uri = music_queue[idx - len(next_queue) - len(done_queue)] return uri def locate_uri(selected_track_index=None, uri=None): with suppress(IndexError): if uri is None: if selected_track_index is None: raise IndexError uri = uri_at_idx(idx=selected_track_index) if uri.startswith('http'): if uri in url_metadata: # if source is from playlist... uri = url_metadata[uri].get('pl_src', uri) open_in_browser(uri) return True if os.path.exists(uri): if platform.system() == 'Windows': Popen(f'explorer /select,"{fix_path(uri)}"') elif platform.system() == 'Linux': try: Popen(['nautilus', uri]) except FileNotFoundError: try: # fallback 1 Popen(['dolphin', uri]) except FileNotFoundError: # fallback 2 Popen(['xdg-open', Path(uri).parent]) return True # tray_notify(gt('ERROR') + ':' + gt('Could not locate URI')) return False def exit_program(quick_exit=False): gui_window.close() close_tray() # stop any active scanning with suppress(NameError, asyncio.TimeoutError, concurrent.futures.TimeoutError): cast_browser.stop_discovery() with suppress(PyChromecastError): if cast is None: stop('exit program') elif cast is not None and cast.app_id == APP_MEDIA_RECEIVER and playing_status.busy(): try: cast.quit_app(30) except PyChromecastError as e: app_log.error('could not cast.quit_app', exc_info=True) handle_exception(e) DiscordPresence.close() if settings['persistent_queue'] and not quick_exit: save_queues() with suppress(RuntimeError): save_queue_thread.join() try: portalocker.unlock(lock_file) except Exception as e: # TODO: remove if errors are no longer raised handle_exception(e) sys.exit() def playlist_action(playlist_name, action='play'): if playlist_name in settings['playlists'] and settings['playlists'][playlist_name]: if action == 'next': next_queue.extend(get_audio_uris(playlist_name)) else: # if action == 'play' or action == 'queue' is_play = action == 'play' if is_play: done_queue.clear() music_queue.clear() shuffle_from = len(music_queue) music_queue.extend(get_audio_uris(playlist_name)) if settings['shuffle']: better_shuffle(music_queue, shuffle_from) if music_queue and (is_play or shuffle_from == 0): play() def other_tray_actions(_tray_item): if _tray_item.startswith('device:'): device_uuid = _tray_item[7:] with suppress(ValueError): change_device(device_uuid) elif _tray_item.startswith('PL:'): # playlist playlist_action(_tray_item[3:]) elif _tray_item == t('Select Folder'): folder_action() elif _tray_item.startswith('PF:'): # play folder folder_index = int(re.search(r'\d+', _tray_item).group()) Thread(target=play_uris, name='PlayFolder', daemon=True, args=[[music_folders[folder_index]]]).start() def event_is_close(main_event, main_values): ignore_events = {'file_action', 'folder_action', 'pl_add_tracks', 'add_music_folder'} return (main_values == Sg.WIN_CLOSED or main_event in {'Escape:27', ''} and gui_window.metadata['last_event'] not in ignore_events) def update_playlist_ui(set_to_index=None): pl_tracks = gui_window.metadata['pl_tracks'] pl_values, pl_length = format_pl_lb(pl_tracks) gui_window['pl_length'].update(value=pl_length) gui_window['pl_tracks'].update(values=pl_values, set_to_index=set_to_index) def read_main_window(): global track_position, track_start, track_end, timer, music_queue, done_queue, SYNC_WITH_CHROMECAST global OLD_CAST_POS, OLD_CAST_VOLUME window_read_tuple: (str, dict) = gui_window.read(timeout=100) # type: ignore main_event, main_values = window_read_tuple if main_event == 'KeyPress': e = gui_window.user_bind_event main_event = e.char if e.char else str(e.keysym) + ':' + str(e.keycode) if event_is_close(main_event, main_values): gui_window.close() if settings['gui_exits_app']: exit_program() return False if settings['mini_mode']: gui_window.TKroot.update_idletasks() main_value = main_values.get(main_event) if 'mouse_leave' not in main_event and 'mouse_enter' not in main_event and main_event != Sg.TIMEOUT_KEY: gui_window.metadata['last_event'] = main_event # update timer text if timer is old if not settings['mini_mode'] and timer == 0 and gui_window['timer_text'].metadata: gui_window['timer_text'].update(t('No Timer Set')) gui_window['timer_text'].metadata = False gui_window['cancel_timer'].update(visible=False) # these events modify main_event (chain events) if main_event.startswith('MouseWheel'): main_event = main_event.split(':', 1)[1] if gui_window.metadata['mouse_hover'] == 'progress_bar': delta = {'Up': settings['scrubbing_delta'], 'Down': -settings['scrubbing_delta']}.get(main_event, 0) if playing_status.busy() and track_length is not None: OLD_CAST_POS = get_track_position() new_position = min(max(track_position + delta, 0), track_length) gui_window['progress_bar'].update(value=new_position) main_values['progress_bar'] = new_position main_event = 'progress_bar' elif gui_window.metadata['mouse_hover'] in {'', 'volume_slider'}: # not in another scroll view with CAST_LOCK: delta = {'Up': settings['volume_delta'], 'Down': -settings['volume_delta']}.get(main_event, 0) new_volume = min(max(0, main_values['volume_slider'] + delta), 100) update_settings('volume', new_volume) update_settings('muted', False) update_volume(new_volume, 'mouse_wheel') OLD_CAST_VOLUME = new_volume SYNC_WITH_CHROMECAST = time.time() + 0.5 elif main_event in {'j', 'l'} and (main_values.get('tab_group', 'tab_queue') == 'tab_queue'): if playing_status.busy() and track_length is not None: delta = {'j': -settings['scrubbing_delta'], 'l': settings['scrubbing_delta']}[main_event] get_track_position() new_position = min(max(track_position + delta, 0), track_length) gui_window['progress_bar'].update(value=new_position) main_values['progress_bar'] = new_position main_event = 'progress_bar' gui_window.refresh() # override keypress events QUEUE_TAB_SELECTED = main_values.get('tab_group') in {'tab_queue', None} if main_event != '__TIMEOUT__': with suppress(KeyError): el = gui_window.find_element_with_focus() if el is not None and el.Key in {'track_format', 'sys_audio_delay'}: main_event, main_value = el.Key, main_values.get(el.Key) if main_event == '__TIMEOUT__': pass # avoids checking multiple if statements # change/select tabs elif main_event == '1:49' and not settings['mini_mode']: # Queue tab [Ctrl + 1] gui_window['tab_queue'].select() elif (main_event == '2:50' and not settings['mini_mode'] or # URL tab [Ctrl + 2] main_event == 'tab_group' and main_values.get('tab_group') == 'tab_url'): gui_window['tab_url'].select() gui_window['url_input'].set_focus() with suppress(pyperclip.PyperclipException): default_text: str = pyperclip.paste() if default_text.startswith('http'): gui_window['url_input'].update(value=default_text) elif (main_event == '3:51' and not settings['mini_mode'] or # Library tab [Ctrl + 3]: main_event == 'tab_group' and main_values['tab_group'] == 'tab_library'): gui_window['tab_library'].select() elif (main_event == '4:52' and not settings['mini_mode'] or # Playlists tab [Ctrl + 4]: main_event == 'tab_group' and main_values['tab_group'] == 'tab_playlists'): with suppress(pyperclip.PyperclipException): default_text: str = pyperclip.paste() if default_text.startswith('http'): gui_window['pl_url_input'].update(value=default_text) gui_window['tab_playlists'].select() gui_window['playlist_combo'].set_focus() elif (main_event == '5:53' and not settings['mini_mode'] or # Timer Tab [Ctrl + 5] main_event == 'tab_group' and main_values['tab_group'] == 'tab_timer'): gui_window['tab_timer'].select() gui_window['timer_input'].set_focus() elif main_event == '6:54' and not settings['mini_mode']: # Metadata tab [Ctrl + 6] gui_window['tab_metadata'].select() gui_window['metadata_file'].set_focus() elif main_event == '7:55' and not settings['mini_mode']: # Settings tab [Ctrl + 7] gui_window['tab_settings'].select() elif main_event in {'progress_bar_mouse_enter', 'queue_mouse_enter', 'pl_tracks_mouse_enter', 'volume_slider_mouse_enter', 'library_mouse_enter'}: if main_event in {'progress_bar_mouse_enter', 'volume_slider_mouse_enter'} and settings['mini_mode']: gui_window.grab_any_where_off() gui_window.metadata['mouse_hover'] = '_'.join(main_event.split('_')[:-2]) elif main_event in {'progress_bar_mouse_leave', 'queue_mouse_leave', 'pl_tracks_mouse_leave', 'volume_slider_mouse_leave', 'library_mouse_leave'}: if main_event in {'progress_bar_mouse_leave', 'volume_slider_mouse_leave'} and settings['mini_mode']: gui_window.grab_any_where_on() if main_event != 'volume_slider_mouse_leave': gui_window.metadata['mouse_hover'] = '' elif main_event == 'pause/resume' or main_event == 'k' and QUEUE_TAB_SELECTED: if playing_status.paused(): resume('gui') elif playing_status.playing(): pause() elif music_queue: play() elif next_queue: music_queue.appendleft(next_queue.popleft()) play() elif done_queue: # start from top again music_queue.extend(done_queue) done_queue.clear() play() else: play_all() # Shift + N elif (main_event == 'next' or main_event == 'N' and QUEUE_TAB_SELECTED) and playing_status.busy(): next_track() # Shift + P || Shift + B elif (main_event == 'prev' or main_event == 'P' or main_event == 'B' and QUEUE_TAB_SELECTED) and playing_status.busy(): prev_track() elif main_event == 'devices': change_device(main_value.id) elif main_event == 'sys_audio_delay': with suppress(ValueError): update_settings('sys_audio_delay', int(main_value)) elif main_event == 'track_format': update_settings('track_format', main_value) elif main_event == 'on_battery_res': with suppress(KeyError): res = get_all_resolutions()[main_value] update_settings('on_battery_res', (res['w'], res['h'])) elif main_event == 'plugged_in_res': with suppress(KeyError): res = get_all_resolutions()[main_value] update_settings('plugged_in_res', (res['w'], res['h'])) elif main_event == 'shuffle': update_settings('shuffle', not settings['shuffle']) elif main_event == 'repeat': cycle_repeat() elif (main_event == 'volume_slider' or ((main_event in {'a', 'd'} or main_event.isdecimal()) and QUEUE_TAB_SELECTED)): # User scrubbed volume bar or pressed a, d, # try: new_volume = int(main_event) * 10 except ValueError: delta = {'a': -settings['volume_delta'], 'd': settings['volume_delta']}.get(main_event, 0) new_volume = main_values['volume_slider'] + delta update_settings('volume', new_volume) # un-mute if volume slider was moved update_settings('muted', False) update_volume(new_volume, 'volume_slider') elif main_event in {'Up:38', 'Down:40'}: focused_element = gui_window.FindElementWithFocus() if settings['mini_mode'] or focused_element not in {gui_window['queue'], gui_window['pl_tracks'], gui_window['music_folders']}: delta = settings['volume_delta'] if main_event == 'Up:38' else -settings['volume_delta'] new_volume = main_values['volume_slider'] + delta update_settings('volume', new_volume) # un-mute if volume slider was moved update_settings('muted', False) update_volume(new_volume, 'Up:38') elif main_event == 'mute': # toggle mute update_volume(0 if update_settings('muted', not settings['muted']) else settings['volume'], 'mute') elif main_event in {'Prior:33', 'Next:34'} and not settings['mini_mode']: # page up, page down focused_element = gui_window.FindElementWithFocus() move = {'Prior:33': -3, 'Next:34': 3}[main_event] if focused_element == gui_window['queue'] and main_values['queue']: new_i = gui_window['queue'].get_indexes()[0] + move new_i = min(max(new_i, 0), len(gui_window['queue'].Values) - 1) gui_window['queue'].update(set_to_index=new_i, scroll_to_index=max(new_i - 3, 0)) elif focused_element == gui_window['pl_tracks'] and main_values['pl_tracks']: new_i = gui_window['pl_tracks'].get_indexes()[0] + move new_i = min(max(new_i, 0), len(gui_window.metadata['pl_tracks']) - 1) gui_window['pl_tracks'].update(set_to_index=new_i, scroll_to_index=max(new_i - 3, 0)) elif main_event == 'queue' and main_value: with suppress(ValueError): selected_uri_index = gui_window['queue'].get_indexes()[0] if selected_uri_index <= len(done_queue): prev_track(times=len(done_queue) - selected_uri_index, forced=True, ignore_timestamps=True) else: next_track(times=selected_uri_index - len(done_queue), forced=True, ignore_timestamps=True) values = create_track_list() dq_len = len(done_queue) gui_window['queue'].update(values=values, set_to_index=dq_len, scroll_to_index=dq_len) elif main_event == 'album' and playing_status.busy(): locate_uri(len(done_queue)) # queue related actions elif main_event == 'mini_mode': update_settings('mini_mode', not settings['mini_mode']) gui_window.close() activate_gui() elif main_event == 'queue_all': queue_all() elif main_event == 'clear_queue': gui_window['queue'].update(set_to_index=0) gui_window['queue'].update(values=[]) if playing_status.busy(): stop('clear_queue') music_queue.clear() next_queue.clear() done_queue.clear() save_queues() gui_window.refresh() elif main_event == 'save_to_pl': indices = gui_window['queue'].get_indexes() if len(indices) <= 1: tracks_to_add = chain(done_queue, [music_queue[0]] if music_queue else [], next_queue, islice(music_queue, 1, None)) else: tracks_to_add = (uri_at_idx(idx) for idx in indices) pl_tracks = gui_window.metadata['pl_tracks'] = [] pl_tracks.extend(tracks_to_add) gui_window.metadata['pl_name'] = '' gui_window['tab_playlists'].select() gui_window['pl_name'].set_focus() gui_window['pl_name'].update(value=gui_window.metadata['pl_name']) pl_tracks_values, pl_tracks_length = format_pl_lb(pl_tracks) gui_window['pl_length'].update(value=pl_tracks_length) gui_window['pl_tracks'].update(values=pl_tracks_values, set_to_index=0) elif main_event == 'locate_uri': if not settings['mini_mode'] and main_values['queue']: for index in gui_window['queue'].get_indexes(): locate_uri(index) else: locate_uri(len(done_queue)) elif main_event == 'copy_uri' or (main_event == '<>' and QUEUE_TAB_SELECTED): with suppress(IndexError): text_to_copy = ', '.join(( uri_at_idx(index) for index in gui_window['queue'].get_indexes())) if text_to_copy: pyperclip.copy(text_to_copy) elif main_event == 'edit_metadata': indices = gui_window['queue'].get_indexes() if len(indices) == 1 and metadata_process_file(uri_at_idx(indices[0]), 'read_main_window:edit_metadata'): gui_window['tab_metadata'].select() elif main_event == 'move_to_next_up': for i, index_to_move in enumerate(gui_window['queue'].get_indexes(), 1): dq_len = len(done_queue) nq_len = len(next_queue) if index_to_move < dq_len: track = done_queue[index_to_move] del done_queue[index_to_move] if settings['reversed_play_next']: next_queue.appendleft(track) else: next_queue.append(track) if i == len(main_values['queue']): # update gui after the last swap values = create_track_list() gui_window['queue'].update(values=values, set_to_index=len(done_queue) + len(next_queue), scroll_to_index=max(len(done_queue) + len(next_queue) - 16, 0)) save_queues() elif index_to_move > dq_len + nq_len: track = music_queue[index_to_move - dq_len - nq_len] del music_queue[index_to_move - dq_len - nq_len] if settings['reversed_play_next']: next_queue.appendleft(track) else: next_queue.append(track) if i == len(main_values['queue']): # update gui after the last swap values = create_track_list() gui_window['queue'].update(values=values, set_to_index=dq_len + len(next_queue), scroll_to_index=max(len(done_queue) + len(next_queue) - 3, 0)) save_queues() gui_window.metadata['update_listboxes'] = False elif main_event == 'move_up': for i, index_to_move in enumerate(gui_window['queue'].get_indexes(), 1): new_i = index_to_move - 1 dq_len = len(done_queue) nq_len = len(next_queue) if index_to_move < dq_len and new_i >= 0: # move within dq # swap places done_queue[index_to_move], done_queue[new_i] = done_queue[new_i], done_queue[index_to_move] elif index_to_move == dq_len and done_queue: # move index -1 to 1 or top of next_queue if next_queue: next_queue.insert(0, done_queue.pop()) else: music_queue.insert(1, done_queue.pop()) elif index_to_move == dq_len + 1: # move 1 to -1 if next_queue: done_queue.append(next_queue.popleft()) else: track = music_queue[1] del music_queue[1] done_queue.append(track) elif next_queue and dq_len < index_to_move <= nq_len + dq_len: # within next_queue nq_i = new_i - dq_len - 1 # swap places, could be more efficient using a custom deque with O(n) swaps instead of O(2n) next_queue[nq_i], next_queue[nq_i + 1] = next_queue[nq_i + 1], next_queue[nq_i] elif next_queue and index_to_move == dq_len + nq_len + 1: # moving into next queue track = music_queue[1] del music_queue[1] next_queue.insert(nq_len - 1, track) elif new_i >= 0: # moving within mq mq_i = new_i - dq_len - nq_len music_queue[mq_i], music_queue[mq_i + 1] = music_queue[mq_i + 1], music_queue[mq_i] else: new_i = max(new_i, 0) if i == len(main_values['queue']): # update gui after moving the last selected track values = create_track_list() gui_window['queue'].update(values=values, set_to_index=new_i, scroll_to_index=max(new_i - 7, 0)) save_queues() elif main_event == 'move_down': for i, index_to_move in enumerate(reversed(gui_window['queue'].get_indexes()), 1): dq_len, nq_len, mq_len = len(done_queue), len(next_queue), len(music_queue) if index_to_move < dq_len + nq_len + mq_len - 1: new_i = index_to_move + 1 if index_to_move == dq_len - 1: # move index -1 to 1 if next_queue: next_queue.appendleft(done_queue.pop()) else: music_queue.insert(1, done_queue.pop()) elif index_to_move < dq_len: # move within dq done_queue[index_to_move], done_queue[new_i] = done_queue[new_i], done_queue[index_to_move] elif index_to_move == dq_len: # move 1 to -1 if next_queue: done_queue.append(next_queue.popleft()) else: track = music_queue[1] del music_queue[1] done_queue.append(track) elif next_queue and index_to_move == dq_len + nq_len: # moving into music_queue music_queue.insert(2, next_queue.pop()) elif index_to_move < dq_len + nq_len + 1: # within next_queue nq_i = index_to_move - dq_len - 1 next_queue[nq_i], next_queue[nq_i - 1] = next_queue[nq_i - 1], next_queue[nq_i] else: # within music_queue mq_i = new_i - dq_len - nq_len # swap places music_queue[mq_i], music_queue[mq_i - 1] = music_queue[mq_i - 1], music_queue[mq_i] if i == len(main_values['queue']): # update gui after moving the last selected track values, scroll_to = create_track_list(), max(new_i - 3, 0) gui_window['queue'].update(values=values, set_to_index=new_i, scroll_to_index=scroll_to) save_queues() elif main_event == 'remove_track' and main_values['queue']: for i, index_to_remove in enumerate(reversed(gui_window['queue'].get_indexes()), 1): dq_len, nq_len, mq_len = len(done_queue), len(next_queue), len(music_queue) if index_to_remove < dq_len: del done_queue[index_to_remove] elif index_to_remove == dq_len: with suppress(IndexError): # remove the "0. XXX" track that could be playing right now music_queue.popleft() if next_queue: music_queue.appendleft(next_queue.popleft()) # if queue is empty but repeat is all AND there are tracks in the done_queue if not music_queue and settings['repeat'] is False and done_queue: music_queue.extend(done_queue) done_queue.clear() # start playing new track if a track was being played if not sar.alive: if music_queue and playing_status.busy(): play() else: stop('remove_track') elif index_to_remove <= nq_len + dq_len: del next_queue[index_to_remove - dq_len - 1] elif index_to_remove < nq_len + mq_len + dq_len: del music_queue[index_to_remove - dq_len - nq_len] if i == len(main_values['queue']): # update gui after the last removal values = create_track_list() new_i = min(len(values), index_to_remove) gui_window['queue'].update(values=values, set_to_index=new_i, scroll_to_index=max(new_i - 3, 0)) elif main_event == 'select_files': Thread(target=file_action, name='FileAction', daemon=True, args=[main_values['fs_action']]).start() elif main_event == 'select_folders': Thread(target=folder_action, name='FolderAction', daemon=True, args=[main_values['fs_action']]).start() elif main_event == 'install_update': if not State.installing_update: gui_window['install_update'].update(visible=False) Thread(target=update_checker.auto_update, kwargs={'from_gui': True}, daemon=True, name='Updater').start() elif main_event == 'play_all': if not any(filter(lambda thread: thread.name == 'PlayAll', threading.enumerate())): Thread(target=play_all, name='PlayAll', daemon=True).start() elif main_event in {'library', 'Play::library', 'Play Next::library', 'Queue::library', 'Locate::library'}: library_metadata = gui_window.metadata['library'] if library_metadata['region'] == 'heading': col_index = library_metadata['column'] if col_index == library_metadata['sort_by']: reverse = library_metadata['ascending'] = not library_metadata['ascending'] else: library_metadata['sort_by'] = col_index reverse = library_metadata['ascending'] = True library_items = gui_window['library'].Values library_items.sort(key=lambda row: row[col_index - 1].casefold(), reverse=not reverse) gui_window['library'].update(library_items) elif main_event == 'Locate::library': for index in main_values['library']: locate_uri(uri=gui_window['library'].Values[index][-1]) elif main_values['library']: paths_to_play = (gui_window['library'].Values[index][-1] for index in main_values['library']) if main_event in {'library', 'Play::library'}: if settings['queue_library']: play_all(paths_to_play) else: play_uris(paths_to_play) else: # play_next has priority over queue_uris play_uris(paths_to_play, queue_uris=True, play_next=main_event == 'Play Next::library') elif main_event == 'progress_bar' and track_length is not None: if playing_status.stopped(): gui_window['progress_bar'].update(disabled=True, value=0) return else: # do not debounce when playing locally track_position = int(main_values['progress_bar']) if cast is None: set_pos(track_position) else: # debounce setting the track position # background_thread will call set_pos seek_queue.append(track_position) SYNC_WITH_CHROMECAST = time.time() + 1 # main window settings tab elif main_event == 'open_email': open_in_browser(create_support_email_url()) elif main_event == 'open_github': open_in_browser('https://github.com/elibroftw/music-caster') elif main_event == 'web_gui': api_key = settings['api_key'] open_in_browser(f'http://{get_lan_ip()}:{State.PORT}?api_key={api_key}') # toggle settings elif main_event in TOGGLEABLE_SETTINGS: update_settings(main_event, main_value) if main_event == 'run_on_startup': start_on_login_modifications() elif main_event == 'persistent_queue': if main_value: save_queues() else: update_settings('queues', {'done': [], 'music': [], 'next': []}) update_settings('populate_queue_startup', False) gui_window['populate_queue_startup'].update(value=False) elif main_event in 'populate_queue_startup': gui_window['persistent_queue'].update(value=False) update_settings('persistent_queue', False) elif main_event == 'discord_rpc': with suppress(Exception): if main_value: if playing_status.busy(): metadata = url_metadata['SYSTEM_AUDIO'] if sar.alive else get_uri_metadata(music_queue[0]) title, artist = metadata['title'], get_first_artist(metadata['artist']) DiscordPresence.connect() if track_start is not None and track_length is not None: DiscordPresence.update(t('By') + f': {artist}', title, 'Listening', end=track_start + track_length) elif not main_value: DiscordPresence.clear() elif main_event in {'show_album_art', 'vertical_gui', 'flip_main_window'}: # re-render main GUI gui_window.close() activate_gui('tab_settings') elif main_event in {'show_track_number', 'show_queue_index'}: gui_window.metadata['update_listboxes'] = True elif main_event == 'scan_folders' and main_value: index_all_tracks() elif main_event == 'folder_cover_override': size = COVER_MINI if settings['mini_mode'] else COVER_NORMAL bg = settings['theme']['background'] try: album_art_data = resize_img(get_current_art(), bg, size, default_art=DEFAULT_ART) except OSError as e: handle_exception(e) album_art_data = resize_img(DEFAULT_ART, bg, size) gui_window['artwork'].update(data=album_art_data) elif main_event == 'lang': State.lang = main_value gui_window.close() activate_gui('tab_settings') refresh_tray(True) elif main_event == 'remove_music_folder' and main_values['music_folders']: with suppress(ValueError): for selected_item in main_values['music_folders']: music_folders.remove(selected_item) gui_window['music_folders'].update(music_folders) refresh_tray() save_settings() if settings['scan_folders']: index_all_tracks() elif main_event == 'add_music_folder': initial_folder = settings['last_folder'] if settings['use_last_folder'] else get_default_music_folder() folder_path = Sg.popup_get_folder(t('Select Folder'), initial_folder=initial_folder, no_window=True, icon=WINDOW_ICON) if folder_path: add_music_folder([folder_path]) elif main_event == 'settings_file': startfile(SETTINGS_FILE) elif main_event == 'changelog_file': try: changelog_path = f'{sys._MEIPASS}/CHANGELOG.txt' except AttributeError: changelog_path = 'CHANGELOG.txt' if not os.path.exists(changelog_path): changelog_url = 'https://github.com/elibroftw/music-caster/blob/master/CHANGELOG.txt' open_in_browser(changelog_url) else: startfile(changelog_path) elif main_event == 'music_folders': with suppress(IndexError): Popen(f'explorer "{fix_path(main_values["music_folders"][0])}"') # url tab elif main_event == 'url_input': gui_window.metadata['url_input'] = main_value elif main_event == 'url_input_cut': cut_text = get_cut_text(gui_window, 'url_input') if cut_text: pyperclip.copy(cut_text) gui_window.metadata['url_input'] = gui_window['url_input'].get() elif main_event == 'url_input_copy': with suppress(TclError): pyperclip.copy(gui_window['url_input'].Widget.selection_get()) elif (main_event in {'\r', 'special 16777220', 'special 16777221', 'url_submit'} and main_values.get('tab_group') == 'tab_url' and main_values['url_input']): urls_to_insert = main_values['url_input'].strip() if '\n' in urls_to_insert: urls_to_insert = urls_to_insert.split('\n') else: urls_to_insert = urls_to_insert.split(';') gui_window['url_input'].update(value='') if main_values['url_play'] or not music_queue: music_queue.extendleft(reversed(urls_to_insert)) gui_window['url_msg'].update(t('Loading URL(s)'), text_color='yellow') gui_window.read(1) play() gui_window['url_msg'].update('') urls_to_insert.pop(0) elif main_values['url_queue']: music_queue.extend(urls_to_insert) gui_window['url_msg'].update(t('Added URL(s)'), text_color='green') gui_window.TKroot.after(2000, lambda: gui_window['url_msg'].update(value='')) else: # add to next queue if settings['reversed_play_next']: next_queue.extendleft(reversed(urls_to_insert)) else: next_queue.extend(urls_to_insert) gui_window['url_msg'].update(t('Added URL(s)'), text_color='green') gui_window.TKroot.after(2000, lambda: gui_window['url_msg'].update(value='')) for inserted_url in urls_to_insert: uris_to_scan.put(inserted_url) gui_window['url_input'].set_focus() gui_window.metadata['update_listboxes'] = True # video tab elif main_event == 'video_select_file': Thread(target=video_file_action(), name='VideoFileAction', daemon=True).start() # timer tab elif main_event == 'cancel_timer': gui_window['timer_text'].update(t('No Timer Set')) gui_window['timer_text'].metadata = False gui_window['timer_error'].update(visible=False) gui_window['cancel_timer'].update(visible=False) cancel_timer() # handle enter/submit event elif main_event in SUBMIT_EVENTS and main_values.get('tab_group') == 'tab_timer': try: timer_value: str = main_values['timer_input'] timer_set_to = set_timer(timer_value) gui_window['timer_text'].update(t('Timer set for $TIME').replace('$TIME', timer_set_to)) gui_window['timer_text'].metadata = True gui_window['cancel_timer'].update(visible=True) gui_window['timer_error'].update(visible=False) gui_window['timer_input'].update(value='') gui_window['timer_input'].set_focus() except ValueError: # flash timer error for _ in range(3): gui_window['timer_error'].update(visible=True, text_color='#ffcccb') gui_window.read(10) gui_window['timer_error'].update(text_color='red') gui_window.read(10) gui_window['timer_input'].set_focus() elif main_event in {'shut_down', 'hibernate', 'sleep', 'timer_stop'}: update_settings('timer_hibernate', main_values['hibernate']) update_settings('timer_sleep', main_values['sleep']) update_settings('timer_shut_down', main_values['shut_down']) # playlists tab elif main_event == 'playlist_combo': # user selected a playlist from the drop-down pl_name = gui_window.metadata['pl_name'] = main_value if main_value in settings['playlists'] else '' pl_tracks = gui_window.metadata['pl_tracks'] = settings['playlists'].get(pl_name, []).copy() gui_window['pl_name'].update(value=pl_name) pl_tracks_values, pl_length = format_pl_lb(pl_tracks) gui_window['pl_length'].update(value=pl_length) gui_window['pl_tracks'].update(values=pl_tracks_values, set_to_index=0) elif main_event in {'new_pl', 'n:78'}: gui_window.metadata['pl_name'] = '' gui_window.metadata['pl_tracks'] = [] gui_window['pl_name'].update(value='') gui_window['pl_name'].set_focus() gui_window['pl_length'].update(value='') gui_window['pl_tracks'].update(values=[]) gui_window['playlist_combo'].update(value='') elif main_event == 'export_pl': if main_values['playlist_combo'] and settings['playlists'].get(main_values['playlist_combo']): playlist_uris = settings['playlists'][main_values['playlist_combo']] playlist_path = export_playlist(main_values['playlist_combo'], playlist_uris) locate_uri(uri=playlist_path) elif main_event == 'delete_pl': pl_name = gui_window.metadata['pl_name'] = main_values.get('playlist_combo', '') settings['playlists'].pop(pl_name, None) pl_name = gui_window.metadata['pl_name'] = next(iter(settings['playlists']), '') gui_window['playlist_combo'].update(value=pl_name, values=tuple(settings['playlists'])) pl_tracks = gui_window.metadata['pl_tracks'] = settings['playlists'].get(pl_name, []).copy() # update playlist editor gui_window['pl_name'].update(value=pl_name) pl_tracks_values, pl_tracks_length = format_pl_lb(pl_tracks) gui_window['pl_length'].update(value=pl_tracks_length) gui_window['pl_tracks'].update(values=pl_tracks_values, set_to_index=0) save_settings() refresh_tray() elif main_event == 'play_pl': playlist_action(main_values['playlist_combo']) elif main_event == 'queue_pl': playlist_action(main_values['playlist_combo'], 'queue') gui_window.metadata['update_listboxes'] = True elif main_event == 'add_next_pl': playlist_action(main_values['playlist_combo'], 'next') gui_window.metadata['update_listboxes'] = True elif main_event in {'pl_save', 's:83'} and main_values.get('tab_group') == 'tab_playlists': # save playlist if main_values['pl_name']: pl_name = gui_window.metadata['pl_name'] save_name = main_values['pl_name'] if pl_name != save_name: # if user is renaming a playlist, remove old data settings['playlists'].pop(pl_name, '') pl_name = gui_window.metadata['pl_name'] = save_name settings['playlists'][pl_name] = gui_window.metadata['pl_tracks'] # sort playlists alphabetically playlist_names = sorted(settings['playlists']) settings['playlists'] = {k: settings['playlists'][k] for k in playlist_names} gui_window['playlist_combo'].update(value=pl_name, values=playlist_names) save_settings() gui_window['pl_saved'].update(visible=True) gui_window.read(1) gui_window.TKroot.after(2000, lambda: gui_window['pl_saved'].update(visible=False)) refresh_tray() elif (main_event == 'pl_rm_items' and main_values['pl_tracks'] and main_values.get('tab_group') == 'tab_playlists'): # remove items from playlist # remove bottom to top to avoid dynamic indices pl_tracks = gui_window.metadata['pl_tracks'] for i, to_remove in enumerate(reversed(gui_window['pl_tracks'].get_indexes()), 1): pl_tracks.pop(to_remove) if i == len(main_values['pl_tracks']): # update gui after the last removal scroll_to_index = max(to_remove - 3, 0) new_values, new_length = format_pl_lb(pl_tracks) gui_window['pl_length'].update(value=new_length) gui_window['pl_tracks'].update(new_values, set_to_index=to_remove, scroll_to_index=scroll_to_index) elif main_event == 'pl_add_tracks': initial_folder = settings['last_folder'] if settings['use_last_folder'] else get_default_music_folder() file_paths = Sg.popup_get_file('Select Audio Files', no_window=True, initial_folder=initial_folder, multiple_files=True, file_types=AUDIO_FILE_TYPES, icon=WINDOW_ICON) if file_paths: pl_tracks = gui_window.metadata['pl_tracks'] pl_tracks.extend(get_audio_uris(file_paths)) update_settings('last_folder', os.path.dirname(file_paths[-1])) with suppress(TclError): gui_window.TKroot.focus_force() gui_window.normal() new_values, pl_length = format_pl_lb(pl_tracks) gui_window['pl_length'].update(value=pl_length) new_i = len(new_values) - 1 gui_window['pl_tracks'].update(new_values, set_to_index=new_i, scroll_to_index=max(new_i - 3, 0)) elif main_event == 'pl_url_input': gui_window.metadata['pl_url_input'] = main_value elif main_event == 'pl_url_input_cut': cut_text = get_cut_text(gui_window, 'pl_url_input') if cut_text: pyperclip.copy(cut_text) gui_window.metadata['pl_url_input'] = gui_window['pl_url_input'].get() elif main_event == 'pl_url_input_copy': with suppress(TclError): pyperclip.copy(gui_window['pl_url_input'].Widget.selection_get()) elif main_event == 'pl_add_url' or (main_event in SUBMIT_EVENTS and main_values.get('tab_group') == 'tab_playlists'): links = main_values['pl_url_input'] if '\n' in links: links = links.split('\n') else: links = links.split(';') for link in links: if link.startswith('http://') or link.startswith('https://'): uris_to_scan.put(link) pl_tracks = gui_window.metadata['pl_tracks'] pl_tracks.append(link) new_values, pl_length = format_pl_lb(pl_tracks) gui_window['pl_length'].update(value=pl_length) new_i = len(new_values) - 1 gui_window['pl_tracks'].update(new_values, set_to_index=new_i, scroll_to_index=max(new_i - 3, 0)) # empty the input field gui_window['pl_url_input'].update(value='') gui_window['pl_url_input'].set_focus() else: tray_notify(t('ERROR') + ': ' + t("Invalid URL. URL's need to start with http:// or https://")) elif main_event == 'pl_move_up': # only allow moving up if 1 item is selected and pl_files is not empty for i, to_move in enumerate(gui_window['pl_tracks'].get_indexes(), 1): if to_move: # can't move the first index up new_i = to_move - 1 pl_tracks = gui_window.metadata['pl_tracks'] pl_tracks.insert(new_i, pl_tracks.pop(to_move)) if i == len(main_values['pl_tracks']): # update gui after the last swap new_values, pl_length = format_pl_lb(pl_tracks) gui_window['pl_length'].update(value=pl_length) gui_window['pl_tracks'].update(new_values, set_to_index=new_i, scroll_to_index=max(new_i - 3, 0)) elif main_event == 'pl_move_down': # only allow moving down if 1 item is selected and pl_files is not empty for i, to_move in enumerate(gui_window['pl_tracks'].get_indexes(), 1): pl_tracks = gui_window.metadata['pl_tracks'] if to_move < len(pl_tracks) - 1: new_i = to_move + 1 pl_tracks.insert(new_i, pl_tracks.pop(to_move)) if i == len(main_values['pl_tracks']): # update gui after the last swap pl_new_values, pl_length = format_pl_lb(pl_tracks) gui_window['pl_length'].update(value=pl_length) gui_window['pl_tracks'].update(values=pl_new_values, set_to_index=new_i, scroll_to_index=max(new_i - 3, 0)) elif main_event in {'pl_locate_selected', 'pl_tracks'}: for i in gui_window['pl_tracks'].get_indexes(): locate_uri(uri=gui_window.metadata['pl_tracks'][i]) elif main_event == 'pl_copy_selected': with suppress(IndexError): text_to_copy = ', '.join(( gui_window.metadata['pl_tracks'][i] for i in gui_window['pl_tracks'].get_indexes())) if text_to_copy: pyperclip.copy(text_to_copy) elif main_event in {'play_pl_selected', 'queue_pl_selected', 'add_next_pl_selected'}: uris = (gui_window.metadata['pl_tracks'][i] for i in gui_window['pl_tracks'].get_indexes()) play_uris(uris, queue_uris=main_event == 'queue_pl_selected', play_next=main_event == 'add_next_pl_selected', natural_sort=settings['shuffle']) # metadata editor tab elif main_event in {'metadata_browse', 'metadata_file'}: initial_folder = settings['last_folder'] if settings['use_last_folder'] else get_default_music_folder() selected_file = Sg.popup_get_file('Select audio file', initial_folder=initial_folder, no_window=True, file_types=AUDIO_FILE_TYPES, icon=WINDOW_ICON) metadata_process_file(selected_file, f'read_main_window:{main_event}') elif main_event == 'metadata_select_art' and gui_window['metadata_file'].get(): selected_file = Sg.popup_get_file('Select image/audio file', no_window=True, file_types=IMG_FILE_TYPES, icon=WINDOW_ICON) if selected_file: if Path(selected_file).suffix.casefold() in AUDIO_EXTS: mime, artwork = get_album_art(selected_file, settings['folder_cover_override']) else: img = Image.open(selected_file).convert('RGB') data = io.BytesIO() img.save(data, format='jpeg', quality=95) mime, artwork = 'image/jpeg', b64encode(data.getvalue()) artwork = None if artwork == DEFAULT_ART else artwork if artwork is not None: try: display_art = resize_img(artwork, settings['theme']['background'], COVER_MINI) gui_window['metadata_art'].metadata = (mime, artwork) gui_window['metadata_art'].update(data=display_art) except OSError as e: handle_exception(e) elif main_event == 'metadata_search_art' and gui_window['metadata_file'].get(): # search for artwork using spotify API gui_window['metadata_msg'].update(value=t('Searching for artwork...'), text_color='yellow') found_artwork = False for mkt in {'MX', 'CA', 'US', 'UK', 'HK'}: title = main_values['metadata_title'] artist = main_values['metadata_artist'] url = f'https://api.spotify.com/v1/search?q={title}' if artist: url += f'+artist:{artist}' url += f'&type=track&market={mkt}' r = requests.get(url, headers=get_spotify_headers()).json() if 'tracks' in r: for art_link in (item['album']['images'][0]['url'] for item in r['tracks']['items']): original_art = b64encode(requests.get(art_link).content).decode() found_artwork = True try: display_art = resize_img(original_art, settings['theme']['background'], COVER_MINI) gui_window['metadata_art'].metadata = ('image/jpeg', original_art) gui_window['metadata_art'].update(data=display_art) except OSError as e: handle_exception(e) found_artwork = False break if found_artwork: gui_window['metadata_msg'].update(value=t('Artwork found'), text_color='green') gui_window.TKroot.after(2000, lambda: gui_window['metadata_msg'].update(value='')) else: gui_window['metadata_msg'].update(value=t('No artwork found'), text_color='red') gui_window.TKroot.after(2000, lambda: gui_window['metadata_msg'].update(value='')) elif main_event == 'metadata_remove_art': gui_window['metadata_art'].metadata = (None, None) gui_window['metadata_art'].update(data=None) elif main_event in {'metadata_save', 's:83'} and main_values.get('tab_group') == 'tab_metadata': if gui_window['metadata_file'].get(): new_metadata = {'title': main_values['metadata_title'], 'artist': main_values['metadata_artist'], 'album': main_values['metadata_album'], 'explicit': main_values['metadata_explicit'], 'track_number': main_values['metadata_track_num']} # album art optional if gui_window['metadata_art'].metadata is not None: mime, art = gui_window['metadata_art'].metadata new_metadata['mime'] = mime new_metadata['art'] = art gui_window['metadata_msg'].update(value=t('Saving metadata'), text_color='yellow') try: set_metadata(gui_window['metadata_file'].get(), new_metadata) gui_window['metadata_msg'].update(value=t('Metadata saved'), text_color='green') except Exception as e: # e.g. ValueError track number incorrectly entered print('error', repr(e)) error = t('ERROR') + ': ' + repr(e) gui_window['metadata_msg'].update(value=error, text_color='red') gui_window.TKroot.after(2000, lambda: gui_window['metadata_msg'].update(value='')) gui_window['title'].update(' ' + gui_window['title'].DisplayText + ' ') # try updating now playing elif main_event == 'exit_program': exit_program() # other GUI updates if gui_window.metadata['update_listboxes'] and not settings['mini_mode']: gui_window.metadata['update_listboxes'] = False dq_len = len(done_queue) lb_tracks = create_track_list() gui_window['queue'].update(values=lb_tracks, set_to_index=dq_len, scroll_to_index=dq_len) pl_tracks = gui_window.metadata['pl_tracks'] pl_values, pl_length = format_pl_lb(pl_tracks) gui_window['pl_length'].update(value=pl_length) gui_window['pl_tracks'].update(values=pl_values) if len(all_tracks) != len(gui_window['library'].Values): lib_data = sorted( ( [ track['title'], get_first_artist(track['artist']), track['album'], uri, ] for uri, track in index_all_tracks(False).items() ), key=lambda cols: cols[1], ) gui_window['library'].update(values=lib_data) if gui_window.metadata['update_volume_slider']: gui_window['mute'].update(image_data=VOLUME_MUTED_IMG if settings['muted'] else VOLUME_IMG) gui_window['mute'].set_tooltip(t('unmute') if settings['muted'] else t('mute')) gui_window['volume_slider'].update(0 if settings['muted'] else settings['volume']) gui_window.metadata['update_volume_slider'] = False # update progress bar if time.time() > SYNC_WITH_CHROMECAST: progress_bar: Sg.Slider = gui_window['progress_bar'] time_elapsed_text, time_left_text = create_progress_bar_texts( get_track_position(), track_length ) if time_elapsed_text != gui_window['time_elapsed'].get(): gui_window['time_elapsed'].update(time_elapsed_text) if time_left_text != gui_window['time_left'].get(): gui_window['time_left'].update(time_left_text) if music_queue and playing_status.busy() and not sar.alive: progress_bar.update(floor(track_position)) return True def start_on_login_modifications(): """ Run platform specific implementation of startup modification """ if platform.system() == 'Windows': app_log.info('removing old startup shortcuts') rm_old_startup_shortcuts() app_log.info('removed old startup shortcuts') app_log.info('creating/removing startup registry entry') start_on_login_win32(working_dir, settings['run_on_startup']) app_log.info('created/removed startup registry entry') else: print('TODO: start_on_login_modifications not implemented for', platform.system()) def cast_monitor(sent: bool = True, msg: dict | None = None, is_callback=True): global track_position, track_start, track_end, OLD_CAST_VOLUME, OLD_CAST_POS if cast is None: return # assume this code can raise exceptions # since I did remove it from that try-catch block try: if msg is None and playing_status.busy(): # block/monitor in background thread if is_callback: # avoid recursion error if playing_status.playing(): raise NotConnected return return cast.media_controller.update_status(callback_function=cast_monitor) except AttributeError: # don't need to monitor if device switched randomly return except (NotConnected, UnsupportedNamespace): app_log.info(f'cast.media_controller player state: {cast.media_controller.status.player_state}') # we might care if not connected with suppress(RequestTimeout): cast.wait(3) return except Exception as e: handle_exception(e) return try: CAST_LOCK.acquire() if cast.app_id == APP_MEDIA_RECEIVER and time.time() > SYNC_WITH_CHROMECAST: media_controller = cast.media_controller is_stopped = media_controller.status.player_is_idle is_live = track_length is None if not is_stopped and playing_status.busy(): # sync track position with chromecast, also allows scrubbing from external apps with suppress(IndexError): # music_queue may be mutated buffer = 2 if music_queue[0].startswith('http') else 0.6 current_time = media_controller.status.adjusted_current_time if current_time is not None and abs(current_time - OLD_CAST_POS) > buffer: if current_time < OLD_CAST_POS: app_log.info(f'cast.media_controller player state: {media_controller.status.player_state}') app_log.info(f'updating OLD_CAST_POS from {OLD_CAST_POS} to {current_time}') OLD_CAST_POS = current_time # update track position only if out of buffer position if abs(current_time - get_track_position()) > buffer: if current_time < track_position and track_position - current_time > 2: app_log.info(f'updating track position from {track_position:.2f} to {current_time:.2f}') track_start = time.monotonic() - track_position if track_length is not None: track_end = track_start + track_length if media_controller.status.player_is_paused and playing_status.playing(): pause('cast_monitor') elif media_controller.status.player_is_playing and playing_status.paused(): resume('cast_monitor') elif (is_stopped and playing_status.busy() and not is_live and time.monotonic() - track_end > 1): # if cast says nothing is playing, only stop if we are not at the end of the track # this will prevent false positives stop('cast_monitor', False) if cast.status is not None: cast_volume = round(cast.status.volume_level * 100, 1) # volume sync if settings['volume'] != cast_volume: if not settings['muted'] and (not isinstance(settings['volume'], (float, int)) or abs(settings['volume'] - cast_volume) > 0.05): # if volume was changed via Google Home App OLD_CAST_VOLUME = cast_volume if update_settings('volume', cast_volume) and settings['muted']: update_settings('muted', False) gui_window.metadata['update_volume_slider'] = True elif playing_status.playing() and cast.media_controller.status.player_is_idle and time.time() - LAST_PLAYED > 300: # paused for more than 5 minutes stop('cast_monitor. app was not running') except (NotConnected, AttributeError): # don't care pass except UnsupportedNamespace: # known error # File "pychromecast/controllers/media.py", line 359, in update_status # File "pychromecast/controllers/init.py", line 91, in send_message # pychromecast.error.UnsupportedNamespace: # Namespace urn:x-cast:com.google.cast.media is not supported by running application. pass except Exception as e: handle_exception(e) finally: with suppress(RuntimeError): CAST_LOCK.release() def handle_action(action): actions = { '__ACTIVATED__': activate_gui, '__UPDATE_GUI__': update_gui, '__EXIT__': exit_program, # from tray menu t('Exit'): exit_program, t('Rescan Library'): index_all_tracks, t('Refresh Devices'): lambda: refresh_tray(True), # isdigit should be an if statement t('Settings'): lambda: activate_gui('tab_settings'), t('Playlists Tab'): lambda: activate_gui('tab_playlists'), # PL should be an if statement t('Set Timer'): lambda: activate_gui('tab_timer'), t('Cancel Timer'): cancel_timer, t('System Audio'): play_system_audio, t('Play URL'): lambda: activate_gui('tab_url', 'url_play'), t('Queue URL'): lambda: activate_gui('tab_url', 'url_queue'), t('Play URL Next'): lambda: activate_gui('tab_url', 'url_play_next'), t('Play Files'): file_action, t('Queue Files'): lambda: file_action('qf'), t('Play Files Next'): lambda: file_action('pfn'), t('Play All'): play_all, t('Pause'): pause, t('Resume'): resume, t('next track', 1): next_track, t('previous track', 1): prev_track, t('Stop'): lambda: stop('tray'), t('Repeat One'): lambda: update_settings('repeat', True), t('Repeat All'): lambda: update_settings('repeat', False), t('Repeat Off'): lambda: update_settings('repeat', None), t('locate track', 1): locate_uri } actions.get(action, lambda: other_tray_actions(action))() update_checker = UpdateChecker() try: start_time = time.monotonic() load_settings(True) # starts indexing all tracks if settings['important_message'] != IMPORTANT_INFORMATION and IMPORTANT_INFORMATION: two_lined_info = [] for line in IMPORTANT_INFORMATION.splitlines(keepends=True): two_lined_info.append(line) if len(two_lined_info) == 2: tray_notify(''.join(two_lined_info), title='Music Caster - Important Information') two_lined_info.clear() tray_notify(''.join(two_lined_info), title='Music Caster - Important Information') update_settings('important_message', IMPORTANT_INFORMATION) if settings['update_message'] == '': tray_notify(WELCOME_MSG) elif settings['update_message'] != UPDATE_MESSAGE and settings['notifications']: tray_notify(UPDATE_MESSAGE) # show important information regardless of notification settings update_settings('update_message', UPDATE_MESSAGE) # set file handlers only if installed from the setup (Not a portable installation) if os.path.exists(UNINSTALLER): with suppress(PermissionError): add_reg_handlers(working_dir / 'Music Caster.exe', add_folder_context=settings['folder_context_menu']) # remove any existing installer file we might've already run with suppress(FileNotFoundError, OSError): os.remove(get_installer_path()) rmtree('Update', ignore_errors=True) Thread(target=background_thread, daemon=True, name='BackgroundTasks').start() zconf = zeroconf.Zeroconf() cast_browser = pychromecast.discovery.CastBrowser(MyCastListener(), zconf) cast_browser.start_discovery() try: audio_player = AudioPlayer() except Exception as exception: tray_notify(t('WARNING: Failed to start audio player. Do not play on local device.')) handle_exception(exception) # system_media_controls = SystemMediaControls(on_smtc_btn_press) # find a port to bind to socket_timeout = 0.5 if args.shell else 0.1 while True: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s1, \ socket.socket(socket.AF_INET6, socket.SOCK_STREAM) as s2: s1.settimeout(socket_timeout) s2.settimeout(socket_timeout) # check if ports are not occupied if s1.connect_ex(('127.0.0.1', State.PORT)) != 0 and s2.connect_ex(('::1', State.PORT)) != 0: with suppress(OSError, PermissionError): # try to start server and bind it to PORT # Linux auto-maps ipv4 to ipv6 however Windows keep them seperate if platform.system() == 'Windows': server_kwargs = {'host': '0.0.0.0', 'port': State.PORT} Thread(target=waitress.serve, name='WaitressServe', daemon=True, args=(app,), kwargs=server_kwargs).start() server_kwargs = {'host': '::', 'port': State.PORT} Thread(target=waitress.serve, name='WaitressServe', daemon=True, args=(app,), kwargs=server_kwargs).start() break State.PORT += 1 # port in use or failed to bind to port with suppress(PermissionError): if is_debug: # only want to store PID of original instance lock_file.read() create_pid_file(port=State.PORT) if not USING_TAURI_FRONTEND: tray_process = mp.Process(target=system_tray, name='Music Caster Tray', args=(daemon_commands, tray_process_queue), daemon=True) tray_process.start() api_key = settings['api_key'] print(f'Running on http://127.0.0.1:{State.PORT}/?api_key={api_key}') print(f'Running on http://[::1]:{State.PORT}/?api_key={api_key}') app_log.info(f'LAN IPV4: {get_ipv4()}:{State.PORT}/') try: app_log.info(f'LAN IPV6: {get_ipv6()}:{State.PORT}/') except StopIteration: app_log.info('Could not get LAN IPV6 address') DiscordPresence.connect(settings['discord_rpc']) if PHANTOMJS_DIR.is_dir() and not cmd_exists('phantomjs'): add_to_path(PHANTOMJS_DIR / 'bin') if args.device is not None: end_time = time.monotonic() + WAIT_TIMEOUT while not change_device(args.device) and time.monotonic() < end_time: time.sleep(0.3) if args.uris or args.start_playing: # wait until previous device has been found or cannot be found end_time = time.monotonic() + WAIT_TIMEOUT while not change_device(settings['device']) and time.monotonic() < end_time: time.sleep(0.3) if args.uris: if args.uris[0].lower().replace(' ', '').replace('_', '') == 'systemaudio': play_system_audio() else: play_uris(args.uris, queue_uris=args.queue, play_next=args.playnext) elif settings['persistent_queue']: # load saved queues from settings.json for queue_name in {'done', 'music', 'next'}: queue = {'done': done_queue, 'music': music_queue, 'next': next_queue}[queue_name] for file_or_url in settings['queues'].get(queue_name, []): if valid_audio_file(file_or_url) or file_or_url.startswith('http'): queue.append(file_or_url) uris_to_scan.put(file_or_url) # position = args.position || previous session's position track_position = args.position if track_position == 0 and settings['position'] > 0: track_position = settings['position'] if args.start_playing: if not music_queue: if next_queue: music_queue.append(next_queue.popleft()) elif done_queue: music_queue.extend(done_queue) done_queue.clear() if music_queue: play(position=track_position, autoplay=not args.queue) elif track_position and music_queue: # restore position play(position=track_position, autoplay=False) elif settings['populate_queue_startup'] or args.start_playing: try: indexing_tracks_thread.join() play_all(queue_only=not args.start_playing or args.queue) except RuntimeError: tray_notify(t('ERROR') + ':' + t('Could not populate queue because library scan is disabled')) # open window if minimized argument not given if not USING_TAURI_FRONTEND and not args.minimized and not settings.get('DEBUG', False): daemon_commands.put('__ACTIVATED__') TIME_TO_START = time.monotonic() - start_time app_log.info('--------------------------------') app_log.info(f'Music Caster Version: {VERSION}') app_log.debug(f'Time to start (excluding imports) is {TIME_TO_START:.2f} seconds') app_log.debug(f'Time to start (including imports) is {TIME_TO_START + TIME_TO_IMPORT:.2f} seconds') last_position_save = time.monotonic() # health check if is_debug(): api_key = settings['api_key'] r = requests.get(f'http://127.0.0.1:{State.PORT}/?api_key={api_key}') assert r.ok while True: while not daemon_commands.empty(): handle_action(daemon_commands.get()) if playing_status.playing() and track_length is not None and time.monotonic() > track_end: app_log.info('calling next track because monotonic time is greater than track_end') next_track(from_timeout=time.monotonic() > track_end) elif timer and time.time() > timer: stop('timer') timer = 0 # use lock to prevent corrupting settings with settings_file_lock: if settings['timer_shut_down']: # shutdown computer os.system('shutdown /p /f') if platform.system() == 'Windows' else os.system('shutdown -h now') elif settings['timer_hibernate']: # hibernate computer if platform.system() == 'Windows': os.system( r'rundll32.exe powrprof.dll,SetSuspendState Hibernate' ) elif settings['timer_sleep']: # sleep computer if platform.system() == 'Windows': os.system('rundll32.exe powrprof.dll,SetSuspendState 0,1,0') # if settings.json was updated outside of Music Caster, reload settings try: if os.path.getmtime(SETTINGS_FILE) != settings_last_modified: load_settings() except FileNotFoundError: load_settings(first_load=True) if settings['persistent_queue'] and time.monotonic() - last_position_save > 2.5: update_settings('position', get_track_position()) last_position_save = time.monotonic() if platform.system() == 'Windows' and None not in (settings['on_battery_res'], settings['plugged_in_res']): if settings['on_battery_res'] != settings['plugged_in_res']: try: user32 = ctypes.windll.user32 res_map = get_all_resolutions() refresh_rate = None if is_plugged_in(throw_error=False): res_info = res_map[fmt_res(*settings['plugged_in_res'])] # check if res differs from desireed res if user32.GetSystemMetrics(0) * res_info['dpi_scale'] != settings['plugged_in_res'][0]: refresh_rate = max(get_all_refresh_rates()) else: # on battery res_info = res_map[fmt_res(*settings['on_battery_res'])] # check if res differs from desireed res if user32.GetSystemMetrics(0) * res_info['dpi_scale'] != settings['on_battery_res'][0]: refresh_rate = 60 if 60 in get_all_refresh_rates() else min(get_all_refresh_rates()) # res differs from desired res if refresh_rate is not None: set_resolution(res_info['w'], res_info['h'], res_info['dpi_scale'], refresh_rate=refresh_rate) refresh_tray_icon() except KeyError: update_settings('plugged_in_res', get_initial_res()) update_settings('on_battery_res', get_initial_res()) tray_notify(t('ERROR') + ': ' + t('Could not set resolution')) if cast is not None: cast_monitor(is_callback=False) if not gui_window.is_closed(): read_main_window() else: time.sleep(0.3) except KeyboardInterrupt: exit_program() except Exception as exception: app_log.exception('FATAL exception detected') # try to auto-update before exiting if not settings.get('DEBUG', False): update_checker.auto_update() handle_exception(exception, True) ================================================ FILE: src/pyoxidizer.bzl ================================================ # This file defines how PyOxidizer application building and packaging is # performed. See PyOxidizer's documentation at # https://gregoryszorc.com/docs/pyoxidizer/stable/pyoxidizer.html for details # of this configuration file format. # Configuration files consist of functions which define build "targets." # This function creates a Python executable and installs it in a destination # directory. def make_exe(): # Obtain the default PythonDistribution for our build target. We link # this distribution into our produced executable and extract the Python # standard library from it. dist = default_python_distribution() # This function creates a `PythonPackagingPolicy` instance, which # influences how executables are built and how resources are added to # the executable. You can customize the default behavior by assigning # to attributes and calling functions. policy = dist.make_python_packaging_policy() # Enable support for non-classified "file" resources to be added to # resource collections. # policy.allow_files = True # Control support for loading Python extensions and other shared libraries # from memory. This is only supported on Windows and is ignored on other # platforms. # policy.allow_in_memory_shared_library_loading = True # Control whether to generate Python bytecode at various optimization # levels. The default optimization level used by Python is 0. # policy.bytecode_optimize_level_zero = True # policy.bytecode_optimize_level_one = True # policy.bytecode_optimize_level_two = True # Package all available Python extensions in the distribution. # policy.extension_module_filter = "all" # Package the minimum set of Python extensions in the distribution needed # to run a Python interpreter. Various functionality from the Python # standard library won't work with this setting! But it can be used to # reduce the size of generated executables by omitting unused extensions. # policy.extension_module_filter = "minimal" # Package Python extensions in the distribution not having additional # library dependencies. This will exclude working support for SSL, # compression formats, and other functionality. # policy.extension_module_filter = "no-libraries" # Package Python extensions in the distribution not having a dependency on # copyleft licensed software like GPL. # policy.extension_module_filter = "no-copyleft" # Controls whether the file scanner attempts to classify files and emit # resource-specific values. # policy.file_scanner_classify_files = True # Controls whether `File` instances are emitted by the file scanner. # policy.file_scanner_emit_files = False # Controls the `add_include` attribute of "classified" resources # (`PythonModuleSource`, `PythonPackageResource`, etc). # policy.include_classified_resources = True # Toggle whether Python module source code for modules in the Python # distribution's standard library are included. # policy.include_distribution_sources = False # Toggle whether Python package resource files for the Python standard # library are included. # policy.include_distribution_resources = False # Controls the `add_include` attribute of `File` resources. # policy.include_file_resources = False # Controls the `add_include` attribute of `PythonModuleSource` not in # the standard library. # policy.include_non_distribution_sources = True # Toggle whether files associated with tests are included. # policy.include_test = False # Resources are loaded from "in-memory" or "filesystem-relative" paths. # The locations to attempt to add resources to are defined by the # `resources_location` and `resources_location_fallback` attributes. # The former is the first/primary location to try and the latter is # an optional fallback. # Use in-memory location for adding resources by default. # policy.resources_location = "in-memory" # Use filesystem-relative location for adding resources by default. # policy.resources_location = "filesystem-relative:prefix" # Attempt to add resources relative to the built binary when # `resources_location` fails. # policy.resources_location_fallback = "filesystem-relative:prefix" # Clear out a fallback resource location. # policy.resources_location_fallback = None # Define a preferred Python extension module variant in the Python distribution # to use. # policy.set_preferred_extension_module_variant("foo", "bar") # Configure policy values to classify files as typed resources. # (This is the default.) # policy.set_resource_handling_mode("classify") # Configure policy values to handle files as files and not attempt # to classify files as specific types. # policy.set_resource_handling_mode("files") # This variable defines the configuration of the embedded Python # interpreter. By default, the interpreter will run a Python REPL # using settings that are appropriate for an "isolated" run-time # environment. # # The configuration of the embedded Python interpreter can be modified # by setting attributes on the instance. Some of these are # documented below. python_config = dist.make_python_interpreter_config() # Make the embedded interpreter behave like a `python` process. # python_config.config_profile = "python" # Set initial value for `sys.path`. If the string `$ORIGIN` exists in # a value, it will be expanded to the directory of the built executable. # python_config.module_search_paths = ["$ORIGIN/lib"] # Use jemalloc as Python's memory allocator. # python_config.allocator_backend = "jemalloc" # Use mimalloc as Python's memory allocator. # python_config.allocator_backend = "mimalloc" # Use snmalloc as Python's memory allocator. # python_config.allocator_backend = "snmalloc" # Let Python choose which memory allocator to use. (This will likely # use the malloc()/free() linked into the program. # python_config.allocator_backend = "default" # Enable the use of a custom allocator backend with the "raw" memory domain. # python_config.allocator_raw = True # Enable the use of a custom allocator backend with the "mem" memory domain. # python_config.allocator_mem = True # Enable the use of a custom allocator backend with the "obj" memory domain. # python_config.allocator_obj = True # Enable the use of a custom allocator backend with pymalloc's arena # allocator. # python_config.allocator_pymalloc_arena = True # Enable Python memory allocator debug hooks. # python_config.allocator_debug = True # Automatically calls `multiprocessing.set_start_method()` with an # appropriate value when OxidizedFinder imports the `multiprocessing` # module. # python_config.multiprocessing_start_method = 'auto' # Do not call `multiprocessing.set_start_method()` automatically. (This # is the default behavior of Python applications.) # python_config.multiprocessing_start_method = 'none' # Call `multiprocessing.set_start_method()` with explicit values. # python_config.multiprocessing_start_method = 'fork' # python_config.multiprocessing_start_method = 'forkserver' # python_config.multiprocessing_start_method = 'spawn' # Control whether `oxidized_importer` is the first importer on # `sys.meta_path`. # python_config.oxidized_importer = False # Enable the standard path-based importer which attempts to load # modules from the filesystem. # python_config.filesystem_importer = True # Set `sys.frozen = False` # python_config.sys_frozen = False # Set `sys.meipass` # python_config.sys_meipass = True # Write files containing loaded modules to the directory specified # by the given environment variable. # python_config.write_modules_directory_env = "/tmp/oxidized/loaded_modules" # Evaluate a string as Python code when the interpreter starts. # python_config.run_command = "" # Run a Python module as __main__ when the interpreter starts. # python_config.run_module = "" # Run a Python file when the interpreter starts. # python_config.run_filename = "/path/to/file" # Produce a PythonExecutable from a Python distribution, embedded # resources, and other options. The returned object represents the # standalone executable that will be built. exe = dist.to_python_executable( name=".", # If no argument passed, the default `PythonPackagingPolicy` for the # distribution is used. packaging_policy=policy, # If no argument passed, the default `PythonInterpreterConfig` is used. config=python_config, ) # Install tcl/tk support files to a specified directory so the `tkinter` Python # module works. # exe.tcl_files_path = "lib" # Never attempt to copy Windows runtime DLLs next to the built executable. # exe.windows_runtime_dlls_mode = "never" # Copy Windows runtime DLLs next to the built executable when they can be # located. # exe.windows_runtime_dlls_mode = "when-present" # Copy Windows runtime DLLs next to the build executable and error if this # cannot be done. # exe.windows_runtime_dlls_mode = "always" # Make the executable a console application on Windows. # exe.windows_subsystem = "console" # Make the executable a non-console application on Windows. # exe.windows_subsystem = "windows" # Invoke `pip download` to install a single package using wheel archives # obtained via `pip download`. `pip_download()` returns objects representing # collected files inside Python wheels. `add_python_resources()` adds these # objects to the binary, with a load location as defined by the packaging # policy's resource location attributes. #exe.add_python_resources(exe.pip_download(["pyflakes==2.2.0"])) # Invoke `pip install` with our Python distribution to install a single package. # `pip_install()` returns objects representing installed files. # `add_python_resources()` adds these objects to the binary, with a load # location as defined by the packaging policy's resource location # attributes. #exe.add_python_resources(exe.pip_install(["appdirs"])) # Invoke `pip install` using a requirements file and add the collected resources # to our binary. #exe.add_python_resources(exe.pip_install(["-r", "requirements.txt"])) # Read Python files from a local directory and add them to our embedded # context, taking just the resources belonging to the `foo` and `bar` # Python packages. #exe.add_python_resources(exe.read_package_root( # path="/src/mypackage", # packages=["foo", "bar"], #)) # Discover Python files from a virtualenv and add them to our embedded # context. #exe.add_python_resources(exe.read_virtualenv(path="/path/to/venv")) # Filter all resources collected so far through a filter of names # in a file. #exe.filter_resources_from_files(files=["/path/to/filter-file"]) # Return our `PythonExecutable` instance so it can be built and # referenced by other consumers of this target. return exe def make_embedded_resources(exe): return exe.to_embedded_resources() def make_install(exe): # Create an object that represents our installed application file layout. files = FileManifest() # Add the generated executable to our install layout in the root directory. files.add_python_resource(".", exe) return files def make_msi(exe): # See the full docs for more. But this will convert your Python executable # into a `WiXMSIBuilder` Starlark type, which will be converted to a Windows # .msi installer when it is built. return exe.to_wix_msi_builder( # Simple identifier of your app. "myapp", # The name of your application. "My Application", # The version of your application. "1.0", # The author/manufacturer of your application. "Alice Jones" ) # Dynamically enable automatic code signing. def register_code_signers(): # You will need to run with `pyoxidizer build --var ENABLE_CODE_SIGNING 1` for # this if block to be evaluated. if not VARS.get("ENABLE_CODE_SIGNING"): return # Use a code signing certificate in a .pfx/.p12 file, prompting the # user for its path and password to open. # pfx_path = prompt_input("path to code signing certificate file") # pfx_password = prompt_password( # "password for code signing certificate file", # confirm = True # ) # signer = code_signer_from_pfx_file(pfx_path, pfx_password) # Use a code signing certificate in the Windows certificate store, specified # by its SHA-1 thumbprint. (This allows you to use YubiKeys and other # hardware tokens if they speak to the Windows certificate APIs.) # sha1_thumbprint = prompt_input( # "SHA-1 thumbprint of code signing certificate in Windows store" # ) # signer = code_signer_from_windows_store_sha1_thumbprint(sha1_thumbprint) # Choose a code signing certificate automatically from the Windows # certificate store. # signer = code_signer_from_windows_store_auto() # Activate your signer so it gets called automatically. # signer.activate() # Call our function to set up automatic code signers. register_code_signers() # Tell PyOxidizer about the build targets defined above. register_target("exe", make_exe) register_target("resources", make_embedded_resources, depends=["exe"], default_build_script=True) register_target("install", make_install, depends=["exe"], default=True) register_target("msi_installer", make_msi, depends=["exe"]) # Resolve whatever targets the invoker of this configuration file is requesting # be resolved. resolve_targets() ================================================ FILE: src/shared.py ================================================ """ Shared functions between app and build """ import platform import re from subprocess import DEVNULL, PIPE, Popen def get_running_processes(look_for='', pid=None, add_exe=True): if platform.system() == 'Windows': cmd = f'tasklist /NH' if look_for: if not look_for.endswith('.exe') and add_exe: look_for += '.exe' cmd += f' /FI "IMAGENAME eq {look_for}"' if pid is not None: cmd += f' /FI "PID eq {pid}"' p = Popen( cmd, shell=True, stdout=PIPE, stdin=DEVNULL, stderr=DEVNULL, text=True, encoding='iso8859-2', ) p.stdout.readline() for task in iter(lambda: p.stdout.readline().strip(), ''): m = re.match(r'(.+?) +(\d+) (.+?) +(\d+) +(\d+.* K).*', task) if m is not None: yield { 'name': m.group(1), 'pid': int(m.group(2)), 'session_name': m.group(3), 'session_num': m.group(4), 'mem_usage': m.group(5), } elif platform.system() == 'Linux': cmd = ['ps', 'h'] if look_for: cmd.extend(('-C', look_for)) p = Popen(cmd, stdout=PIPE, stdin=PIPE, stderr=DEVNULL, text=True) for task in iter(lambda: p.stdout.readline().strip(), ''): m = task.split(maxsplit=4) yield {'name': m[-1], 'pid': int(m[0])} def is_already_running(look_for='Music Caster', threshold=1, pid=None) -> bool: """ Returns True if more processes than `threshold` were found # TODO: threshold feature for Linux """ if platform.system() == 'Windows': for _ in get_running_processes(look_for=look_for, pid=pid): threshold -= 1 if threshold < 0: return True else: # Linux p = Popen( ['ps', 'h', '-C', look_for, '-o', 'comm'], stdout=PIPE, stdin=PIPE, stderr=DEVNULL, text=True, ) return p.stdout.readline().strip() != '' return False ================================================ FILE: src/static/style.css ================================================ :root { --accent: #00bfff; } html { font-size: large; } body { font-family: 'Roboto', Arial, Verdana, sans-serif; background-color: #121212; } a { text-decoration: none; color: inherit; } .playLink { display: inline-block; width: 85%; } .playPlaylist { display: inline-block; width: 95%; } .playNext { margin: 1px 5px 0 5px; } button { background-color: #2196f3; border: none; border-radius: 5px; color: white; padding: 5px 10px; text-align: center; text-decoration: none; display: inline-block; font-size: 16px; cursor: pointer; } input:focus { outline: none !important; } #timerMinutes { width: 5em; } .center { margin: 0; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); } #player-container { text-align: center; display: flex; width: 90%; max-height: 90%; background-color: #121212; border-radius: 0.25rem; } #player-container > div { width: 50%; } #body-container { flex-grow: 2; display: flex; flex-direction: column; justify-content: space-between; gap: 1em; } .cover-art-container { background-color: #121212; } .cover-art-container img { max-width: 100%; max-height: 100%; border-radius: 0.25rem 0.25rem 0 0; margin: auto; } .list { display: flex; margin: 0; padding: 0; list-style-type: none; } .body__buttons, .body-info, .player__footer { padding-right: 2rem; padding-left: 2rem; } .list--cover, .list--footer { justify-content: space-between; } .list--header .list__link, .list--footer .list__link { color: #fff; fill: #fff; } .list--cover { position: absolute; top: 0.5rem; width: 100%; } .list--cover li:first-of-type { margin-left: 0.75rem; } .list--cover li:last-of-type { margin-right: 0.75rem; } .list--cover a { font-size: 1.15rem; color: #fff; } .range { position: relative; top: -1.5rem; right: 0; left: 0; margin: auto; background: rgba(255, 255, 255, .95); width: 80%; height: 0.125rem; border-radius: 0.25rem; cursor: pointer; } .range:before, .range:after { content: ""; position: absolute; cursor: pointer; } .range:before { width: 3rem; height: 100%; background: linear-gradient(to right, rgba(211, 3, 32, .5), rgba(211, 3, 32, .85)); border-radius: 0.25rem; overflow: hidden; } .range:after { top: -0.375rem; left: 3rem; z-index: 3; width: 0.875rem; height: 0.875rem; background: #fff; border-radius: 50%; box-shadow: 0 0 3px rgba(0, 0, 0, .15), 0 2px 4px rgba(0, 0, 0, .15); transition: all 0.2s cubic-bezier(0.4, 0, 1, 1); } .range:focus:after, .range:hover:after { background: rgba(211, 3, 32, .95); } .body-info { /* padding-top: 1.5rem; padding-bottom: 1.25rem; */ display: flex; flex-direction: column; justify-content: space-evenly; flex-grow: 2; max-height: 40%; } #info__album, #info__track { margin-bottom: 0.5rem; } #info__artist, #info__album { font-size: 1rem; font-weight: 300; color: #666; } #info__track { font-size: 1.5rem; font-weight: 400; color: var(--accent); } .body__buttons { /* padding-bottom: 1rem; */ } .list--buttons { align-items: center; justify-content: center; } .list--buttons li:nth-of-type(n+2) { margin-left: 1.25rem; } .ctrl-btn { border-radius: 50%; box-shadow: 0 3px 12px rgba(214, 214, 214, 0.4); display: inline-block; } .list--buttons a { padding-top: 0.6rem; padding-right: 0.75rem; padding-bottom: 0.6rem; padding-left: 0.75rem; font-size: 1rem; color: #fff; opacity: 0.5; } .list--buttons a:focus, .list--buttons a:hover { opacity: 0.75; } #play-pause-btn { padding-top: 0.9rem; padding-right: 1rem; padding-bottom: 0.9rem; padding-left: 1.19rem; margin-left: 0.5rem; font-size: 1.25rem; } #play-pause-btn:hover { opacity: 1; } #repeat-one { position: absolute; margin-left: 1em; color: var(--accent); opacity: .9 !important; } .repeat-enabled, .shuffle-enabled { color: var(--accent); opacity: .9 !important; } #prev-btn, #next-btn { font-size: 0.95rem; } .list__link { transition: all 0.2s cubic-bezier(0.4, 0, 1, 1); } .list__link:focus, .list__link:hover, .list__link>img:hover { color: var(--accent); fill: var(--accent); } .player__footer { padding-bottom: .5em; } .list--footer { padding: 1em; margin: 1em 2em; } .list--footer a { opacity: 0.5; } .list--footer a:focus, .list--footer a:hover { opacity: 0.9; } .fa.fa-pause { margin-left: -3px; } .modal { display: none; position: fixed; z-index: 5; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgba(0, 0, 0, .8); } .modal-content { width: 50%; position: relative; top: 0%; margin: 0 auto; text-align: center; } .modal-content > ul { padding: 0; } h2 { width: 80%; font-size: x-large; color: white; text-align: center; margin-left: auto; margin-right: auto; } .modal-title { position: sticky; top: 10px; background-color: #0006; border-radius: 10px; padding: .2em; z-index: 10; } .track, .trackRow { width: 100%; width: -moz-available; /* For Mozzila */ width: -webkit-fill-available; /* For Chrome */ width: stretch; margin-left: auto; margin-right: auto; display: inline-block; background-color: #121212b2; color: whitesmoke; padding: 1em; text-align: left; border: 1px solid black; font-size: large; } .track:hover, .trackRow a:first-child:hover, .modalRow:hover, .cyan, .queueTrack:hover, .downloadTrack:hover { color: cyan; } .playNext:hover svg { fill: cyan; } .modalRow { display: flex; justify-content: space-between; width: 100%; width: -moz-available; width: -webkit-fill-available; width: stretch; margin-left: auto; margin-right: auto; background-color: #121212b2; color: whitesmoke; cursor: pointer; flex-direction: row; height: 34px; line-height: 34px; padding: 1em; text-align: left; font-size: large; } .switch { position: relative; display: inline-block; width: 60px; height: 36px; } .switch input { opacity: 0; width: 0; height: 0; } .slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; -webkit-transition: .4s; transition: .4s; } .slider:before { position: absolute; content: ""; height: 26px; width: 26px; left: 4px; bottom: 4px; background-color: white; -webkit-transition: .4s; transition: .4s; } input:checked+.slider { background-color: #2196F3; } input:focus+.slider { box-shadow: 0 0 1px #2196F3; } input:checked+.slider:before { -webkit-transform: translateX(26px); -ms-transform: translateX(26px); transform: translateX(26px); } /* Rounded sliders */ .slider.round { border-radius: 34px; } .slider.round:before { border-radius: 50%; } #searchBar { color: whitesmoke; background-color: #121212b2; /* background-image: url('https://www.w3schools.com/css/searchicon.png'); */ background-position: 10px 12px; background-repeat: no-repeat; font-size: large; width: 100%; width: -moz-available; width: -webkit-fill-available; width: stretch; padding: 1em 20px 1em 40px; margin-bottom: 12px; border: 1px solid black; border-radius: 2em; position: sticky; top: 20px; } #volControl svg { margin-right: .5em; } #volControl path { fill: #888; } #volRange { width: 80%; background: #121212; -webkit-appearance: none; } #volRange:focus { outline: none; } #volRange::-moz-range-progress { background-color: #00bfff; height: 5px; } #volRange::-moz-range-track, #volRange::-webkit-slider-runnable-track { background-color: #e7eaea; height: 5px; } #volRange::-webkit-slider-runnable-track { width: 80%; cursor: pointer; box-shadow: 1.7px 1.7px 1px rgba(0, 0, 0, 0.2), 0px 0px 1.7px rgba(13, 13, 13, 0.2); border-radius: 25px; border: 0px solid #010101; margin-top: -18px; } /* #volRange::-webkit-progress-value {} e7eaea */ #volRange::-webkit-slider-thumb { box-shadow: 0px 0px 0.6px rgba(0, 0, 0, 0.1), 0px 0px 0px rgba(13, 13, 13, 0.1); border: 0.5px solid rgba(0, 0, 0, 0.6); height: 20px; width: 20px; border-radius: 50px; background: #ffffff; cursor: pointer; -webkit-appearance: none; margin-top: -5.8px; } #volRange:focus::-webkit-slider-runnable-track { background: #f5f6f6; } #audioStream { padding: 1em; } .row-filler { padding: 1em; height: 1em; } #devices { padding: 0.5em; margin: 0.5em 2em; } /* TOAST */ #toast { visibility: hidden; min-width: 250px; margin-left: -150px; background-color: rgba(0, 0, 0, .9); color: #fff; text-align: center; border-radius: 20px; padding: 16px; position: fixed; z-index: 10000; font-size: 20px; left: 50%; bottom: 60px } #toast.show { visibility: visible; -webkit-animation: fadein .5s, fadeout .5s 2s; animation: fadein .5s, fadeout .5s 2s } /* END TOAST */ @media screen and (max-width:1100px) { .modal-content { width: 60%; } } /* MOBILE */ @media screen and (max-width: 800px) { #player-container { flex-direction: column; } #player-container > div { width: 100%; } .modal-content { width: 85%; } #wrapper { margin-top: 1em; } .playLink, .playPlaylist { width: 80%; } .player-container { width: 100%; margin-top: auto; } } ================================================ FILE: src/sys_tray.py ================================================ import multiprocessing as mp import platform import io import sys from itertools import islice import threading import time import os from base64 import b64decode import ctypes def system_tray(main_queue: mp.Queue, child_queue: mp.Queue): from b64_images import FILLED_ICON, UNFILLED_ICON if platform.system() == 'Linux': os.environ['PYSTRAY_BACKEND'] = 'appindicator' elif platform.system() == 'Windows' and getattr(sys, 'frozen', False): my_app_id = 'elijahlopez.music_caster' ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(my_app_id) import pystray from PIL import Image filled_icon = Image.open(io.BytesIO(b64decode(FILLED_ICON))) unfilled_icon = Image.open(io.BytesIO(b64decode(UNFILLED_ICON))) def create_menu(lst, root=True): # e.g. ['Item 1', ('Item 2 Display', 'item_2_key'), ['Sub Menu Title', ('Sub Menu Item 1 Display', 'KEY')]] items = [] if root: items.append( pystray.MenuItem( '', get_tray_action('__ACTIVATED__'), default=True, visible=False ) ) for element in lst: if isinstance(element, list): items.append( pystray.MenuItem( element[0], create_menu(islice(element, 1, None), root=False) ) ) elif isinstance(element, tuple) and len(element) == 2: element, key = element items.append(pystray.MenuItem(element, get_tray_action(element, key))) else: items.append(pystray.MenuItem(element, get_tray_action(element))) return pystray.Menu(*items) def get_tray_action(string, key=''): def tray_action(): try: main_queue.put(key) if key else main_queue.put(string) if key == '__EXIT__': child_queue.put({'close': None}) except ValueError: child_queue.put({'close': None}) return tray_action def background(): while True: while not child_queue.empty(): for parent_cmd, arguments in child_queue.get().items(): if parent_cmd == 'tooltip': tray.title = arguments elif parent_cmd == 'menu': # set icon to unfilled if tray.HAS_MENU: tray.menu = create_menu(arguments) tray.update_menu() else: print('pystray: menu not supported') elif parent_cmd == 'filled': # set icon to filled tray.icon = filled_icon elif parent_cmd == 'unfilled': # set icon to unfilled tray.icon = unfilled_icon elif parent_cmd == 'notify': if tray.HAS_NOTIFICATION: tray.notify( arguments['message'], title=arguments.get('title') ) # msg, title else: print('pystray: notify not supported') elif parent_cmd == 'hide': tray.visible = False elif parent_cmd in {'close', 'exit', '__EXIT__'}: tray.stop() sys.exit() time.sleep(0.1) tray = pystray.Icon( 'Music Caster SystemTray', unfilled_icon, title='Music Caster [LOADING]' ) threading.Thread(target=background, daemon=True).start() tray.run() ================================================ FILE: src/templates/index.html ================================================ Music Caster - {{device_name}}
Artwork
{{metadata['album'] if metadata['album'] else '
'|safe}}
{{metadata['title']}}
{{metadata['artist'] if metadata['artist'] else '
'|safe}}
{% if stream_url %} {% else %}
{% endif %}
================================================ FILE: src/test_cases/ipconfig.py ================================================ IPCONFIG_ELIBROFTW = '''Windows IP Configuration Ethernet adapter Ethernet 3: Media State . . . . . . . . . . . : Media disconnected Connection-specific DNS Suffix . : Ethernet adapter Ethernet: Connection-specific DNS Suffix . : Link-local IPv6 Address . . . . . : fe80::536e:5298:cc0b:3007%15 IPv4 Address. . . . . . . . . . . : 192.168.56.1 Subnet Mask . . . . . . . . . . . : 255.255.255.0 Default Gateway . . . . . . . . . : Wireless LAN adapter Wi-Fi 2: Media State . . . . . . . . . . . : Media disconnected Connection-specific DNS Suffix . : Wireless LAN adapter Wi-Fi 3: Media State . . . . . . . . . . . : Media disconnected Connection-specific DNS Suffix . : Wireless LAN adapter Wi-Fi 4: Media State . . . . . . . . . . . : Media disconnected Connection-specific DNS Suffix . : ht.home Wireless LAN adapter Wi-Fi: Connection-specific DNS Suffix . : cgocable.net IPv6 Address. . . . . . . . . . . : 2001:1970:46c7:6f00:b2a6:1d41:da88:1d67 Temporary IPv6 Address. . . . . . : 2001:1970:46c7:6f00:9dcc:d93a:540a:f506 Link-local IPv6 Address . . . . . : fe80::e427:6d89:2e48:2c23%8 IPv4 Address. . . . . . . . . . . : 192.168.0.89 Subnet Mask . . . . . . . . . . . : 255.255.255.0 Default Gateway . . . . . . . . . : fe80::d26d:c9ff:fe4d:774%8 192.168.0.1 Ethernet adapter Bluetooth Network Connection: Media State . . . . . . . . . . . : Media disconnected Connection-specific DNS Suffix . : ''' IPCONFIG_ERICCHAN1989_ALL = ''' Windows IP Configuration Unknown adapter NordLynx: Connection-specific DNS Suffix . : Description . . . . . . . . . . . : NordLynx Tunnel Physical Address. . . . . . . . . : DHCP Enabled. . . . . . . . . . . : No Autoconfiguration Enabled . . . . : Yes Link-local IPv6 Address . . . . . : fe80::e911:84b9:1c8:cded%55(Preferred) IPv4 Address. . . . . . . . . . . : 10.5.0.2(Preferred) Subnet Mask . . . . . . . . . . . : 255.255.0.0 Default Gateway . . . . . . . . . : 0.0.0.0 DNS Servers . . . . . . . . . . . : 103.86.96.100 103.86.99.100 NetBIOS over Tcpip. . . . . . . . : Enabled Ethernet adapter Ethernet: Connection-specific DNS Suffix . : Description . . . . . . . . . . . : Realtek Gaming 2.5GbE Family Controller Physical Address. . . . . . . . . : [removed for security purpose] DHCP Enabled. . . . . . . . . . . : No Autoconfiguration Enabled . . . . : Yes IPv4 Address. . . . . . . . . . . : 192.168.2.2(Preferred) Subnet Mask . . . . . . . . . . . : 255.255.255.0 Default Gateway . . . . . . . . . : 192.168.2.1 DNS Servers . . . . . . . . . . . : 1.1.1.1 8.8.8.8 NetBIOS over Tcpip. . . . . . . . : Enabled Unknown adapter OpenVPN Data Channel Offload for NordVPN: Media State . . . . . . . . . . . : Media disconnected Connection-specific DNS Suffix . : Description . . . . . . . . . . . : OpenVPN Data Channel Offload Physical Address. . . . . . . . . : DHCP Enabled. . . . . . . . . . . : Yes Autoconfiguration Enabled . . . . : Yes Unknown adapter Local Area Connection: Media State . . . . . . . . . . . : Media disconnected Connection-specific DNS Suffix . : Description . . . . . . . . . . . : TAP-NordVPN Windows Adapter V9 Physical Address. . . . . . . . . : [removed for security purpose] DHCP Enabled. . . . . . . . . . . : Yes Autoconfiguration Enabled . . . . : Yes Wireless LAN adapter WiFi: Media State . . . . . . . . . . . : Media disconnected Connection-specific DNS Suffix . : Description . . . . . . . . . . . : Intel(R) Wi-Fi 6 AX201 160MHz Physical Address. . . . . . . . . : [removed for security purpose] DHCP Enabled. . . . . . . . . . . : Yes Autoconfiguration Enabled . . . . : Yes Wireless LAN adapter Local Area Connection* 1: Media State . . . . . . . . . . . : Media disconnected Connection-specific DNS Suffix . : Description . . . . . . . . . . . : Microsoft Wi-Fi Direct Virtual Adapter Physical Address. . . . . . . . . : [removed for security purpose] DHCP Enabled. . . . . . . . . . . : Yes Autoconfiguration Enabled . . . . : Yes Wireless LAN adapter Local Area Connection* 10: Media State . . . . . . . . . . . : Media disconnected Connection-specific DNS Suffix . : Description . . . . . . . . . . . : Microsoft Wi-Fi Direct Virtual Adapter #2 Physical Address. . . . . . . . . : [removed for security purpose] DHCP Enabled. . . . . . . . . . . : Yes Autoconfiguration Enabled . . . . : Yes Ethernet adapter Bluetooth Network Connection: Media State . . . . . . . . . . . : Media disconnected Connection-specific DNS Suffix . : Description . . . . . . . . . . . : Bluetooth Device (Personal Area Network) Physical Address. . . . . . . . . : [removed for security purpose] DHCP Enabled. . . . . . . . . . . : Yes Autoconfiguration Enabled . . . . : Yes Ethernet adapter vEthernet (Default Switch): Connection-specific DNS Suffix . : Description . . . . . . . . . . . : Hyper-V Virtual Ethernet Adapter Physical Address. . . . . . . . . : [removed for security purpose] DHCP Enabled. . . . . . . . . . . : No Autoconfiguration Enabled . . . . : Yes Link-local IPv6 Address . . . . . : fe80::8593:9017:f01e:e94c%25(Preferred) IPv4 Address. . . . . . . . . . . : 172.29.0.1(Preferred) Subnet Mask . . . . . . . . . . . : 255.255.240.0 Default Gateway . . . . . . . . . : DHCPv6 IAID . . . . . . . . . . . : 419435869 DHCPv6 Client DUID. . . . . . . . : 00-01-00-01-2E-2D-51-14-70-A6-CC-B2-9B-5C NetBIOS over Tcpip. . . . . . . . : Enabled Ethernet adapter vEthernet (WSL (Hyper-V firewall)): Connection-specific DNS Suffix . : Description . . . . . . . . . . . : Hyper-V Virtual Ethernet Adapter #2 Physical Address. . . . . . . . . : [removed for security purpose] DHCP Enabled. . . . . . . . . . . : No Autoconfiguration Enabled . . . . : Yes Link-local IPv6 Address . . . . . : fe80::d5b0:1ea6:d494:c06f%50(Preferred) IPv4 Address. . . . . . . . . . . : 172.21.160.1(Preferred) Subnet Mask . . . . . . . . . . . : 255.255.240.0 Default Gateway . . . . . . . . . : DHCPv6 IAID . . . . . . . . . . . : 838866269 DHCPv6 Client DUID. . . . . . . . : 00-01-00-01-2E-2D-51-14-70-A6-CC-B2-9B-5C NetBIOS over Tcpip. . . . . . . . : Enabled ''' IPCONFIG_ERICCHAN1989 = ''' Windows IP Configuration Unknown adapter NordLynx: Connection-specific DNS Suffix . : Link-local IPv6 Address . . . . . : fe80::e911:84b9:1c8:cded%55 IPv4 Address. . . . . . . . . . . : 10.5.0.2 Subnet Mask . . . . . . . . . . . : 255.255.0.0 Default Gateway . . . . . . . . . : 0.0.0.0 Ethernet adapter Ethernet: Connection-specific DNS Suffix . : IPv4 Address. . . . . . . . . . . : 192.168.2.2 Subnet Mask . . . . . . . . . . . : 255.255.255.0 Default Gateway . . . . . . . . . : 192.168.2.1 Unknown adapter OpenVPN Data Channel Offload for NordVPN: Media State . . . . . . . . . . . : Media disconnected Connection-specific DNS Suffix . : Unknown adapter Local Area Connection: Media State . . . . . . . . . . . : Media disconnected Connection-specific DNS Suffix . : Wireless LAN adapter WiFi: Media State . . . . . . . . . . . : Media disconnected Connection-specific DNS Suffix . : Wireless LAN adapter Local Area Connection* 1: Media State . . . . . . . . . . . : Media disconnected Connection-specific DNS Suffix . : Wireless LAN adapter Local Area Connection* 10: Media State . . . . . . . . . . . : Media disconnected Connection-specific DNS Suffix . : Ethernet adapter Bluetooth Network Connection: Media State . . . . . . . . . . . : Media disconnected Connection-specific DNS Suffix . : Ethernet adapter vEthernet (Default Switch): Connection-specific DNS Suffix . : Link-local IPv6 Address . . . . . : fe80::8593:9017:f01e:e94c%13 IPv4 Address. . . . . . . . . . . : 172.19.80.1 Subnet Mask . . . . . . . . . . . : 255.255.240.0 Default Gateway . . . . . . . . . : Ethernet adapter vEthernet (WSL (Hyper-V firewall)): Connection-specific DNS Suffix . : Link-local IPv6 Address . . . . . : fe80::b8db:cb1d:44eb:e56a%32 IPv4 Address. . . . . . . . . . . : 172.21.160.1 Subnet Mask . . . . . . . . . . . : 255.255.240.0 Default Gateway . . . . . . . . . : ''' ================================================ FILE: src/test_harness.py ================================================ from base64 import b64decode from contextlib import suppress import io from itertools import chain import os import platform from pathlib import Path import time from mutagen._util import MutagenError from PIL import Image import pytest from modules.db import DatabaseConnection from b64_images import DEFAULT_ART from meta import COVER_MINI, COVER_NORMAL, VERSION from shared import get_running_processes, is_already_running from test_cases.ipconfig import IPCONFIG_ELIBROFTW, IPCONFIG_ERICCHAN1989, IPCONFIG_ERICCHAN1989_ALL from utils import ( IPV4_GENERAL_PATTERN, IPV4_WIFI_PATTERN, REPEAT_ALL_IMG, REPEAT_OFF_IMG, REPEAT_ONE_IMG, InvalidAudioFile, State, SystemAudioRecorder, Unknown, better_shuffle, create_progress_bar_texts, custom_art, export_playlist, clean_ipconfig, fix_path, get_album_art, get_audio_length, get_deezer_tracks, get_default_output_device, get_display_lang, get_file_name, get_first_artist, get_ipv4, get_ipv6, get_lang_pack, get_languages, get_latest_release, get_mac, get_metadata, get_proxy, get_spotify_tracks, get_translation, get_youtube_comments, get_yt_id, natural_key_file, parse_m3u, repeat_img_tooltip, resize_img, t, valid_audio_file, valid_color_code, ydl_extract_info, ) from modules.url_metadata import ydl_get_metadata MUSIC_FILE_WITH_ALBUM_ART = ( r'C:\Users\maste\OneDrive\Music\6ixbuzz, Pressa, Houdini - Up & Down.mp3' ) TEST_MUSIC_FILES = [ r'C:\Users\maste\OneDrive\Music\deadmau5 - My Pet Coelacanth.mp3', r'C:\Users\maste\OneDrive\Music\deadmau5 - Not Exactly.mp3', r"C:\Users\maste\OneDrive\Music\deadmau5 - Phantoms Can't Hang.mp3", r'C:\Users\maste\OneDrive\Music\deadmau5 - Rio.mp3', r'C:\Users\maste\OneDrive\Music\deadmau5 - SATRN.mp3', r'C:\Users\maste\OneDrive\Music\deadmau5 - Saved.mp3', r'C:\Users\maste\OneDrive\Music\deadmau5 - Slip.mp3', r'C:\Users\maste\OneDrive\Music\deadmau5 - So There I Was.mp3', # DNE r'C:\Users\maste\OneDrive\Music\deadmau5 - Sofi Needs a Ladder.mp3', r'C:\Users\maste\OneDrive\Music\deadmau5 - Some Kind of Blue.mp3', r'C:\Users\maste\OneDrive\Music\deadmau5 - Sometimes Things Get, Whatever.mp3', r'C:\Users\maste\OneDrive\Music\deadmau5 - Three Pound Chicken Wing.mp3', r'C:\Users\maste\OneDrive\Music\deadmau5 & Kaskade - I Remember.mp3', r'C:\Users\maste\OneDrive\Music\deadmau5, Grabbitz - Let Go.mp3', r'C:\Users\maste\OneDrive\Music\Diplo, Trippie Redd - Wish.mp3', r'C:\Users\maste\OneDrive\Music\Dirty South, Alesso, Ruben Haze - City Of Dreams.mp3', r"C:\Users\maste\OneDrive\Music\Dogzilla - Without You (John O'Callaghan Extended Remix).mp3", r'C:\Users\maste\OneDrive\Music\Dogzilla - Without You (Ronald van Gelderen Extended Remix).mp3', r'C:\Users\maste\OneDrive\Music\Dogzilla - Without You (Will Atkinson Remix).mp3', r"C:\Users\maste\OneDrive\Music\Drake - Hold On, We're Going Home.mp3", r'C:\Users\maste\OneDrive\Music\Drake - Over (Ayobi Remix).mp3', r'C:\Users\maste\OneDrive\Music\Drake - Passionfruit.mp3', ] LIST_TO_NAT_SORT_1 = [ '1. Hello World', '3. Hello World', '10. Hello World', '2. Hello World', '9. Hello World', '11. Hello World', '12. Hello World', ] NAT_SORTED_LIST_1 = [ '1. Hello World', '2. Hello World', '3. Hello World', '9. Hello World', '10. Hello World', '11. Hello World', '12. Hello World', ] LIST_TO_NAT_SORT_2 = [ 'C:/Users/maste/Documents/MEGA/Music/1. Hello World', 'C:/Users/maste/Documents/MEGA/Music/3. Hello World', 'C:/Users/maste/Documents/MEGA/Music/10. Hello World', 'C:/Users/maste/Documents/MEGA/Music/2. Hello World', 'C:/Users/maste/Documents/MEGA/Music/9. Hello World', 'C:/Users/maste/Documents/MEGA/Music/11. Hello World', 'C:/Users/maste/Documents/MEGA/Music/12. Hello World', ] NAT_SORTED_LIST_2 = [ 'C:/Users/maste/Documents/MEGA/Music/1. Hello World', 'C:/Users/maste/Documents/MEGA/Music/2. Hello World', 'C:/Users/maste/Documents/MEGA/Music/3. Hello World', 'C:/Users/maste/Documents/MEGA/Music/9. Hello World', 'C:/Users/maste/Documents/MEGA/Music/10. Hello World', 'C:/Users/maste/Documents/MEGA/Music/11. Hello World', 'C:/Users/maste/Documents/MEGA/Music/12. Hello World', ] GET_METADATA_FROM = [ r'C:\Users\maste\Documents\MEGA\Music\$teven Cannon - Inxanity.mp3', r'C:\Users\maste\Documents\MEGA\Music\6ixbuzz, Pressa, Houdini - Up & Down.mp3', r'C:\Users\maste\Documents\MEGA\Music\88GLAM, Lil Yachty - Lil Boat.mp3', r'C:\Users\maste\Documents\MEGA\Music\Adam K & Soha - Twilight.mp3', ] EXPECTED_METADATA = [ { 'album': 'Inxanity', 'artist': '$teven Cannon', 'explicit': True, 'sort_key': 'inxanity - $teven cannon', 'title': 'Inxanity', 'track_number': '1', }, { 'album': '6ixupsidedown', 'artist': '6ixbuzz, Pressa, Houdini', 'explicit': True, 'title': 'Up & Down', 'sort_key': 'up & down - 6ixbuzz, pressa, houdini', 'track_number': '1', }, { 'album': '88GLAM2.5', 'artist': '88GLAM, Lil Yachty', 'explicit': True, 'title': 'Lil Boat', 'sort_key': 'lil boat - 88glam, lil yachty', 'track_number': '6', }, { 'album': 'Rebirth Classics - Ibiza', 'artist': 'Adam K & Soha', 'explicit': False, 'title': 'Twilight', 'sort_key': 'twilight - adam k & soha', 'track_number': '4', }, ] EXPECTED_FIRST_ARTIST = ['$teven Cannon', '6ixbuzz', '88GLAM', 'Adam K & Soha'] AUDIO_FILE_AND_NAMES = [ ( r'C:\Users\maste\Documents\MEGA\Music\Alesso, Matthew Koma - Years.mp3', 'Alesso, Matthew Koma - Years', ), ( 'C:/Users/maste/Documents/MEGA/Music/Alesso, Matthew Koma - Years.mp3', 'Alesso, Matthew Koma - Years', ), ( r'Music\Afrojack, Steve Aoki, Miss Palmer - No Beef.mp3', 'Afrojack, Steve Aoki, Miss Palmer - No Beef', ), ( 'Music/Afrojack, Steve Aoki, Miss Palmer - No Beef.mp3', 'Afrojack, Steve Aoki, Miss Palmer - No Beef', ), ] def test_get_running_processes(): assert len(list(get_running_processes())) > 0 for process in get_running_processes(): # 5 keys assert len(process) == 5 assert isinstance(process['pid'], int) @pytest.mark.parametrize('file_path,expected', AUDIO_FILE_AND_NAMES) def test_get_file_name(file_path, expected): assert get_file_name(file_path) == expected def test_display_lang(): lang = get_display_lang() assert isinstance(lang, str) assert len(lang) > 0 def test_internationalization(): assert isinstance(get_languages(), list) # check if cache works assert isinstance(get_languages(), list) for code in get_languages(): assert isinstance(code, str) @pytest.mark.parametrize('code', ('en', 'es')) def test_get_lang_pack(code): pack = get_lang_pack(code) assert len(pack) > 0 if code == 'en': assert isinstance(pack, dict) else: assert isinstance(pack, list) @pytest.mark.parametrize('code', ('es', 'de', 'en')) def test_get_translation(code): State.lang = code for line in get_lang_pack('en'): get_translation(line, code) unknown_title = Unknown('Title') assert isinstance(unknown_title > 'unknown title', bool) assert isinstance(unknown_title < 'unknown title', bool) assert isinstance(unknown_title <= 'unknown title', bool) assert isinstance(unknown_title >= 'unknown title', bool) @pytest.mark.parametrize( 'ext', ( '.mp3', '.flac', '.m4a', '.mp4', '.aac', '.mpeg', '.ogg', '.opus', '.wma', '.wav', ), ) def test_valid_audio_file(ext): assert valid_audio_file(f'x{ext}') @pytest.mark.parametrize( 'file', chain( TEST_MUSIC_FILES, ['https://audio.tv', 'https://audio.com', 'audio.mp3', 'https://audio.mp4'], ), ) def test_audio_length(file): try: assert get_audio_length(file) > 0 assert valid_audio_file(file) except InvalidAudioFile: assert not os.path.exists(file) @pytest.mark.parametrize('file', ('audio_player.py', 'file.mp4', 'README.txt')) def test_audio_length_fail(file): # the music players expects bad files to only raise InvalidAudioFile with pytest.raises(InvalidAudioFile): get_audio_length(file) @pytest.mark.skipif( platform.system() != 'Windows', reason='get_default_output_device only implemented on Windows', ) @pytest.mark.no_ci def test_default_output_device(): assert get_default_output_device() print('Default Audio Device:', get_default_output_device()) sar = SystemAudioRecorder() sar.start() # start system audio recording time.sleep(0.5) sar.stop() # stop system audio recording @pytest.mark.parametrize( 'unsorted,expected', [(LIST_TO_NAT_SORT_1, NAT_SORTED_LIST_1), (LIST_TO_NAT_SORT_2, NAT_SORTED_LIST_2)], ) def test_natural_sort(unsorted, expected): assert sorted(unsorted, key=natural_key_file) == expected @pytest.mark.parametrize( 'color_code', ( '#fff', '#ffffff', '#aaa', '#abc', '#999', '#000', '#010', '#000000', '#999999', '#aaaaaa', ), ) def test_valid_color_code(color_code): assert valid_color_code(color_code) @pytest.mark.parametrize( 'color_code', ( 'fff', '000', 'abcdef', '999999', '.', 'czc/z', '#...', '#/.;ads', '#fff.aa', '#999999a', '#ggg', ), ) def test_invalid_color_codes(color_code): assert not valid_color_code(color_code) @pytest.mark.parametrize( 'file,expected,expected_first_artist', zip(GET_METADATA_FROM, EXPECTED_METADATA, EXPECTED_FIRST_ARTIST), ) @pytest.mark.no_ci def test_get_metadata(file, expected, expected_first_artist): assert os.path.exists(file) with suppress(MutagenError): metadata = get_metadata(file) assert metadata.pop('length') > 0 assert metadata.pop('time_modified') > 0 assert metadata == expected assert get_first_artist(metadata['artist']) == expected_first_artist def test_ipv4(): assert get_ipv4().count('.') == 3 def test_ipv6(): assert get_ipv6().count(':') > 0 def test_mac(): assert get_mac().count(':') == 5 def test_ipv4_wifi_match(): ipconfig_cleaned = clean_ipconfig(IPCONFIG_ELIBROFTW) wifi_match = IPV4_WIFI_PATTERN.findall(ipconfig_cleaned) assert len(wifi_match) > 0 assert wifi_match[-1][-1] == '192.168.0.89' def test_ipv4_general_match(): ipconfig_cleaned = clean_ipconfig(IPCONFIG_ERICCHAN1989_ALL) assert len(IPV4_WIFI_PATTERN.findall(ipconfig_cleaned)) == 0 matches = IPV4_GENERAL_PATTERN.findall(ipconfig_cleaned) assert matches[-1] == '192.168.2.2' ipconfig_cleaned = clean_ipconfig(IPCONFIG_ERICCHAN1989) assert len(IPV4_WIFI_PATTERN.findall(ipconfig_cleaned)) == 0 matches = IPV4_GENERAL_PATTERN.findall(ipconfig_cleaned) assert matches[-1] == '192.168.2.2' def test_better_shuffle(): test_better_shuffle = list(range(10000)) better_shuffle(test_better_shuffle, 1, -2) # shuffle everything except for the first and last element assert test_better_shuffle[0] == 0 assert test_better_shuffle[-1] == 9999 def test_is_already_running(): assert isinstance(is_already_running(), bool) @pytest.mark.parametrize( 'url,expected_id', ( ('https://youtu.be/Dlxu28sQfkE', 'Dlxu28sQfkE'), ('https://www.youtube.com/watch?v=Dlxu28sQfkE&feature=youtu.be', 'Dlxu28sQfkE'), ('https://www.youtube.com/watch/Dlxu28sQfkE', 'Dlxu28sQfkE'), ('https://www.youtube.com/embed/Dlxu28sQfkE', 'Dlxu28sQfkE'), ('https://www.youtube.com/v/Dlxu28sQfkE', 'Dlxu28sQfkE'), ( 'https://www.youtube.com/playlist?list=PLRbcUrcJVEmX_eaAsubNOWfE4SlhGqjW4', 'PLRbcUrcJVEmX_eaAsubNOWfE4SlhGqjW4', ), ), ) def test_yt_id(url, expected_id): assert get_yt_id(url) == expected_id def test_custom_art(): assert custom_art('sys') @pytest.mark.parametrize('file', TEST_MUSIC_FILES + ['DEFAULT_ART']) def test_album_art(file): _mime, img_data = get_album_art(file) assert isinstance(img_data, bytes) @pytest.mark.parametrize( 'option,expected_img,expected_label', ( (None, REPEAT_OFF_IMG, 'Repeat All'), (True, REPEAT_ONE_IMG, 'Repeat Off'), (False, REPEAT_ALL_IMG, 'Repeat One'), ), ) def test_repeat_img_tooltip(option, expected_img, expected_label): assert repeat_img_tooltip(option) == (expected_img, t(expected_label)) @pytest.mark.parametrize('size', ((125, 425), COVER_MINI, COVER_NORMAL)) def test_resize_img(size): base64data = resize_img(DEFAULT_ART, '#121212', new_size=size) img_data = io.BytesIO(b64decode(base64data)) img: Image.Image = Image.open(img_data) assert img.size == size @pytest.mark.parametrize( 'url', ( 'https://open.spotify.com/track/0Memc4WL8oO0xUnkXCsNnV?si=Mg58OQxeTj6lTkvNV919wg', # spotify track 'https://open.spotify.com/album/2JSiQ1wnqVEdaf6Y39DsAJ?highlight=spotify:track:0Memc4WL8oO0xUnkXCsNnV', 'https://open.spotify.com/album/47MVgO7XNmxzoYSJIvqxAG', # spotify album 'https://open.spotify.com/playlist/37i9dQZF1DXarRysLJmuju', # spotify playlist ), ) @pytest.mark.skipif(True, reason='spotify web API access removal') def test_spotify(url): try: metadata_list = get_spotify_tracks(url) assert isinstance(metadata_list, list) for metadata in metadata_list: assert metadata['src'] assert 'explicit' in metadata except AssertionError: print('WARNING: Spotify down') time.sleep(0.5) @pytest.mark.parametrize( 'url', ( 'https://www.deezer.com/track/65404135?utm_campaign=clipboard-generic', # deezer track 'https://deezer.page.link/NTW1c5cRdkzy28P19', 'https://deezer.page.link/Prw6jnAYCNe8VrV17', 'https://www.deezer.com/album/217794942', # deezer album 'https://deezer.page.link/XGPUgE6HN5LryeBE7', 'https://www.deezer.com/playlist/1963962142', # deezer playlist 'https://deezer.page.link/URU2yh1GX1wyaoZy9', ), ) @pytest.mark.no_ci def test_deezer(url): with suppress(LookupError): metadata_list = get_deezer_tracks(url) assert isinstance(metadata_list, list) for metadata in metadata_list: assert metadata['src'] assert 'explicit' in metadata assert isinstance(metadata['expiry'], (int, float)) assert metadata['url'] @pytest.fixture def running_in_ci(request): return request.config.getoption('--ci') @pytest.mark.no_ci @pytest.mark.parametrize( 'url', ('https://www.youtube.com/watch?v=PNP0hku7hSo', 'https://youtu.be/5XADIh_mJM4'), ) def test_ydl(running_in_ci, url): try: info = ydl_extract_info(url) assert isinstance(info, dict) metadata = ydl_get_metadata(info) assert metadata['title'] assert metadata['artist'] assert metadata['url'] assert metadata['src'] assert metadata['url'] assert metadata['audio_url'] assert metadata['expiry'] assert metadata['id'] assert metadata['ext'] assert metadata['album'] assert metadata['ytid'] # assert isinstance(metadata['duration'], int) assert metadata['timestamps'] assert isinstance(metadata['is_live'], bool) assert metadata.type == 'youtube' if 'thumbnail' in info: assert metadata['album_cover_url'] is not None except Exception: if not running_in_ci: raise def test_get_proxies(): for _ in range(3): get_proxy() @pytest.mark.parametrize( 'path,expected', ((r'C:\Users\maste\OneDrive', 'C:/Users/maste/OneDrive'),) ) def test_fix_path(path, expected): assert fix_path(path, False) == expected @pytest.mark.parametrize( 'path,expected', (('C:/Users/maste/OneDrive', r'C:\Users\maste\OneDrive'),) ) @pytest.mark.skipif( platform.system() != 'Windows', reason='this test checks if a posix path gets converted into a windows path', ) def test_fix_path_win32(path, expected): assert fix_path(path) == expected # expected is (time elapse, time remaining) @pytest.mark.parametrize( 'position,length,expected', ( (30, 300, ('0:30', '4:30')), (60, 300, ('1:00', '4:00')), (90, 300, ('1:30', '3:30')), (90, 180, ('1:30', '1:30')), (180, 180, ('3:00', '0:00')), (45, 125, ('0:45', '1:20')), (105, 125, ('1:45', '0:20')), (105, 300, ('1:45', '3:15')), ), ) def test_progress_bar_texts(position, length, expected): assert create_progress_bar_texts(position, length) == expected def test_export_playlist(): test_uris = TEST_MUSIC_FILES + ['https://www.youtube.com/watch?v=_jh9lMUjBLo'] path = export_playlist('test_playlist_support', test_uris) for expected_uri, actual_uri in zip(test_uris, parse_m3u(path)): print(expected_uri, actual_uri) assert Path(expected_uri) == Path(actual_uri) os.remove(path) @pytest.mark.parametrize('url', ('https://www.youtube.com/watch?v=MTk-Hwr15ao',)) def test_youtube_comments(url): comments = list(get_youtube_comments(url, 10)) assert len(comments) > 0 @pytest.fixture def uploading_after(request): return request.config.getoption('--upload') @pytest.fixture def test_auto_update(request): return request.config.getoption('--test-auto-update') def test_get_latest_release(uploading_after, test_auto_update): version = [int(x) for x in VERSION.split('.')] latest_release = get_latest_release(VERSION, VERSION, True) assert isinstance(latest_release, dict) compare_ver = latest_release['version'] compare_ver = [int(x) for x in compare_ver.split('.')] if test_auto_update: assert version < compare_ver elif uploading_after: assert compare_ver < version else: assert compare_ver <= version @pytest.mark.ci_only def test_database(): DatabaseConnection.DEFAULT_DATABASE_FILE.parent.mkdir(parents=True, exist_ok=True) with DatabaseConnection(DatabaseConnection.DEFAULT_DATABASE_FILE) as _: pass if DatabaseConnection.DEFAULT_DATABASE_FILE.exists(): os.remove(DatabaseConnection.DEFAULT_DATABASE_FILE) ================================================ FILE: src/theme/LICENSE ================================================ MIT License Copyright (c) 2021 rdbende Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: src/theme/dark.tcl ================================================ # Copyright © 2021 rdbende # A stunning dark theme for ttk based on Microsoft's Sun Valley visual style package require Tk 8.6 namespace eval ttk::theme::sun-valley-dark { variable version 1.0 package provide ttk::theme::sun-valley-dark $version ttk::style theme create sun-valley-dark -parent clam -settings { proc load_images {imgdir} { variable images foreach file [glob -directory $imgdir *.png] { set images([file tail [file rootname $file]]) \ [image create photo -file $file -format png] } } load_images [file join [file dirname [info script]] dark] array set colors { -fg "#ffffff" -bg "#1c1c1c" -disabledfg "#595959" -selectfg "#ffffff" -selectbg "#2f60d8" } ttk::style layout TButton { Button.button -children { Button.padding -children { Button.label -side left -expand 1 } } } ttk::style layout Toolbutton { Toolbutton.button -children { Toolbutton.padding -children { Toolbutton.label -side left -expand 1 } } } ttk::style layout TMenubutton { Menubutton.button -children { Menubutton.padding -children { Menubutton.label -side left -expand 1 Menubutton.indicator -side right -sticky nsew } } } ttk::style layout TOptionMenu { OptionMenu.button -children { OptionMenu.padding -children { OptionMenu.label -side left -expand 1 OptionMenu.indicator -side right -sticky nsew } } } ttk::style layout Accent.TButton { AccentButton.button -children { AccentButton.padding -children { AccentButton.label -side left -expand 1 } } } ttk::style layout TCheckbutton { Checkbutton.button -children { Checkbutton.padding -children { Checkbutton.indicator -side left Checkbutton.label -side right -expand 1 } } } ttk::style layout Switch.TCheckbutton { Switch.button -children { Switch.padding -children { Switch.indicator -side left Switch.label -side right -expand 1 } } } ttk::style layout Toggle.TButton { ToggleButton.button -children { ToggleButton.padding -children { ToggleButton.label -side left -expand 1 } } } ttk::style layout TRadiobutton { Radiobutton.button -children { Radiobutton.padding -children { Radiobutton.indicator -side left Radiobutton.label -side right -expand 1 } } } ttk::style layout Vertical.TScrollbar { Vertical.Scrollbar.trough -sticky ns -children { Vertical.Scrollbar.uparrow -side top Vertical.Scrollbar.downarrow -side bottom Vertical.Scrollbar.thumb -expand 1 } } ttk::style layout Horizontal.TScrollbar { Horizontal.Scrollbar.trough -sticky ew -children { Horizontal.Scrollbar.leftarrow -side left Horizontal.Scrollbar.rightarrow -side right Horizontal.Scrollbar.thumb -expand 1 } } ttk::style layout TSeparator { TSeparator.separator -sticky nsew } ttk::style layout TCombobox { Combobox.field -sticky nsew -children { Combobox.padding -expand 1 -sticky nsew -children { Combobox.textarea -sticky nsew } } null -side right -sticky ns -children { Combobox.arrow -sticky nsew } } ttk::style layout TSpinbox { Spinbox.field -sticky nsew -children { Spinbox.padding -expand 1 -sticky nsew -children { Spinbox.textarea -sticky nsew } } null -side right -sticky nsew -children { Spinbox.uparrow -side left -sticky nsew Spinbox.downarrow -side right -sticky nsew } } ttk::style layout Card.TFrame { Card.field { Card.padding -expand 1 } } ttk::style layout TLabelframe { Labelframe.border { Labelframe.padding -expand 1 -children { Labelframe.label -side left } } } ttk::style layout TNotebook { Notebook.border -children { TNotebook.Tab -expand 1 Notebook.client -sticky nsew } } ttk::style layout Treeview.Item { Treeitem.padding -sticky nsew -children { Treeitem.image -side left -sticky {} Treeitem.indicator -side left -sticky {} Treeitem.text -side left -sticky {} } } # Button ttk::style configure TButton -padding {8 4} -anchor center -foreground $colors(-fg) ttk::style map TButton -foreground \ [list disabled #7a7a7a \ pressed #d0d0d0] ttk::style element create Button.button image \ [list $images(button-rest) \ {selected disabled} $images(button-disabled) \ disabled $images(button-disabled) \ selected $images(button-rest) \ pressed $images(button-pressed) \ active $images(button-hover) \ ] -border 4 -sticky nsew # Toolbutton ttk::style configure Toolbutton -padding {8 4} -anchor center ttk::style element create Toolbutton.button image \ [list $images(empty) \ {selected disabled} $images(button-disabled) \ selected $images(button-rest) \ pressed $images(button-pressed) \ active $images(button-hover) \ ] -border 4 -sticky nsew # Menubutton ttk::style configure TMenubutton -padding {8 4 0 4} ttk::style element create Menubutton.button \ image [list $images(button-rest) \ disabled $images(button-disabled) \ pressed $images(button-pressed) \ active $images(button-hover) \ ] -border 4 -sticky nsew ttk::style element create Menubutton.indicator image $images(arrow-down) -width 28 -sticky {} # OptionMenu ttk::style configure TOptionMenu -padding {8 4 0 4} ttk::style element create OptionMenu.button \ image [list $images(button-rest) \ disabled $images(button-disabled) \ pressed $images(button-pressed) \ active $images(button-hover) \ ] -border 4 -sticky nsew ttk::style element create OptionMenu.indicator image $images(arrow-down) -width 28 -sticky {} # Accent.TButton ttk::style configure Accent.TButton -padding {8 4} -anchor center -foreground #000000 ttk::style map Accent.TButton -foreground \ [list pressed #25536a \ disabled #a5a5a5] ttk::style element create AccentButton.button image \ [list $images(button-accent-rest) \ {selected disabled} $images(button-accent-disabled) \ disabled $images(button-accent-disabled) \ selected $images(button-accent-rest) \ pressed $images(button-accent-pressed) \ active $images(button-accent-hover) \ ] -border 4 -sticky nsew # Checkbutton ttk::style configure TCheckbutton -padding 4 ttk::style element create Checkbutton.indicator image \ [list $images(check-unsel-rest) \ {alternate disabled} $images(check-tri-disabled) \ {selected disabled} $images(check-disabled) \ disabled $images(check-unsel-disabled) \ {pressed alternate} $images(check-tri-hover) \ {active alternate} $images(check-tri-hover) \ alternate $images(check-tri-rest) \ {pressed selected} $images(check-hover) \ {active selected} $images(check-hover) \ selected $images(check-rest) \ {pressed !selected} $images(check-unsel-pressed) \ active $images(check-unsel-hover) \ ] -width 26 -sticky w # Switch.TCheckbutton ttk::style element create Switch.indicator image \ [list $images(switch-off-rest) \ {selected disabled} $images(switch-on-disabled) \ disabled $images(switch-off-disabled) \ {pressed selected} $images(switch-on-pressed) \ {active selected} $images(switch-on-hover) \ selected $images(switch-on-rest) \ {pressed !selected} $images(switch-off-pressed) \ active $images(switch-off-hover) \ ] -width 46 -sticky w # Toggle.TButton ttk::style configure Toggle.TButton -padding {8 4 8 4} -anchor center -foreground $colors(-fg) ttk::style map Toggle.TButton -foreground \ [list {selected disabled} #a5a5a5 \ {selected pressed} #d0d0d0 \ selected #000000 \ pressed #25536a \ disabled #7a7a7a ] ttk::style element create ToggleButton.button image \ [list $images(button-rest) \ {selected disabled} $images(button-accent-disabled) \ disabled $images(button-disabled) \ {pressed selected} $images(button-rest) \ {active selected} $images(button-accent-hover) \ selected $images(button-accent-rest) \ {pressed !selected} $images(button-accent-rest) \ active $images(button-hover) \ ] -border 4 -sticky nsew # Radiobutton ttk::style configure TRadiobutton -padding 4 ttk::style element create Radiobutton.indicator image \ [list $images(radio-unsel-rest) \ {selected disabled} $images(radio-disabled) \ disabled $images(radio-unsel-disabled) \ {pressed selected} $images(radio-pressed) \ {active selected} $images(radio-hover) \ selected $images(radio-rest) \ {pressed !selected} $images(radio-unsel-pressed) \ active $images(radio-unsel-hover) \ ] -width 26 -sticky w # Scrollbar ttk::style element create Horizontal.Scrollbar.trough image $images(scroll-hor-trough) -sticky ew -border 6 ttk::style element create Horizontal.Scrollbar.thumb image $images(scroll-hor-thumb) -sticky ew -border 3 ttk::style element create Horizontal.Scrollbar.rightarrow image $images(scroll-right) -sticky {} -width 12 ttk::style element create Horizontal.Scrollbar.leftarrow image $images(scroll-left) -sticky {} -width 12 ttk::style element create Vertical.Scrollbar.trough image $images(scroll-vert-trough) -sticky ns -border 6 ttk::style element create Vertical.Scrollbar.thumb image $images(scroll-vert-thumb) -sticky ns -border 3 ttk::style element create Vertical.Scrollbar.uparrow image $images(scroll-up) -sticky {} -height 12 ttk::style element create Vertical.Scrollbar.downarrow image $images(scroll-down) -sticky {} -height 12 # Scale ttk::style element create Horizontal.Scale.trough image $images(scale-trough-hor) \ -border 5 -padding 0 ttk::style element create Vertical.Scale.trough image $images(scale-trough-vert) \ -border 5 -padding 0 ttk::style element create Scale.slider \ image [list $images(scale-thumb-rest) \ disabled $images(scale-thumb-disabled) \ pressed $images(scale-thumb-pressed) \ active $images(scale-thumb-hover) \ ] -sticky {} # Progressbar ttk::style element create Horizontal.Progressbar.trough image $images(progress-trough-hor) \ -border 1 -sticky ew ttk::style element create Horizontal.Progressbar.pbar image $images(progress-pbar-hor) \ -border 2 -sticky ew ttk::style element create Vertical.Progressbar.trough image $images(progress-trough-vert) \ -border 1 -sticky ns ttk::style element create Vertical.Progressbar.pbar image $images(progress-pbar-vert) \ -border 2 -sticky ns # Entry ttk::style configure TEntry -foreground $colors(-fg) ttk::style map TEntry -foreground \ [list disabled #757575 \ pressed #cfcfcf ] ttk::style element create Entry.field \ image [list $images(entry-rest) \ {focus hover !invalid} $images(entry-focus) \ invalid $images(entry-invalid) \ disabled $images(entry-disabled) \ {focus !invalid} $images(entry-focus) \ hover $images(entry-hover) \ ] -border 5 -padding 8 -sticky nsew # Combobox ttk::style configure TCombobox -foreground $colors(-fg) ttk::style map TCombobox -foreground \ [list disabled #757575 \ pressed #cfcfcf ] ttk::style configure ComboboxPopdownFrame -borderwidth 1 -relief solid ttk::style map TCombobox -selectbackground [list \ {readonly hover} $colors(-selectbg) \ {readonly focus} $colors(-selectbg) \ ] -selectforeground [list \ {readonly hover} $colors(-selectfg) \ {readonly focus} $colors(-selectfg) \ ] ttk::style element create Combobox.field \ image [list $images(entry-rest) \ {readonly disabled} $images(button-disabled) \ {readonly pressed} $images(button-pressed) \ {readonly hover} $images(button-hover) \ readonly $images(button-rest) \ invalid $images(entry-invalid) \ disabled $images(entry-disabled) \ focus $images(entry-focus) \ hover $images(entry-hover) \ ] -border 5 -padding {8 8 28 8} ttk::style element create Combobox.arrow image $images(arrow-down) -width 35 -sticky {} # Spinbox ttk::style configure TSpinbox -foreground $colors(-fg) ttk::style map TSpinbox -foreground \ [list disabled #757575 \ pressed #cfcfcf ] ttk::style element create Spinbox.field \ image [list $images(entry-rest) \ invalid $images(entry-invalid) \ disabled $images(entry-disabled) \ focus $images(entry-focus) \ hover $images(entry-hover) \ ] -border 5 -padding {8 8 54 8} -sticky nsew ttk::style element create Spinbox.uparrow image $images(arrow-up) -width 35 -sticky {} ttk::style element create Spinbox.downarrow image $images(arrow-down) -width 35 -sticky {} # Sizegrip ttk::style element create Sizegrip.sizegrip image $images(sizegrip) \ -sticky nsew # Separator ttk::style element create TSeparator.separator image $images(separator) # Card ttk::style element create Card.field image $images(card) \ -border 10 -padding 4 -sticky nsew # Labelframe ttk::style element create Labelframe.border image $images(card) \ -border 5 -padding 4 -sticky nsew # Notebook ttk::style configure TNotebook -padding 1 ttk::style element create Notebook.border \ image $images(notebook-border) -border 5 -padding 5 ttk::style element create Notebook.client image $images(notebook) ttk::style element create Notebook.tab \ image [list $images(tab-rest) \ selected $images(tab-selected) \ active $images(tab-hover) \ ] -border 13 -padding {16 14 16 6} -height 32 # Treeview ttk::style element create Treeview.field image $images(card) \ -border 5 ttk::style element create Treeheading.cell \ image [list $images(treeheading-rest) \ pressed $images(treeheading-pressed) \ active $images(treeheading-hover) ] -border 5 -padding 15 -sticky nsew ttk::style element create Treeitem.indicator \ image [list $images(arrow-right) \ user2 $images(empty) \ user1 $images(arrow-down) \ ] -width 26 -sticky {} ttk::style configure Treeview -background $colors(-bg) -rowheight [expr {[font metrics font -linespace] + 2}] ttk::style map Treeview \ -background [list selected #292929] \ -foreground [list selected $colors(-selectfg)] # Panedwindow # Insane hack to remove clam's ugly sash ttk::style configure Sash -gripcount 0 } } ================================================ FILE: src/theme/light.tcl ================================================ # Copyright © 2021 rdbende # A stunning light theme for ttk based on Microsoft's Sun Valley visual style package require Tk 8.6 namespace eval ttk::theme::sun-valley-light { variable version 1.0 package provide ttk::theme::sun-valley-light $version ttk::style theme create sun-valley-light -parent clam -settings { proc load_images {imgdir} { variable images foreach file [glob -directory $imgdir *.png] { set images([file tail [file rootname $file]]) \ [image create photo -file $file -format png] } } load_images [file join [file dirname [info script]] light] array set colors { -fg "#202020" -bg "#fafafa" -disabledfg "#a0a0a0" -selectfg "#ffffff" -selectbg "#2f60d8" } ttk::style layout TButton { Button.button -children { Button.padding -children { Button.label -side left -expand 1 } } } ttk::style layout Toolbutton { Toolbutton.button -children { Toolbutton.padding -children { Toolbutton.label -side left -expand 1 } } } ttk::style layout TMenubutton { Menubutton.button -children { Menubutton.padding -children { Menubutton.label -side left -expand 1 Menubutton.indicator -side right -sticky nsew } } } ttk::style layout TOptionMenu { OptionMenu.button -children { OptionMenu.padding -children { OptionMenu.label -side left -expand 1 OptionMenu.indicator -side right -sticky nsew } } } ttk::style layout Accent.TButton { AccentButton.button -children { AccentButton.padding -children { AccentButton.label -side left -expand 1 } } } ttk::style layout TCheckbutton { Checkbutton.button -children { Checkbutton.padding -children { Checkbutton.indicator -side left Checkbutton.label -side right -expand 1 } } } ttk::style layout Switch.TCheckbutton { Switch.button -children { Switch.padding -children { Switch.indicator -side left Switch.label -side right -expand 1 } } } ttk::style layout Toggle.TButton { ToggleButton.button -children { ToggleButton.padding -children { ToggleButton.label -side left -expand 1 } } } ttk::style layout TRadiobutton { Radiobutton.button -children { Radiobutton.padding -children { Radiobutton.indicator -side left Radiobutton.label -side right -expand 1 } } } ttk::style layout Vertical.TScrollbar { Vertical.Scrollbar.trough -sticky ns -children { Vertical.Scrollbar.uparrow -side top Vertical.Scrollbar.downarrow -side bottom Vertical.Scrollbar.thumb -expand 1 } } ttk::style layout Horizontal.TScrollbar { Horizontal.Scrollbar.trough -sticky ew -children { Horizontal.Scrollbar.leftarrow -side left Horizontal.Scrollbar.rightarrow -side right Horizontal.Scrollbar.thumb -expand 1 } } ttk::style layout TSeparator { TSeparator.separator -sticky nsew } ttk::style layout TCombobox { Combobox.field -sticky nsew -children { Combobox.padding -expand 1 -sticky nsew -children { Combobox.textarea -sticky nsew } } null -side right -sticky ns -children { Combobox.arrow -sticky nsew } } ttk::style layout TSpinbox { Spinbox.field -sticky nsew -children { Spinbox.padding -expand 1 -sticky nsew -children { Spinbox.textarea -sticky nsew } } null -side right -sticky nsew -children { Spinbox.uparrow -side left -sticky nsew Spinbox.downarrow -side right -sticky nsew } } ttk::style layout Card.TFrame { Card.field { Card.padding -expand 1 } } ttk::style layout TLabelframe { Labelframe.border { Labelframe.padding -expand 1 -children { Labelframe.label -side left } } } ttk::style layout TNotebook { Notebook.border -children { TNotebook.Tab -expand 1 Notebook.client -sticky nsew } } ttk::style layout Treeview.Item { Treeitem.padding -sticky nsew -children { Treeitem.image -side left -sticky {} Treeitem.indicator -side left -sticky {} Treeitem.text -side left -sticky {} } } # Button ttk::style configure TButton -padding {8 4} -anchor center -foreground $colors(-fg) ttk::style map TButton -foreground \ [list disabled #a2a2a2 \ pressed #636363 \ active #1a1a1a] ttk::style element create Button.button image \ [list $images(button-rest) \ {selected disabled} $images(button-disabled) \ disabled $images(button-disabled) \ selected $images(button-rest) \ pressed $images(button-pressed) \ active $images(button-hover) \ ] -border 4 -sticky nsew # Toolbutton ttk::style configure Toolbutton -padding {8 4} -anchor center ttk::style element create Toolbutton.button image \ [list $images(empty) \ {selected disabled} $images(button-disabled) \ selected $images(button-rest) \ pressed $images(button-pressed) \ active $images(button-hover) \ ] -border 4 -sticky nsew # Menubutton ttk::style configure TMenubutton -padding {8 4 0 4} ttk::style element create Menubutton.button \ image [list $images(button-rest) \ disabled $images(button-disabled) \ pressed $images(button-pressed) \ active $images(button-hover) \ ] -border 4 -sticky nsew ttk::style element create Menubutton.indicator image $images(arrow-down) -width 28 -sticky {} # OptionMenu ttk::style configure TOptionMenu -padding {8 4 0 4} ttk::style element create OptionMenu.button \ image [list $images(button-rest) \ disabled $images(button-disabled) \ pressed $images(button-pressed) \ active $images(button-hover) \ ] -border 4 -sticky nsew ttk::style element create OptionMenu.indicator image $images(arrow-down) -width 28 -sticky {} # Accent.TButton ttk::style configure Accent.TButton -padding {8 4} -anchor center -foreground #ffffff ttk::style map Accent.TButton -foreground \ [list disabled #ffffff \ pressed #c1d8ee] ttk::style element create AccentButton.button image \ [list $images(button-accent-rest) \ {selected disabled} $images(button-accent-disabled) \ disabled $images(button-accent-disabled) \ selected $images(button-accent-rest) \ pressed $images(button-accent-pressed) \ active $images(button-accent-hover) \ ] -border 4 -sticky nsew # Checkbutton ttk::style configure TCheckbutton -padding 4 ttk::style element create Checkbutton.indicator image \ [list $images(check-unsel-rest) \ {alternate disabled} $images(check-tri-disabled) \ {selected disabled} $images(check-disabled) \ disabled $images(check-unsel-disabled) \ {pressed alternate} $images(check-tri-hover) \ {active alternate} $images(check-tri-hover) \ alternate $images(check-tri-rest) \ {pressed selected} $images(check-hover) \ {active selected} $images(check-hover) \ selected $images(check-rest) \ {pressed !selected} $images(check-unsel-pressed) \ active $images(check-unsel-hover) \ ] -width 26 -sticky w # Switch.TCheckbutton ttk::style element create Switch.indicator image \ [list $images(switch-off-rest) \ {selected disabled} $images(switch-on-disabled) \ disabled $images(switch-off-disabled) \ {pressed selected} $images(switch-on-pressed) \ {active selected} $images(switch-on-hover) \ selected $images(switch-on-rest) \ {pressed !selected} $images(switch-off-pressed) \ active $images(switch-off-hover) \ ] -width 46 -sticky w # Toggle.TButton ttk::style configure Toggle.TButton -padding {8 4 8 4} -anchor center -foreground $colors(-fg) ttk::style map Toggle.TButton -foreground \ [list {selected disabled} #ffffff \ {selected pressed} #636363 \ selected #ffffff \ pressed #c1d8ee \ disabled #a2a2a2 \ active #1a1a1a ] ttk::style element create ToggleButton.button image \ [list $images(button-rest) \ {selected disabled} $images(button-accent-disabled) \ disabled $images(button-disabled) \ {pressed selected} $images(button-rest) \ {active selected} $images(button-accent-hover) \ selected $images(button-accent-rest) \ {pressed !selected} $images(button-accent-rest) \ active $images(button-hover) \ ] -border 4 -sticky nsew # Radiobutton ttk::style configure TRadiobutton -padding 4 ttk::style element create Radiobutton.indicator image \ [list $images(radio-unsel-rest) \ {selected disabled} $images(radio-disabled) \ disabled $images(radio-unsel-disabled) \ {pressed selected} $images(radio-pressed) \ {active selected} $images(radio-hover) \ selected $images(radio-rest) \ {pressed !selected} $images(radio-unsel-pressed) \ active $images(radio-unsel-hover) \ ] -width 26 -sticky w # Scrollbar ttk::style element create Horizontal.Scrollbar.trough image $images(scroll-hor-trough) -sticky ew -border 6 ttk::style element create Horizontal.Scrollbar.thumb image $images(scroll-hor-thumb) -sticky ew -border 3 ttk::style element create Horizontal.Scrollbar.rightarrow image $images(scroll-right) -sticky {} -width 12 ttk::style element create Horizontal.Scrollbar.leftarrow image $images(scroll-left) -sticky {} -width 12 ttk::style element create Vertical.Scrollbar.trough image $images(scroll-vert-trough) -sticky ns -border 6 ttk::style element create Vertical.Scrollbar.thumb image $images(scroll-vert-thumb) -sticky ns -border 3 ttk::style element create Vertical.Scrollbar.uparrow image $images(scroll-up) -sticky {} -height 12 ttk::style element create Vertical.Scrollbar.downarrow image $images(scroll-down) -sticky {} -height 12 # Scale ttk::style element create Horizontal.Scale.trough image $images(scale-trough-hor) \ -border 5 -padding 0 ttk::style element create Vertical.Scale.trough image $images(scale-trough-vert) \ -border 5 -padding 0 ttk::style element create Scale.slider \ image [list $images(scale-thumb-rest) \ disabled $images(scale-thumb-disabled) \ pressed $images(scale-thumb-pressed) \ active $images(scale-thumb-hover) \ ] -sticky {} # Progressbar ttk::style element create Horizontal.Progressbar.trough image $images(progress-trough-hor) \ -border 1 -sticky ew ttk::style element create Horizontal.Progressbar.pbar image $images(progress-pbar-hor) \ -border 2 -sticky ew ttk::style element create Vertical.Progressbar.trough image $images(progress-trough-vert) \ -border 1 -sticky ns ttk::style element create Vertical.Progressbar.pbar image $images(progress-pbar-vert) \ -border 2 -sticky ns # Entry ttk::style configure TEntry -foreground $colors(-fg) ttk::style map TEntry -foreground \ [list disabled #0a0a0a \ pressed #636363 \ active #626262 ] ttk::style element create Entry.field \ image [list $images(entry-rest) \ {focus hover !invalid} $images(entry-focus) \ invalid $images(entry-invalid) \ disabled $images(entry-disabled) \ {focus !invalid} $images(entry-focus) \ hover $images(entry-hover) \ ] -border 5 -padding 8 -sticky nsew # Combobox ttk::style configure TCombobox -foreground $colors(-fg) ttk::style configure ComboboxPopdownFrame -borderwidth 1 -relief solid ttk::style map TCombobox -foreground \ [list disabled #0a0a0a \ pressed #636363 \ active #626262 ] ttk::style map TCombobox -selectbackground [list \ {readonly hover} $colors(-selectbg) \ {readonly focus} $colors(-selectbg) \ ] -selectforeground [list \ {readonly hover} $colors(-selectfg) \ {readonly focus} $colors(-selectfg) \ ] ttk::style element create Combobox.field \ image [list $images(entry-rest) \ {readonly disabled} $images(button-disabled) \ {readonly pressed} $images(button-pressed) \ {readonly hover} $images(button-hover) \ readonly $images(button-rest) \ invalid $images(entry-invalid) \ disabled $images(entry-disabled) \ focus $images(entry-focus) \ hover $images(entry-hover) \ ] -border 5 -padding {8 8 28 8} ttk::style element create Combobox.arrow image $images(arrow-down) -width 35 -sticky {} # Spinbox ttk::style configure TSpinbox -foreground $colors(-fg) ttk::style map TSpinbox -foreground \ [list disabled #0a0a0a \ pressed #636363 \ active #626262 ] ttk::style element create Spinbox.field \ image [list $images(entry-rest) \ invalid $images(entry-invalid) \ disabled $images(entry-disabled) \ focus $images(entry-focus) \ hover $images(entry-hover) \ ] -border 5 -padding {8 8 54 8} -sticky nsew ttk::style element create Spinbox.uparrow image $images(arrow-up) -width 35 -sticky {} ttk::style element create Spinbox.downarrow image $images(arrow-down) -width 35 -sticky {} # Sizegrip ttk::style element create Sizegrip.sizegrip image $images(sizegrip) \ -sticky nsew # Separator ttk::style element create TSeparator.separator image $images(separator) # Card ttk::style element create Card.field image $images(card) \ -border 10 -padding 4 -sticky nsew # Labelframe ttk::style element create Labelframe.border image $images(card) \ -border 5 -padding 4 -sticky nsew # Notebook ttk::style configure TNotebook -padding 1 ttk::style element create Notebook.border \ image $images(notebook-border) -border 5 -padding 5 ttk::style element create Notebook.client image $images(notebook) ttk::style element create Notebook.tab \ image [list $images(tab-rest) \ selected $images(tab-selected) \ active $images(tab-hover) \ ] -border 13 -padding {16 14 16 6} -height 32 # Treeview ttk::style element create Treeview.field image $images(card) \ -border 5 ttk::style element create Treeheading.cell \ image [list $images(treeheading-rest) \ pressed $images(treeheading-pressed) \ active $images(treeheading-hover) ] -border 5 -padding 15 -sticky nsew ttk::style element create Treeitem.indicator \ image [list $images(arrow-right) \ user2 $images(empty) \ user1 $images(arrow-down) \ ] -width 26 -sticky {} ttk::style configure Treeview -foregound #1a1a1a -background $colors(-bg) -rowheight [expr {[font metrics font -linespace] + 2}] ttk::style map Treeview \ -background [list selected #f0f0f0] \ -foreground [list selected #191919] # Panedwindow # Insane hack to remove clam's ugly sash ttk::style configure Sash -gripcount 0 } } ================================================ FILE: src/theme/sun-valley.tcl ================================================ # Copyright © 2021 rdbende source [file join [file dirname [info script]] light.tcl] source [file join [file dirname [info script]] dark.tcl] option add *tearOff 0 proc set_theme {mode} { if {$mode == "dark"} { ttk::style theme use "sun-valley-dark" array set colors { -fg "#ffffff" -bg "#1c1c1c" -disabledfg "#595959" -selectfg "#ffffff" -selectbg "#2f60d8" } ttk::style configure . \ -background $colors(-bg) \ -foreground $colors(-fg) \ -troughcolor $colors(-bg) \ -focuscolor $colors(-selectbg) \ -selectbackground $colors(-selectbg) \ -selectforeground $colors(-selectfg) \ -insertwidth 1 \ -insertcolor $colors(-fg) \ -fieldbackground $colors(-selectbg) \ -font {"Segoe Ui" 10} \ -borderwidth 1 \ -relief flat tk_setPalette \ background [ttk::style lookup . -background] \ foreground [ttk::style lookup . -foreground] \ highlightColor [ttk::style lookup . -focuscolor] \ selectBackground [ttk::style lookup . -selectbackground] \ selectForeground [ttk::style lookup . -selectforeground] \ activeBackground [ttk::style lookup . -selectbackground] \ activeForeground [ttk::style lookup . -selectforeground] ttk::style map . -foreground [list disabled $colors(-disabledfg)] option add *font [ttk::style lookup . -font] option add *Menu.selectcolor $colors(-fg) option add *Menu.background #2f2f2f } elseif {$mode == "light"} { ttk::style theme use "sun-valley-light" array set colors { -fg "#202020" -bg "#fafafa" -disabledfg "#a0a0a0" -selectfg "#ffffff" -selectbg "#2f60d8" } ttk::style configure . \ -background $colors(-bg) \ -foreground $colors(-fg) \ -troughcolor $colors(-bg) \ -focuscolor $colors(-selectbg) \ -selectbackground $colors(-selectbg) \ -selectforeground $colors(-selectfg) \ -insertwidth 1 \ -insertcolor $colors(-fg) \ -fieldbackground $colors(-selectbg) \ -font {"Segoe Ui" 10} \ -borderwidth 0 \ -relief flat tk_setPalette background [ttk::style lookup . -background] \ foreground [ttk::style lookup . -foreground] \ highlightColor [ttk::style lookup . -focuscolor] \ selectBackground [ttk::style lookup . -selectbackground] \ selectForeground [ttk::style lookup . -selectforeground] \ activeBackground [ttk::style lookup . -selectbackground] \ activeForeground [ttk::style lookup . -selectforeground] ttk::style map . -foreground [list disabled $colors(-disabledfg)] option add *font [ttk::style lookup . -font] option add *Menu.selectcolor $colors(-fg) option add *Menu.background #e7e7e7 } } ================================================ FILE: src/updater.go ================================================ package main import ( "archive/zip" "encoding/json" "errors" "fmt" "io" "io/ioutil" "net/http" "os" "os/exec" "path/filepath" "strings" "time" ) func loadSettings() map[string]interface{} { settingsFile := "settings.json" loadedSettings := map[string]interface{}{"DEBUG": false} jsonFile, err := os.Open(settingsFile) if err == nil { byteValue, err := ioutil.ReadAll(jsonFile) if err == nil { json.Unmarshal(byteValue, &loadedSettings) } } return loadedSettings } func main() { releasesURL := "https://api.github.com/repos/elibroftw/music-caster/releases/latest" ex, _ := os.Executable() os.Chdir(filepath.Dir(ex)) // load settings client := http.Client{Timeout: time.Second * 5} req, _ := http.NewRequest(http.MethodGet, releasesURL, nil) res, err := client.Do(req) if err != nil { fmt.Println("Failed to make request", err) return } if res.Body != nil { defer res.Body.Close() } body, err := ioutil.ReadAll(res.Body) if err == nil { var jsonResponse map[string]interface{} if json.Unmarshal(body, &jsonResponse) == nil { // if json parsing successful var setupDownloadURL, portableDownloadURL string assets := jsonResponse["assets"].([]interface{}) for _, v := range assets { asset := v.(map[string]interface{}) if strings.HasSuffix(asset["name"].(string), ".exe") { setupDownloadURL = asset["browser_download_url"].(string) } else if strings.Contains(strings.ToLower(asset["name"].(string)), "portable") { portableDownloadURL = asset["browser_download_url"].(string) } } fmt.Println("Latest Version:", jsonResponse["tag_name"].(string)) fmt.Println("Installer: ", setupDownloadURL) fmt.Println("Portable: ", portableDownloadURL) loadedSettings := loadSettings() if debugSetting, ok := loadedSettings["DEBUG"].(bool); ok && debugSetting { // don't download anything return } file, err := os.Open("unins000.exe") file.Close() if errors.Is(err, os.ErrNotExist) { // portable or linux? installation // TODO: Linux support download(portableDownloadURL, "Portable.zip") exec.Command("Music Caster", "--nupdate").Start() } else { // installer exists download(setupDownloadURL, "MC_Installer.exe") exec.Command("MC_Installer.exe", `/VERYSILENT /CLOSEAPPLICATIONS /FORCECLOSEAPPLICATIONS /MERGETASKS="!desktopicon"`) } } } } func extractZip(src string) error { // https://golangcode.com/unzip-files-in-go/ var filenames []string r, err := zip.OpenReader(src) if err != nil { return err } defer r.Close() src, _ = filepath.Abs(src) dest := filepath.Dir(src) fmt.Println("Extracting", src, "to", dest) for _, f := range r.File { // Store filename/path for returning and using later on fpath := filepath.Join(dest, f.Name) // Check for ZipSlip. More Info: http://bit.ly/2MsjAWE if !strings.HasPrefix(fpath, filepath.Clean(dest)+string(os.PathSeparator)) { return fmt.Errorf("%s: illegal file path", fpath) } filenames = append(filenames, fpath) if f.FileInfo().IsDir() { // Make Folder os.MkdirAll(fpath, os.ModePerm) continue } // Make File if err = os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil { return err } outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) if err == nil { rc, err := f.Open() if err != nil { return err } _, err = io.Copy(outFile, rc) // Close the file without defer to close before next iteration of loop outFile.Close() rc.Close() if err != nil { return err } } } // delete file r.Close() err = os.Remove(src) return err } func download(url string, filepath string) { // https://golangcode.com/download-a-file-from-a-url/ fmt.Println("Downloading", filepath) resp, err := http.Get(url) if err != nil { return } defer resp.Body.Close() // Create the file out, err := os.Create(filepath) if err != nil { return } // Write the body to file _, err = io.Copy(out, resp.Body) out.Close() if err != nil { panic(err) } if strings.HasSuffix(filepath, ".zip") { extractZip(filepath) } } // go build -ldflags "-w -s" ================================================ FILE: src/utils.py ================================================ # flake8: noqa: E402 import audioop import base64 import ctypes import glob import io import locale import logging import os import platform import re import shutil import socket import subprocess import sys import tarfile import tempfile import time from typing import Tuple import unicodedata import webbrowser from base64 import b64decode, b64encode from contextlib import suppress from functools import lru_cache, wraps from itertools import chain, cycle, repeat from math import floor from pathlib import Path from queue import Empty, LifoQueue from random import getrandbits from subprocess import DEVNULL, PIPE, CalledProcessError, Popen, check_output from threading import Thread from urllib.parse import parse_qs, urlencode, urlparse from uuid import getnode from zipfile import ZipFile # 3rd party imports import deemix.utils.localpaths as __lp # local imports from b64_images import DEFAULT_ART, REPEAT_ALL_IMG, REPEAT_OFF_IMG, REPEAT_ONE_IMG from deezer import TrackFormats __lp.musicdata = '/dz' import mutagen import mutagen._file import mutagen.flac import mutagen.id3 import pyaudio import pypresence import requests from meta import AUDIO_EXTS, AUDIO_HANDLER_EXTS, COVER_NORMAL, USER_AGENT, State from mutagen._util import MutagenError from mutagen.aac import AAC from mutagen.id3._util import ID3NoHeaderError from mutagen.mp3 import MP3, EasyMP3, HeaderNotFoundError from mutagen.mp4 import MP4, MP4Cover from mutagen.oggopus import OggOpus from mutagen.oggvorbis import OggVorbis from mutagen.wave import WAVE from PIL import Image, ImageDraw, ImageFile, ImageFont, UnidentifiedImageError from pychromecast import CastInfo from wavinfo import WavInfoEOFError, WavInfoReader # until mutagen supports .wav from youtube_comment_downloader import SORT_BY_POPULAR, YoutubeCommentDownloader # CONSTANTS IS_FROZEN = getattr(sys, 'frozen', False) ImageFile.LOAD_TRUNCATED_IMAGES = True yt_comment_downloader = YoutubeCommentDownloader() SPOTIFY_API = 'https://api.spotify.com/v1' # for stealing focus when bring window to front class SystemAudioRecorder: __slots__ = 'STREAM_CHUNK', 'BITS_PER_SAMPLE', 'pa', 'sample_rate', 'channels', 'alive', 'data_stream', 'lag' def __init__(self): self.STREAM_CHUNK = 1024 self.BITS_PER_SAMPLE = 16 self.pa = None self.sample_rate = None self.channels = None self.alive = False self.lag = 0.0 self.data_stream = LifoQueue() def get_audio_data(self, delay=0): if not self.alive: return # ensure that start() was called silent_wav = b'\x00' * self.STREAM_CHUNK yield self.get_wav_header() yield silent_wav * delay * 1000 last_sleep = time.time() + 1 while self.alive: if self.lag and time.time() - last_sleep > 1: sleep_for = min(0.2, self.lag) # sleep for max 0.2 seconds at a time self.lag -= sleep_for time.sleep(sleep_for) last_sleep = time.time() try: t1 = time.time() yield self.data_stream.get(timeout=0.09) t2 = time.time() - t1 - 0.05 if t2 > 0: # account for lag if chunk was recorded in late self.lag = t2 self.data_stream.task_done() # discard old data with suppress(Empty): while True: self.data_stream.get(False) self.data_stream.task_done() except Empty: yield silent_wav def _start_recording(self): if self.alive: return self.alive = True selected_device = get_default_output_device() stream = self.create_stream(selected_device) for chunk in iter(lambda: audioop.mul(stream.read(self.STREAM_CHUNK), 2, 2) if self.alive else None, None): self.data_stream.put(chunk) default_output = get_default_output_device() # check if output device has changed if selected_device != default_output: selected_device = default_output stream.close() stream = self.create_stream(selected_device) def create_stream(self, output_device): for i in range(self.pa.get_device_count()): device_info = self.pa.get_device_info_by_index(i) host_api_info = self.pa.get_host_api_info_by_index(device_info['hostApi']) if (host_api_info['name'] == 'Windows WASAPI' and device_info['maxOutputChannels'] > 0 and device_info['name'] == output_device): self.channels = min(device_info['maxOutputChannels'], 2) self.sample_rate = int(device_info['defaultSampleRate']) # e.g. 48,000 bits return self.pa.open(format=pyaudio.paInt16, input=True, as_loopback=True, channels=self.channels, input_device_index=device_info['index'], rate=self.sample_rate, frames_per_buffer=self.STREAM_CHUNK) raise RuntimeError('Default Output Device Not Found') def get_wav_header(self): data_size = 2000 * 10 ** 6 o = bytes('RIFF', 'ascii') # 4 bytes Marks file as RIFF o += (data_size + 36).to_bytes(4, 'little') # (4 bytes) File size in bytes excluding this and RIFF marker o += bytes('WAVE', 'ascii') # 4 bytes File type o += bytes('fmt ', 'ascii') # 4 bytes Format Chunk Marker o += (16).to_bytes(4, 'little') # 4 bytes Length of above format data o += (1).to_bytes(2, 'little') # 2 bytes Format type (1 - PCM) o += self.channels.to_bytes(2, 'little') # 2 bytes o += self.sample_rate.to_bytes(4, 'little') # 4 bytes o += (self.sample_rate * self.channels * self.BITS_PER_SAMPLE // 8).to_bytes(4, 'little') # 4 bytes o += (self.channels * self.BITS_PER_SAMPLE // 8).to_bytes(2, 'little') # 2 bytes o += self.BITS_PER_SAMPLE.to_bytes(2, 'little') # 2 bytes o += bytes('data', 'ascii') # 4 bytes Data Chunk Marker o += data_size.to_bytes(4, 'little') # 4 bytes Data size in bytes return o def stop(self): self.alive = False def start(self): if platform.system() == 'Windows': if not self.alive: if self.pa is None: self.pa = pyaudio.PyAudio() # initialization process takes ~0.2 seconds Thread(target=self._start_recording, name='SystemAudioRecorder', daemon=True).start() else: print('TODO: SystemAudioRecorder') class InvalidAudioFile(Exception): pass class Unknown(str): __slots__ = 'property' def __new__(cls, _property): obj = super(Unknown, cls).__new__(cls) obj.property = _property return obj def __repr__(self): return t(f'Unknown {self.property}') def __str__(self): return self.__repr__() def __lt__(self, other): return str(self).__lt__(other) def __le__(self, other): return str(self).__le__(other) def __gt__(self, other): return str(self).__gt__(other) def __ge__(self, other): return str(self).__ge__(other) def __eq__(self, other): return str(other) == str(self) def __ne__(self, other): return not self.__eq__(str(other)) def split(self, *args, **kwargs): return str(self).split(*args, **kwargs) def __len__(self): return len(str(self)) def exception_wrapper(f): @wraps(f) def wrapper(*args, **kwargs): try: f(*args, **kwargs) except Exception as e: print(f'Handled exception in {f.__name__}:', e) return wrapper class DiscordPresence: """ Exception safe wrapper for pypresence """ rich_presence: pypresence.Presence = None MUSIC_CASTER_DISCORD_ID = '696092874902863932' @classmethod @exception_wrapper def init_rpc(cls): if cls.rich_presence is None: cls.rich_presence = pypresence.Presence(cls.MUSIC_CASTER_DISCORD_ID, timeout=2) @classmethod @exception_wrapper def connect(cls, confirm_connect=True): if confirm_connect: cls.init_rpc() cls.rich_presence.connect() @classmethod @exception_wrapper def update(cls, state: str, details: str, large_text: str, end: int = 0, large_image='default', small_image='logo', small_text='Music Caster', confirm_connect=True): if confirm_connect: cls.init_rpc() cls.rich_presence.update(state=state, details=details, large_image=large_image, large_text=large_text, small_image=small_image, small_text=small_text) @classmethod @exception_wrapper def clear(cls, confirm=True): if confirm: cls.rich_presence.clear() @classmethod @exception_wrapper def close(cls): if cls.rich_presence is not None: cls.rich_presence.close() # friendly interface to create system tray menus out of local device or cast device # or in the future, other wireless devices # otherwise would have to write lots of conditionals to make things work smoothly # TODO: should be interloped with playback functionalities as well to abstract that PITA class Device: CHECK_MARK = '✓' def __init__(self, cast_info_or_none=None): self.__device = cast_info_or_none self.is_cast_info = isinstance(self.__device, CastInfo) self.is_local_device = not self.is_cast_info @property def id(self): return str(self.__device.uuid) if isinstance(self.__device, CastInfo) else None @classmethod def LOCAL_DEVICE(cls): return t('Local device') @property def name(self): if isinstance(self.__device, CastInfo): return self.__device.friendly_name return self.LOCAL_DEVICE() def as_tray_name(self, active_id): if active_id == self.id: return f'{self.CHECK_MARK} {self.name}' return f' {self.name}' @property def tray_key(self): return f'device:{self.id}' if self.is_cast_info else 'device:0' @property def gui_key(self): return f'device::{self.id}' if self.is_cast_info else 'device::0' def as_tray_item(self, active_id) -> tuple: return self.as_tray_name(active_id), self.tray_key def __eq__(self, other): return self.id == other.id def __str__(self): return self.name def __repr__(self): return f'Device(id={self.id}, name={self.name})' def get_file_name(file_path): return Path(file_path).stem # decorators def timing(f): @wraps(f) def wrapper(*args, **kwargs): _start = time.monotonic() result = f(*args, **kwargs) print(f'@timing {f.__name__} = {result} ELAPSED TIME:', time.monotonic() - _start) return result return wrapper def time_cache(max_age, maxsize=None, typed=False): """Least-recently-used cache decorator with time-based cache invalidation. max_age: Time to live for cached results (in seconds). maxsize: Maximum cache size (see `functools.lru_cache`). typed: Cache on distinct input types (see `functools.lru_cache`).""" def _decorator(fn): @lru_cache(maxsize=maxsize, typed=typed) def _new(*args, __time_salt, **kwargs): return fn(*args, **kwargs) @wraps(fn) def _wrapped(*args, **kwargs): return _new(*args, **kwargs, __time_salt=int(time.time() / max_age)) return _wrapped return _decorator try: LANGUAGES_FOLDER = f'{sys._MEIPASS}/languages' except AttributeError: if os.path.exists('src/languages'): print('WARNING 345: application not running in src directory') LANGUAGES_FOLDER = 'src/languages' else: LANGUAGES_FOLDER = 'languages' @lru_cache(maxsize=1) def get_languages(): return list(chain([''], (get_file_name(lang) for lang in glob.iglob(f'{glob.escape(LANGUAGES_FOLDER)}/*.txt')))) @lru_cache(maxsize=3) def get_lang_pack(lang): # fails if not in src directory en_lang_pack, other_lang_pack = {}, [] with open(f'{LANGUAGES_FOLDER}/{lang}.txt', encoding='utf-8') as f: i = 0 line = f.readline().strip() while line: if not line.startswith('#'): if lang == 'en': en_lang_pack[line] = i else: other_lang_pack.append(line) i += 1 line = f.readline().strip() return en_lang_pack if lang == 'en' else other_lang_pack def get_display_lang(): if platform.system() == 'Windows': kernal32 = ctypes.windll.kernel32 return locale.windows_locale[kernal32.GetUserDefaultUILanguage()].split('_', 1)[0] else: return os.environ['LANG'].split('_', 1)[0] @lru_cache def log_translation_error(string, lang): log = logging.getLogger('music_caster') log.error(f'failed to translate `{string}` to {lang}', exc_info=True) def get_translation(string, lang='', as_title=False): """ Translates string from English to lang or display language if valid :param string: English string :param lang: Optional code to translate to. Defaults to using display language :param as_title: The phrase returned has each word capitalized :return: string translated to display language """ try: string = get_lang_pack(lang or get_display_lang())[get_lang_pack('en')[string]] except (IndexError, KeyError, FileNotFoundError): if lang != 'en' and lang != '': log_translation_error(string, lang) if as_title: string = ' '.join(word[0].upper() + word[1:] for word in string.split()) return string def t(string, as_title=False): return get_translation(string, lang=State.lang, as_title=as_title) def natural_key_file(filename): filename = unicodedata.normalize('NFKD', get_file_name(filename).casefold()) filename = u''.join([c for c in filename if not unicodedata.combining(c)]) return [int(s) if s.isdigit() else s for s in re.split(r'(\d+)', filename)] def valid_color_code(code): match = re.search(r'^#(?:[\da-fA-F]{3}){1,2}$', code) return match def get_audio_length(file_path) -> int: """ throws InvalidAudioFile if file is invalid :param file_path: :return: length in seconds """ try: if file_path.casefold().endswith('.wav'): a = WavInfoReader(file_path) length = a.data.frame_count / a.fmt.sample_rate # type:ignore elif file_path.casefold().endswith('.wma'): try: audio_info = mutagen.File(file_path).info # type:ignore length = audio_info.length except AttributeError: audio_info = AAC(file_path).info length = audio_info.length elif file_path.casefold().endswith('.opus'): audio_info = mutagen.File(file_path).info # type:ignore length = audio_info.length else: audio_info = mutagen.File(file_path).info # type:ignore length = audio_info.length return length except (AttributeError, HeaderNotFoundError, MutagenError, WavInfoEOFError, StopIteration) as e: raise InvalidAudioFile(f'{file_path} is an invalid audio file') from e def valid_audio_file(uri) -> bool: """ check if uri has a valid audio extension uri does not have to be a file that exists """ return Path(uri).suffix.casefold() in AUDIO_EXTS def set_metadata(file_path: str, metadata: dict): ext = os.path.splitext(file_path)[1].casefold() audio: mutagen._file.FileType = mutagen.File(file_path) # type: ignore title = metadata['title'] artists = metadata['artist'].split(', ') if ', ' in metadata['artist'] else metadata['artist'].split(',') album = metadata['album'] track_place = metadata['track_number'] # X/Y track_number = track_place.split('/')[0] # X rating = '1' if metadata['explicit'] else '0' # b64 album art data should be b64 as a string not as bytes if isinstance(metadata.get('art'), bytes): metadata['art'] = metadata['art'].decode() if '/' not in track_place: tracks = max(1, int(track_place)) track_place = f'{track_place}/{tracks}' if isinstance(audio, (MP3, WAVE)) or ext in {'.mp3', '.wav'}: if title: audio['TIT2'] = mutagen.id3._frames.TIT2(text=metadata['title']) if artists: audio['TPE1'] = mutagen.id3._frames.TPE1(text=artists) audio['TPE2'] = mutagen.id3._frames.TPE1(text=artists[0]) # album artist audio['TCMP'] = mutagen.id3._frames.TCMP(text=track_number) audio['TRCK'] = mutagen.id3._frames.TRCK(text=track_place) audio['TPOS'] = mutagen.id3._frames.TPOS(text=track_place) if album: audio['TALB'] = mutagen.id3._frames.TALB(text=album) # audio['TDRC'] = mutagen.id3.TDRC(text=metadata['year']) # audio['TCON'] = mutagen.id3.TCON(text=metadata['genre']) # audio['TPUB'] = mutagen.id3.TPUB(text=metadata['publisher']) audio['TXXX:RATING'] = mutagen.id3._frames.TXXX(text=rating, desc='RATING') audio['TXXX:ITUNESADVISORY'] = mutagen.id3._frames.TXXX(text=rating, desc='ITUNESADVISORY') if metadata.get('art') is not None: img_data = b64decode(metadata['art']) audio['APIC:'] = mutagen.id3._frames.APIC(encoding=0, mime=metadata['mime'], type=3, data=img_data) else: # remove all album art for k in tuple(audio.keys()): if 'APIC:' in k: audio.pop(k) elif isinstance(audio, MP4): if title: audio['©nam'] = [title] if artists: audio['©ART'] = artists if album: audio['©alb'] = [album] audio['trkn'] = [tuple((int(x) for x in track_place.split('/')))] audio['rtng'] = [int(rating)] if metadata.get('art') is not None: image_format = 14 if metadata['mime'].endswith('png') else 13 img_data = b64decode(metadata['art']) audio['covr'] = [MP4Cover(img_data, imageformat=image_format)] elif 'covr' in audio: del audio['covr'] elif isinstance(audio, (OggOpus, OggVorbis)): if title: audio['title'] = [title] if artists: audio['artist'] = artists if album: audio['album'] = [album] audio['rtng'] = [rating] audio['trkn'] = track_place if metadata.get('art') is not None: img_data = metadata['art'] # b64 data audio['metadata_block_picture'] = img_data audio['mime'] = metadata['mime'] else: audio.pop('APIC:', None) audio.pop('metadata_block_picture', None) else: # FLAC? if title: audio['TITLE'] = title # type: ignore if artists: audio['ARTIST'] = artists # type: ignore if album: audio['ALBUM'] = album # type: ignore audio['TRACKNUMBER'] = track_number # type: ignore audio['TRACKTOTAL'] = track_place.split('/')[1] # type: ignore audio['ITUNESADVISORY'] = rating # type: ignore if metadata.get('art') is not None: if ext == '.flac': img_data = b64decode(metadata['art']) pic = mutagen.flac.Picture() pic.mime = metadata['mime'] pic.data = img_data pic.type = 3 audio.clear_pictures() # type: ignore audio.add_picture(pic) # type: ignore else: audio['APIC:'] = metadata['art'] # type: ignore audio['mime'] = metadata['mime'] # type: ignore else: # remove existing album art if ext == '.flac': audio.clear_pictures() # type: ignore else: # remove all album art for k in tuple(audio.keys()): if 'APIC:' in k: audio.pop(k) audio.save() def get_metadata(file_path: str): title = unknown_title = Unknown('Title') artist = unknown_artist = Unknown('Artist') album = unknown_album = Unknown('Album') length = None try: a = mutagen.File(file_path) with suppress(AttributeError): length = a.info.length if isinstance(a, MP3): audio = dict(EasyMP3(file_path)) audio['rating'] = a.get('TXXX:RATING', a.get('TXXX:ITUNESADVISORY', ['0'])) elif isinstance(a, MP4): audio = dict(mutagen.File(file_path)) audio['rating'] = audio.get('rtng', [0]) for (tag, normalized) in (('©nam', 'title'), ('©alb', 'album'), ('©ART', 'artist')): if tag in audio: audio[normalized] = audio.pop(tag) audio['tracknumber'] = audio.get('trkn', [('1', '1')])[0] elif isinstance(a, (OggOpus, OggVorbis)): audio = dict(a) if 'rtng' in audio: audio['rating'] = audio.pop('rtng') if 'trkn' in audio: audio['tracknumber'] = audio.pop('trkn') elif isinstance(a, WAVE) or file_path.endswith('.wav'): audio = WavInfoReader(file_path).info.to_dict() audio = {'title': [audio['title']], 'artist': [audio['artist']], 'album': [audio['product']]} elif a is not None: audio = dict(a) audio = {k.casefold(): audio[k] for k in audio} if file_path.endswith('.wma'): audio = {k: [audio[k][0].value] for k in audio} else: audio = {} except TypeError as e: logging.getLogger('music_caster').error(repr(e)) logging.getLogger('music_caster').info(f'Could not open {file_path} as audio file') raise InvalidAudioFile(f'Is {file_path} a valid audio file?') from e except (ID3NoHeaderError, HeaderNotFoundError, AttributeError, WavInfoEOFError, StopIteration): logging.getLogger('music_caster').info(f'Metadata not found for {file_path}') audio = {} title = str(audio.get('title', [title])[0]) album = str(audio.get('album', [album])[0]) try: is_explicit = audio.get('rating', audio.get('itunesadvisory', ['0']))[0] not in {'C', 'T', '0', 0} except IndexError: is_explicit = False track_number = str(audio['tracknumber'][0]).split('/', 1)[0] if 'tracknumber' in audio else None with suppress(KeyError, TypeError): if len(audio['artist']) == 1: # in case the sep char is a slash try: audio['artist'] = audio['artist'][0].split('/') except AttributeError: audio['artist'] = [unknown_artist] artist = ', '.join(audio['artist']) if not title: title = unknown_title if not artist: artist = unknown_artist if not album: album = unknown_album if title == unknown_title or artist == unknown_artist: # if title or artist are unknown, use the basename of the URI (excluding extension) sort_key = get_file_name(file_path) else: sort_key = State.track_format.replace('&title', title).replace('&artist', artist) sort_key.replace('&album', album if album != unknown_album else '') sort_key = sort_key.replace('&trck', track_number or '') metadata = {'title': title, 'artist': artist, 'album': album, 'explicit': is_explicit, 'sort_key': sort_key.casefold(), 'track_number': '1' if track_number is None else track_number, # float works with sqlite REAL 'time_modified': os.path.getmtime(file_path)} if length is not None: metadata['length'] = length return metadata def open_in_browser(url): t = Thread(target=webbrowser.open, daemon=True, args=(url,)) t.start() return t def get_album_art(file_path: str, folder_cover_override=False) -> Tuple[str, bytes]: # mime: str, data: str with suppress(MutagenError, AttributeError): folder = os.path.dirname(file_path) if folder_cover_override: for ext in ('png', 'jpg', 'jpeg'): folder_cover = os.path.join(folder, f'cover.{ext}') if os.path.exists(folder_cover): with open(folder_cover, 'rb') as f: return ext, base64.b64encode(f.read()) audio = mutagen.File(file_path) # type: ignore if isinstance(audio, mutagen.flac.FLAC): pics = mutagen.flac.FLAC(file_path).pictures with suppress(IndexError): return pics[0].mime, base64.b64encode(pics[0].data) elif isinstance(audio, MP4): with suppress(KeyError, IndexError): cover = audio['covr'][0] image_format = cover.imageformat mime = 'image/png' if image_format == 14 else 'image/jpeg' return mime, base64.b64encode(cover) elif isinstance(audio, (OggOpus, OggVorbis)): with suppress(KeyError, IndexError): mime = audio.get('mime') if mime is None: mime = ['image/jpeg'] mime = mime[0] return mime, audio['metadata_block_picture'][0] else: # ID3 or something else if audio is not None: for tag in audio.keys(): if 'APIC' in tag: try: return audio[tag].mime, base64.b64encode(audio[tag].data) except AttributeError: mime = audio['mime'][0].value if 'mime' in audio else 'image/jpeg' return mime, base64.b64encode(audio[tag][0].value) app_logger = logging.getLogger('music_caster') app_logger.info(f'File {Path(file_path).name} does not have album art. Returning image/jpeg, DEFAULT_ART instead') return 'image/jpeg', DEFAULT_ART def fix_path(path, by_os=True): return str(Path(path)) if by_os else path.replace('\\', '/') def get_first_artist(artists: str) -> str: return artists.split(', ', 1)[0] def get_ipv6(): # return next((i[4][0] for i in socket.getaddrinfo(socket.gethostname(), None) if i[0] == socket.AF_INET6)) if platform.system() == 'Linux': for logical_name in os.listdir('/sys/class/net'): cmd = f"ip addr show dev {logical_name} | awk '{{if ($1==\"inet6\") {{print $2}}}}'" p = Popen(cmd, shell=True, stdout=PIPE, stdin=DEVNULL, stderr=DEVNULL, text=True) ip = p.stdout.readline().strip() if ip != '': return ip with socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) as s: try: # doesn't even have to be reachable s.connect(('fe80::116a:fd0a:4a0a:42a7', 1)) ip = f'[{s.getsockname()[0]}]' except Exception: ip = get_ipv4() return ip # https://regex101.com/ IPV4_WIFI_PATTERN = re.compile(r'Wireless LAN adapter Wi-Fi:((.|\n)*?)?\s+IPv4 Address.*:\s*(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})') IPV4_GENERAL_PATTERN = re.compile(r'IPv4 Address.*:\s*(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})') def clean_ipconfig(ipconfig_raw): # TODO: there is a way to optimise this and return a single IP, but that requires matching each description ipconfig_output_split = ipconfig_raw.split('\n\n')[1:] filtered_output = '' for i in range(len(ipconfig_output_split) // 2): if ( 'WSL' not in ipconfig_output_split[i * 2] and 'vEthernet' not in ipconfig_output_split[i * 2] and 'Hyper-V' not in ipconfig_output_split[i * 2 + 1] ): filtered_output += ipconfig_output_split[i * 2] + ipconfig_output_split[i * 2 + 1] return filtered_output def get_ipv4(): try: if platform.system() != 'Windows': raise FileNotFoundError ipconfig_output = clean_ipconfig(check_output(['ipconfig'], shell=True, text=True, encoding='iso8859-2')) wifi_match = IPV4_WIFI_PATTERN.findall(ipconfig_output) if wifi_match: return wifi_match[-1][-1] return IPV4_GENERAL_PATTERN.findall(ipconfig_output)[-1] except (IndexError, CalledProcessError, FileNotFoundError): # fallback in case the ipv4 cannot be found in ipconfig # return next((i[4][0] for i in socket.getaddrinfo(socket.gethostname(), None) if i[0] == socket.AF_INET)) with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: try: # doesn't even have to be reachable s.connect(('192.168.1.2', 1)) ip = s.getsockname()[0] except Exception: ip = '127.0.0.1' return ip def get_lan_ip() -> str: return get_ipv6() def get_mac(): return ':'.join(['{:02x}'.format((getnode() >> ele) & 0xff) for ele in range(0, 8 * 6, 8)][::-1]) def better_shuffle(seq, first=0, last=-1): """ Shuffles based on indices """ n = len(seq) with suppress(IndexError, ZeroDivisionError): first = first % n last = last % n # use Fisher-Yates shuffle (Durstenfeld method) for i in range(first, last + 1): size = last - i + 1 j = getrandbits(size.bit_length()) % size + i seq[i], seq[j] = seq[j], seq[i] return seq @lru_cache(maxsize=1) def dz(): from deemix.__main__ import Deezer # 1.4 seconds. 0.4 due to Downloader return Deezer() @lru_cache(maxsize=2) def ydl(proxy=None, quiet=False): from yt_dlp import YoutubeDL opts = { 'quiet': quiet, 'verbose': not quiet, 'socket_timeout': 10 } if proxy is not None: opts['proxy'] = proxy return YoutubeDL(opts) def ydl_extract_info(url, quiet=False): """ Raises IOError instead of YoutubeDL's DownloadError, saving us time on imports """ from yt_dlp.utils import DownloadError with suppress(DownloadError): return ydl(quiet=quiet).extract_info(url, download=False) try: return ydl(get_proxy(False)['https'], quiet=quiet).extract_info(url, download=False) except DownloadError as e: raise IOError from e @lru_cache(maxsize=1) def get_yt_id(url, ignore_playlist=False): query = urlparse(url) if query.hostname == 'youtu.be': return query.path[1:] if query.hostname in {'www.youtube.com', 'youtube.com', 'music.youtube.com'}: if not ignore_playlist: with suppress(KeyError): return parse_qs(query.query)['list'][0] if query.path == '/watch': return parse_qs(query.query)['v'][0] if query.path[:7] == '/watch/': return query.path.split('/')[2] if query.path[:7] == '/embed/': return query.path.split('/')[2] if query.path[:3] == '/v/': return query.path.split('/')[2] def get_yt_urls(video_id): """ Returns possible youtube URL's for a single video id """ yield f'https://youtu.be/{video_id}' for prefix in ('https://', 'https://www.'): yield f'{prefix}youtube.com/watch?v={video_id}' yield f'{prefix}youtube.com/watch/{video_id}' yield f'{prefix}youtube.com/embed/{video_id}' yield f'{prefix}youtube.com/v/{video_id}' @lru_cache(maxsize=1) def is_os_64bit(): return platform.machine().endswith('64') def delete_sub_key(root, current_key): import winreg as wr access = wr.KEY_ALL_ACCESS | wr.KEY_WOW64_64KEY with suppress(FileNotFoundError): with wr.OpenKeyEx(root, current_key, 0, access) as parent_key: info_key = wr.QueryInfoKey(parent_key) for x in range(info_key[0]): sub_key = wr.EnumKey(parent_key, x) try: wr.DeleteKeyEx(parent_key, sub_key, access) except OSError: delete_sub_key(root, '\\'.join([current_key, sub_key])) wr.DeleteKeyEx(parent_key, '', access) def add_reg_handlers(path_to_exe, add_folder_context=True): """ Register Music Caster as a program to open audio files and folders """ # https://docs.microsoft.com/en-us/visualstudio/extensibility/registering-verbs-for-file-name-extensions?view=vs-2019 import winreg as wr classes_path = r'SOFTWARE\Classes' mc_file = 'MusicCaster_file' write_access = wr.KEY_WRITE | wr.KEY_WOW64_64KEY read_access = wr.KEY_READ | wr.KEY_WOW64_64KEY path_to_exe = str(path_to_exe) # create URL protocol handler url_protocol = fr'{classes_path}\music-caster' with wr.CreateKeyEx(wr.HKEY_CURRENT_USER, url_protocol, 0, write_access) as key: wr.SetValueEx(key, None, 0, wr.REG_SZ, 'URL:music-caster Protocol') with wr.CreateKeyEx(wr.HKEY_CURRENT_USER, url_protocol, 0, write_access) as key: wr.SetValueEx(key, 'URL Protocol', 0, wr.REG_SZ, '') with wr.CreateKeyEx(wr.HKEY_CURRENT_USER, fr'{url_protocol}\DefaultIcon', 0, write_access) as key: wr.SetValueEx(key, None, 0, wr.REG_SZ, f'"{path_to_exe}"') with wr.CreateKeyEx(wr.HKEY_CURRENT_USER, fr'{url_protocol}\shell\open\command', 0, write_access) as key: wr.SetValueEx(key, None, 0, wr.REG_SZ, f'"{path_to_exe}" --urlprotocol "%1"') # create Audio File type with wr.CreateKeyEx(wr.HKEY_CURRENT_USER, fr'{classes_path}\{mc_file}', 0, write_access) as key: wr.SetValueEx(key, None, 0, wr.REG_SZ, 'Audio File') with wr.CreateKeyEx(wr.HKEY_CURRENT_USER, fr'{classes_path}\{mc_file}\DefaultIcon', 0, write_access) as key: wr.SetValueEx(key, None, 0, wr.REG_SZ, path_to_exe) # define icon location # create play context | open handler with wr.CreateKeyEx(wr.HKEY_CURRENT_USER, fr'{classes_path}\{mc_file}\shell\open', 0, write_access) as key: wr.SetValueEx(key, None, 0, wr.REG_SZ, t('Play with Music Caster')) wr.SetValueEx(key, 'MultiSelectModel', 0, wr.REG_SZ, 'Player') wr.SetValueEx(key, 'Icon', 0, wr.REG_SZ, path_to_exe) command_path = fr'{classes_path}\{mc_file}\shell\open\command' with wr.CreateKeyEx(wr.HKEY_CURRENT_USER, command_path, 0, write_access) as key: wr.SetValueEx(key, None, 0, wr.REG_SZ, f'"{path_to_exe}" --shell "%1"') # create queue context with wr.CreateKeyEx(wr.HKEY_CURRENT_USER, fr'{classes_path}\{mc_file}\shell\queue', 0, write_access) as key: wr.SetValueEx(key, None, 0, wr.REG_SZ, t('Queue in Music Caster')) wr.SetValueEx(key, 'MultiSelectModel', 0, wr.REG_SZ, 'Player') wr.SetValueEx(key, 'Icon', 0, wr.REG_SZ, path_to_exe) command_path = fr'{classes_path}\{mc_file}\shell\queue\command' with wr.CreateKeyEx(wr.HKEY_CURRENT_USER, command_path, 0, write_access) as key: wr.SetValueEx(key, None, 0, wr.REG_SZ, f'"{path_to_exe}" -q --shell "%1"') # create play next context with wr.CreateKeyEx(wr.HKEY_CURRENT_USER, fr'{classes_path}\{mc_file}\shell\play_next', 0, write_access) as key: wr.SetValueEx(key, None, 0, wr.REG_SZ, t('Play next in Music Caster')) wr.SetValueEx(key, 'MultiSelectModel', 0, wr.REG_SZ, 'Player') wr.SetValueEx(key, 'Icon', 0, wr.REG_SZ, path_to_exe) command_path = fr'{classes_path}\{mc_file}\shell\play_next\command' with wr.CreateKeyEx(wr.HKEY_CURRENT_USER, command_path, 0, write_access) as key: wr.SetValueEx(key, None, 0, wr.REG_SZ, f'"{path_to_exe}" -n --shell "%1"') # set file handlers for ext in AUDIO_HANDLER_EXTS: key_path = fr'{classes_path}\.{ext}' try: # check if key exists with wr.OpenKeyEx(wr.HKEY_CURRENT_USER, key_path, 0, read_access) as _: pass except (WindowsError, FileNotFoundError): # create key for extension if it does not exist with MC as the default program with wr.CreateKeyEx(wr.HKEY_CURRENT_USER, key_path, 0, write_access) as key: # set as default program unless .mp4 because that's a video format wr.SetValueEx(key, None, 0, wr.REG_SZ, mc_file) # add to Open With (prompts user to set default program when they try playing a file) with wr.CreateKeyEx(wr.HKEY_CURRENT_USER, fr'{key_path}\\OpenWithProgids', 0, write_access) as key: wr.SetValueEx(key, mc_file, 0, wr.REG_NONE, b'') # type needs to be bytes play_folder_key_path = fr'{classes_path}\Directory\shell\MusicCasterPlayFolder' queue_folder_key_path = fr'{classes_path}\Directory\shell\MusicCasterQueueFolder' play_next_folder_key_path = fr'{classes_path}\Directory\shell\MusicCasterPlayNextFolder' if add_folder_context: # set "open folder in Music Caster" command with wr.CreateKeyEx(wr.HKEY_CURRENT_USER, play_folder_key_path, 0, write_access) as key: wr.SetValueEx(key, None, 0, wr.REG_SZ, t('Play with Music Caster')) wr.SetValueEx(key, 'Icon', 0, wr.REG_SZ, path_to_exe) with wr.CreateKeyEx(wr.HKEY_CURRENT_USER, fr'{play_folder_key_path}\\command', 0, write_access) as key: wr.SetValueEx(key, None, 0, wr.REG_SZ, f'"{path_to_exe}" --shell "%1"') # set "queue folder in Music Caster" command with wr.CreateKeyEx(wr.HKEY_CURRENT_USER, queue_folder_key_path, 0, write_access) as key: wr.SetValueEx(key, None, 0, wr.REG_SZ, t('Queue in Music Caster')) wr.SetValueEx(key, 'Icon', 0, wr.REG_SZ, path_to_exe) with wr.CreateKeyEx(wr.HKEY_CURRENT_USER, fr'{queue_folder_key_path}\\command', 0, write_access) as key: wr.SetValueEx(key, None, 0, wr.REG_SZ, f'"{path_to_exe}" -q --shell "%1"') # set "play folder next in Music Caster" command with wr.CreateKeyEx(wr.HKEY_CURRENT_USER, play_next_folder_key_path, 0, write_access) as key: wr.SetValueEx(key, None, 0, wr.REG_SZ, t('Play next in Music Caster')) wr.SetValueEx(key, 'Icon', 0, wr.REG_SZ, path_to_exe) with wr.CreateKeyEx(wr.HKEY_CURRENT_USER, fr'{play_next_folder_key_path}\\command', 0, write_access) as key: wr.SetValueEx(key, None, 0, wr.REG_SZ, f'"{path_to_exe}" -n --shell "%1"') else: # remove commands for folders delete_sub_key(wr.HKEY_CURRENT_USER, play_folder_key_path) delete_sub_key(wr.HKEY_CURRENT_USER, queue_folder_key_path) delete_sub_key(wr.HKEY_CURRENT_USER, play_next_folder_key_path) def get_default_output_device(): """ returns the PyAudio formatted name of the default output device """ import winreg as wr read_access = wr.KEY_READ | wr.KEY_WOW64_64KEY audio_path = r'SOFTWARE\Microsoft\Windows\CurrentVersion\MMDevices\Audio\Render' audio_key = wr.OpenKeyEx(wr.HKEY_LOCAL_MACHINE, audio_path, 0, read_access) num_devices = wr.QueryInfoKey(audio_key)[0] active_last_used, active_device_name = -1, None for i in range(num_devices): device_key_path = f'{audio_path}\\{wr.EnumKey(audio_key, i)}' device_key = wr.OpenKeyEx(wr.HKEY_LOCAL_MACHINE, device_key_path, 0, read_access) if wr.QueryValueEx(device_key, 'DeviceState')[0] == 1: # if enabled properties_path = f'{device_key_path}\\Properties' properties = wr.OpenKeyEx(wr.HKEY_LOCAL_MACHINE, properties_path, 0, read_access) device_name = wr.QueryValueEx(properties, '{b3f8fa53-0004-438e-9003-51a46e139bfc},6')[0] device_type = wr.QueryValueEx(properties, '{a45c254e-df1c-4efd-8020-67d146a850e0},2')[0] pa_name = f'{device_type} ({device_name})' # name shown in PyAudio with suppress(FileNotFoundError): last_used = wr.QueryValueEx(device_key, 'Level:0')[0] if last_used > active_last_used: # the bigger the number, the more recent it was used active_last_used = last_used active_device_name = pa_name return active_device_name def resize_img(base64data: bytes, bg, new_size=COVER_NORMAL, default_art=None) -> bytes: """ Resize and return b64 img data to new_size (w, h). (use .decode() on return statement for str) """ try: img_data = io.BytesIO(b64decode(base64data)) art_img: Image.Image = Image.open(img_data) except UnidentifiedImageError as e: if default_art is None: raise OSError from e img_data = io.BytesIO(b64decode(default_art)) art_img: Image.Image = Image.open(img_data) w, h = art_img.size if w == h: # resize a square img = art_img.resize(new_size, Image.Resampling.LANCZOS) else: # resize by shrinking the longest side to the new_size ratios = (1, h / w) if w > h else (w / h, 1) ratio_size = (round(new_size[0] * ratios[0]), round(new_size[1] * ratios[1])) art_img = art_img.resize(ratio_size, Image.Resampling.LANCZOS) paste_width = (new_size[0] - ratio_size[0]) // 2 paste_height = (new_size[1] - ratio_size[1]) // 2 img = Image.new('RGB', new_size, color=bg) img.paste(art_img, (paste_width, paste_height)) data = io.BytesIO() if img.mode == 'CMYK': img = img.convert('RGB') img.save(data, format='png') return b64encode(data.getvalue()) def export_playlist(playlist_name, uris): # exports uris to ~/Downloads/safe(playlist_name).m3u playlist_name = re.sub(r'(?u)[^-\w. ]', '', playlist_name) # clean name playlist_path = Path.home() / 'Downloads' playlist_path.mkdir(parents=True, exist_ok=True) playlist_path /= f'{playlist_name}.m3u' with open(playlist_path, 'w', encoding='utf-8') as f: f.write('#EXTM3U\n') for uri in uris: if uri.replace('\\', '/') != playlist_path: f.write(uri + '\n') return str(playlist_path) def parse_m3u(playlist_file): with open(playlist_file, errors='ignore', encoding='utf-8') as f: for line in iter(lambda: f.readline(), ''): if not line.startswith('#'): line = line.lstrip('file:').lstrip('/').rstrip() # an m3u file cannot contain itself if line != playlist_file: yield line def get_latest_release(ver, this_version, force=False): """ returns {'version': latest_ver, 'setup': 'setup_link'} if the latest release version is newer (>) than VERSION if latest release version <= VERSION, returns false if force: return latest release even if latest version <= VERSION """ releases_url = 'https://api.github.com/repos/elibroftw/music-caster/releases/latest' with suppress(requests.RequestException): release = requests.get(releases_url) if release.status_code >= 400: release = requests.get(releases_url, proxies=get_proxy(False)) release = release.json() latest_ver = release.get('tag_name', f'v{this_version}')[1:] _version = [int(x) for x in ver.split('.')] compare_ver = [int(x) for x in latest_ver.split('.')] if compare_ver > _version or force: for asset in release.get('assets', []): # check if setup exists if 'exe' in asset['name']: return {'version': latest_ver, 'setup': asset['browser_download_url']} return False @time_cache(600, maxsize=1) def get_proxies(add_local=True): from bs4 import ( BeautifulSoup, # 0.32 seconds if at top level, here it is 0.1 seconds ) try: response = requests.get('https://free-proxy-list.net/', headers={'user-agent': USER_AGENT}) scraped_proxies = set() soup = BeautifulSoup(response.text, 'lxml') table = soup.find('table') for row in table.find_all('tr'): # type: ignore count = 0 proxy = '' try: is_https = row.find('td', {'class': 'hx'}).text == 'yes' except AttributeError: is_https = False if is_https: for cell in row.find_all('td'): if count == 1: proxy += ':' + cell.text.replace(' ', '') scraped_proxies.add(proxy) break proxy += cell.text.replace(' ', '') count += 1 proxies: list = [None, None, None, None, None] if add_local else [] for proxy in sorted(scraped_proxies): proxies.extend(repeat(proxy, 3)) except (requests.RequestException, AttributeError): return cycle([None]) return cycle(proxies) def get_proxy(add_local=True): proxy = next(get_proxies(add_local)) return {'http': proxy, 'https': proxy} @time_cache(max_age=3500, maxsize=1) def get_spotify_headers(): # access token key expires in ~1 hour r = requests.get('https://open.spotify.com/', headers={'user-agent': USER_AGENT}) access_token = re.search('"accessToken":"([^"]*)', r.text).group(1) return {'Authorization': f'Bearer {access_token}'} # TODO: main_event == 'metadata_search_art' and gui_window['metadata_file'].get(): def search_album_art_spotify(title, artist, mkt): for mkt in {'MX', 'CA', 'US', 'UK', 'HK'}: url = f'https://api.spotify.com/v1/search?q={title}' if artist: url += f'+artist:{artist}' url += f'&type=track&market={mkt}' r = requests.get(url, headers=get_spotify_headers()).json() if 'tracks' in r: for art_link in (item['album']['images'][0]['url'] for item in r['tracks']['items']): original_art = base64.b64encode(requests.get(art_link).content).decode() return original_art def parse_spotify_track(track_obj, parent_url='') -> dict: """ Returns a metadata dict for a given Spotify track """ try: artist = ', '.join((artist['name'] for artist in track_obj['artists'] if artist['type'] == 'artist')) except KeyError: artist = Unknown('Artist') title = track_obj['name'] is_explicit = track_obj['explicit'] album = track_obj['album']['name'] try: src_url = track_obj['external_urls']['spotify'] except KeyError: src_url = parent_url track_number = str(track_obj['track_number']) sort_key = State.track_format.replace('&title', title).replace('&artist', artist).replace('&album', str(album)) sort_key = sort_key.replace('&trck', track_number).casefold() metadata = {'src': src_url, 'title': title, 'artist': artist, 'album': album, 'explicit': is_explicit, 'sort_key': sort_key, 'track_number': track_number} with suppress(IndexError): metadata['art'] = track_obj['album']['images'][0]['url'] return metadata @lru_cache def get_spotify_track(url): try: track_id = urlparse(url).path.split('/track/', 1)[1] except IndexError: # e.g. */album/*?highlight=spotify:track:587w9pOR9UNvFJOwkW7NgD track_id = re.search(r'track:.*', url).group()[6:] track = requests.get(f'{SPOTIFY_API}/tracks/{track_id}', headers=get_spotify_headers()).json() return {**parse_spotify_track(track), 'src': url} @lru_cache def get_spotify_album(url): album_id = urlparse(url).path.split('/album/', 1)[1] api_url = f'{SPOTIFY_API}/albums/{album_id}' r = requests.get(api_url, headers=get_spotify_headers()).json() return [parse_spotify_track({**track, 'album': r}, parent_url=url) for track in r['tracks']['items']] def get_spotify_playlist(url): playlist_id = urlparse(url).path.split('/playlist/', 1)[1] api_url = f'{SPOTIFY_API}/playlists/{playlist_id}/tracks' response = requests.get(api_url, headers=get_spotify_headers()).json() results = response['items'] while response['next'] is not None: response = requests.get(response['next'], headers=get_spotify_headers()).json() results.extend(response['items']) return [parse_spotify_track(result['track'], url) for result in results if isinstance(result['track'], dict)] def get_spotify_tracks(url): """ Returns a list of spotify track objects stemming from a Spotify url Could raise: AttributeError, RequestException, KeyError, more? """ if 'track' in url: return [get_spotify_track(url)] if 'album' in url: return get_spotify_album(url) if 'playlist' in url: return get_spotify_playlist(url) return [] def get_cookies(domain_contains, cookie_name='', return_first=True, return_value=True): """ get_cookies('.youtube.com', '', False, False) """ import sqlite3 import browser_cookie3 as bc3 # 0.388 seconds if on top level, 0.06 here for cookie_storage in (bc3.chrome, bc3.firefox, bc3.opera, bc3.edge, bc3.chromium): cookies = [] with suppress(bc3.BrowserCookieError, sqlite3.OperationalError): cookie_storage = cookie_storage() for cookie in cookie_storage: if cookie.domain.count(domain_contains): formatted_cookie = f'{cookie.name}={cookie.value}' if (not cookie_name or cookie.name == cookie_name) and not cookie.is_expired(): cookie_to_use = cookie.value if return_value else formatted_cookie if return_first: return cookie_to_use cookies.append(cookie_to_use) if cookies: return 'Cookie: ' + '; '.join(cookies) return '' @lru_cache def parse_deezer_page(url): if 'page.link' in url: r = requests.get(url) url = r.url if '/track/' in url: _type = 'track' elif '/album/' in url: _type = 'album' elif '/playlist/' in url: _type = 'playlist' elif '/user/' in url: _type = 'user' else: raise ValueError('Unknown URL') _id = re.search(r'\d+', urlparse(url).path).group() return {'type': _type, 'sng_id': _id} def parse_deezer_track(track_obj) -> dict: from deemix.decryption import generateBlowfishKey, generateCryptedStreamURL artists = [] sng_contributors = track_obj['SNG_CONTRIBUTORS'] if isinstance(sng_contributors, list): sng_contributors = {'main_artist': sng_contributors} try: main_artists = sng_contributors['main_artist'] except KeyError: main_artists = sng_contributors['mainartist'] for artist in main_artists + sng_contributors.get('featuring', []): include = True for added_artist in artists: if added_artist in artist: include = False break if include: artists.append(artist) artist_str = ', '.join(artists) art = f"https://cdns-images.dzcdn.net/images/cover/{track_obj['ALB_PICTURE']}/1000x1000-000000-80-0-0.jpg" title, album = track_obj['SNG_TITLE'], track_obj['ALB_TITLE'] length = int(track_obj['DURATION']) is_explicit = track_obj['EXPLICIT_TRACK_CONTENT']['EXPLICIT_LYRICS_STATUS'] == '1' sng_id = track_obj['SNG_ID'] metadata = { 'art': art, 'title': title, 'ext': 'mp3', 'artist': artist_str or Unknown('Artist'), 'album': album, 'length': length, 'sng_id': sng_id, 'explicit': is_explicit } with suppress(KeyError): md5 = track_obj.get('FALLBACK', track_obj)['MD5_ORIGIN'] file_url = generateCryptedStreamURL(sng_id, md5, track_obj['MEDIA_VERSION'], TrackFormats.MP3_128) bf_key = generateBlowfishKey(sng_id) metadata['file_url'] = file_url metadata['bf_key'] = bf_key expiry_time = time.time() + 1800 # 30 minute expiry metadata['expiry'] = expiry_time return metadata def set_dz_url(metadata): src_url = metadata['src'] metadata['url'] = f'http://{get_ipv4()}:{State.PORT}/dz?{urlencode({"url": src_url})}' # metadata['url'] = metadata['file_url'] def get_deezer_track(url): sng_id = parse_deezer_page(url)['sng_id'] metadata = parse_deezer_track(dz().gw.get_track(sng_id)) metadata['src'] = url set_dz_url(metadata) return metadata def get_deezer_album(url): alb_id = parse_deezer_page(url)['sng_id'] tracks = [] for track in dz().gw.get_album_tracks(alb_id): metadata = parse_deezer_track(track) sng_id = metadata['sng_id'] metadata['src'] = f'https://www.deezer.com/track/{sng_id}' set_dz_url(metadata) tracks.append(metadata) return tracks def get_deezer_playlist(url): pl_id = parse_deezer_page(url)['sng_id'] tracks = [] for track in dz().gw.get_playlist_tracks(pl_id): metadata = parse_deezer_track(track) sng_id = metadata['sng_id'] metadata['src'] = f'https://www.deezer.com/track/{sng_id}' set_dz_url(metadata) tracks.append(metadata) return tracks @lru_cache def get_deezer_tracks(url, login=True): if login: if not dz().logged_in: if not dz().login_via_arl(get_cookies('.deezer.com', cookie_name='arl')): raise LookupError('Not logged into deezer.com') dz_type = parse_deezer_page(url)['type'] if dz_type == 'track': return [get_deezer_track(url)] elif dz_type == 'album': return get_deezer_album(url) elif dz_type == 'playlist': return get_deezer_playlist(url) return [] @lru_cache def custom_art(text): img_data = io.BytesIO(b64decode(DEFAULT_ART)) art_img: Image.Image = Image.open(img_data) size = art_img.size x1 = y1 = size[0] * 0.95 x0 = x1 - len(text) * 0.0625 * size[0] y0 = y1 - 0.11 * size[0] d = ImageDraw.Draw(art_img) try: username = os.getenv('USERNAME') fnt = ImageFont.truetype(f"C:/Users/{username}/AppData/Local/Microsoft/Windows/Fonts/MYRIADPRO-BOLD.OTF", 80) shift = 5 except OSError: try: fnt = ImageFont.truetype('gadugib.ttf', 80) shift = -5 except OSError: try: fnt = ImageFont.truetype('arial.ttf', 80) shift = 0 except OSError: # Linux fnt = ImageFont.truetype('/usr/share/fonts/truetype/freefont/FreeMono.ttf', 80, encoding='unic') shift = 0 d.rounded_rectangle((x0, y0, x1, y1), fill='#cc1a21', radius=7) d.text(((x0 + x1) / 2, (y0 + y1) / 2 + shift), text, fill='#fff', font=fnt, align='center', anchor='mm') data = io.BytesIO() art_img.save(data, format='png', quality=95) return b64encode(data.getvalue()) def get_youtube_comments(url, limit=-1): # -> generator # TODO: use proxies = get_proxy() return yt_comment_downloader.get_comments_from_url(url, sort_by=SORT_BY_POPULAR, limit=limit) def timestamp_to_time(text): times = re.findall(r'\d+:(?:\d+:)*\d+', text) times = sorted({sum(int(x) * 60 ** i for i, x in enumerate(reversed(_time.split(':')))) for _time in times}) return times def get_video_timestamps(video_info): # try parsing chapters with suppress(KeyError): chapters = video_info['chapters'] times = set() for chapter in chapters: times.add(chapter['start_time']) times.add(chapter['end_time']) return sorted(times) # try parsing description description_timestamps = timestamp_to_time(video_info['description']) if len(description_timestamps) > 1: return description_timestamps # try parsing comments url = video_info['webpage_url'] with suppress(ValueError, RuntimeError): for count, comment in enumerate(get_youtube_comments(url, limit=10)): times = timestamp_to_time(comment['text']) if len(times) > 2: return times return [] # GUI utilitiies def repeat_img_tooltip(repeat_setting): if repeat_setting is None: return REPEAT_OFF_IMG, t('Repeat All') elif repeat_setting: return REPEAT_ONE_IMG, t('Repeat Off') return REPEAT_ALL_IMG, t('Repeat One') def create_progress_bar_texts(position, length): """":return: time_elapsed_text, time_left_text""" position = floor(position) mins_elapsed, secs_elapsed = floor(position / 60), floor(position % 60) if secs_elapsed < 10: secs_elapsed = f'0{secs_elapsed}' elapsed_text = f'{mins_elapsed}:{secs_elapsed}' try: time_left = round(length) - position mins_left, secs_left = time_left // 60, time_left % 60 if secs_left < 10: secs_left = f'0{secs_left}' time_left_text = f'{mins_left}:{secs_left}' except TypeError: time_left_text = '∞' return elapsed_text, time_left_text def truncate_title(title): """ truncate title for mini mode """ if len(title) > 29: return title[:26] + '...' return title # TKDnD def drop_target_register(widget, *dndtypes): widget.tk.call('tkdnd::drop_target', 'register', widget._w, dndtypes) def dnd_bind(widget, sequence=None, func=None, add=None, need_cleanup=True): """Internal function.""" what = ('bind', widget._w) if isinstance(func, str): widget.tk.call(what + (sequence, func)) elif func: func_id = widget._register(func, widget._substitute_dnd, need_cleanup) cmd = '%s%s %s' % (add and '+' or '', func_id, widget._subst_format_str_dnd) widget.tk.call(what + (sequence, cmd)) return func_id elif sequence: return widget.tk.call(what + (sequence,)) else: return widget.tk.splitlist(widget.tk.call(what)) def get_cut_text(window, key): # fix for weird GUI cut/copy behaviour cut_text = '' new_text = window[key].get() if not new_text: return window.metadata[key] i = 0 for v in window.metadata[key]: if i >= len(new_text) or v != new_text[i]: cut_text += v else: i += 1 return cut_text def start_on_login_win32(working_dir, create_key=True, is_debug=True): import winreg as wr classes_path = r'SOFTWARE\Microsoft\Windows\CurrentVersion\Run' access = wr.KEY_ALL_ACCESS | wr.KEY_WOW64_64KEY path_to_exe = working_dir / 'Music Caster.exe' if IS_FROZEN else working_dir / 'music_caster.bat' if not IS_FROZEN and not os.path.exists(path_to_exe): with open('music_caster.bat', 'w') as f: f.write(f'pythonw "{os.path.basename(sys.argv[0])}" -m') with wr.OpenKeyEx(wr.HKEY_CURRENT_USER, classes_path, 0, access) as key: if create_key and (IS_FROZEN or is_debug): wr.SetValueEx(key, 'Music Caster', 0, wr.REG_SZ, f'"{path_to_exe}" -m') if not create_key or (not IS_FROZEN and is_debug): with suppress(FileNotFoundError): wr.DeleteValue(key, 'Music Caster') def rm_old_startup_shortcuts(): if platform.system() == 'Windows': from knownpaths import FOLDERID, sh_get_known_folder_path startup_dir = sh_get_known_folder_path(FOLDERID.Startup) shortcut_paths = (f"{startup_dir}\\{item}.lnk" for item in ('Music Caster', 'Music Caster (Python)', 'Music Caster [DEBUG]')) for shortcut_path in shortcut_paths: with suppress(FileNotFoundError): os.remove(shortcut_path) def startfile(file): if platform.system() == 'Windows': try: return os.startfile(file) except OSError: return Popen(f'explorer "{fix_path(file)}"') elif platform.system() == 'Darwin': return Popen(['open', file]) # Linux return Popen(['xdg-open', file]) def add_to_path(path): if platform.system() == 'Windows': os.environ['PATH'] += f'{path};' else: os.environ['PATH'] += f':{path}' def cmd_exists(cmd): if platform.system() == 'Windows': return subprocess.call(f'where {cmd}', shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) == 0 return subprocess.call(f'type {cmd}', shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) == 0 def install_phantomjs(install_directory): """Downloads PhantomJS zip, extracts to install_dir. Does not bin dir to path Raises multiple exceptions! Args: install_directory (Pathlike): path to extract phantomjs to """ # download phantomJS tags = requests.get('https://api.github.com/repos/ariya/phantomjs/tags').json() latest_tag = tags[0]['name'] if platform.system() == 'Windows': dir_name = f'phantomjs-{latest_tag}-windows' dl_link = f'https://bitbucket.org/ariya/phantomjs/downloads/{dir_name}.zip' elif platform.system() == 'Linux': dir_name = f'phantomjs-{latest_tag}-linux' dl_link = f'https://bitbucket.org/ariya/phantomjs/downloads/phantomjs-{latest_tag}-linux-x86_64.tar.bz2' elif platform.system() == 'Darwin': # Mac OSX dir_name = f'phantomjs-{latest_tag}-windows' dl_link = f'https://bitbucket.org/ariya/phantomjs/downloads/phantomjs-{latest_tag}-macosx.zip' r = requests.get(dl_link, stream=True) temp_dir = tempfile.mkdtemp() if dl_link.endswith('zip'): with ZipFile(io.BytesIO(r.content)) as zf: zf.extractall(temp_dir) else: with tarfile.open(fileobj=r.raw, mode='r|bz2') as tf: tf.extractall(temp_dir) shutil.move(Path(temp_dir) / dir_name, install_directory) ================================================ FILE: src/webview_demo.py ================================================ import webview import platform import sys import os import socket frameless = platform.system() == 'Windows' try: frontend_entry = f'{sys._MEIPASS}/frontend/index.html' except AttributeError: frontend_entry = 'frontend/index.html' if not os.path.exists(frontend_entry): # assume running in DEBUG frontend_entry = 'http://localhost:5173/' webview.create_window('Music Caster', frontend_entry, frameless=frameless) webview.start() # python -Om PyInstaller webview_demo.py