[
  {
    "path": ".dockerignore",
    "content": ".ruff_cache/\n.pytest_cache/\ngit/\ngithub/\n.idea/\n.vscode/\n__pycache__/\nsrc/venv/\nsrc/.venv/\nbuild/\n_build/\ndist/\nimages/\ntest_files/\nvenv/\n.venv/\nsrc/.flatpak-builder\n.env\nsettings.json\nsettings.json.bak\ntest.*\ndemo.py\nerror.log\nmusic_caster.log*\nresources/assets.png\n.vs/\n*.rsuser\n*.suo\n*.user\n*.userosscache\n*.sln.docstates\n*.userprefs\n[Dd]ebug/\n[Dd]ebugPublic/\n[Rr]elease/\n[Rr]eleases/\nx64/\nx86/\n[Ww][Ii][Nn]32/\n[Aa][Rr][Mm]/\n[Aa][Rr][Mm]64/\nbld/\n[Bb]in/\n[Oo]bj/\n[Ll]og/\n[Ll]ogs/\ntest.txt\nmusic_caster.log\nmusic_caster.log1\nresources/assets.png\nsettings.json.bak\n*.syso\n*.pid\nmusic_caster.lock\nmusic_caster.db\nsrc/phantomjs/\n"
  },
  {
    "path": ".github/workflows/build.yml",
    "content": "name: App Builder\non:\n  push:\n    branches: master\n  workflow_dispatch:\nconcurrency:\n  cancel-in-progress: true\n  # we want to group based on the push type not the ref\n  group: ${{ github.ref_type }}\n\nenv:\n  ARTIFACT_BASE_NAME: 'music_caster_artifacts' # + '_${runsOn}'\n  github: ${{ secrets.GITHUB_TOKEN }}\n\njobs:\n  build:\n    strategy:\n      matrix:\n        platform: [windows-latest]\n    runs-on: ${{matrix.platform}}\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-python@v5\n        with:\n          python-version: 3.14\n          cache: pip\n      - uses: actions/setup-go@v5\n\n      - name: Install Inno Setup\n        run: choco install innosetup --yes\n\n      - name: Install system deps\n        run: python build.py --deps\n\n      - name: Create Python venv\n        run: |\n          python -m venv .venv\n\n      - name: Install venv dependencies\n        run: |\n          .venv\\Scripts\\Activate.ps1\n          python build.py --deps\n\n      - name: Build\n        run: |\n          .venv\\Scripts\\Activate.ps1\n          python build.py --ci --no-deps --no-tests --no-install\n\n      - name: Test build\n        id: test\n        run: |\n          .venv\\Scripts\\Activate.ps1\n          python build.py --ci --no-deps --no-build --no-install\n\n      - name: Save Failed Build\n        if: failure() && steps.test.outcome == 'failure'\n        uses: actions/upload-artifact@v4\n        with:\n          name: failed-build\n          path: |\n            dist/Music Caster Setup.exe\n\n      # upload the build continuously to avoid duplicate build on a tag\n      # script will exit with zero if release for VERSION exists since --ci is used\n      - name: Upload build\n        if: ${{ github.event_name == 'push' && github.ref_name == 'master' }}\n        run: |\n          .venv\\Scripts\\Activate.ps1\n          python build.py -u --ci --no-deps --no-build --no-tests --no-install\n\n  # TODO: if/when MacOS is supported?\n  # release:\n  #   needs: build\n  #   runs-on: ubuntu-latest\n"
  },
  {
    "path": ".github/workflows/winget.yml",
    "content": "name: Publish to WinGet\non:\n  release:\n    types: [released]\njobs:\n  publish:\n    runs-on: windows-latest # action can only be run on windows\n    steps:\n      - uses: vedantmgoyal2009/winget-releaser@v2\n        with:\n          identifier: ElijahLopez.MusicCaster\n          token: ${{ secrets.WINGET_TOKEN }}\n"
  },
  {
    "path": ".gitignore",
    "content": ".idea/\n.vscode/\n__pycache__/\nsrc/venv/\nsrc/.venv/\nbuild/\n_build/\ndist/\nimages/\ntest_files/\nvenv/\n.venv/\nsrc/.flatpak-builder\n.env\nsettings.json\nsettings.json.bak\ntest.*\ndemo.py\nerror.log\nmusic_caster.log*\nresources/assets.png\n.vs/\n*.rsuser\n*.suo\n*.user\n*.userosscache\n*.sln.docstates\n*.userprefs\n[Dd]ebug/\n[Dd]ebugPublic/\n[Rr]elease/\n[Rr]eleases/\nx64/\nx86/\n[Ww][Ii][Nn]32/\n[Aa][Rr][Mm]/\n[Aa][Rr][Mm]64/\nbld/\n[Bb]in/\n[Oo]bj/\n[Ll]og/\n[Ll]ogs/\ntest.txt\nmusic_caster.log\nmusic_caster.log1\nresources/assets.png\nsettings.json.bak\n*.syso\n*.pid\nmusic_caster.lock\nmusic_caster.db\nsrc/phantomjs/\nsrc/music_caster.db-journal\nsrc/thumb.jpg\napp/node_modules/\napp/src-tauri/target/\nstats.html\n"
  },
  {
    "path": "CHANGELOG.txt",
    "content": "Music Caster Changelog\n\n5.25.2\n- [Fix] URL processing\n\n5.25.1\n- [Fix] Settings save/load ?\n- [Fix] Typing\n\n5.25.0\n- [Fix] Forwards compatibility for v6\n- [Fix] Maintenance (Python 3.14)\n\n5.24.0\n- [Feat] Performance when indexing many URLs\n- [Fix] Maintenance (Python 3.14)\n\n5.23.8\n- [Fix] URL processing\n\n5.23.7\n- [Fix] Death loop\n\n5.23.6\n- [Fix] URL processing\n\n5.23.5\n- [Fix] URL processing\n\n5.23.4\n- [Fix] URL processing\n\n5.23.3\n- [Fix] URL processing\n\n5.23.2\n- [Fix] Reopen GUI on Restart\n\n5.23.1\n- [Fix] URL processing\n\n5.23.0\n- [UI] Added Slovak translation\n\n5.22.22\n- [Fix] Radio-like streams\n\n5.22.21\n- [Fix] Volume syncing\n- [Fix] Improved reconnecting upon abrupt disconnect\n\n5.22.20\n- [Fix] Album art for cast devices\n\n5.22.19\n- Upgrade dependencies\n\n5.22.18\n- [Fix] Album art optimization for cast devices\n\n5.22.17\n- [Fix] Translation for restarting\n\n5.22.16\n- [Fix] Album art optimization for cast devices\n\n5.22.15\n- [Fix] Album art optimization for cast devices\n\n5.22.14\n- [Fix] Track position syncing with cast\n\n5.22.13\n- [Misc] Add more logging\n\n5.22.12\n- [Fix] Do not stop if cast app id is None\n\n5.22.11\n- [Misc] Add more logging\n\n5.22.10\n- [Fix] Track numbers from MP4/M4A files\n\n5.22.9\n- [Fix] Save file corruption on system crash\n\n5.22.8\n- [Fix] Save file corruption on system crash\n- [Fix] URL processing\n\n5.22.7\n- [Fix] URL processing\n\n5.22.6\n- [Fix] URL processing\n\n5.22.5\n- [Fix] DE translation\n\n5.22.4\n- [Fix] Playing from shell (REST API)\n\n5.22.3\n- [Feat] Support \"System Audio\" in REST API\n\n5.22.2\n- [Feat] Support \"System Audio\" in CLI\n\n5.22.1\n- [Fix] URL processing\n\n5.22.0\n- [UI] Added Português (Brazil) translation\n- [Fix] URL processing\n\n5.21.3\n- [Fix] Auto update\n- [Fix] Button font legibility2\n\n5.21.2\n- [Fix] Notification app id\n- [Fix] Security update\n\n5.21.1\n- [Fix] Open web gui\n\n5.21.0\n- [UI] Rounded rectangle buttons\n- [UI] Improve spacing and alignments\n\n5.20.2\n- [Fix] Hide install update button\n\n5.20.1\n- [Fix] Getting local IP address for casting\n- [Fix] Update notifier\n\n5.20.0\n- [Fix] Getting local IP address for casting\n\n5.19.13\n- [Fix] Code cleanup\n- [Fix] Sort Library by artist by default (don't know why I took so many years to do this)\n\n5.19.12\n- [Fix] Local playback scrubbing\n\n5.19.11\n- [Experiment] Better connection?\n\n5.19.10\n- [Fix] Unexpected crash - chromecast related\n\n5.19.9\n- [Fix] Playing offline if URL in queue\n\n5.19.8\n- [Fix] Unexpected crash when chromecast stops working\n\n5.19.7\n- [Fix] Unexpected metadata while playing (dirty patch)\n\n5.19.6\n- [Fix] Experimental theme setting\n- [Fix] Saving metadata without album art\n\n5.19.5\n- [Fix] Handling unresponsive cast device\n\n5.19.4\n- [Fix] Crash when playing\n\n5.19.3\n- [Fix] Cast syncing\n\n5.19.2\n- [Fix] Update from GUI\n\n5.19.1\n- [Fix] Windows-specific issues\n\n5.19.0\n- [Feat] Update from GUI\n\n5.18.9\n- [Fix] Locally played track scrubbing\n\n5.18.8\n- [Fix] Improve track scrubbing performance\n\n5.18.7\n- [Fix] logging\n\n5.18.6\n- [Fix] GUI freezing from seeking\n- [Fix] GUI improve set volume performance\n\n5.18.5\n- [Fix] GUI freezing from seeking\n\n5.18.4\n- [Fix] Increase wait timeout\n\n5.18.3\n- [Fix] Handle experimental feature error\n\n5.18.2\n- [Fix] Changing cast device\n\n5.18.1\n- [Fix] Security update\n- [Fix] Play AIFF from explorer\n\n5.18.0\n- [Feat] Local support for AIFF?\n\n5.17.7\n- [GUI] Timer tab translation\n\n5.17.6\n- [Fix] Better exception handling when pausing Chromecast\n\n5.17.5\n- [Fix] Setting position of Chromecast from GUI again\n\n5.17.4\n- [Fix] Volume Chromecast\n\n5.17.3\n- [Fix] Upgrade pychromecast\n\n5.17.2\n- [Fix] Pausing Chromecast\n\n5.17.1\n- [Fix] Setting position of Chromecast from GUI\n\n5.17.0\n- [Feat] Migrated error capturing endpoint from pipedream to under mine (lenerva)\n\n5.16.8\n- [Fix] Chromecast status\n\n5.16.7\n- [Fix] Chromecast status\n\n5.16.6\n- [Fix] Chromecast status callback\n\n5.16.5\n- [Fix] Chromecast status callback\n\n5.16.4\n- [Fix] pychromecast v14 support\n\n5.16.3\n- [Fix] use GitHub downloads instead of instances\n\n5.16.2\n- [Fix] Metadata time modified error\n- [Fix] Clear queue\n\n5.16.1\n- [Fix] Remove old startup shortcuts + background thread\n\n5.16.0\n- [Feat] Linux support\n- [Fix] Remove old startup shortcuts\n\n5.15.0\n- [Fix] Security update\n\n5.14.0\n- [Dev] New build process; might break app\n\n5.13.43\n- [Fix] Stop chromecast after 5 minutes of pausing\n\n5.13.42\n- [Fix] Install size\n- [Fix] False positive not playing on chromecast\n\n5.13.41\n- [Fix] Use latest Pillow\n\n5.13.40\n- [Fix] Revert PyInstaller upgrade causing anti-virus detection\n\n5.13.39\n- [Fix] Exit program\n\n5.13.38\n- [Fix] Added error handling for Metadata editor artwork\n\n5.13.37\n- [Fix] Select metadata art from other audio files\n\n5.13.36\n- [Fix] Linux local audio player\n- [Fix] Default music folder\n\n5.13.35\n- [Fix] register handlers\n\n5.13.34\n- [Fix] Start on login\n\n5.13.33\n- [Fix] Playlist delete\n\n5.13.32\n- [Fix] Fetching local IPV4\n\n5.13.31\n- [UI] Danish supported\n- [Fix] Playlist tab\n\n5.13.30\n- [Feat] Playlist length\n- [UI] ListBox controls alignment\n\n5.13.29\n- [Fix] Experimental UI tooltip text now legible\n\n5.13.28\n- [UI] Add url with <Enter>\n- [UI] UI informs when playlist was saved by user\n\n5.13.27\n- [Fix] URL formatting\n\n5.13.26\n- [Fix] web UI and API\n\n5.13.25\n- [Fix] web UI and API\n\n5.13.24\n- [Fix] get_ipv4 fallback bug\n\n5.13.23\n- [Fix] Next up bug\n- [Fix] Don't cache spotify playlist\n- [Fix] Metadata bug\n- [Fix] Locate track bug\n\n5.13.22\n- [UI] Tooltip for all settings\n\n5.13.21\n- [UI] Added French translation\n\n5.13.20\n- [UI] Added Russian translation\n- [Fix] Sort order when playing folder with missing album metadata\n- [Fix] Ukrainian translation\n\n5.13.19\n- [UI] Show artist as unknown in queue\n\n5.13.18\n- [UI] Added Ukrainian translation\n\n5.13.17\n- Remove old code\n\n5.13.16\n- Re-read metadata if file was modified\n\n5.13.15\n- Upgrade dependencies\n\n5.13.14\n- [Fix] set_pos crash when not connected\n\n5.13.13\n- [Fix] Shift + P for previous track\n\n5.13.12\n- [Fix] Ignore copyright errors\n- [Fix] create_shortcut_windows\n\n5.13.11\n- [Fix] Metadata select artwork allows JPG/JPEG images\n\n5.13.10\n- Upgrade dependencies\n\n5.13.9\n- Rollback dependencies\n\n5.13.8\n- Upgrade dependencies\n\n5.13.7\n- Upgrade dependencies\n\n5.13.6\n- [Fix] Opening GUI with timer\n\n5.13.5\n- [Fix] PhantomJS auto install\n\n5.13.4\n- [Fix] SSL links\n\n5.13.3\n- [Fix] Playing YouTube playlists\n\n5.13.2\n- [Fix] Local address fetcher\n\n5.13.1\n- [Fix] Local address fetcher\n\n5.13.0\n- [Feat] Works with VPN\n\n5.12.12\n- [Fix] ytdlp\n\n5.12.11\n- [Fix] ytdlp\n\n5.12.10\n- [Fix] ytdlp\n\n5.12.9\n- [Fix] Open PhantomJS download page\n\n5.12.8\n- [Fix] Weird metadata\n\n5.12.7\n- [Fix] Invalid URL crash\n\n5.12.6\n- [Fix] Resolution switcher\n- Upgrade dependencies\n\n5.12.5\n- [Fix] Ctrl + C for queue\n\n5.12.4\n- [Fix] Deezer, Spotify search\n\n5.12.3\n- [Fix] Exit app crash\n\n5.12.2\n- Upgrade dependencies\n\n5.12.1\n- [Fix] Queue action accuracy\n- [Note] New REST API\n\n5.12.0\n- [Feat] Edit metadata of a track in the queue\n- [Feat] Save selected items in queue to playlist\n- [Feat] Alternative UI (Settings > Misc > Experimental features)\n- [Fix] Handle user prematurely removing bad uri\n\n5.11.0\n- [Feat] Copy playlist URIs\n- [Fix] upgrade PyInstaller\n\n5.10.4\n- [Fix] locate uri\n\n5.10.3\n- [Fix] Volume sync with chromecast\n\n5.10.2\n- [Fix] Exit program after sleeping\n\n5.10.1\n- [Fix] Relative audio files in Portable mode\n\n5.10.0\n- [Feat] Copy URIs (text only)\n- [Fix] Move track up\n- [Fix] Discord presence blocking\n\n5.9.13\n- [Fix] Play from stopped\n\n5.9.12\n- [Fix] Resolution switcher\n\n5.9.11\n- [Fix] Replaying queue from GUI\n\n5.9.10\n- [Fix] Non existent files\n\n5.9.9\n- [Fix] Covert art playlist videos\n\n5.9.8\n- [UX] YouTube source URL\n\n5.9.7\n- [Fix] Covert art\n\n5.9.6\n- [Fix] Long lived GUI bug\n\n5.9.5\n- [Optimization] YouTube playlists\n\n5.9.4\n- [Fix] Volume\n\n5.9.3\n- [Fix] Cast volume\n\n5.9.2\n- [Fix] No files selected\n- [Fix] Playing URLs\n\n5.9.1\n- [Fix] Audio playback\n\n5.9.0\n- [Feat] YouTube livestreams\n\n5.8.10\n- [Fix] Improve API\n\n5.8.9\n- [Fix] Cast errors\n\n5.8.8\n- [Fix] Deezer URLs\n\n5.8.7\n- [Fix] Resolution setter\n\n5.8.6\n- [Fix] Deezer URLs\n\n5.8.5\n- [Fix] Update checker\n\n5.8.4\n- [Fix] Deezer URLs\n\n5.8.3\n- [Fix] URL metadata\n- [Fix] Crashing when Cast NotConnected\n\n5.8.2\n- [Fix] Memory leaks\n\n5.8.1\n- [Fix] Also dynamically update refresh rate\n\n5.8.0\n- [Feat] Battery Resolution Switcher\n- [Fix] Upgrade dependencies\n\n5.7.9\n- [Fix] Account for edge case\n\n5.7.8\n- [Fix] URL track number\n\n5.7.7\n- [Fix] Radio urls\n\n5.7.6\n- [Fix] Radio urls\n\n5.7.5\n- [Fix] FLAC album art\n\n5.7.4\n- [Fix] Cancel timer\n- [Feat] Window only keyboard shortcuts\n\n5.7.3\n- [Fix] MP4/M4A track number\n\n5.7.2\n- [Fix] Album sorting\n- [Fix] Files with no track number\n\n5.7.1\n- [Fix] German translation\n\n5.7.0\n- [UI] Added German / Deutsch translation\n\n5.6.14\n- [Fix] Improve GUI error handling\n\n5.6.13\n- [Fix] Improved sorting\n\n5.6.12\n- [Fix] Improved sorting for albums\n\n5.6.11\n- [Fix] Window position edge case\n\n5.6.10\n- [Fix] Cast not connected edge case\n\n5.6.9\n- [Fix] Folder select edge case\n\n5.6.8\n- [Fix] Locate track\n\n5.6.7\n- [Fix] Drag and drop files\n\n5.6.6\n- [Fix] Initial dir for dialog\n\n5.6.5\n- [Fix] TclError\n\n5.6.4\n- [Fix] URLs\n\n5.6.3\n- [Fix] Audio only URLs\n\n5.6.2\n- [Fix] Website integration\n\n5.6.1\n- [Fix] ujson security update\n\n5.6.0\n- [Feat] Save queue also saves position\n\n5.5.7\n- [Fix] YouTube URL Metadata\n\n5.5.6\n- [Fix] Web player UX\n\n5.5.5\n- [Fix] Open settings file\n\n5.5.4\n- [Fix] YouTube comment parsing\n- [Fix] Queue multiple folders\n\n5.5.3\n- [Feat] &alb format\n\n5.5.2\n- [Fix] URL support\n\n5.5.1\n- [Fix] Volume GUI shortcut\n\n5.5.0\n- [Feat] Support more URLs\n\n5.4.11\n- [Fix] Volume\n\n5.4.10\n- [Fix] Volume\n\n5.4.9\n- [Fix] Web GUI settings\n- [Fix] Background errors\n\n5.4.8\n- [Fix] VLC Race Condition\n\n5.4.7\n- [Fix] Better error handling\n\n5.4.6\n- [Fix] Reading Metadata\n- [Fix] Spotify Playlists\n\n5.4.5\n- [Fix] Opus track number\n\n5.4.4\n- [Fix] Casting race conditions\n\n5.4.3\n- [Fix] Playback after startup race condition\n\n5.4.2\n- [Fix] Playlist Listbox\n\n5.4.1\n- [Fix] GUI\n\n5.4.0\n- [UI] Add device selection\n- [UI] Simplified file/folder actions\n- [UI] Fix Listbox height when artwork is off\n\n5.3.1\n- [UI] Update Web UI settings modal\n\n5.3.0\n- [Feat] Exit app on GUI close\n\n5.2.5\n- [Fix] Viewing changelog in python or portable mode\n\n5.2.4\n- [Fix] Race conditions\n\n5.2.3\n- [Fix] Cast pausing after playing\n\n5.2.2\n- [Fix] Unexpected behaviour after turning off cast\n\n5.2.1\n- [Fix] PyInstaller\n\n5.2.0\n- [UI] New Web UI layout\n- [OS] Better Linux support\n\n5.1.24\n- [Fix] Single instance\n\n5.1.23\n- [Fix] Spotify playlists local files\n\n5.1.22\n- [Fix] Open settings.json\n\n5.1.21\n- [Fix] command line arguments\n\n5.1.20\n- [Feat] --device=<device> option added to specify the device to use\n- [Feat] Ctrl + Shift + Q in GUI to exit program\n\n5.1.19\n- [Fix] URL casting\n\n5.1.18\n- [Fix] Youtube Playlist Web\n\n5.1.17\n- [Fix] Portable\n\n5.1.16\n- [Fix] Corner cases\n\n5.1.15\n- [Fix] URL Metadata\n\n5.1.14\n- [Fix] Encoding\n\n5.1.13\n- [Fix] Metadata\n\n5.1.12\n- [Fix] Startup\n\n5.1.11\n- [Fix] GUI\n\n5.1.10\n- [Fix] URL Metadata\n\n5.1.9\n- [Fix] URL Metadata\n\n5.1.8\n- [Fix] URL Metadata\n\n5.1.7\n- [Fix] URL Metadata\n\n5.1.6\n- [Fix] start_playing\n\n5.1.5\n- [Fix] Spelling\n\n5.1.4\n- [Fix] Chromecast connection\n\n5.1.3\n- [Fix] GUI Listbox\n\n5.1.2\n- [Optimization] Faster startup\n- [Fix] Queue from explorer\n- [Fix] Prev track\n- [Fix] Cast updates\n\n5.1.1\n- [Fix] Override track format\n- [Fix] URL Metadata\n\n5.1.0\n- [UI] Override track format\n- [Fix] Dialog blocking\n\n5.0.13\n- [Fix] Long track names\n\n5.0.12\n- [Fix] M4A files\n\n5.0.11\n- [Fix] Setting png as cover art\n\n5.0.10\n- [Fix] Performance\n\n5.0.9\n- [Fix] TCLError Mini-mode\n\n5.0.8\n- [Fix] Temp TCLError fix\n\n5.0.7\n- [Fix] Prev track with next queue\n\n5.0.6\n- [Fix] Chromecast\n- [Fix] URL metadata\n\n5.0.5\n- [Fix] Dependencies\n\n5.0.4\n- [Fix] Catch errors\n\n5.0.3\n- [Fix] Smart queue\n- [Fix] Logging\n- [Fix] Tray device names\n\n5.0.2\n- [Fix] TKdnd error\n\n5.0.1\n- [Fix] Better end of list behaviour\n\n5.0.0\n- [New] Using Python 3.10 64-bit\n\n4.90.157\n- [Fix] Invalid audio file error\n\n4.90.156\n- [Fix] Loading settings\n\n4.90.155\n- [Optimization] Mini-mode\n\n4.90.154\n- [Fix] Error handling\n\n4.90.153\n- [Fix] Chromecast connection issues\n\n4.90.152\n- [Fix] AAC, MP4, OggVorbis\n\n4.90.151\n- [Fix] Explorer play/playnext/queue behaviour\n\n4.90.150\n- [Fix] Opus file cover art\n- [Fix] Keyboard shortcuts\n\n4.90.149\n- [Fix] Spotify links\n\n4.90.148\n- [Fix] Keyboard\n- [Fix] Chromecast errors\n\n4.90.147\n- [Fix] URL\n\n4.90.146\n- [Fix] URL and Chromecast errors\n\n4.90.145\n- [Fix] Next/Previous track from queue\n\n4.90.144\n- [Fix] Handle chromecast disconnect\n\n4.90.143\n- [Fix] Previous track\n\n4.90.142\n- [Fix] Web GUI\n\n4.90.141\n- [Fix] Dutch\n\n4.90.140\n- [Fix] Change Chromecast\n\n4.90.139\n- [Fix] IPv6\n\n4.90.138\n- [Fix] Import playlist\n\n4.90.137\n- [Fix] Timer\n- [Fix] Export playlist\n- [UI] Added Dutch translation\n\n4.90.136\n- [Fix] URL support\n\n4.90.135\n- [Fix] get ipv4\n\n4.90.134\n- [Feat] Mobile playback\n- [Fix] URL local muted playback\n\n4.90.133\n- [Fix] GUI activation\n\n4.90.132\n- [Fix] URL support\n\n4.90.131\n- [Fix] Metadata Tab\n- [Fix] Discord Presence\n\n4.90.130\n- [Fix] GUI activation\n\n4.90.129\n- [Fix] APIs\n\n4.90.128\n- [Fix] YouTube and GUI focus\n\n4.90.127\n- [Fix] ComboBox button color\n\n4.90.126\n- [UX] Support music.youtube.com\n- [UI] Added link to GitHub\n- [UX] Flash border of mini-mode\n\n4.90.125\n- [Fix] Improved pause and resume logging\n\n4.90.124\n- [Fix] Log file location\n\n4.90.123\n- [Fix] Tray not closing\n\n4.90.122\n- [Fix] Move track down\n\n4.90.121\n- [Fix] Focus on first window creation\n\n4.90.120\n- [Fix] json serialization\n\n4.90.119\n- [Fix] Activate instance using IPv6\n\n4.90.118\n- [Fix] Race condition if previous_device cannot be found\n- [Fix] Allow IPv6 connections\n\n4.90.117\n- [Fix] Playing from command line arguments\n- [Fix] Chromecast errors\n\n4.90.116\n- [Fix] Cleaner dual instance exit\n- [Fix] Improved /state/ and\n\n4.90.115\n- [Fix] Cleaner exit\n\n4.90.114\n- [Fix] Revert minimizing when closing\n\n4.90.113\n- [Fix] Chromecast errors\n- [Fix] Discord rich presence\n- [Optimization] Improve startup time by reducing top level imports\n- [Optimization] Using ujson to load and save settings faster\n- [Optimization] Auto-update does not block usage\n\n4.90.112\n- [Fix] Don't send known errors\n\n4.90.111\n- [Fix] URL parsing if URL results in 404\n\n4.90.110\n- [Fix] Maybe fix cast connection issues\n\n4.90.109\n- [Fix] Tray closing on non-fatal error\n\n4.90.108\n- [Fix] Auto-update on system startup\n\n4.90.107\n- [Fix] Disalbed Discord RPC For Sure\n\n4.90.106\n- [Fix] More error catching\n\n4.90.105\n- [Fix] Tray theme\n\n4.90.104\n- [Fix] Already connected errors\n\n4.90.103\n- [Fix] Portable\n\n4.90.102\n- [Fix] Updated pychromecast and zeroconf\n\n4.90.101\n- [Fix] Disabled Discord RPC\n\n4.90.100\n- [Fix] Files web UI\n\n4.90.99\n- [Fix] Fix mini-mode bug\n\n4.90.98\n- [Fix] Fix reading explicit/rating tag\n\n4.90.97\n- [Fix] Add more DEBUG features\n\n4.90.96\n- [Fix] Better YouTube playback\n\n4.90.95\n- [Fix] Handle bad metadata input\n- [Fix] Update Flask semantics\n\n4.90.94\n- [Fix] Improved --help output\n\n4.90.93\n- [Fix] Web UI upload button\n\n4.90.92\n- [Fix] Web UI modal CSS\n\n4.90.91\n- [Fix] Web UI Mobile CSS\n- [Feat] Web UI Dark Theme\n\n4.90.90\n- [Fix] Folder action when using a different language\n\n4.90.89\n- [Feat] New /devices/ API\n- [Fix] Translation for \"locate track\"\n\n4.90.88\n- [Fix] Removed polling\n- [Fix] Fixed anti-virus detection?\n\n4.90.87\n- [Fix] Polling (Hibernation bug)\n\n4.90.86\n- [Fix] Polling\n\n4.90.85\n- [Fix] Strip URL's\n\n4.90.84\n- [Fix] Proxies\n\n4.90.83\n- [Fix] Polling\n\n4.90.82\n- [Fix] Polling\n\n4.90.81\n- [Fix] Window focus\n- [Feat] Specify delay (beta)\n\n4.90.80\n- [Fix] Auto-update restart minimized\n\n4.90.79\n- [Feat] Open window when run\n\n4.90.78\n- [Fix] Update dependencies\n\n4.90.77\n- [Fix] Fix YouTube link expiry\n\n4.90.76\n- [Fix] Better tray exit behaviour\n\n4.90.75\n- [Fix] Timer 24hr support\n\n4.90.74\n- [Fix] YT Comment Parser\n\n4.90.73\n- [Fix] Timer settings Web GUI\n\n4.90.72\n- [Fix] Default volume now 50%\n\n4.90.71\n- [Fix] YT Comment Parser\n\n4.90.70\n- [Fix] Added more logging\n\n4.90.69\n- [Fix] Web GUI\n\n4.90.68\n- [Fix] Metadata\n\n4.90.67\n- [Fix] Mini-mode drag\n\n4.90.66\n- [Fix] YouTube parsing\n\n4.90.65\n- [Fix] Scanning\n\n4.90.64\n- [Fix] Tray devices\n\n4.90.63\n- [Fix] Combo boxes\n\n4.90.62\n- [Fix] Library selection colors\n\n4.90.61\n- [Fix] URL\n\n4.90.60\n- [Fix] GUI\n\n4.90.59\n- [Feat] Upload files to PC\n\n4.90.58\n- [Fix] Buffering\n\n4.90.57\n- [Fix] Buffering\n\n4.90.56\n- [Fix] Icon\n\n4.90.55\n- [Fix] GUI\n\n4.90.54\n- [Fix] M3U files\n\n4.90.53\n- [Fix] Timer API\n\n4.90.52\n- [Fix] GUI\n\n4.90.51\n- [Fix] GUI\n\n4.90.50\n- [Fix] MP4/M4A/AAC\n\n4.90.49\n- [Fix] Export playlist\n\n4.90.48\n- [Fix] Web GUI\n- [Fix] Playlist tab\n\n4.90.47\n- [Fix] MP4/M4A/AAC\n\n4.90.46\n- [Fix] MP4/M4A/AAC\n\n4.90.45\n- [Fix] Playlist\n- [Fix] MP4/M4A/AAC\n\n4.90.44\n- [Fix] Flac files\n- [Fix] Better proxy support\n\n4.90.43\n- [Fix] Favicon\n\n4.90.42\n- [Fix] Favicon\n\n4.90.41\n- [Fix] Settings tooltip\n\n4.90.40\n- [Fix] Metadata tab\n\n4.90.39\n- [Fix] Mini-mode\n\n4.90.38\n- [Fix] Notifications and upgrade Python\n\n4.90.37\n- [Fix] Mini-mode\n\n4.90.36\n- [Optimization] GUI\n- [Optimization] Scrubbing\n\n4.90.35\n- [Fix] Auto next track\n\n4.90.34\n- [Optimization] Smaller executable\n\n4.90.33\n- [Fix] Expired URLs\n\n4.90.32\n- [Fix] Cut/Copy url inputs\n\n4.90.31\n- [Fix] GUI\n- [Fix] Stop\n\n4.90.30\n- [Optimization] Memory\n\n4.90.29\n- [Fix] Metadata setting\n\n4.90.28\n- [Fix] Switching devices\n- [Fix] Seeking\n\n4.90.27\n- [Fix] Web GUI\n\n4.90.26\n- [Fix] Queue all\n\n4.90.25\n- [Feat] Add Mini-mode shortcut\n- [Fix] Album art setting\n\n4.90.24\n- [Fix] Spotify\n\n4.90.23\n- [Fix] Italian translation\n- [Fix] DND URL support\n\n4.90.22\n- [Fix] Timer button\n\n4.90.21\n- [Fix] Cookie parsing\n\n4.90.20\n- [Fix] Queue prev/next track\n\n4.90.19\n- [Fix] YouTube comment parsing\n- [Fix] Up and Down change volume\n- [Fix] Default URL input for playlists tab\n\n4.90.18\n- [Fix] Registry errors\n- [Fix] YouTube comment parsing\n\n4.90.17\n- [Fix] Deleted video handler\n\n4.90.16\n- [Fix] Playlist name input\n\n4.90.15\n- [Fix] GUI queue\n\n4.90.14\n- [Fix] Sorting (DND) files\n- [Fix] Spotify tracks\n\n4.90.13\n- [Fix] Improve chapter parsing\n\n4.90.12\n- [Fix] Proxy errors\n\n4.90.11\n- [Fix] Persistent queue\n\n4.90.10\n- [Fix] Deezer support\n\n4.90.9\n- [Fix] URL added feedback - Italian translation\n\n4.90.8\n- [UI] URL added feedback\n\n4.90.7\n- [Fix] System audio streaming related\n\n4.90.6\n- [Fix] YouTube comment parsing\n- [Fix] Dynamic artwork\n\n4.90.5\n- [Fix] Better internal clocks\n- [Fix] Mini-mode\n\n4.90.4\n- [Fix] Removed blocking logic\n\n4.90.3\n- [Fix] Main GUI\n\n4.90.2\n- [Fix] Library tab\n\n4.90.1\n- [Fix] YouTube playlists\n\n4.90.0\n- [Feat] Drag and Drop\n\n4.89.0\n- [Feat] Smart URL F-FWD and RWD\n\n4.88.0\n- [Feat] Metadata setter\n\n4.87.6\n- [Fix] Playlists tab\n\n4.87.5\n- [Fix] Playlists tab\n\n4.87.4\n- [Feat] Twitch support\n\n4.87.3\n- [Fix] Playlists tab\n- [Fix] Move to next queue\n\n4.87.2\n- [Fix] Volume slider\n- [Fix] URL resumption after long time\n- [Fix] Prevent sleep after resuming playback\n\n4.87.1\n- [Fix] Multiple URL input support\n\n4.87.0\n- [Feat] Smart queue (auto skip)\n\n4.86.6\n- [Fix] Mini-mode\n\n4.86.5\n- [Fix] Library tab\n\n4.86.4\n- [Fix] Fix web GUI\n\n4.86.3\n- [Fix] Library sorting\n\n4.86.2\n- [Fix] Web GUI search\n\n4.86.1\n- [Optimization] Library table\n\n4.86.0\n- [UI] Added library tab\n- [UI] Improve multi-selections\n\n4.85.2\n- [UI] Better (rounded) buttons\n- [UI] Web GUI playlist options\n- [UI] Web GUI file options\n\n4.85.1\n- [Fix] Deezer support\n\n4.85.0\n- [Fix] Registry\n- [Feat] URL Protocol music-caster\n\n4.84.3\n- [Fix] Audio Player\n\n4.84.2\n- [Fix] Playlist format\n- [Fix] Folder and file actions\n- [UI] Playlist metadata scanning\n- [Optimization] Spotify support\n\n4.84.1\n- [Fix] Resume playing with keyboard\n- [Fix] Mini-mode with unknown metadata\n- [Fix] Shuffle\n- [Fix] Playing URL's\n\n4.84.0\n- [Feat] Deezer support\n- [Fix] Spotify album support\n- [Fix] System audio\n- [Fix] Move tracks within next queue\n\n4.83.0\n- [Feat] Locate tracks in playlists\n- [Feat] Added option to remember selected folder\n- [Optimization] Spotify URL\n- [Fix] YouTube/Soundcloud playlist/set support\n\n4.82.14\n- [Fix] Better persistent queue\n- [Fix] Web GUI translation\n- [Fix] M3U(8) File Handler\n\n4.82.13\n- [Fix] Web GUI auto-fresh\n\n4.82.12\n- [UI] Translation framework\n\n4.82.11\n- [Fix] Play URL double count\n\n4.82.10\n- [Fix] Main GUI when repeat is disabled\n\n4.82.9\n- [Fix] URL support\n- [Fix] Soundcloud support\n\n4.82.8\n- [Fix] URL support\n\n4.82.7\n- [Fix] Tray select files\n- [Fix] Metadata error\n- [Fix] Commandline arguments\n- [Optimization] Lowered CPU usage\n- [Feat] Queue library always\n\n4.82.6\n- [Fix] Delete old files\n\n4.82.5\n- [Fix] Sharp noise when playing audio\n- [Fix] Unknown album errors\n\n4.82.4\n- [Fix] Spotify handling\n\n4.82.3\n- [Fix] Unknown property bugs\n- [Fix] Better web GUI\n\n4.82.2\n- [Fix] Prevent Windows from sleeping\n\n4.82.1\n- [Fix] M3U support\n\n4.82.0\n- [Feat] M3U(8) beta support\n- [Fix] Translations\n- [Fix] Playlists tab\n\n4.81.14\n- [Fix] URL Tab auto paste\n- [UI] Playlists tab\n\n4.81.13\n- [Fix] Translations\n- [Fix] Web GUI auto reload\n\n4.81.12\n- [Fix] Web GUI\n\n4.81.11\n- [Fix] URL expiry\n\n4.81.10\n- [UI] Added Italian translation\n\n4.81.9\n- [Fix] Local url playback\n- [Fix] Improve YouTube local playback quality\n- [Fix] Instance resolver\n\n4.81.8\n- [Fix] Locate uri\n\n4.81.7\n- [Fix] Locate uri\n\n4.81.6\n- [Fix] Scan queued urls\n\n4.81.5\n- [UI] Clicking title/artist/album text opens url or explorer\n- [Fix] Locate track for mini-mode\n\n4.81.4\n- [Fix] Spotify URL\n\n4.81.3\n- [Fix] Spotify URL\n\n4.81.2\n- [Fix] Spotify URL\n\n4.81.1\n- [Fix] Spotify URL\n\n4.81.0\n- [Feat] Added Spotify support\n\n4.80.6\n- [Fix] URL metadata\n\n4.80.5\n- [Fix] Persistent URL's\n\n4.80.4\n- [Fix] Web GUI\n\n4.80.3\n- [Optimization] Blazing fast startup + open GUI\n\n4.80.2\n- [Fix] Vertical GUI size\n- [Fix] Repeat button tooltip\n\n4.80.1\n- [Fix] Missing translation\n- [Fix] High pitch noise\n- [Fix] Locate track\n- [Feat] Add option to pick language\n- [UI] Improved fonts and UI looks\n\n4.80.0\n- [Feat] Play URL on local device\n- [Fix] Tray menu\n\n4.79.5\n- [Fix] Translation framework\n\n4.79.4\n- [UI] Added Spanish translation\n- [Fix] Translation framework\n- [Fix] Metadata\n\n4.79.3\n- [UI] Web GUI improvements\n\n4.79.2\n- [Optimization] Selecting from queue\n\n4.79.1\n- [Fix] Auto update notification\n\n4.79.0\n- [Feat] Added option to hide queue index\n- [Feat] Web GUI interactive queue\n- [UI] Better queue\n- [UI] More intuitive mini-mode behaviour\n- [UI] Integrated URL window with main window\n- [Optimization] Moving tracks\n\n4.78.1\n- [Fix] Web GUI\n\n4.78.0\n- [UI] Created framework for translations\n\n4.77.0\n- [Feat] Support for YouTube playlists and SoundCloud Sets\n- [Fix] Playing YouTube links\n\n4.76.4\n- [Fix] Folder browse and playlists add tracks\n\n4.76.3\n- [Fix] Tray not showing up\n\n4.76.2\n- [Fix] Get latest release\n\n4.76.1\n- [Fix] Registry error\n\n4.76.0\n- [Feat] Move track in queue to next up\n- [UI] Better queue button\n- [Optimization] Tray has its own process to reduce drag stuttering and high cpu usage\n\n4.75.1\n- [Fix] Album and Title alignment resizing window\n\n4.75.0\n- [Feat] Live shuffle and un-shuffle the queue using the shuffle button\n- [Optimization] Using deques instead of lists for internal queues (faster for massive queues)\n\n4.74.28\n- [Fix] Sorting improvements\n- [Fix] Improved Web GUI searching\n- [Fix] Improved Web GUI file display\n\n4.74.27\n- [Fix] No space error handling while saving settings\n\n4.74.26\n- [Fix] Album art display\n\n4.74.25\n- [Fix] Better download error handling\n\n4.74.24\n- [Fix] Playlists\n\n4.74.23\n- [Fix] Better scrubbing\n\n4.74.22\n- [Fix] Better scrubbing\n\n4.74.21\n- [Fix] Better image handling\n\n4.74.20\n- [Fix] Folder cover override UX\n\n4.74.19\n- [Fix] Improved binaries\n\n4.74.18\n- [Fix] Web GUI timer options\n\n4.74.17\n- [Fix] Removed internet available calls\n\n4.74.16\n- [Fix] Error handling\n\n4.74.15\n- [Fix] Loading settings\n\n4.74.14\n- [Fix] Wav file error\n\n4.74.13\n- [Fix] Loading numerical settings\n\n4.74.12\n- [Fix] Saving settings\n\n4.74.11\n- [Fix] Populate queue on startup\n- [Fix] Loading settings\n- [Fix] Saving settings\n- [Fix] Improve web GUI\n\n4.74.10\n- [Fix] Improved natural sort\n\n4.74.9\n- [Fix] Metadata scanning for folders\n\n4.74.8\n- [Fix] Add folder in settings\n- [Fix] Fixed Portable.zip compression\n\n4.74.7\n- [Fix] Playlist tab\n\n4.74.6\n- [Fix] Mini-mode\n\n4.74.5\n- [Fix] Mute icon staying when slider is moved\n\n4.74.4\n- [Fix] Shuffle and repeat off by default\n\n4.74.3\n- [Fix] Timer tab\n\n4.74.2\n- [UI] Sort playlists alphabetically\n- [UI] Open Changelog file from settings\n- [UI] Settings icons\n- [Fix] Mute button starting tooltip\n\n4.74.1\n- [Fix] Mini-mode\n\n4.74.0\n- [UI] Show album name in now playing\n- [Optimized] Metadata scanning\n\n4.73.8\n- [Fix] Volume control on startup\n- [Fix] Volume control in GUI\n\n4.73.7\n- [Fix] Hot-key window focus\n\n4.73.6\n- [Fix] Discord presence errors\n\n4.73.5\n- [Optimized] Faster GUI activation via hot-key\n\n4.73.4\n- [Fix] Web shuffle\n- [Fix] Main window activation through second instance\n\n4.73.3\n- [Fix] Tray icon\n\n4.73.2\n- [Fix] Stops at the end of tracks\n\n4.73.1\n- [UI] Improved mini-mode\n\n4.73.0\n- [Feat] Added shuffle to controls\n\n4.72.1\n- [Fix] Random stops\n\n4.72.0\n- [Feat] Added setting to disable folder scan\n\n4.71.43\n- [Fix] Removed default folder re-addition\n\n4.71.42\n- [Fix] Switching device error\n- [Fix] Cleaner code\n\n4.71.41\n- [Fix] Cleaner code\n- [Feat] Improved privacy by hashing MAC\n\n4.71.40\n- [Fix] Play from explorer\n- [Fix] Album art issues\n\n4.71.39\n- [Fix] Chromecast not connected case\n\n4.71.38\n- [Fix] Resume after long pause\n- [Fix] Random stopping\n\n4.71.37\n- [Fix] Web GUI\n- [Fix] Improved GUI performance when playing non-library files\n- [Fix] Code quality\n\n4.71.36\n- [Fix] Playlist tab moving a track up or down\n\n4.71.35\n- [Fix] Thumbnail fetching\n- [Fix] Album art resizing issue\n- [Fix] Portable auto-update on cancel\n- [Fix] Invalid WAV files\n\n4.71.34\n- [Fix] Discord RPC error\n\n4.71.33\n- [Fix] Debugging keyboard errors\n\n4.71.32\n- [Fix] Better web error handling\n\n4.71.31\n- [Fix] Fix build\n\n4.71.30\n- [Fix] Improve artist parsing for metadata that uses '/' as a separator\n\n4.71.29\n- [Fix] Ctrl + # in mini mode\n\n4.71.28\n- [Fix] Fix using j,k,l on playlist tab\n- [Fix] Fix using k to pause in mini-mode\n- [Fix] Improve typing speed for main window\n\n4.71.27\n- [Fix] Delete playlist behaviour\n\n4.71.26\n- [Fix] Removing current track from queue\n- [Fix] Set timer with HH:MM\n\n4.71.25\n- [Optimized] Caching\n\n4.71.24\n- [UI] Improved tray menu\n\n4.71.23\n- [Fix] Double Digit Track Numbers\n\n4.71.22\n- [Fix] Exit\n\n4.71.21\n- [Fix] Removed a dependency\n\n4.71.20\n- [Fix] Pressing Enter while using mini-mode\n\n4.71.19\n- [Fix] Improved mini-mode border padding\n\n4.71.18\n- [Web] Added version to web GUI settings\n\n4.71.17\n- [Fix] Better timing\n- [Fix] Random stopping\n- [Fix] Playing YouTube links\n- [Fix] Playing tooltip\n\n4.71.16\n- [Fix] GUI volume slider not updating\n\n4.71.15\n- [Fix] Switching devices while paused\n\n4.71.14\n- [Fix] Window Icon\n\n4.71.13\n- [Fix] Web GUI search bar\n\n4.71.12\n- [Fix] files.html\n\n4.71.11\n- [Fix] Instance checker\n\n4.71.10\n- [Fix] Instance checker\n\n4.71.9\n- [Fix] Fixed playing youtube links\n- [Fix] Changed locate file to locate track\n- [Fix] Added save icon to playlist tab\n\n4.71.8\n- [Fix] Patched more errors\n\n4.71.7\n- [Fix] Update youtube-dl\n\n4.71.6\n- [Fix] Improved error handling\n\n4.71.5\n- [Fix] Improved logging\n\n4.71.4\n- [Fix] Improved logging\n\n4.71.3\n- [Fix] Auto-update True by default\n- [Fix] Better default music folder\n\n4.71.2\n- [Fix] Main GUI\n\n4.71.1\n- [Fix] Web GUI play file\n- [Fix] Web GUI styling\n- [Fix] Web GUI view files\n\n4.71.0\n- [Feat] Added option to reverse play next behaviour\n\n4.70.11\n- [Fix] Handle no space bug\n\n4.70.10\n- [Fix] Web GUI improvements\n\n4.70.9\n- [Fix] Remove old installed files\n\n4.70.8\n- [Fix] Web GUI shuffle option\n\n4.70.7\n- [Fix] Fix log lines in email\n\n4.70.6\n- [Fix] Playlists tab\n\n4.70.5\n- [Fix] Queue from explorer\n\n4.70.4\n- [Fix] Rescan library from web GUI\n\n4.70.3\n- [Fix] Folder cover art\n\n4.70.2\n- [Fix] Reduce size\n\n4.70.1\n- [Fix] Track numbering\n\n4.70.0\n- [Feat] Added more to Web GUI\n- [Feat] Added timer options to Web GUI\n- [Fix] Web GUI resume\n- [Fix] Man window tabs order\n\n4.69.0\n- [Feat] Playlist Tab (play, queue, edit)\n- [Feat] Removed playlist related windows\n- [Feat] Playlists support URLs (youtube and soundcloud)\n- [Fix] Better email link\n\n4.68.3\n- [Fix] Folder dir as album cover\n- [Fix] Improved error handling\n\n4.68.2\n- [Fix] Track number not showing\n\n4.68.1\n- [Fix] Settings field\n\n4.68.0\n- [Feat] Added format template in settings.json\n- [Fix] Queue formatting\n\n4.67.0\n- [Feat] Queue all button\n- [Feat] Show track number\n- [Fix] Play file queueing 1+ files\n- [Fix] Enable folder cover override\n- [Fix] Disable folder context menu\n\n4.66.0\n- [Feat] Exit command\n- [Feat] Shutdown API\n\n4.65.24\n- [Fix] Random crashes\n- [Fix] Discord Rich Presence\n\n4.65.23\n- [Fix] Improved command-line argument updating\n\n4.65.22\n- [Fix] Play / queue from explorer\n\n4.65.21\n- [Feat] Command line supports playlist names\n\n4.65.20\n- [Fix] Playing at launch\n- [Fix] Better local volume control\n\n4.65.19\n- [Fix] Play / queue from explorer for multiple files\n\n4.65.18\n- [Fix] Play / queue from explorer\n\n4.65.17\n- [Feat] Folder cover override option\n- [Feat] Queue from explorer\n- [Fix] Remove folder context menu\n- [Fix] Play from explorer\n\n4.65.16\n- [Fix] Added first run notification\n\n4.65.15\n- [Fix] Improved command line\n\n4.65.14\n- [Fix] Album art bugs\n\n4.65.13\n- [Fix] Main window queue bug\n\n4.65.12\n- [Fix] .wav file bug\n\n4.65.11\n- [UI] Improved queue look\n\n4.65.10\n- [Fix] Better folder action sorting\n\n4.65.9\n- [Fix] Discord RPC\n\n4.65.8\n- [Fix] Antivirus False Positives\n\n4.65.7\n- [Misc] Updated icon\n\n4.65.6\n- [Fix] Play URL Next from tray if something is playing\n\n4.65.5\n- [Fix] Validate color codes in settings\n\n4.65.4\n- [Fix] Validate color codes in settings\n\n4.65.3\n- [Fix] Switch device LIVE audio\n\n4.65.2\n- [Fix] Roll back multi folder dialog\n\n4.65.1\n- [Fix] Exit behaviour\n\n4.65.0\n- [Feat] URL actions links pasted by default\n\n4.64.25\n- [Feat] Multi dir support\n- [Fix] Weird looking folder selection\n\n4.64.24\n- [Fix] File action GUI queue population (not noticeable)\n\n4.64.23\n- [Fix] Getting files in folders\n- [Fix] Folder action GUI queue population\n\n4.64.22\n- [Fix] Switching to local device\n\n4.64.21\n- [Fix] Random stopping\n\n4.64.20\n- [Fix] Show default art when stopped\n\n4.64.19\n- [Fix] Background tasks fixes\n- [Fix] YouTube thumbnails\n\n4.64.18\n- [Fix] Update on exit\n\n4.64.17\n- [UI] Better Mini Mode\n- [Optimized] Portable updater in C#\n- [Fix] Double Instance (Faster now too)\n\n4.64.16\n- [Fix] Update on exit\n\n4.64.15\n- [Fix] Main GUI length parameter\n\n4.64.14\n- [Fix] Better logging\n\n4.64.13\n- [Fix] Better logging (smaller log file)\n\n4.64.11\n- [Fix] Premature stopping\n\n4.64.10\n- [Fix] Better logging\n\n4.64.9\n- [Fix] !show album art\n\n4.64.8\n- [Fix] Album art mini mode\n- [Fix] Persistent Queues\n\n4.64.7\n- [Fix] Automated uploading\n\n4.64.6\n- [Fix] Improved local audio player\n- [Fix] Improved logging\n\n4.64.5\n- [Fix] Offline playback\n- [Fix] Starting with arguments\n- [Fix] Settings modal Web GUI\n\n4.64.4\n- [Fix] Improved behaviour with unresponsive devices pt. 1\n- [Fix] Improved debugging\n\n4.64.3\n- [Fix] Better exit behaviour\n\n4.64.2\n- [Fix] Main GUI queue rendering\n- [Fix] Log file location\n\n4.64.1\n- [Fix] Main GUI queue rendering\n\n4.64.0\n- [Feat] Save queue as a playlist\n- [Feat] Update on exit\n- [Feat] Notify when updates available\n- [UI] Better play URL window\n- [UI] Move Mini Mode button above clear queue\n- [Optimized] Better next and prev track from keyboard shortcuts\n- [Fix] Mini-mode keyboard shortcuts\n- [Fix] Mini-mode not respecting show album art setting\n- [Fix] Stop from the tray\n\n4.63.11\n- [Fix] Reset progress\n- [Fix] Switching device bug\n- [Fix] Improved logging\n\n4.63.10\n- [Fix] Better keyboard pause/resume handling\n- [Fix] Save Mini-mode position\n- [Fix] Progress bar text\n- [UI] Converted 3 buttons into images\n\n4.63.9\n- [Fix] Main Window UX\n\n4.63.8\n- [Fix] Mini mode sliders\n- [UI] Check for updates periodically\n\n4.63.7\n- [UI] Playlist drop-down only shows if playlists exist\n- [UI] Better Vertical GUI\n- [Fix] No album art setting\n\n4.63.6\n- [Fix] Volume scrubbing in mini mode\n\n4.63.4\n- [Fix] Metadata again\n- [Fix] If files don't exist\n- [Fix] Live Audio\n\n4.63.3\n- [Fix] Seeking in mini-mode\n- [Fix] Metadata\n\n4.63.2\n- [Fix] Play tracks\n\n4.63.1\n- [Fix] Settings tooltip\n\n4.63.0\n- [Fix] Playing an updated file\n- [UI] Vertical UI\n- [UI] Mini mode UI\n- [UI] Album art is optional\n\n4.62.2\n- [Fix] Main GUI\n\n4.62.1\n- [Fix] Live Audio uses default output device now\n- [Fix] Live Audio follows default output device\n- [Fix] Stop() fix\n\n4.62.0\n- [UI] Album art in Main Window\n- [UI] Better buttons with images\n\n4.61.1\n- [Fix] GUI when playing live audio\n\n4.61.0\n- [Feat] Cast Live System Audio\n\n4.60.8\n- [Web GUI] Added \"Refresh Devices\" to settings modal\n- [UI] More tray play URL options\n- [Optimized] Reduced memory footprint\n- [Optimized] File indexing\n- [Fix] Play folder window\n- [Fix] Corrupt file handling\n- [Fix] Internet cutting out scenarios\n\n4.60.7\n- [Fix] Pausing / Resuming local playback bugs\n- [Fix] Play file with new instance\n\n4.60.6\n- [Fix] Refresh Devices\n\n4.60.5\n- [Fix] Maxing volume on local device\n\n4.60.4\n- [Fix] Main window queuing\n\n4.60.3\n- [Fix] Mute behaviour\n\n4.60.2\n- [UI] Better UI styling\n- [Fix] Timer error text\n- [Fix] Support for .mpeg files\n\n4.60.1\n- [Fix] Discord Presence Errors\n- [Fix] Better Registry Modifications\n\n4.60.0 (August 2020)\n- [Feat] Register Music Caster in the registry as a program to open audio files and folders\n\n4.59.5\n- [Optimized] Delete old files when updating\n\n4.59.4\n- [Optimized] images folder no longer needed\n- [Fix] Album art not showing on devices\n\n4.59.3\n- [UI] Slightly better icon\n- [UI] New start menu tile (matte black style)\n- [UI] Renamed Song to Track\n- [UI] Better queue modification experience\n- [Fix] Move track up in the queue\n- [Fix] Web GUI selected device\n\n4.59.2\n- [Dependency] Updated youtube-dl\n\n4.59.1\n- [Fix] Change device\n\n4.59.0\n- [Feat] Better progress bar\n- [Feat] Support for all formats locally (thanks to VLC bindings)\n- [Feat] 32-bit support\n\n4.58.0\n- [Feat] See and download available files through /files/\n\n4.57.0\n- [Feat] Play URL supports SoundCloud\n- [Fix] Play URL\n- [Fix] WAV files\n\n4.56.4\n- [Fix] Music Queue with non-existent files\n\n4.56.3\n- [Fix] Switching device after a long time\n\n4.56.2\n- [Fix] Cancelling file selector closes the window\n\n4.56.1\n- [UI] Made buttons wider\n\n4.56.0\n- [UI] Merged timer window as a tab on the main window\n- [UI] Added Queue URL to the main window\n- [UI] Added Ctrl + {1, 2, 3} for main window tab control\n- [UI] Use Ctrl + Shift + Alt + M to launch main window\n- [UI] Added fast-forward, rewind, and pause keyboard shortcuts (L, J, K)\n- [UI] Added more play options to the main window\n- [Optimized] Cached YouTube URLs\n- [Optimized] Even faster startup\n- [Fix] Made populate and save session queues mutually exclusive\n- [Fix] Queue files\n- [Fix] Get metadata\n- [Fix] Discord rich presence\n- [Fix] Playlist Selector and Editor\n\n4.55.0\n- [Optimized] Reduced installation size\n- [Fix] Error handling\n- [Fix] Resume playback after long time\n\n4.54.8\n- [Fix] Auto-updating\n\n4.54.7\n- [UI] Sort cast devices by groups first\n\n4.54.6\n- [Fix] Cast groups detection (zeroconf == 0.24.5)\n\n4.54.5\n- [Fix] Exit after downloading update\n\n4.54.4\n- [Fix] No metadata WAV files\n\n4.54.2\n- [Fix] Auto-update\n\n4.54.1\n- [Fix] Auto-update\n- [Feat] Better url support\n\n4.54.0\n- [Feat] Change device via web GUI\n\n4.53.1\n- [Optimized] Faster startup time (-70%)\n- [Fix] Auto update\n\n4.53.0\n- [UI] Removed ugly border from buttons\n- [UI] Merged Tab's 1 and 2 for Main Window\n- [UI] Merged Settings with Main Window\n- [Optimized] Settings Window\n- [Optimized] Faster update checking\n- [Optimized] Better Portable Updater\n- [Fix] Queue file\n- [Fix] Save window positions\n\n4.52.0\n- [Feat] Play URL works alright now (youtube only)\n- [Fix] Web shortcut icons\n\n4.51.3\n- [Optimized] Main Tray\n- [UI] Swapped \"Controls\" and \"Play\" in the tray menu\n- [UI] New text color #d7d7d7 (either delete settings.json or enter it manually)\n- [Fix] Startup error if MC is already running\n- [Fix] Population/Save queue will load queue now\n\n4.51.2\n- [Fix] Settings loading\n\n4.51.1\n- [Fix] Main window closing after cancelling  queue file\n\n4.51.0\n- [Feat] Populate queue on startup\n- [Feat] Save queue between sessions\n\n4.50.2\n- [Fix] Startup error\n\n4.50.1\n- [Optimized] Threaded file selection windows\n- [Fix] Play Folder\n\n4.50.0\n- [Feat] Music library will build in the background\n- [Feat] Added \"Refresh Library\" to tray\n\n4.49.5\n- [Fix] Better update error handling\n\n4.49.4\n- [Fix] Pause bug\n- [Fix] Main Window file picking close\n\n4.49.3\n- [Fix] Tooltips\n- [Fix] Play All\n\n4.49.2\n- [Fix] Discord rich presence bug\n\n4.49.0\n- [Feat] Added do nothing to timer\n- [Fix] .opus files\n\n4.48.1\n- [Fix] Icon Quality\n\n4.48.0\n- [Feat] Improved Quality of Icon\n\n4.47.1\n- [Fix] Forgot to update version\n\n4.47.0\n- [Feat] Reorganized Main Window Tab 2\n- [Feat] Added support for .wma files (cast only)\n\n4.46.0\n- [Feat] Queue file now supports multiple files\n- [Feat] Play Youtube links (EXPERIMENTAL)\n- Does not support pause, skip, or any repeating\n- [Optimized] Settings file loading\n\n4.45.0\n- [Feat] Scroll to the playing song for Web GUI music queue\n\n4.44.0\n- [Feat] Added support for .wav\n- [Fix] Better metadata logic\n\n4.43.3\n- [Fix] Web GUI style fix\n\n4.43.2\n- [Fix] Volume control actually works now\n\n4.43.1\n- [Fix] Auto-update will be disabled if something goes wrong while updating\n\n4.43.0\n- [Feat] Added View queue to Web GUI\n- [Feat] Added Volume Control to Web GUI\n\n4.42.2\n- [Feat] Changed default setting for discord RPC to false\n- [Feat] Web GUI title now includes the PC's name\n\n4.42.1\n- [Fix] Shortcut creation\n\n4.42.0\n- [Feat] Added setting UI to Web GUI\n\n4.41.3\n- [Fix] Music Queue double click to play\n\n4.41.2\n- [Fix] Web GUI\n\n4.41.1\n- [Feat] Web GUI on mobile doesn't make the keyboard come up\n\n4.41.0\n- [Feat] Added support for {*.flac,*.m4a,*.mp4,*.aac,*.ogg,*.opus,*.wav} (for non-local devices)\n\n4.40.0\n- [Optimized] Reduced startup time by ~2 seconds\n- [Optimized] No more copying files from different drives, the solution was so simple but took ~10 months to find!\n- [Fix] Stopped Music Caster collisions between two devices on the same network\n- [Security] Server will only serve music files and only if the correct UUID is passed\n\n4.39.5\n- [Optimized] Instance checker, startup time reduce by ~9 seconds!\n- [Fix] Reverted how keyboard commands work\n\n4.39.4\n- [Fix] Music queue remove\n\n4.39.3\n- [Fix] Music queue\n\n4.39.2\n- [Fix] Music queue\n- [Fix] Better email hyperlink\n\n4.39.1\n- [Fix] Web GUI\n\n4.39.0\n- [Feat] Added search to web GUI\n- [Feat] Better mobile web GUI\n\n4.38.1\n- [Fix] Handled more metadata errors\n- [Fix] Installer installs all files now\n\n4.38.0\n- [Feat] Can now play files from the web GUI\n- [Fix] Removed duplicate code\n- [Fix] Better Chromecast detection\n\n4.37.0\n- [Feat] Added notification if MC was updated\n- [Feat] Double click a song in the music queue to play the song\n- [Feat] Locate File moved to second tab\n- [Fix] Opening Music Caster a second time\n\n4.36.1\n- [Fix] Handled HeaderNotFoundError\n\n4.36.0\n- [Optimized] Embedded Updater (+ updating notification)\n- [Optimized] Less cpu usage when idle\n\n4.35.0\n- [Optimized] Image assets\n- [Feat] Main GUI volume image acts like mute/unmute button\n- [Fix] Settings window error\n\n4.34.2\n- [Fix] Add folder works with save window positions\n\n4.34.1\n- [Fix] Errors are actually sent to me now\n\n4.34.0\n- [Feat] Added repeat off option (click repeat button 2x)\n- [Feat] Added \"Save window positions\" setting\n- [Optimized] Custom Chromecast finder (email me if MC doesn't detect any chromecasts)\n- [Optimized] Better CPU usage\n- [Optimized] More threading\n\n4.33.3\n- [Fix] lag caused by Discord RPC\n\n4.33.2\n- [Feat] Added tooltips for the QR Code and web GUI link\n- Removed \"Email:\" text and added tooltip to the email link\n\n4.33.1\n- [Fix] Discord RPC bug when using web GUI\n\n4.33.0\n- [Feat] Added QR Code to settings for quick access to the web GUI\n- [Fix] Error when handling exception\n- [Fix] Web GUI when no file is playing\n\n4.32.3\n- [Fix] Updater.exe AGAIN\n- [Feat] Added basic optional Discord RPC integration\n- [Fix] playlist editor window (window position is not saved for now)\n\n4.31.0\n- [Optimized] Executables size and startup time\n- [Optimized] Portable updater now in C#\n- [Feat] Window positions are now remembered\n- [Feat] Added some tooltips\n- [Fix] tray menu updates again if repeat is pressed\n\n4.30.4\n- [Fix] Main GUI and shortcut creation\n\n4.30.3\n- [Fix] Playlist editor arrow key handler\n\n4.30.2\n- [Feat] Beefed up error messages that are sent to me\n- [Fix] Better temp music folder handling\n\n4.30.1\n- [Feat] You can now open a file with Music Caster\n- File handling in the context menu\n\n4.29.0\n- [Feat] Added \"Queue Folder\" to the main GUI (Tab 2)\n- [Feat] Added \"Clear Queue\" to main GUI (Tab 2)\n- [Fix] Handled ID3 error when playing files\n- Reordered tray play menu\n\n4.28.0\n- [Feat] Added play folder to tray (found under tray play menu)\n- Moved Playlists to Play tray menu\n\n4.27.4\n- [Fix] Fixed Web GUI album art\n\n4.27.3\n- [Fix] Volume scrolling on main window popup\n- [Error handling] Updater raises fewer errors\n- [Fix] Error handling bug\n\n4.27.2\n- [Fix] Better handling of errors\n- [Fix] Play file works even without any folders\n\n4.27.1\n- Better duplicate detection\n- Fixed compile all songs bugs\n\n4.27.0\n- Select device no devices Fix\n- WEB GUI fix\n- Better Auto-update\n\n4.26.2\n- Fixed bug\n\n4.26.1\n- Removed irrelevant data that was sent to me (install folder)\n- Added POST request so I know how many users use Music Caster\n\n4.26.0\n- Better Play All (+ fixed a bug I created in the process before releasing)\n- Because of this bug, I have added a helper function to time my functions\n- Optimized `change_settings` (faster skips, although negligible)\n- Building a GUI tab to show all music\n- Update Web UI (looks good on mobile now, hard to access though)\n- Changed setup name\n- Fixed auto-update error handling (ironic)\n- Fixed volume not changing when scrolling bug\n\n4.25.0\n- Added locate file option to main GUI (folder icon)\n- Fixed play file next bug\n- Updated timer window GUI\n\n4.24.4\n- Fixed volume scrolling behavior\n- Removed volume from settings window (use the main window instead)\n\n4.24.3\n- Better error handling\n- Added more error logging information\n\n4.24.2\n- Scrolling in the  settings or main window by default changes volume\n\n4.24.1\n- Fixes\n\n4.24.0\n- Web GUI fully functional\n- ID3 tags reading fix\n- Fixed song position bug\n\n4.23.0\n- Volume and scrubbing now support mouse scrolling!\n- Settings window \"Enable notifications\" -> \"Notifications\"\n- Tray tooltip now says \"Download Update...\" instead of disappearing\n- Fixed restart on error?\n\n4.22.4\n- Fixed IndexError's when no file sin queue\n\n4.22.3\n- Fixed bugs to do with changing the music queue\n\n4.22.2\n- Fixed web access\n- Fixed song position bug (maybe)\n- Fixed port conflict bug\n\n4.22.1\n- Added button text color option\n- Fixed bug (reactivating a window through the tray)\n- Fixed volume slider bug\n\n4.22.0\n- New main window update\n- Added image for the repeat button\n- Added volume control\n- Added music queue control\n- GUI volume slider's update when the volume is updated through the home app\n- Added a checkmark next to repeat tray menu if the repeating song is True\n- Main GUI is now accessible to all! Just click the icon\n- Change accent color\n- Added settings.json options to change accent color (requires restart)\n- Fixed keyboard shortcut option\n\n4.21.2\n- bigger try-except to catch more bugs\n\n4.21.1\n- fixed bug where \"open settings.json\" would fail if the user had no JSON file handler\n\n4.21.0\n- Errors are now sent to me automatically\n- Information that is shared with me\n    - Music Caster Version\n    - OS platform and version\n    - Traceback error message\n    - (see bottom of music_caster.py)\n- Updated error.log to include Music Caster's version\n\n4.20.1\n- Fixed no response bug when you switch devices while song is paused\n\n4.20.0\n- Works if song is scrubbed from home app\n- Better song timing (when the next song will play)\n\n4.19.1\n- FIXED: now playing text (MAIN GUI) was displaying song when playback was stopped\n- Can now use Up/Down and Page Up/Down to move through music queue GUI (EXPERIMENTAL)\n- CPU won't spike anymore for a few seconds when trying to play something on cast device\n\n4.19.0\n- Changed tooltip to have song info\n- Tray shows which device is currently selected\n\n4.18.6\n- Support for subfolders when using \"Play All\"\n\n4.18.5\n- Fixed bug where all devices showed the same name\n\n4.18.4\n- Minor refactoring + change in selecting device logic\n- Change in chromecast devices sorting criteria (alpha, then UUID)\n\n4.18.3\n- Sorted device list alphabetically with (local device) being the exception\n\n4.18.2\n- Fixed multiple Chromecasts bug\n\n4.18.1\n- Fixed timer to work with improved performance\n- Changed \"Stop Timing\" to \"Cancel Timer\"\n\n4.18.0\n- Added \"Locate File\" feature to Controls tray menu\n- Added changelog\n\n4.17.27\n- Streamlined controls\n- New EXPERIMENTAL keyword in settings.json (set to true for main window access)\n\n4.17.26\n- Fixed bug to do with wanting to create/edit a playlist name with 'q'\n- Streamlined Play options (Play File, Play a File Next -> Play File Next, Play All) to be in a cascaded menu\n- Improved performance (theoretically) by not reading the tray if the program is not in use\n- Edited settings window to not have a copy button anymore, makes life harder than just clicking the email hyperlink\n- Updated GUI library to the latest\n- Fixed bug with main GUI\n- Main GUI is almost done, test it out by putting `\"EXPERIMENTAL\": True` in `settings.json`\n\n4.17.25\n- Fixed chromecast buffering bug that would screw up when the next song would start playing\n\nWow you scrolled all the way to the end.\nPre v4.17.25 changes found here https://github.com/elibroftw/music-caster/releases?after=v4.17.25\n"
  },
  {
    "path": "Dockerfile",
    "content": "# this images allows building music caster into a folder that can be run\n#\nFROM fedora:latest\nENV PY=python3.14\nENV PIP_ROOT_USER_ACTION=ignore\n# install required packages\nRUN dnf upgrade -y && dnf install -y \\\n    $PY $PY-devel $PY-virtualenv python3-devel python3-tkinter python3-pyaudio \\\n    dnf-plugins-core libappindicator-gtk3 binutils\n# install some dependencies here to reduce the dependencies installed at run time\nCOPY requirements.txt build_files/ music-caster/\nRUN $PY -m pip install --upgrade pip && $PY -m pip install pyaudio\nRUN cd music-caster && $PY -m pip install --upgrade -r requirements.txt\nRUN rm -rf ./music-caster\n# when running this image, need to mount the work directory to /var/music-caster\nCMD if [ ! -d /var/music-caster ] ; then git clone https://github.com/elibroftw/music-caster/ /var/music-caster ; fi && cd /var/music-caster && \\\n    $PY -m pip install --upgrade pip && \\\n    $PY -m pip install --upgrade -r requirements.txt && \\\n    $PY -m pip install -r requirements-dev.txt && \\\n    $PY -O -m PyInstaller build_files/onedir.spec && \\\n    echo \"done! check your dist folder\"\n    # TODO: create AppImage from dist/Music Caster OneDir\n"
  },
  {
    "path": "LICENSE",
    "content": "Copyright © Elijah Lopez\n\nYou cannot sell Music Caster, unless there's an at least 50% royalty payment to the majority contributor (Elijah Lopez).\nAny modified version of Music Caster cannot be sold, unless again there's a 50%+ royalty\nto the majority contributor of the original version (Elijah Lopez).\nAny use of source code from Music Caster for the purpose of selling a competitor to Music Caster\nshall also have to meet a 50%+ royalty payment to the projects majority contributor (Elijah Lopez).\n\n(Obviously) No contributor of Music Caster is responsible for any\ndamages caused by Music Caster, if any occur. This is not confirmation that damages are even remotely likely,\nit is simply a statement to protect contributors of this FREE and OPEN software.\n\nThis license cannot restrict you from modifying the source code, compiling, and running it on your machine.\nThis license also cannot restrict you from distributing PATCHES for Music Caster.\n\nThis license is to be understood by spirit and motivation not by its letter.\n"
  },
  {
    "path": "README.md",
    "content": "\n<p align=\"center\"><img src=\"https://user-images.githubusercontent.com/21298211/171323258-5818355a-2c55-444b-8d0d-b0e3feee36e4.png\" /> </>\n\n[![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)\n[![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/)\n\nMusic 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.\n\nDisplay languages: English, German, Spanish, French, Italian, Dutch, Russian\\*, and Ukrainian\\*\n\nUnique users as of April 23rd 2023: 3,800\n\n[Screenshots](https://elijahlopez.ca/music-caster/)\n\n### Donate or Translate\n\n- monero:84PR6SkYd5zaFLKDjAFrQfbaAg2c7SV3q3XDZ15QCpEZUggrN4YzY7n8m9XC3deXjo41yWHTm1LrsUpPTYGnRQbD9Cwp8En\n- [PayPal](https://www.paypal.me/elibroftw)\n- [Translate](https://github.com/elibroftw/music-caster/issues/12#issuecomment-808658776) Music Caster to other languages\n\n## Install\n\n### [Windows Download Music.Caster.Setup.exe](https://github.com/elibroftw/music-caster/releases/latest)\n\n- **IMPORTANT INFORMATION:** The tray icon will be in the tray, so you will need to move it to your taskbar\n- Command line installation: `winget install \"Music Caster\"`\n- [VirusTotal scan](https://www.virustotal.com/gui/file/40a1c61e5cb2c5eed714eb70bb84f138e9fd9742076ea665b4ac85fc8f372abf)\n  - If Music Caster is auto-removed, open \"Virus & threat protection\", then \"protection history,\" and restore all files related to Music Caster\n\n### Linux\n\nNot 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).\n\n```bash\nmkdir -p ~/bin && git clone --depth 1 https://github.com/elibroftw/music-caster.git ~/bin/music-caster\ncd ~/bin/music-caster\n./linux_install.sh # use sudo for non-interactive install in case a dependency needs to be installed\n```\n\n## Demo\n\n<a href=\"https://www.youtube.com/watch?v=5xwHkLPgvtQ\" title=\"Music Caster Video Demo\">\n  <p align=\"center\">\n    <img width=75% src=\"https://img.youtube.com/vi/5xwHkLPgvtQ/maxresdefault.jpg\" alt=\"Music Caster Video Demo Thumbnail\"/>\n  </p>\n</a>\n\n## Limitations\n\n- Chromecasts only support the AAC version of WMA files\n- Emojis might not work well. There's always settings.json + WEB GUI though\n- [Road Map](https://github.com/elibroftw/music-caster/projects/1)\n\n## Power User Features\n\n- Global media hot-keys are supported\n- Web GUI (QR code in Settings window)\n- [Command Line Arguments](https://github.com/elibroftw/music-caster/wiki/Command-Line-Arguments)\n\nHere are Music Caster specific keyboard shortcuts aside from the global media hot-keys.\n\n| **Shortcut**           | **Window** | **Behaviour**                  |\n|------------------------|------------|--------------------------------|\n| Ctrl + Shift + Alt + M | Global     | Activate Main Window           |\n| Ctrl + (Shift) + }     | Main       | Toggle mini-mode               |\n| Esc                    | Main       | Close Window                   |\n| Ctrl + Shift + Q       | Main       | Exit Program                   |\n| Scroll                 | Main       | Volume and Progress Bar        |\n| ⬆ / A                  | Main       | Decrease Volume by 5%          |\n| ⬇ / D                  | Main       | Increase Volume by 5%          |\n| #                      | Main       | Set Volume to # * 10%          |\n| K                      | Main       | Pause / Resume / Start Playing |\n| Shift + N              | Main       | Next Track                     |\n| Shift + P / Shift + B  | Main       | Previous Track                 |\n| J                      | Main       | Rewind 5 seconds               |\n| L                      | Main       | Fast-forward 5 seconds         |\n| Ctrl + R               | Main       | Cycle Repeat                   |\n| Ctrl + M               | Main       | Mute                           |\n| Ctrl + 1               | Main       | Go to Tab 1 (Queue)            |\n| Ctrl + 2               | Main       | Go to Tab 2 (URL)              |\n| Ctrl + 3               | Main       | Go to Tab 3 (Library)          |\n| Ctrl + 4               | Main       | Go to Tab 4 (Playlists)        |\n| Ctrl + 5               | Main       | Go to Tab 5 (Timer)            |\n| Ctrl + 6               | Main       | Go to Tab 6 (Metadata)         |\n| Ctrl + 7               | Main       | Go to Tab 7 (Settings)         |\n\n### Editing `settings.json`\n\n- I do not recommend editing unless you know what you are doing\n- Music Caster will detect changes within 10 seconds of editing `settings.json`\n- Some settings values are hidden from the GUI for good reason\n\n## Data Collection / Privacy Policy\n\nBelow is the reasonable data that is collected when errors are encountered. I'm sure other programs collect\nway more than necessary.\n\n```python\n# in handle_exception,\npayload = {'VERSION': VERSION, 'FATAL': restart_program, 'EXCEPTION TYPE': exc_type.__name__,\n           'LINE': exc_tb.tb_lineno, 'TRACEBACK': trace_back_msg, 'LOG': log_lines,\n           'MQ[0]': playing_uri, 'PLAYING_STATUS': str(playing_status), 'DEVICE': device,\n           'CWD': os.getcwd(), 'PORTABLE': not os.path.exists(UNINSTALLER),\n           'MAC': hashlib.md5(get_mac().encode()).hexdigest(), 'OS': platform.platform(), 'TIME': current_time}\n```\n\nIn addition, I collect MD5 hashed MAC addresses and IP addresses in a Google Excel Sheet.\nOnly 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.\n\n- Hashed MAC so that I know how many users without knowing the actual MAC addresses\n- IP because I can map out the IPs to a visual map to see where my users are located\n\n[Developer Guide](https://github.com/elibroftw/music-caster/wiki/Developer-Guide)\n\n### Linux Build Guide\n\n- Define correct PY variable (requires rebuilding the image)\n- Obtain the mc-builder Image\n  - Option A: `docker pull elibroftw/mc-builder`\n  - Option B: `docker build . -t elibroftw/mc-builder`\n    - Remember to have Docker desktop/daemon running already\n- Build source code using: `docker run --rm --volume .:/var/music-caster elibroftw/mc-builder`\n\n### Virtualenv\n\nYou need to use the free threading version (python3.14).\n\n```sh\npython3.14 -m venv .venv\n.venv\\Scripts\\activate     # Windows\nsource .venv/bin/activate  # Non-Windows\n```\n\n### Resources\n\n- [Generate sound waves for any audio file](https://gist.github.com/elibroftw/0fc6ed102dbe3f99863829a7e989dcc2)\n\n## Upgrading Python Version\n\nUpdate the version found in the following files\n\n- .github/workflows/build.yml\n- Dockerfile\n- README#virtualenv\n- linux_install.sh\n- scripts/debian-install.sh\n\n1. Next, in a another directory clone PyInstaller like so: `git clone git@github.com:pyinstaller/pyinstaller.git`\n2. We need to [build the bootloader](https://pyinstaller.org/en/stable/bootloader-building.html) ourselves to avoid being flagged by Anti Virus.\n3. Run `py -m build` (not in the bootloader directory)\n4. Compile [pyaudio_portaudio](https://github.com/elibroftw/pyaudio_portaudio)\n5. After this, we need to update the requirements-dev.txt.\n6. Create venv\n\nFrom here, you can test the app in the newer version of Python (need to initialize a new virtualenv)\n\nLastly, 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.\n"
  },
  {
    "path": "build.cmd",
    "content": "@echo off\nset args=%1\nshift\n:start\nif [%1] == [] goto done\nset args=%args% %1\nshift\ngoto start\n:done\npython -m venv .venv 2>NUL\n\".venv/Scripts/python.exe\" build.py %args%\n"
  },
  {
    "path": "build.py",
    "content": "#!/usr/bin/env python3\nimport argparse\nimport glob\nimport math\nimport os\nimport platform\nimport shutil\nimport sys\nimport threading\nimport time\nimport traceback\nimport zipfile\nfrom contextlib import suppress\nfrom datetime import datetime\nfrom pathlib import Path\nfrom subprocess import DEVNULL, CalledProcessError, Popen, check_call, getoutput\nfrom multiprocessing import freeze_support\n\nif __name__ == '__main__':\n    freeze_support()\n\nfrom src.meta import VERSION, USING_TAURI_FRONTEND\n\n# build constants\nscript_dir = Path(__file__).parent\nSRC_DIR = script_dir / 'src'\nDIST_DIR = script_dir / 'dist'\nbuild_files = script_dir / 'build_files'\nSETUP_OUTPUT_NAME = 'Music Caster Setup'\nVERSION_FILE = build_files / 'mc_version_info.txt'\nINSTALLER_SCRIPT = build_files / 'setup_script.iss'\nPORTABLE_SPEC = build_files / 'portable.spec'\nDAEMON_SPEC = build_files / 'daemon.spec'\nONEDIR_SPEC = build_files / 'onedir.spec'\nUPDATER_SPEC_FILE = build_files / 'updater.spec'\nCHANGELOG_FILE = script_dir / 'CHANGELOG.txt'\nTKDND_FILES = (build_files / 'tkdnd2.9.2', build_files / 'TkinterDnD2')\nUPDATER_MANIFEST_FILE = build_files / 'Updater.exe.MANIFEST'\nUPDATER_ICO = build_files / 'updater.ico'\nUPDATER_DIST = DIST_DIR / 'Updater.exe'\nREQUIREMENTS_FILE = script_dir / 'requirements.txt'\nREQUIREMENTS_DEV_FILE = script_dir / 'requirements-dev.txt'\nSRC_FRONTEND = script_dir / 'src-frontend'\n\nIS_VENV = sys.prefix != sys.base_prefix\n\n\nclass ProgressUpload:\n    # 1MB chunk size\n    def __init__(self, filename, chunk_size=1_024_000):\n        self.filename = filename\n        self.chunk_size = chunk_size\n        self.file_size = os.path.getsize(filename)\n        self.size_read = 0\n        self.divisor = min(math.floor(math.log(self.file_size, 1000)) * 3,\n                           9)  # cap unit at a GB\n        self.unit = {0: 'B', 3: 'KB', 6: 'MB', 9: 'GB'}[self.divisor]\n        self.divisor = 10**self.divisor\n\n    def __iter__(self):\n        progress_str = f'0 / {self.file_size / self.divisor:.2f} {self.unit} (0 %)'\n        sys.stderr.write(f'\\rUploading {self.filename}: {progress_str}')\n        with open(self.filename, 'rb') as file_to_upload:\n            for chunk in iter(lambda: file_to_upload.read(self.chunk_size),\n                              b''):\n                self.size_read += len(chunk)\n                yield chunk\n                sys.stderr.write('\\b' * len(progress_str))\n                percentage = self.size_read / self.file_size * 100\n                completed_str = f'{self.size_read / self.divisor:.2f}'\n                to_complete_str = f'{self.file_size / self.divisor:.2f} {self.unit}'\n                progress_str = (\n                    f'{completed_str} / {to_complete_str} ({percentage:.2f} %)'\n                )\n                sys.stderr.write(progress_str)\n                sys.stderr.flush()\n        sys.stderr.write('\\n')\n\n    def __len__(self):\n        return self.file_size\n\n\ndef read_env(env_file='.env'):\n    if not args.ci:\n        with open(env_file, encoding='utf-8') as env_file:\n            env_line = env_file.readline()\n            while env_line:\n                k, v = env_line.split('=', 1)\n                os.environ[k] = v.strip()\n                env_line = env_file.readline()\n    return os.environ\n\n\ndef add_new_changes(prev_changes: str):\n    changes = set(prev_changes.split('\\n'))\n    with open(CHANGELOG_FILE, encoding='utf-8') as _file:\n        add_changes = False\n        line = _file.readline()\n        while line:\n            line = line.strip()\n            if line == VERSION:\n                add_changes = True\n            elif add_changes:\n                if line == '':\n                    break\n                changes.add(line)\n            line = _file.readline()\n    if not add_changes:\n        # TODO: generate changelog from commits\n        #       could use AI/LLM for summarizing subjects\n        #       see modern-desktop-app\n        print(f'CHANGELOG does not contain changes for {VERSION}...')\n        if args.ci:\n            sys.exit(3)\n        input('Press enter to try again...')\n        return add_new_changes(prev_changes)\n    return '\\n'.join(sorted(changes, key=lambda item: item.casefold()))\n\n\ndef set_spec_debug(debug_option):\n    for file_name in (ONEDIR_SPEC, PORTABLE_SPEC, UPDATER_SPEC_FILE):\n        with open(file_name, 'r+', encoding='utf-8', newline='\\n') as _f:\n            new_spec = _f.read().replace(f'debug={not debug_option}',\n                                         f'debug={debug_option}')\n            new_spec = new_spec.replace(f'console={not debug_option}',\n                                        f'console={debug_option}')\n            _f.seek(0)\n            _f.write(new_spec)\n            _f.truncate()\n\n\ndef create_zip(zip_filename, files_to_zip, compression=zipfile.ZIP_BZIP2):\n    with zipfile.ZipFile(zip_filename, 'w', compression=compression) as zf:\n        for file_to_zip in files_to_zip:\n            try:\n                if type(file_to_zip) == tuple:\n                    zf.write(*file_to_zip)\n                else:\n                    zf.write(file_to_zip)\n            except FileNotFoundError:\n                print(f'{file_to_zip} not found')\n\n\ndef update_versions(version):\n    \"\"\"Update versions of version file and installer script\"\"\"\n    with open(VERSION_FILE, 'r+', encoding='utf-8', newline='\\n') as version_info_file:\n        lines = version_info_file.readlines()\n        for i, line in enumerate(lines):\n            if line.startswith('    prodvers'):\n                version_tuple = ', '.join(version.split('.'))\n                lines[i] = f'    prodvers=({version_tuple}, 0),\\n'\n            elif line.startswith('    filevers'):\n                version_tuple = ', '.join(version.split('.'))\n                lines[i] = f'    filevers=({version_tuple}, 0),\\n'\n            elif line.startswith(\"        StringStruct('FileVersion\"):\n                lines[\n                    i] = f\"        StringStruct('FileVersion', '{version}.0'),\\n\"\n            elif line.startswith(\"        StringStruct('LegalCopyright'\"):\n                lines[\n                    i] = f\"        StringStruct('LegalCopyright', 'Copyright (c) 2019 - {YEAR}, Elijah Lopez'),\\n\"\n            elif line.startswith(\"        StringStruct('ProductVersion\"):\n                lines[\n                    i] = f\"        StringStruct('ProductVersion', '{version}.0')])\\n\"\n                break\n        version_info_file.seek(0)\n        version_info_file.writelines(lines)\n        version_info_file.truncate()\n\n    with open(INSTALLER_SCRIPT, 'r+', encoding='utf-8', newline='\\n') as version_info_file:\n        lines = version_info_file.readlines()\n        for i, line in enumerate(lines):\n            if line.startswith('#define MyAppVersion'):\n                lines[i] = f'#define MyAppVersion \"{version}\"\\n'\n            elif line.startswith('OutputBaseFilename'):\n                lines[i] = f'OutputBaseFilename={SETUP_OUTPUT_NAME}\\n'\n                break\n        version_info_file.seek(0)\n        version_info_file.writelines(lines)\n        version_info_file.truncate()\n\n\ndef local_install():\n    exe = os.getenv('LOCALAPPDATA') + '/Programs/Music Caster/Music Caster.exe'\n    cmd = [\n        str(DIST_DIR / 'Music Caster Setup.exe'),\n        '/FORCECLOSEAPPLICATIONS',\n        '/VERYSILENT',\n        '/MERGETASKS=\"!desktopicon\"',\n    ]\n    cmd.extend(('&&', exe))\n    if not player_state.get('gui_open', False):\n        cmd.append('--minimized')\n    if player_state.get('status', 'NOT PLAYING') in ('PLAYING', 'PAUSED'):\n        cmd.append('--start-playing')\n        if player_state['status'] == 'PAUSED':\n            cmd.append('--queue')\n    if position := player_state.get('position', 0) > 0:\n        cmd.append(f'--position={position}')\n    Popen(cmd, shell=True)\n\n\ndef test(title, fn, assert_statement=False):\n    try:\n        if assert_statement:\n            assert fn(), title\n        else:\n            fn()\n    except Exception as _e:\n        print('---')\n        print('TEST FAILED', title)\n        print('TEST TRACEBACK', traceback.format_exc())\n        print('---')\n        raise _e\n\n\ndef upgrade_yt_dlp():\n    latest_ytdl = 'https://api.github.com/repos/yt-dlp/yt-dlp/commits/master'\n    latest_mc = 'https://api.github.com/repos/elibroftw/music-caster/releases/latest'\n    yt_dlp_master = requests.get(latest_ytdl).json()\n    ytdl_publish = yt_dlp_master['commit']['author']['date']\n    yt_dlp_release_time = datetime.strptime(ytdl_publish, '%Y-%m-%dT%H:%M:%SZ')\n    mc_publish = requests.get(latest_mc).json()['published_at']\n    mc_release_time = datetime.strptime(mc_publish, '%Y-%m-%dT%H:%M:%SZ')\n    if mc_release_time < yt_dlp_release_time:  # latest yt-dlp not used in latest MC\n        print('New YouTube-dl found, updating Music Caster version')\n        # if youtube-dl was released after the latest music-caster, update version and publish\n        maj, _min, fix = VERSION.split('.')\n        fix = int(fix) + 1\n        new_version = f'{maj}.{_min}.{fix}'\n        with open('meta.py', 'r+', encoding='utf-8', newline='\\n') as f:\n            # VERSION = latest_version = '5.0.0'\n            new_txt = f.read().replace(\n                f\"VERSION = latest_version = '{VERSION}'\",\n                f\"VERSION = latest_version = '{new_version}'\",\n            )\n            f.seek(0)\n            f.write(new_txt)\n        with open(CHANGELOG_FILE, 'r+', encoding='utf-8', newline='\\n') as f:\n            content = ''.join(\n                (f.readline(), f'\\n{VERSION}\\n- Upgrade dependencies\\n',\n                 f.read()))\n            f.seek(0)\n            f.write(content)\n        update_versions(new_version)\n        # commit and push change\n        from git import Repo\n\n        repo = Repo('.git')\n        repo.git.add(update=True)\n        repo.index.commit('Upgraded yt-dlp')\n        origin = repo.remote(name='origin')\n        origin.push()\n\n\nif __name__ == '__main__':\n    start_time = time.time()\n    starting_dir = os.path.dirname(os.path.abspath(sys.argv[0]))\n    os.chdir(starting_dir)\n    # CONSTANTS\n    YEAR = datetime.today().year\n\n    parser = argparse.ArgumentParser(description='Music Caster Build Script')\n    parser.add_argument(\n        '--debug',\n        '-d',\n        default=False,\n        action='store_true',\n        help='build as console app + debug=True',\n    )\n    parser.add_argument(\n        '--ver_update',\n        '-v',\n        default=False,\n        action='store_true',\n        help=\"Only update build files' version\",\n    )\n    parser.add_argument(\n        '--clean',\n        '-c',\n        default=False,\n        action='store_true',\n        help='Use pyinstaller --clean flag',\n    )\n    parser.add_argument(\n        '--upload',\n        '-u',\n        '--publish',\n        default=False,\n        action='store_true',\n        help='Upload and Publish to GitHub after building',\n    )\n    parser.add_argument(\n        '--skip-build',\n        '--no-build',\n        default=False,\n        action='store_true',\n        help='Skip to testing / uploading',\n    )\n    parser.add_argument('--skip-tests',\n                        '--no-tests',\n                        '--st',\n                        default=False,\n                        action='store_true',\n                        help='Skip testing')\n    parser.add_argument(\n        '--force-install',\n        '-f',\n        default=False,\n        action='store_true',\n        help='Force install after build',\n    )\n    parser.add_argument(\n        '--deps',\n        '--dry',\n        default=False,\n        action='store_true',\n        help='does not modify anything',\n    )\n    parser.add_argument(\n        '--test-auto-update',\n        default=False,\n        action='store_true',\n        help='use if testing auto update',\n    )\n    parser.add_argument(\n        '--skip-deps',\n        '--no-deps',\n        '-i',\n        default=False,\n        action='store_true',\n        help='skips installation of dependencies',\n    )\n    parser.add_argument(\n        '--no-install',\n        default=False,\n        action='store_true',\n        help='do not install after building',\n    )\n    parser.add_argument(\n        '--ytdl',\n        default=False,\n        action='store_true',\n        help='version++ if new youtube-dl available',\n    )\n    parser.add_argument(\n        '--ci',\n        default=False,\n        action='store_true',\n        help='if running in a CI do not prompt just fail',\n    )\n    args = parser.parse_args()\n\n    if args.clean:\n        shutil.rmtree(DIST_DIR, True)\n        shutil.rmtree('build', True)\n        for file in glob.iglob('*.log'):\n            os.remove(file)\n        sys.exit()\n\n    if args.deps:\n        print('Installing Music Caster dependencies')\n    else:\n        print('Building Music Caster')\n\n    if args.ytdl:\n        upgrade_yt_dlp()\n    else:\n        update_versions(VERSION)\n    print('Updated versions of build files')\n    if args.ver_update:\n        sys.exit()\n    install_to_user = '' if IS_VENV else '--user'\n    pip_cmd = f'\"{sys.executable}\" -m pip install --upgrade {install_to_user} --upgrade-strategy eager -r \"{REQUIREMENTS_FILE}\" -r \"{REQUIREMENTS_DEV_FILE}\"'\n    if args.deps or (not args.skip_build and not args.skip_deps):\n        print('Installing and/or upgrading dependencies...')\n        if platform.system() == 'Windows':\n            # install tkdnd custom way\n            sys_dir_name = Path(sys.executable).parent\n            if IS_VENV:\n                shutil.copytree(\n                    TKDND_FILES[0],\n                    f'{sys_dir_name}/tcl/tkdnd2.9.2',\n                    dirs_exist_ok=True,\n                )\n                shutil.copytree(\n                    TKDND_FILES[1],\n                    f'{sys_dir_name.parent}/Lib/site-packages/TkinterDnD2',\n                    dirs_exist_ok=True,\n                )\n            else:\n                shutil.copytree(TKDND_FILES[0],\n                                f'{sys_dir_name}/tcl/tkdnd2.9.2',\n                                dirs_exist_ok=True)\n                shutil.copytree(\n                    TKDND_FILES[1],\n                    f'{sys_dir_name}/Lib/site-packages/TkinterDnD2',\n                    dirs_exist_ok=True,\n                )\n        install_time_start = time.time()\n        p = Popen(pip_cmd, stdin=DEVNULL, stdout=None if args.deps else DEVNULL, text=True)\n        if p.wait() > 0:\n            print(\n                'ERROR: pip install failed\\n',\n                pip_cmd,\n            )\n            sys.exit(1)\n        if args.deps:\n            print(f'Dependencies installed ({time.time() - install_time_start:.0} seconds)')\n            sys.exit()\n    assert IS_VENV\n    # import third party libraries\n    import requests\n    from git import Repo\n\n    sys.argv = sys.argv[:1]\n    from src.shared import get_running_processes, is_already_running\n\n    args.upload = args.upload and not args.test_auto_update\n    try:\n        player_state = requests.get('http://[::1]:2001/state').json()\n        requests.get('http://[::1]:2001/exit')\n        time.sleep(1)  # wait for MC to exit\n    except requests.exceptions.RequestException:\n        player_state = {}\n    for process in get_running_processes('Music Caster.exe'):\n        # force close any other instances of MC\n        pid = process['pid']\n        with suppress(PermissionError):\n            os.kill(pid, 9)\n    if args.debug:\n        set_spec_debug(True)\n    else:\n        set_spec_debug(False)\n    if args.upload:\n        print('Will upload to GitHub after building')\n    if args.test_auto_update:\n        print(\"This test should test auto update and won't publish to GitHub\")\n\n    if not args.skip_build:\n        # remove existing builds\n        try:\n            with suppress(FileNotFoundError):\n                shutil.rmtree('dist/Music Caster OneDir', False)\n        except PermissionError:\n            print('Files in \"dist/Music Caster OneDir\" are in use somehow')\n            sys.exit()\n        main_file = 'Music Caster'\n        if platform.system() == 'Windows':\n            main_file += '.exe'\n        for dist_file in (main_file, f'{SETUP_OUTPUT_NAME}.exe',\n                          'Portable.zip'):\n            with suppress(FileNotFoundError):\n                dist_file = DIST_DIR / dist_file\n                print(f'Removing {dist_file}')\n                os.remove(dist_file)\n\n    if not args.skip_build:\n        print(f'building executables with debug={args.debug}')\n        additional_args = '--log=DEBUG' if args.debug else ''\n        if args.clean:\n            additional_args += ' --clean'\n\n        if USING_TAURI_FRONTEND:\n            # Only build daemon for Tauri frontend\n            print('Building daemon for Tauri frontend...')\n            check_call(\n                f'{sys.executable} -O -m PyInstaller -y {additional_args} {DAEMON_SPEC}',\n                shell=True,\n            )\n            s1 = None\n            s4 = None\n        else:\n            # build frontend\n            # check_call('yarn build', cwd=SRC_FRONTEND, shell=True)\n            if platform.system() == 'Windows':\n                s1 = Popen(\n                    f'{sys.executable} -O -m PyInstaller -y {additional_args} {PORTABLE_SPEC}',\n                    shell=True,\n                )\n            else:\n                s1 = None\n            try:\n                # build Updater\n                # install go dependencies\n                check_call('go install github.com/akavel/rsrc@latest')\n                check_call(\n                    f'rsrc -manifest \"{UPDATER_MANIFEST_FILE}\" -ico \"{UPDATER_ICO}\"'\n                )\n                check_call(\n                    f'go build -ldflags \"-s -w -H windowsgui\" -o \"{UPDATER_DIST}\"',\n                    cwd=SRC_DIR)\n            except Exception as e:\n                if args.upload:\n                    raise Exception('failed to build updater') from e\n                print(f'WARNING: {e}')\n            check_call(\n                f'{sys.executable} -O -m PyInstaller -y {additional_args} {ONEDIR_SPEC}',\n                shell=True,\n            )\n            try:\n                if platform.system() == 'Windows':\n                    s4 = Popen(f'iscc \"{INSTALLER_SCRIPT}\"')\n                else:\n                    s4 = None\n            except FileNotFoundError:\n                s4 = None\n                print(\n                    'WARNING: could not create an installer because iscc is not installed or is not on PATH'\n                )\n\n            try:\n                portable_failed = s1.wait()\n            except AttributeError:\n                portable_failed = False\n            if args.debug:\n                set_spec_debug(False)\n            if portable_failed:\n                print('Portable installation failed')\n                print(s1.communicate()[1])\n                sys.exit()\n\n        # Portable\n        if platform.system() == 'Windows':\n            music_caster_portable = (DIST_DIR / 'Music Caster.exe',\n                                     'Music Caster.exe')\n            updater_portable = (DIST_DIR / 'Updater.exe', 'Updater.exe')\n            portable_files = [\n                music_caster_portable,\n                (CHANGELOG_FILE, 'CHANGELOG.txt'),\n                updater_portable,\n            ]\n            vlc_ext = 'dll' if platform.system() == 'Windows' else 'so'\n            print('Creating dist/Portable.zip')\n            create_zip(\n                DIST_DIR / 'Portable.zip',\n                portable_files,\n                compression=zipfile.ZIP_DEFLATED,\n            )\n        # zip directory for Linux or Darwin\n        elif platform.system() == 'Darwin':\n            pass\n        else:\n            shutil.rmtree(DIST_DIR / 'Music Caster OneDir/share/')\n            linux_dist = DIST_DIR / 'Music Caster (Linux)'\n            print(f'Creating {linux_dist}.zip')\n            shutil.make_archive(linux_dist, 'zip', 'dist/Music Caster OneDir')\n        with suppress(AttributeError):\n            s4.wait()  # Wait for InnoSetup script to finish\n        print(f'v{VERSION} Build Time:', round(time.time() - start_time, 2),\n              'seconds')\n        print('Last commit: ' + getoutput('git log --format=\"%H\" -n 1'))\n\n    if platform.system() == 'Windows':\n        dist_files = ('Music Caster Setup.exe', 'Portable.zip')\n    elif platform.system() == 'Darwin':\n        dist_files = ('Music Caster (OSX).zip', )\n    else:\n        dist_files = ('Music Caster (Linux).zip', )\n\n    # check if all files were built\n    dist_files_exist = True\n    for dist_file in dist_files:\n        dist_file_path = DIST_DIR / dist_file\n        if os.path.exists(dist_file_path):\n            file_size = os.path.getsize(dist_file_path) // 1000  # KB\n            file_exists_str = f'EXISTS {file_size:,} KB'.rjust(12)\n        else:\n            file_exists_str = 'DOES NOT EXIST!'\n            dist_files_exist = False\n        print((dist_file + ':').ljust(30) + file_exists_str)\n\n    if dist_files_exist and platform.system() == 'Windows':\n        with zipfile.ZipFile(DIST_DIR / 'Portable.zip') as portable_zip:\n            if 'Updater.exe' in portable_zip.namelist():\n                print('Portable.zip/Updater.exe:'.ljust(30) + 'EXISTS')\n            else:\n                print('Portable.zip/Updater.exe:'.ljust(30) +\n                      'DOES NOT EXIST!')\n                dist_files_exist = False\n\n    daemon_dist = DIST_DIR / 'Music Caster Daemon.exe'\n    if daemon_dist.exists() and USING_TAURI_FRONTEND:\n        shutil.copy2(daemon_dist, DIST_DIR / 'music-caster-daemon-x86_64-pc-windows-msvc.exe')\n\n    if not args.skip_tests and dist_files_exist and not USING_TAURI_FRONTEND:\n        try:\n            sys.argv = sys.argv[:1]\n            pytest_args = ['pytest']\n            if args.upload:\n                pytest_args.append('--upload')\n            if args.test_auto_update:\n                pytest_args.append('--test-auto-update')\n            if args.ci:\n                pytest_args.append('--ci')\n            pytest_args.append('--capture=no')\n            check_call(pytest_args, cwd=SRC_DIR)\n        except CalledProcessError:\n            print('pytest: failed')\n            sys.exit(1)\n        # Test if executable can be run\n        import appdirs\n        user_data_dir = Path(appdirs.user_data_dir(roaming=True))\n        test(\n            'User data dir exists',\n            lambda: user_data_dir.exists(),\n            True,\n        )\n        p = Popen(\n            f'\"{DIST_DIR}/Music Caster OneDir/Music Caster\" -m --debug',\n            shell=True)\n        time.sleep(5)\n        p.poll()\n        if p.returncode is not None:\n            print('got return code', p.returncode)\n        test(\n            'No return code',\n            lambda: p.returncode is None,\n            True,\n        )\n        test(\n            'Music Caster Should Be Running',\n            lambda: is_already_running(threshold=1),\n            True,\n        )\n        time.sleep(2)\n        test('Music Caster Should Accept Exit API',\n             lambda: requests.post('http://[::1]:2001/exit'))\n        time.sleep(2)\n        test('Music Caster Should Have Exited',\n             lambda: not is_already_running(), True)\n\n    if args.debug or not dist_files_exist:\n        print(\n            'Exiting early to avoid upload or installation of possibly broken build'\n        )\n        sys.exit(0 if args.dist_files_exist else 2)\n    print(f'Build v{VERSION} complete')\n    print('Time taken:', round(time.time() - start_time, 2), 'seconds')\n    print('Last commit: ' + getoutput('git log --format=\"%H\" -n 1'))\n    if args.upload:\n        print('Will try to upload to GitHub')\n        # upload to GitHub\n        github = read_env()['github']\n        headers = {\n            'Authorization': f'token {github}',\n            'Accept': 'application/vnd.github.v3+json',\n        }\n        USERNAME = 'elibroftw'\n        github_api = 'https://api.github.com'\n\n        # check if tag vVERSION does not exist\n        r = requests.get(\n            f'{github_api}/repos/{USERNAME}/music-caster/releases/tags/v{VERSION}',\n            headers=headers,\n        )\n        if r.status_code != 404:\n            if args.ci:\n                print('INFO: not uploading build since tag already exists')\n                sys.exit(0)\n            print(f'ERROR: Release for tag \"v{VERSION}\" already exists')\n            sys.exit(1)\n\n        old_release = requests.get(\n            f'{github_api}/repos/{USERNAME}/music-caster/releases/latest'\n        ).json()\n        try:\n            old_release_id = old_release['id']\n        except KeyError:\n            print(\n                'rate limit exceeded, upload manually at https://github.com/elibroftw/music-caster/releases'\n            )\n            sys.exit()\n        # keep changes of current major version if new version is a minor update\n        body = '' if VERSION.endswith('.0') else old_release['body']\n        body = add_new_changes(body)\n        if any(Repo('.git').index.diff(None)):\n            # possible if build.cmd -v wasn't run before\n            print('Warning: Changes detected.')\n            print(Repo('.git').index.diff(None))\n            if not args.ci:\n                input(\n                    'Changed (not committed) files detected. Press enter to confirm upload.\\n'\n                )\n        print('Will upload and install at the same time!')\n        t = threading.Thread(target=local_install)\n        t.start()\n\n        new_release = {\n            'tag_name': f'v{VERSION}',\n            'target_commitish': 'master',\n            'name': f'Music Caster v{VERSION}',\n            'body': body,\n            'draft': True,\n            'prerelease': False,\n        }\n        r = requests.post(\n            f'{github_api}/repos/{USERNAME}/music-caster/releases',\n            json=new_release,\n            headers=headers,\n        )\n        release = r.json()\n        upload_url = release['upload_url'][:-13]\n        release_id = release['id']\n        # upload assets\n        for dist_file in dist_files:\n            requests.post(\n                upload_url,\n                data=ProgressUpload(DIST_DIR / dist_file),\n                params={'name': dist_file},\n                headers={\n                    **headers, 'Content-Type': 'application/octet-stream'\n                },\n            )\n        requests.post(\n            f'{github_api}/repos/{USERNAME}/music-caster/releases/{release_id}',\n            headers=headers,\n            json={\n                'body': body,\n                'draft': False\n            },\n        )\n        # since winget is slower on the PR's, it's better to not delete anything\n        # if not VERSION.endswith('.0'):\n        #     # delete old release if not a new major build\n        #     requests.delete(f'{github_api}/repos/{USERNAME}/music-caster/releases/{old_release_id}', headers=headers)\n        print(f'Published Release v{VERSION}')\n        print(\n            f'v{VERSION} Total Time Taken:',\n            round(time.time() - start_time, 2),\n            'seconds',\n        )\n        t.join()\n    elif not args.no_install and (not args.skip_tests or args.force_install):\n        print(\n            'Installing Music Caster and it will be launched after installation.'\n        )\n        local_install()\n"
  },
  {
    "path": "build_files/TkinterDnD2/TkinterDnD.py",
    "content": "# -*- coding: utf-8 -*-\n\n\"\"\"Python wrapper for the tkdnd tk extension.\nThe tkdnd extension provides an interface to native, platform specific\ndrag and drop mechanisms. Under Unix the drag & drop protocol in use is\nthe XDND protocol version 5 (also used by the Qt toolkit, and the KDE and\nGNOME desktops). Under Windows, the OLE2 drag & drop interfaces are used.\nUnder Macintosh, the Cocoa drag and drop interfaces are used.\n\nOnce the TkinterDnD2 package is installed, it is safe to do:\n\nfrom TkinterDnD2 import *\n\nThis will add the classes TkinterDnD.Tk and TkinterDnD.TixTk to the global\nnamespace, plus the following constants:\nPRIVATE, NONE, ASK, COPY, MOVE, LINK, REFUSE_DROP,\nDND_TEXT, DND_FILES, DND_ALL, CF_UNICODETEXT, CF_TEXT, CF_HDROP,\nFileGroupDescriptor, FileGroupDescriptorW\n\nDrag and drop for the application can then be enabled by using one of the\nclasses TkinterDnD.Tk() or (in case the tix extension shall be used)\nTkinterDnD.TixTk() as application main window instead of a regular\ntkinter.Tk() window. This will add the drag-and-drop specific methods to the\nTk window and all its descendants.\"\"\"\n\ntry:\n    import Tkinter as tkinter # type: ignore\n    import tkinter.ttk as ttk # type: ignore\nexcept ImportError:\n    import tkinter\n    import tkinter.ttk as ttk\n\nTkdndVersion = None\n\n\ndef _require(tkroot):\n    \"\"\"Internal function.\"\"\"\n    global TkdndVersion\n    try:\n        TkdndVersion = tkroot.tk.call('package', 'require', 'tkdnd')\n    except tkinter.TclError:\n        raise RuntimeError('Unable to load tkdnd library.')\n    return TkdndVersion\n\n\nclass DnDEvent:\n    \"\"\"Internal class.\n    Container for the properties of a drag-and-drop event, similar to a\n    normal tkinter.Event.\n    An instance of the DnDEvent class has the following attributes:\n        action (string)\n        actions (tuple)\n        button (int)\n        code (string)\n        codes (tuple)\n        commonsourcetypes (tuple)\n        commontargettypes (tuple)\n        data (string)\n        name (string)\n        types (tuple)\n        modifiers (tuple)\n        supportedsourcetypes (tuple)\n        sourcetypes (tuple)\n        type (string)\n        supportedtargettypes (tuple)\n        widget (widget instance)\n        x_root (int)\n        y_root (int)\n    Depending on the type of DnD event however, not all attributes may be set.\n    \"\"\"\n    pass\n\n\nclass DnDWrapper:\n    \"\"\"Internal class.\"\"\"\n    # some of the percent substitutions need to be enclosed in braces\n    # so we can use splitlist() to convert them into tuples\n    _subst_format_dnd = ('%A', '%a', '%b', '%C', '%c', '{%CST}',\n                         '{%CTT}', '%D', '%e', '{%L}', '{%m}', '{%ST}',\n                         '%T', '{%t}', '{%TT}', '%W', '%X', '%Y')\n    _subst_format_str_dnd = \" \".join(_subst_format_dnd)\n    tkinter.BaseWidget._subst_format_dnd = _subst_format_dnd\n    tkinter.BaseWidget._subst_format_str_dnd = _subst_format_str_dnd\n\n    def _substitute_dnd(self, *args):\n        \"\"\"Internal function.\"\"\"\n        if len(args) != len(self._subst_format_dnd):\n            return args\n\n        def getint_event(s):\n            try:\n                return int(s)\n            except ValueError:\n                return s\n\n        def splitlist_event(s):\n            try:\n                return self.tk.splitlist(s)\n            except ValueError:\n                return s\n\n        # valid percent substitutions for DnD event types\n        # (tested with tkdnd-2.8 on debian jessie):\n        # <<DragInitCmd>> : %W, %X, %Y %e, %t\n        # <<DragEndCmd>> : %A, %W, %e\n        # <<DropEnter>> : all except : %D (always empty)\n        # <<DropLeave>> : all except %D (always empty)\n        # <<DropPosition>> :all except %D (always empty)\n        # <<Drop>> : all\n        A, a, b, C, c, CST, CTT, D, e, L, m, ST, T, t, TT, W, X, Y = args\n        ev = DnDEvent()\n        ev.action = A\n        ev.actions = splitlist_event(a)\n        ev.button = getint_event(b)\n        ev.code = C\n        ev.codes = splitlist_event(c)\n        ev.commonsourcetypes = splitlist_event(CST)\n        ev.commontargettypes = splitlist_event(CTT)\n        ev.data = D\n        ev.name = e\n        ev.types = splitlist_event(L)\n        ev.modifiers = splitlist_event(m)\n        ev.supportedsourcetypes = splitlist_event(ST)\n        ev.sourcetypes = splitlist_event(t)\n        ev.type = T\n        ev.supportedtargettypes = splitlist_event(TT)\n        try:\n            ev.widget = self.nametowidget(W)\n        except KeyError:\n            ev.widget = W\n        ev.x_root = getint_event(X)\n        ev.y_root = getint_event(Y)\n        return ev,\n\n    tkinter.BaseWidget._substitute_dnd = _substitute_dnd\n\n    def _dnd_bind(self, what, sequence, func, add, needcleanup=True):\n        \"\"\"Internal function.\"\"\"\n        if isinstance(func, str):\n            self.tk.call(what + (sequence, func))\n        elif func:\n            funcid = self._register(func, self._substitute_dnd, needcleanup)\n            # FIXME: why doesn't the \"return 'break'\" mechanism work here??\n            # cmd = ('%sif {\"[%s %s]\" == \"break\"} break\\n' % (add and '+' or '',\n            #                              funcid, self._subst_format_str_dnd))\n            cmd = '%s%s %s' % (add and '+' or '', funcid,\n                               self._subst_format_str_dnd)\n            self.tk.call(what + (sequence, cmd))\n            return funcid\n        elif sequence:\n            return self.tk.call(what + (sequence,))\n        else:\n            return self.tk.splitlist(self.tk.call(what))\n\n    tkinter.BaseWidget._dnd_bind = _dnd_bind\n\n    def dnd_bind(self, sequence=None, func=None, add=None):\n        \"\"\"Bind to this widget at drag and drop event SEQUENCE a call\n        to function FUNC.\n        SEQUENCE may be one of the following:\n        <<DropEnter>>, <<DropPosition>>, <<DropLeave>>, <<Drop>>,\n        <<Drop:type>>, <<DragInitCmd>>, <<DragEndCmd>> .\n        The callbacks for the <Drop*>> events, with the exception of\n        <<DropLeave>>, should always return an action (i.e. one of COPY,\n        MOVE, LINK, ASK or PRIVATE).\n        The callback for the <<DragInitCmd>> event must return a tuple\n        containing three elements: the drop action(s) supported by the\n        drag source, the format type(s) that the data can be dropped as and\n        finally the data that shall be dropped. Each of these three elements\n        may be a tuple of strings or a single string.\"\"\"\n        return self._dnd_bind(('bind', self._w), sequence, func, add)\n\n    tkinter.BaseWidget.dnd_bind = dnd_bind\n\n    def drag_source_register(self, button=None, *dndtypes):\n        \"\"\"This command will register SELF as a drag source.\n        A drag source is a widget than can start a drag action. This command\n        can be executed multiple times on a widget.\n        When SELF is registered as a drag source, optional DNDTYPES can be\n        provided. These DNDTYPES will be provided during a drag action, and\n        it can contain platform independent or platform specific types.\n        Platform independent are DND_Text for dropping text portions and\n        DND_Files for dropping a list of files (which can contain one or\n        multiple files) on SELF. However, these types are\n        indicative/informative. SELF can initiate a drag action with even a\n        different type list. Finally, button is the mouse button that will be\n        used for starting the drag action. It can have any of the values 1\n        (left mouse button), 2 (middle mouse button - wheel) and 3\n        (right mouse button). If button is not specified, it defaults to 1.\"\"\"\n        # hack to fix a design bug from the first version\n        if button is None:\n            button = 1\n        else:\n            try:\n                button = int(button)\n            except ValueError:\n                # no button defined, button is actually\n                # something like DND_TEXT\n                dndtypes = (button,) + dndtypes\n                button = 1\n        self.tk.call(\n            'tkdnd::drag_source', 'register', self._w, dndtypes, button)\n\n    tkinter.BaseWidget.drag_source_register = drag_source_register\n\n    def drag_source_unregister(self):\n        \"\"\"This command will stop SELF from being a drag source. Thus, window\n        will stop receiving events related to drag operations. It is an error\n        to use this command for a window that has not been registered as a\n        drag source with drag_source_register().\"\"\"\n        self.tk.call('tkdnd::drag_source', 'unregister', self._w)\n\n    tkinter.BaseWidget.drag_source_unregister = drag_source_unregister\n\n    def drop_target_register(self, *dndtypes):\n        \"\"\"This command will register SELF as a drop target. A drop target is\n        a widget than can accept a drop action. This command can be executed\n        multiple times on a widget. When SELF is registered as a drop target,\n        optional DNDTYPES can be provided. These types list can contain one or\n        more types that SELF will accept during a drop action, and it can\n        contain platform independent or platform specific types. Platform\n        independent are DND_Text for dropping text portions and DND_Files for\n        dropping a list of files (which can contain one or multiple files) on\n        SELF.\"\"\"\n        self.tk.call('tkdnd::drop_target', 'register', self._w, dndtypes)\n\n    tkinter.BaseWidget.drop_target_register = drop_target_register\n\n    def drop_target_unregister(self):\n        \"\"\"This command will stop SELF from being a drop target. Thus, SELF\n        will stop receiving events related to drop operations. It is an error\n        to use this command for a window that has not been registered as a\n        drop target with drop_target_register().\"\"\"\n        self.tk.call('tkdnd::drop_target', 'unregister', self._w)\n\n    tkinter.BaseWidget.drop_target_unregister = drop_target_unregister\n\n    def platform_independent_types(self, *dndtypes):\n        \"\"\"This command will accept a list of types that can contain platform\n        independent or platform specific types. A new list will be returned,\n        where each platform specific type in DNDTYPES will be substituted by\n        one or more platform independent types. Thus, the returned list may\n        have more elements than DNDTYPES.\"\"\"\n        return self.tk.split(self.tk.call(\n            'tkdnd::platform_independent_types', dndtypes))\n\n    tkinter.BaseWidget.platform_independent_types = platform_independent_types\n\n    def platform_specific_types(self, *dndtypes):\n        \"\"\"This command will accept a list of types that can contain platform\n        independent or platform specific types. A new list will be returned,\n        where each platform independent type in DNDTYPES will be substituted\n        by one or more platform specific types. Thus, the returned list may\n        have more elements than DNDTYPES.\"\"\"\n        return self.tk.split(self.tk.call(\n            'tkdnd::platform_specific_types', dndtypes))\n\n    tkinter.BaseWidget.platform_specific_types = platform_specific_types\n\n    def get_dropfile_tempdir(self):\n        \"\"\"This command will return the temporary directory used by TkDND for\n        storing temporary files. When the package is loaded, this temporary\n        directory will be initialised to a proper directory according to the\n        operating system. This default initial value can be changed to be the\n        value of the following environmental variables:\n        TKDND_TEMP_DIR, TEMP, TMP.\"\"\"\n        return self.tk.call('tkdnd::GetDropFileTempDirectory')\n\n    tkinter.BaseWidget.get_dropfile_tempdir = get_dropfile_tempdir\n\n    def set_dropfile_tempdir(self, tempdir):\n        \"\"\"This command will change the temporary directory used by TkDND for\n        storing temporary files to TEMPDIR.\"\"\"\n        self.tk.call('tkdnd::SetDropFileTempDirectory', tempdir)\n\n    tkinter.BaseWidget.set_dropfile_tempdir = set_dropfile_tempdir\n\n\n#      The main window classes that enable Drag & Drop for\n#      themselves and all their descendant widgets:\nclass Tk(tkinter.Tk, DnDWrapper):\n    \"\"\"Creates a new instance of a tkinter.Tk() window; all methods of the\n    DnDWrapper class apply to this window and all its descendants.\"\"\"\n\n    def __init__(self, *args, **kw):\n        tkinter.Tk.__init__(self, *args, **kw)\n        self.TkdndVersion = _require(self)\n\n\nclass TixTk(tkinter.Tk, DnDWrapper):\n    \"\"\"Creates a new instance of a tkinter.Tk() window with ttk support; all methods of the\n    DnDWrapper class apply to this window and all its descendants.\"\"\"\n\n    def __init__(self, *args, **kw):\n        tkinter.Tk.__init__(self, *args, **kw)\n        self.TkdndVersion = _require(self)\n"
  },
  {
    "path": "build_files/TkinterDnD2/__init__.py",
    "content": "# dnd actions\nPRIVATE = 'private'\nNONE = 'none'\nASK = 'ask'\nCOPY = 'copy'\nMOVE = 'move'\nLINK = 'link'\nREFUSE_DROP = 'refuse_drop'\n# dnd types\nDND_TEXT = 'DND_Text'\nDND_FILES = 'DND_Files'\nDND_ALL = '*'\nCF_UNICODETEXT = 'CF_UNICODETEXT'\nCF_TEXT = 'CF_TEXT'\nCF_HDROP = 'CF_HDROP'\nFileGroupDescriptor = 'FileGroupDescriptor - FileContents'# ??\nFileGroupDescriptorW = 'FileGroupDescriptorW - FileContents'# ??\n\nfrom TkinterDnD2 import TkinterDnD\n"
  },
  {
    "path": "build_files/Updater.cs.txt",
    "content": "﻿// NOTE: This was the old portable updater\n//  the new updater is updater.go\nusing System;\nusing System.Collections.Generic;\nusing System.Diagnostics;\nusing System.IO;\nusing System.IO.Compression;\nusing System.Net;\nusing System.Text.Json;\n\n/**\nold code in build.py that was used to build the updater\ndef get_msbuild():\n    import re\n    import winreg\n    reg = winreg.ConnectRegistry(None, winreg.HKEY_LOCAL_MACHINE)\n    root_key = winreg.OpenKey(reg, r'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',\n                              0, winreg.KEY_READ | winreg.KEY_WOW64_32KEY)\n    num_sub_keys = winreg.QueryInfoKey(root_key)[0]\n    vs = {}\n    for i in range(num_sub_keys):\n        with suppress(EnvironmentError):\n            software: dict = {}\n            software_key = winreg.EnumKey(root_key, i)\n            software_key = winreg.OpenKey(root_key, software_key)\n            info_key = winreg.QueryInfoKey(software_key)\n            for value in range(info_key[1]):\n                value = winreg.EnumValue(software_key, value)\n                software[value[0]] = value[1]\n            display_name = software.get('DisplayName', '')\n            if re.search(r'Visual Studio (Community|Professional|Enterprise)', display_name):\n                software['ver'] = int(software['DisplayName'].rsplit(maxsplit=1)[1])\n                vs_ver = vs.get('ver', 0)\n                if software['ver'] > vs_ver:\n                    vs = software\n    if vs is None: raise RuntimeWarning('No installation of Visual Studio could be found')\n    ms_build_path = vs['InstallLocation'] + r'\\MSBuild\\Current\\Bin\\MSBuild.exe'\n    return ms_build_path\n\n...\nms_build = get_msbuild()\ncheck_call(f'{ms_build} \"{starting_dir}/Music Caster Updater/Music Caster Updater.sln\"'\n           f' /t:Build /p:Configuration=Release /p:PlatformTarget=x86')\n...\n# portable_files.extend([(f, os.path.basename(f)) for f in glob.iglob(f'{glob.escape(UPDATER_DIST_PATH)}/*.*')])\n*/\n\nnamespace Music_Caster_Updater\n{\n    class Program\n    {\n        private static void ExtractZip(string fileName)\n        {\n            /**\n             * Extracts fileName (ends with .zip) to root directory\n             * Deletes fileName after\n             */\n            using (ZipArchive archive = ZipFile.OpenRead(fileName))\n            {\n                foreach (ZipArchiveEntry entry in archive.Entries)\n                {\n                    string dir = Path.GetDirectoryName(entry.FullName);\n                    if (dir != \"\" && !Directory.Exists(dir)) Directory.CreateDirectory(dir);\n                    try\n                    {\n                        if (File.Exists(entry.FullName)) File.Delete(entry.FullName);\n                        entry.ExtractToFile(entry.FullName);\n                    }\n                    catch (IOException) { }\n                    catch (System.UnauthorizedAccessException) { }\n                }\n            }\n            File.Delete(fileName);\n        }\n        private static void Download(string url, string outfile)\n        {\n            // Downloads url to outfile\n            // If outfile is a zip, extract it\n            Debug.WriteLine($\"Downloading {outfile}\");\n            using WebClient myWebClient = new WebClient();\n            myWebClient.DownloadFile(url, outfile);\n            if (outfile.EndsWith(\".zip\")) ExtractZip(outfile);\n        }\n\n        private static List<string> DirectorySearch(string dir)\n        {   // returns all files in a dir and its subdirs recursively\n            List<string> files = new List<string>();\n            try\n            {\n                foreach (string f in Directory.GetFiles(dir)) files.Add(Path.GetFileName(f));\n                foreach (string d in Directory.GetDirectories(dir)) files.AddRange(DirectorySearch(d));\n            }\n            catch (Exception) { }\n            return files;\n        }\n\n\n        static void Main()\n        {\n            // use @ for string literals\n            const string releasesURL = @\"https://api.github.com/repos/elibroftw/music-caster/releases/latest\";\n            const string settingsFile = \"settings.json\";\n            Directory.SetCurrentDirectory(AppDomain.CurrentDomain.BaseDirectory);  // Change working dir to dir of this program\n            Dictionary<string, object> loadedSettings = new Dictionary<string, object>() { { \"DEBUG\", false } };\n\n            if (File.Exists(settingsFile))\n            {\n                using StreamReader fs = new StreamReader(settingsFile);\n                loadedSettings = JsonSerializer.Deserialize<Dictionary<string, object>>(fs.ReadToEnd());\n            }\n            bool debugSetting = false;\n            try\n            {\n                debugSetting = ((JsonElement)loadedSettings.GetValueOrDefault(\"DEBUG\")).GetBoolean();\n            }\n            catch (InvalidCastException) { }\n\n\n            Dictionary<string, object> jsonResponse;\n\n            HttpWebRequest request = (HttpWebRequest)WebRequest.Create(releasesURL);\n            request.Method = \"GET\";\n            request.UserAgent = \"MusicCasterUpdaterC#\";\n            using (HttpWebResponse response = (HttpWebResponse)request.GetResponse())\n            using (Stream stream = response.GetResponseStream())\n            using (StreamReader reader = new StreamReader(stream))\n            {\n                jsonResponse = JsonSerializer.Deserialize<Dictionary<string, object>>((new StreamReader(response.GetResponseStream())).ReadToEnd());\n            }\n\n            string setupDownloadURL = \"\", portableDownloadURL = \"\";\n\n            JsonElement assets = (JsonElement) jsonResponse.GetValueOrDefault(\"assets\");\n            foreach (JsonElement asset in assets.EnumerateArray())\n            {\n                if (asset.GetProperty(\"name\").ToString().Contains(\"exe\"))\n                    setupDownloadURL = asset.GetProperty(\"browser_download_url\").ToString();\n                else if (asset.GetProperty(\"name\").ToString().Tocasefold().Contains(\"portable\"))\n                    portableDownloadURL = asset.GetProperty(\"browser_download_url\").ToString();\n            }\n            if (debugSetting)\n            {\n                string latestVersion = jsonResponse.GetValueOrDefault(\"tag_name\").ToString();\n                Debug.WriteLine($\"Latest Version: {latestVersion}\");\n                Debug.WriteLine($\"Portable:       {portableDownloadURL}\");\n                Debug.WriteLine($\"Installer:      {setupDownloadURL}\");\n            }\n            else if (File.Exists(\"unins000.exe\"))\n            {   // Was installed using the Installer\n                Download(setupDownloadURL, \"MC_Installer.exe\");\n                Process.Start(\"MC_Installer.exe\", \"/VERYSILENT /CLOSEAPPLICATIONS /FORCECLOSEAPPLICATIONS /MERGETASKS=\\\"!desktopicon\\\"\");\n            }\n            else\n            {   // portable installation\n                Download(portableDownloadURL, \"Portable.zip\");\n                Process.Start(\"\\\"Music Caster.exe\\\" --nupdate\");\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "build_files/Updater.exe.MANIFEST",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<assembly xmlns=\"urn:schemas-microsoft-com:asm.v1\" manifestVersion=\"1.0\">\n  <assemblyIdentity type=\"win32\" name=\"updater\" processorArchitecture=\"x86\" version=\"1.0.0.0\"/>\n  <dependency>\n    <dependentAssembly>\n      <assemblyIdentity type=\"win32\" name=\"Microsoft.Windows.Common-Controls\" language=\"*\" processorArchitecture=\"*\" version=\"6.0.0.0\" publicKeyToken=\"6595b64144ccf1df\"/>\n      <compatibility xmlns=\"urn:schemas-microsoft-com:compatibility.v1\"/>\n    </dependentAssembly>\n  </dependency>\n  <compatibility xmlns=\"urn:schemas-microsoft-com:compatibility.v1\">\n    <application>\n      <!-- Windows Vista -->\n      <supportedOS Id=\"{e2011457-1546-43c5-a5fe-008deee3d3f0}\"/>\n      <!-- Windows 7 -->\n      <supportedOS Id=\"{35138b9a-5d96-4fbd-8e2d-a2440225f93a}\"/>\n      <!-- Windows 8 -->\n      <supportedOS Id=\"{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}\"/>\n      <!-- Windows 8.1 -->\n      <supportedOS Id=\"{1f676c76-80e1-4239-95bb-83d0f6d0da78}\"/>\n      <!-- Windows 10 -->\n      <supportedOS Id=\"{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}\"/>\n    </application>\n  </compatibility>\n  <trustInfo xmlns=\"urn:schemas-microsoft-com:asm.v3\">\n        <security>\n            <requestedPrivileges>\n                <!--\n                  UAC settings:\n                  - app should run at same integrity level as calling process\n                  - app does not need to manipulate windows belonging to higher-integrity-level processes\n                  -->\n                <requestedExecutionLevel level=\"asInvoker\" uiAccess=\"false\"\n                />\n            </requestedPrivileges>\n        </security>\n    </trustInfo>\n</assembly>"
  },
  {
    "path": "build_files/daemon.spec",
    "content": "# -*- mode: python ; coding: utf-8 -*-\nimport os\nfrom PyInstaller.building.api import PYZ, EXE\nfrom PyInstaller.building.build_main import Analysis, Tree # type: ignore\nfrom PyInstaller.config import CONF\nimport platform\n\nCONF['distpath'] = './dist' # type: ignore\n# CONF['workpath'] = './build'\nblock_cipher = None\na = Analysis([f'{os.getcwd()}/src/music_caster.py'],\n             pathex=[os.getcwd()],\n             binaries=[],\n             datas=[('../CHANGELOG.TXT', '.')],\n             hiddenimports=['pystray._win32', 'zeroconf._utils.ipaddress', 'zeroconf._handlers.answers'],\n             hookspath=[],\n             runtime_hooks=[],\n             excludes=['crypto', 'cryptography', 'pycryptodome', 'pandas', 'gevent',\n                       'numpy', 'simplejson', 'PySide2', 'PyQt5', 'greenlet'],\n             win_no_prefer_redirects=False,\n             win_private_assemblies=False,\n             cipher=block_cipher,\n             noarchive=False)\n\na.datas.extend(Tree('src/templates', 'templates'))\na.datas.extend(Tree('src/static', 'static'))\nVLC_EXCLUDES = ['*.dll', '*.so', '*.so*', '*.dylib*', '*.dylib']\nif platform.system() == 'Windows':\n    VLC_EXCLUDES.remove('*.dll')\nelif platform.system() == 'Darwin':\n    VLC_EXCLUDES.remove('*.dylib*')\n    VLC_EXCLUDES.remove('*.dylib')\nelif platform.system() == 'Linux':\n    VLC_EXCLUDES.remove('*.so*')\n    VLC_EXCLUDES.remove('*.so')\na.datas.extend(Tree('src/vlc_lib', 'vlc_lib', excludes=VLC_EXCLUDES))\na.datas.extend(Tree('src/languages', 'languages'))\na.datas.extend(Tree('build_files/tkdnd2.9.2', 'tkdnd2.9.2'))\na.datas.extend(Tree('src/theme', 'theme'))\n# a.datas.extend(Tree('src-frontend/dist', 'frontend'))\n\npyz = PYZ(a.pure, a.zipped_data,\n          cipher=block_cipher)\nexe = EXE(pyz,\n          a.scripts,\n          a.binaries,\n          a.zipfiles,\n          a.datas,\n          [],\n          name='Music Caster Daemon',\n          debug=False,\n          bootloader_ignore_signals=False,\n          strip=False,\n          upx=False,\n          runtime_tmpdir=None,\n          # TODO: use ENV variable\n          console=True, version='mc_version_info.txt', icon=os.path.abspath('resources/Music Caster Icon.ico'))\n"
  },
  {
    "path": "build_files/flatpak-pip-generator.py",
    "content": "#!/usr/bin/env python3\n\n__license__ = 'MIT'\n\nimport argparse\nimport json\nimport hashlib\nimport os\nimport shutil\nimport subprocess\nimport sys\nimport tempfile\nimport urllib.request\n\nfrom collections import OrderedDict\nfrom typing import Dict\n\nimport requirements\n\nparser = argparse.ArgumentParser()\nparser.add_argument('packages', nargs='*')\nparser.add_argument('--python2', action='store_true',\n                    help='Look for a Python 2 package')\nparser.add_argument('--cleanup', choices=['scripts', 'all'],\n                    help='Select what to clean up after build')\nparser.add_argument('--requirements-file', '-r',\n                    help='Specify requirements.txt file')\nparser.add_argument('--build-only', action='store_const',\n                    dest='cleanup', const='all',\n                    help='Clean up all files after build')\nparser.add_argument('--build-isolation', action='store_true',\n                    default=False,\n                    help=(\n                        'Do not disable build isolation. '\n                        'Mostly useful on pip that does\\'t '\n                        'support the feature.'\n                    ))\nparser.add_argument('--checker-data', action='store_true',\n                    help='Include x-checker-data in output for the \"Flatpak External Data Checker\"')\nparser.add_argument('--output', '-o',\n                    help='Specify output file name')\nparser.add_argument('--runtime',\n                    help='Specify a flatpak to run pip inside of a sandbox, ensures python version compatibility')\nparser.add_argument('--yaml', action='store_true',\n                    help='Use YAML as output format instead of JSON')\nopts = parser.parse_args()\n\nif opts.yaml:\n    try:\n        import yaml\n    except ImportError:\n        exit('PyYAML modules is not installed. Run \"pip install PyYAML\"')\n\n\ndef get_pypi_url(name: str, filename: str) -> str:\n    url = 'https://pypi.org/pypi/{}/json'.format(name)\n    print('Extracting download url for', name)\n    with urllib.request.urlopen(url) as response:\n        body = json.loads(response.read().decode('utf-8'))\n        for release in body['releases'].values():\n            for source in release:\n                if source['filename'] == filename:\n                    return source['url']\n        raise Exception('Failed to extract url from {}'.format(url))\n\n\ndef get_tar_package_url_pypi(name: str, version: str) -> str:\n    url = 'https://pypi.org/pypi/{}/{}/json'.format(name, version)\n    with urllib.request.urlopen(url) as response:\n        body = json.loads(response.read().decode('utf-8'))\n        for ext in ['bz2', 'gz', 'xz', 'zip']:\n            for source in body['urls']:\n                if source['url'].endswith(ext):\n                    return source['url']\n        err = 'Failed to get {}-{} source from {}'.format(name, version, url)\n        raise Exception(err)\n\n\ndef get_package_name(filename: str) -> str:\n    if filename.endswith(('bz2', 'gz', 'xz', 'zip')):\n        segments = filename.split('-')\n        if len(segments) == 2:\n            return segments[0]\n        return '-'.join(segments[:len(segments) - 1])\n    elif filename.endswith('whl'):\n        segments = filename.split('-')\n        if len(segments) == 5:\n            return segments[0]\n        candidate = segments[:len(segments) - 4]\n        # Some packages list the version number twice\n        # e.g. PyQt5-5.15.0-5.15.0-cp35.cp36.cp37.cp38-abi3-manylinux2014_x86_64.whl\n        if candidate[-1] == segments[len(segments) - 4]:\n            return '-'.join(candidate[:-1])\n        return '-'.join(candidate)\n    else:\n        raise Exception(\n            'Downloaded filename: {} does not end with bz2, gz, xz, zip, or whl'.format(filename)\n        )\n\n\ndef get_file_version(filename: str) -> str:\n    name = get_package_name(filename)\n    segments = filename.split(name + '-')\n    version = segments[1].split('-')[0]\n    for ext in ['tar.gz', 'whl', 'tar.xz', 'tar.gz', 'tar.bz2', 'zip']:\n        version = version.replace('.' + ext, '')\n    return version\n\n\ndef get_file_hash(filename: str) -> str:\n    sha = hashlib.sha256()\n    print('Generating hash for', filename.split('/')[-1])\n    with open(filename, 'rb') as f:\n        while True:\n            data = f.read(1024 * 1024 * 32)\n            if not data:\n                break\n            sha.update(data)\n        return sha.hexdigest()\n\n\ndef download_tar_pypi(url: str, tempdir: str) -> None:\n    with urllib.request.urlopen(url) as response:\n        file_path = os.path.join(tempdir, url.split('/')[-1])\n        with open(file_path, 'x+b') as tar_file:\n            shutil.copyfileobj(response, tar_file)\n\n\ndef parse_continuation_lines(fin):\n    for line in fin:\n        line = line.rstrip('\\n')\n        while line.endswith('\\\\'):\n            try:\n                line = line[:-1] + next(fin).rstrip('\\n')\n            except StopIteration:\n                exit('Requirements have a wrong number of line continuation characters \"\\\\\"')\n        yield line\n\n\ndef fprint(string: str) -> None:\n    separator = '=' * 72  # Same as `flatpak-builder`\n    print(separator)\n    print(string)\n    print(separator)\n\n\npackages = []\nif opts.requirements_file:\n    requirements_file = os.path.expanduser(opts.requirements_file)\n    try:\n        with open(requirements_file, 'r') as req_file:\n            reqs = parse_continuation_lines(req_file)\n            reqs_as_str = '\\n'.join([r.split('--hash')[0] for r in reqs])\n            packages = list(requirements.parse(reqs_as_str))\n    except FileNotFoundError:\n        pass\n\nelif opts.packages:\n    packages = list(requirements.parse('\\n'.join(opts.packages)))\n    with tempfile.NamedTemporaryFile('w', delete=False, prefix='requirements.') as req_file:\n        req_file.write('\\n'.join(opts.packages))\n        requirements_file = req_file.name\nelse:\n    exit('Please specifiy either packages or requirements file argument')\n\nfor i in packages:\n    if i[\"name\"].casefold().startswith(\"pyqt\"):\n        print(\"PyQt packages are not supported by flapak-pip-generator\")\n        print(\"However, there is a BaseApp for PyQt available, that you should use\")\n        print(\"Visit https://github.com/flathub/com.riverbankcomputing.PyQt.BaseApp for more information\")\n        sys.exit(0)\n\nwith open(requirements_file, 'r') as req_file:\n    use_hash = '--hash=' in req_file.read()\n\npython_version = '2' if opts.python2 else '3'\nif opts.python2:\n    pip_executable = 'pip2'\nelse:\n    pip_executable = 'pip3'\n\nif opts.runtime:\n    flatpak_cmd = [\n        'flatpak',\n        '--devel',\n        '--share=network',\n        '--filesystem=/tmp',\n        '--command={}'.format(pip_executable),\n        'run',\n        opts.runtime\n    ]\n    if opts.requirements_file:\n        requirements_file = os.path.expanduser(opts.requirements_file)\n        if os.path.exists(requirements_file):\n            prefix = os.path.realpath(requirements_file)\n            flag = '--filesystem={}'.format(prefix)\n            flatpak_cmd.insert(1,flag)\nelse:\n    flatpak_cmd = [pip_executable]\n\nif opts.output:\n    output_package = opts.output\nelif opts.requirements_file:\n    output_package = 'python{}-{}'.format(\n        python_version,\n        os.path.basename(opts.requirements_file).replace('.txt', ''),\n    )\nelif len(packages) == 1:\n    output_package = 'python{}-{}'.format(\n        python_version, packages[0].name,\n    )\nelse:\n    output_package = 'python{}-modules'.format(python_version)\nif opts.yaml:\n    output_filename = output_package + '.yaml'\nelse:\n    output_filename = output_package + '.json'\n\nmodules = []\nvcs_modules = []\nsources = {}\n\ntempdir_prefix = 'pip-generator-{}'.format(os.path.basename(output_package))\nwith tempfile.TemporaryDirectory(prefix=tempdir_prefix) as tempdir:\n    pip_download = flatpak_cmd + [\n        'download',\n        '--exists-action=i',\n        '--dest',\n        tempdir,\n        '-r',\n        requirements_file\n    ]\n    if use_hash:\n        pip_download.append('--require-hashes')\n\n    fprint('Downloading sources')\n    cmd = ' '.join(pip_download)\n    print('Running: \"{}\"'.format(cmd))\n    try:\n        subprocess.run(pip_download, check=True)\n    except subprocess.CalledProcessError:\n        print('Failed to download')\n        print('Please fix the module manually in the generated file')\n\n    if not opts.requirements_file:\n        try:\n            os.remove(requirements_file)\n        except FileNotFoundError:\n            pass\n\n    fprint('Downloading arch independent packages')\n    for filename in os.listdir(tempdir):\n        if not filename.endswith(('bz2', 'any.whl', 'gz', 'xz', 'zip')):\n            version = get_file_version(filename)\n            name = get_package_name(filename)\n            url = get_tar_package_url_pypi(name, version)\n            print('Deleting', filename)\n            try:\n                os.remove(os.path.join(tempdir, filename))\n            except FileNotFoundError:\n                pass\n            print('Downloading {}'.format(url))\n            download_tar_pypi(url, tempdir)\n\n    files = {get_package_name(f): [] for f in os.listdir(tempdir)}\n\n    for filename in os.listdir(tempdir):\n        name = get_package_name(filename)\n        files[name].append(filename)\n\n    # Delete redundant sources, for vcs sources\n    for name in files:\n        if len(files[name]) > 1:\n            zip_source = False\n            for f in files[name]:\n                if f.endswith('.zip'):\n                    zip_source = True\n            if zip_source:\n                for f in files[name]:\n                    if not f.endswith('.zip'):\n                        try:\n                            os.remove(os.path.join(tempdir, f))\n                        except FileNotFoundError:\n                            pass\n\n    vcs_packages = {\n        x.name: {'vcs': x.vcs, 'revision': x.revision, 'uri': x.uri}\n        for x in packages\n        if x.vcs\n    }\n\n    fprint('Obtaining hashes and urls')\n    for filename in os.listdir(tempdir):\n        name = get_package_name(filename)\n        sha256 = get_file_hash(os.path.join(tempdir, filename))\n\n        if name in vcs_packages:\n            uri = vcs_packages[name]['uri']\n            revision = vcs_packages[name]['revision']\n            vcs = vcs_packages[name]['vcs']\n            url = 'https://' + uri.split('://', 1)[1]\n            s = 'commit'\n            if vcs == 'svn':\n                s = 'revision'\n            source = OrderedDict([\n                ('type', vcs),\n                ('url', url),\n                (s, revision),\n            ])\n            is_vcs = True\n        else:\n            url = get_pypi_url(name, filename)\n            source = OrderedDict([\n                ('type', 'file'),\n                ('url', url),\n                ('sha256', sha256)])\n            if opts.checker_data:\n                source['x-checker-data'] = {\n                    'type': 'pypi',\n                    'name': name}\n                if url.endswith(\".whl\"):\n                    source['x-checker-data']['packagetype'] = 'bdist_wheel'\n            is_vcs = False\n        sources[name] = {'source': source, 'vcs': is_vcs}\n\n# Python3 packages that come as part of org.freedesktop.Sdk.\nsystem_packages = ['cython', 'easy_install', 'mako', 'markdown', 'meson', 'pip', 'pygments', 'setuptools', 'six', 'wheel']\n\nfprint('Generating dependencies')\nfor package in packages:\n\n    if package.name is None:\n        print('Warning: skipping invalid requirement specification {} because it is missing a name'.format(package.line), file=sys.stderr)\n        print('Append #egg=<pkgname> to the end of the requirement line to fix', file=sys.stderr)\n        continue\n    elif package.name.casefold() in system_packages:\n        continue\n\n    if len(package.extras) > 0:\n        extras = '[' + ','.join(extra for extra in package.extras) + ']'\n    else:\n        extras = ''\n\n    version_list = [x[0] + x[1] for x in package.specs]\n    version = ','.join(version_list)\n\n    if package.vcs:\n        revision = ''\n        if package.revision:\n            revision = '@' + package.revision\n        pkg = package.uri + revision + '#egg=' + package.name\n    else:\n        pkg = package.name + extras + version\n\n    dependencies = []\n    # Downloads the package again to list dependencies\n\n    tempdir_prefix = 'pip-generator-{}'.format(package.name)\n    with tempfile.TemporaryDirectory(prefix='{}-{}'.format(tempdir_prefix, package.name)) as tempdir:\n        pip_download = flatpak_cmd + [\n            'download',\n            '--exists-action=i',\n            '--dest',\n            tempdir,\n        ]\n        try:\n            print('Generating dependencies for {}'.format(package.name))\n            subprocess.run(pip_download + [pkg], check=True, stdout=subprocess.DEVNULL)\n            for filename in os.listdir(tempdir):\n                dep_name = get_package_name(filename)\n                if dep_name.casefold() in system_packages:\n                    continue\n                dependencies.append(dep_name)\n\n        except subprocess.CalledProcessError:\n            print('Failed to download {}'.format(package.name))\n\n    is_vcs = True if package.vcs else False\n    package_sources = []\n    for dependency in dependencies:\n        if dependency in sources:\n            source = sources[dependency]\n        elif dependency.replace('_', '-') in sources:\n            source = sources[dependency.replace('_', '-')]\n        else:\n            continue\n\n        if not (not source['vcs'] or is_vcs):\n            continue\n\n        package_sources.append(source['source'])\n\n    if package.vcs:\n        name_for_pip = '.'\n    else:\n        name_for_pip = pkg\n\n    module_name = 'python{}-{}'.format(python_version, package.name)\n\n    pip_command = [\n        pip_executable,\n        'install',\n        '--verbose',\n        '--exists-action=i',\n        '--no-index',\n        '--find-links=\"file://${PWD}\"',\n        '--prefix=${FLATPAK_DEST}',\n        '\"{}\"'.format(name_for_pip)\n    ]\n    if not opts.build_isolation:\n        pip_command.append('--no-build-isolation')\n\n    module = OrderedDict([\n        ('name', module_name),\n        ('buildsystem', 'simple'),\n        ('build-commands', [' '.join(pip_command)]),\n        ('sources', package_sources),\n    ])\n    if opts.cleanup == 'all':\n        module['cleanup'] = ['*']\n    elif opts.cleanup == 'scripts':\n        module['cleanup'] = ['/bin', '/share/man/man1']\n\n    if package.vcs:\n        vcs_modules.append(module)\n    else:\n        modules.append(module)\n\nmodules = vcs_modules + modules\nif len(modules) == 1:\n    pypi_module = modules[0]\nelse:\n    pypi_module = {\n        'name': output_package,\n        'buildsystem': 'simple',\n        'build-commands': [],\n        'modules': modules,\n    }\n\nprint()\nwith open(output_filename, 'w') as output:\n    if opts.yaml:\n        class OrderedDumper(yaml.Dumper):\n            def increase_indent(self, flow=False, indentless=False):\n                return super(OrderedDumper, self).increase_indent(flow, False)\n\n        def dict_representer(dumper, data):\n            return dumper.represent_dict(data.items())\n\n        OrderedDumper.add_representer(OrderedDict, dict_representer)\n\n        yaml.dump(pypi_module, output, Dumper=OrderedDumper)\n    else:\n        output.write(json.dumps(pypi_module, indent=4))\n    print('Output saved to {}'.format(output_filename))\n"
  },
  {
    "path": "build_files/mc_version_info.txt",
    "content": "# UTF-8\n# For more details about fixed file info 'ffi' see: http://msdn.microsoft.com/en-us/library/ms646997.aspx\nVSVersionInfo(\n  ffi=FixedFileInfo(\n    prodvers=(5, 25, 1, 0),\n    filevers=(5, 25, 1, 0),\n    # Contains a bitmask that specifies the valid bits 'flags'r\n    mask=0x17,\n    # Contains a bitmask that specifies the Boolean attributes of the file.\n    flags=0x0,\n    # The operating system for which this file was designed.\n    # 0x4 - NT and there is no need to change it.\n    OS=0x4,\n    # The general type of file.\n    # 0x1 - the file is an application.\n    fileType=0x1,\n    # The function of the file.\n    # 0x0 - the function is not defined for this fileType\n    subtype=0x0,\n    # Creation date and time stamp.\n    date=(0, 0)\n    ),\n  kids=[\n    StringFileInfo(\n      [\n      StringTable(\n        '000004b0',\n        [StringStruct('CompanyName', 'Elijah Lopez'),\n        StringStruct('FileDescription', 'Music Caster'),\n        StringStruct('FileVersion', '5.25.1.0'),\n        StringStruct('InternalName', 'Music Caster'),\n        StringStruct('LegalCopyright', 'Copyright (c) 2019 - 2025, Elijah Lopez'),\n        StringStruct('OriginalFilename', 'Music Caster.exe'),\n        StringStruct('ProductName', 'Music Caster'),\n        StringStruct('ProductVersion', '5.25.1.0')])\n      ]),\n    VarFileInfo([VarStruct('Translation', [0, 1200])])\n  ]\n)\n"
  },
  {
    "path": "build_files/mcu_version_info.txt",
    "content": "# UTF-8\nVSVersionInfo(\n    ffi=FixedFileInfo(\n        prodvers=(2, 3, 0, 0),\n        filevers=(2, 3, 0, 0),\n        mask=0x17,\n        flags=0x0,\n        OS=0x4,\n        fileType=0x1,\n        subtype=0x0,\n        date=(0, 0)),\n    kids=[StringFileInfo(\n        [StringTable(\n            '000004b0',\n            [StringStruct('CompanyName', 'Elijah Lopez'),\n            StringStruct('FileDescription', 'Updater for Music Caster'),\n            StringStruct('FileVersion', '2.3.0.0'),\n            StringStruct('InternalName', 'Music Caster Updater'),\n            StringStruct('LegalCopyright', 'Copyright (c) 2019 - 2020, Elijah Lopez'),\n            StringStruct('OriginalFilename', 'Updater.exe'),\n            StringStruct('ProductName', 'Music Caster Updater'),\n            StringStruct('ProductVersion', '2.3.0.0')])]),\n        VarFileInfo([VarStruct('Translation', [0, 1200])])])\n"
  },
  {
    "path": "build_files/onedir.spec",
    "content": "# -*- mode: python ; coding: utf-8 -*-\nimport os\nfrom PyInstaller.building.api import PYZ, EXE, COLLECT\nfrom PyInstaller.building.build_main import Analysis, Tree # type: ignore\nfrom PyInstaller.config import CONF\nimport platform\n\nCONF['distpath'] = './dist' # type: ignore\nblock_cipher = None\n# CONF['workpath'] = './build'\n# TODO: test on MAC OSX\ndata_files = [('../CHANGELOG.txt', '.')]\na = Analysis([f'{os.getcwd()}/src/music_caster.py'],\n             pathex=[os.getcwd()],\n             binaries=[],\n             datas=data_files,\n             hiddenimports=['pystray._win32', 'zeroconf._utils.ipaddress', 'zeroconf._handlers.answers'],\n             hookspath=[],\n             runtime_hooks=[],\n             excludes=['crypto', 'cryptography', 'pycryptodome', 'pandas', 'gevent',\n                       'numpy', 'simplejson', 'PySide2', 'PyQt5', 'greenlet'],\n             win_no_prefer_redirects=False,\n             win_private_assemblies=False,\n             cipher=block_cipher,\n             noarchive=False)\na.datas.extend(Tree('src/templates', 'templates'))\na.datas.extend(Tree('src/static', 'static'))\nVLC_EXCLUDES = ['*.dll', '*.so', '*.so*', '*.dylib*', '*.dylib']\nif platform.system() == 'Windows':\n    VLC_EXCLUDES.remove('*.dll')\nelif platform.system() == 'Darwin':\n    VLC_EXCLUDES.remove('*.dylib*')\n    VLC_EXCLUDES.remove('*.dylib')\nelif platform.system() == 'Linux':\n    VLC_EXCLUDES.remove('*.so*')\n    VLC_EXCLUDES.remove('*.so')\na.datas.extend(Tree('src/vlc_lib', 'vlc_lib', excludes=VLC_EXCLUDES))\na.datas.extend(Tree('src/languages', 'languages'))\na.datas.extend(Tree('build_files/tkdnd2.9.2', 'tkdnd2.9.2'))\na.datas.extend(Tree('src/theme', 'theme'))\n# a.datas.extend(Tree('src-frontend/dist', 'frontend'))\n\npyz = PYZ(a.pure, a.zipped_data,\n          cipher=block_cipher)\nexe = EXE(pyz,\n          a.scripts,\n          [],\n          exclude_binaries=True,\n          name='Music Caster',\n          debug=False,\n          bootloader_ignore_signals=False,\n          strip=False,\n          upx=False,\n          console=False, version='mc_version_info.txt', icon=os.path.abspath('resources/Music Caster Icon.ico'))\ncoll = COLLECT(exe,\n               a.binaries,\n               a.zipfiles,\n               a.datas,\n               strip=False,\n               upx=False,\n               name='Music Caster OneDir')\n"
  },
  {
    "path": "build_files/portable.spec",
    "content": "# -*- mode: python ; coding: utf-8 -*-\nimport os\nfrom PyInstaller.building.api import PYZ, EXE\nfrom PyInstaller.building.build_main import Analysis, Tree # type: ignore\nfrom PyInstaller.config import CONF\nimport platform\n\nCONF['distpath'] = './dist' # type: ignore\n# CONF['workpath'] = './build'\nblock_cipher = None\na = Analysis([f'{os.getcwd()}/src/music_caster.py'],\n             pathex=[os.getcwd()],\n             binaries=[],\n             datas=[('../CHANGELOG.TXT', '.')],\n             hiddenimports=['pystray._win32', 'zeroconf._utils.ipaddress', 'zeroconf._handlers.answers'],\n             hookspath=[],\n             runtime_hooks=[],\n             excludes=['crypto', 'cryptography', 'pycryptodome', 'pandas', 'gevent',\n                       'numpy', 'simplejson', 'PySide2', 'PyQt5', 'greenlet'],\n             win_no_prefer_redirects=False,\n             win_private_assemblies=False,\n             cipher=block_cipher,\n             noarchive=False)\n\na.datas.extend(Tree('src/templates', 'templates'))\na.datas.extend(Tree('src/static', 'static'))\nVLC_EXCLUDES = ['*.dll', '*.so', '*.so*', '*.dylib*', '*.dylib']\nif platform.system() == 'Windows':\n    VLC_EXCLUDES.remove('*.dll')\nelif platform.system() == 'Darwin':\n    VLC_EXCLUDES.remove('*.dylib*')\n    VLC_EXCLUDES.remove('*.dylib')\nelif platform.system() == 'Linux':\n    VLC_EXCLUDES.remove('*.so*')\n    VLC_EXCLUDES.remove('*.so')\na.datas.extend(Tree('src/vlc_lib', 'vlc_lib', excludes=VLC_EXCLUDES))\na.datas.extend(Tree('src/languages', 'languages'))\na.datas.extend(Tree('build_files/tkdnd2.9.2', 'tkdnd2.9.2'))\na.datas.extend(Tree('src/theme', 'theme'))\n# a.datas.extend(Tree('src-frontend/dist', 'frontend'))\n\npyz = PYZ(a.pure, a.zipped_data,\n          cipher=block_cipher)\nexe = EXE(pyz,\n          a.scripts,\n          a.binaries,\n          a.zipfiles,\n          a.datas,\n          [],\n          name='Music Caster',\n          debug=False,\n          bootloader_ignore_signals=False,\n          strip=False,\n          upx=False,\n          runtime_tmpdir=None,\n          # TODO: use ENV variable\n          console=False, version='mc_version_info.txt', icon=os.path.abspath('resources/Music Caster Icon.ico'))\n"
  },
  {
    "path": "build_files/setup_script.iss",
    "content": "#define MyAppName \"Music Caster\"\n#define MyAppVersion \"5.25.1\"\n#define MyAppPublisher \"Elijah Lopez\"\n#define MyAppURL \"https://elijahlopez.ca/software#music-caster\"\n#define MyAppExeName \"Music Caster.exe\"\n\n[Setup]\n; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications.\n; (To generate a new GUID, click Tools | Generate GUID inside the IDE.)\nAppId={{FBE8A652-58D6-482D-B6A9-B3D7931CC9C5}\nAppName={#MyAppName}\nAppVersion={#MyAppVersion}\n;AppVerName={#MyAppName} {#MyAppVersion}\nAppPublisher={#MyAppPublisher}\nAppPublisherURL={#MyAppURL}\nAppSupportURL={#MyAppURL}\nAppUpdatesURL={#MyAppURL}\nDefaultDirName={autopf}\\{#MyAppName}\nDisableProgramGroupPage=yes\nCompression=lzma\nSolidCompression=yes\nWizardStyle=modern\nMinVersion=0,6.1.7600\n; Minimum version is Windows 10 or later\n; Remove the following line to run in administrative install mode (install for all users.)\nPrivilegesRequired=lowest\nOutputDir={#SourcePath}\\..\\dist\nOutputBaseFilename=Music Caster Setup\nUninstallDisplayName=Music Caster\nUninstallDisplayIcon={app}\\{#MyAppExeName}\nUninstallLogMode=overwrite\nSetupIconFile=\"..\\resources\\Music Caster Icon.ico\"\n\n[Languages]\nName: \"english\"; MessagesFile: \"compiler:Default.isl\"\n\n[Files]\nSource: \"{#SourcePath}\\..\\dist\\Music Caster OneDir\\*\"; DestDir: \"{app}\"; Flags: ignoreversion recursesubdirs\nSource: \"{#SourcePath}..\\CHANGELOG.txt\"; DestDir: \"{app}\"; DestName: \"CHANGELOG.txt\"; Flags: ignoreversion\n; NOTE: Don't use \"Flags: ignoreversion\" on any shared system files\n\n[InstallDelete]\nType: files; Name: {app}\\*.pyd\nType: files; Name: {app}\\*.dll\n; delete previous version folders and those that may contain old files\nType: filesandordirs; Name: {app}\\_internal\nType: filesandordirs; Name: {app}\\Crypto\nType: filesandordirs; Name: {app}\\Cryptodome\nType: filesandordirs; Name: {app}\\google\nType: filesandordirs; Name: {app}\\numpy\nType: filesandordirs; Name: {app}\\PIL\nType: filesandordirs; Name: {app}\\psutil\nType: filesandordirs; Name: {app}\\gevent\nType: filesandordirs; Name: {app}\\greenlet\nType: filesandordirs; Name: {app}\\templates\nType: filesandordirs; Name: {app}\\setuptools*\nType: filesandordirs; Name: {app}\\images\nType: filesandordirs; Name: {app}\\lib2to3\nType: filesandordirs; Name: {app}\\lxml\nType: filesandordirs; Name: {app}\\markupsafe\nType: filesandordirs; Name: {app}\\pygame\nType: filesandordirs; Name: {app}\\PyQt5\nType: filesandordirs; Name: {app}\\wx\nType: filesandordirs; Name: {app}\\vlc\nType: filesandordirs; Name: {app}\\vlc_lib\nType: filesandordirs; Name: {app}\\importlib_metadata*\nType: filesandordirs; Name: {app}\\keyring*\nType: filesandordirs; Name: {app}\\lz4*\nType: filesandordirs; Name: {app}\\websockets*\nType: filesandordirs; Name: {app}\\wheel*\n\n[Icons]\nName: \"{autoprograms}\\{#MyAppName}\"; Filename: \"{app}\\{#MyAppExeName}\"\n\n[Run]\nFilename: \"{app}\\{#MyAppExeName}\"; Description: \"{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}\"; Flags: nowait postinstall skipifsilent\n"
  },
  {
    "path": "build_files/tkdnd2.9.2/pkgIndex.tcl",
    "content": "package ifneeded tkdnd 2.9.2 \\\n  \"source \\{$dir/tkdnd.tcl\\} ; \\\n   tkdnd::initialise \\{$dir\\} libtkdnd2.9.2[info sharedlibextension] tkdnd\"\n\npackage ifneeded tkdnd::utils 2.9.2 \\\n  \"source \\{$dir/tkdnd_utils.tcl\\} ; \\\n   package provide tkdnd::utils 2.9.2\"\n"
  },
  {
    "path": "build_files/tkdnd2.9.2/tkdnd.tcl",
    "content": "#\n# tkdnd.tcl --\n#\n#    This file implements some utility procedures that are used by the TkDND\n#    package.\n#\n# This software is copyrighted by:\n# George Petasis, National Centre for Scientific Research \"Demokritos\",\n# Aghia Paraskevi, Athens, Greece.\n# e-mail: petasis@iit.demokritos.gr\n#\n# The following terms apply to all files associated\n# with the software unless explicitly disclaimed in individual files.\n#\n# The authors hereby grant permission to use, copy, modify, distribute,\n# and license this software and its documentation for any purpose, provided\n# that existing copyright notices are retained in all copies and that this\n# notice is included verbatim in any distributions. No written agreement,\n# license, or royalty fee is required for any of the authorized uses.\n# Modifications to this software may be copyrighted by their authors\n# and need not follow the licensing terms described here, provided that\n# the new terms are clearly indicated on the first page of each file where\n# they apply.\n#\n# IN NO EVENT SHALL THE AUTHORS OR DISTRIBUTORS BE LIABLE TO ANY PARTY\n# FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES\n# ARISING OUT OF THE USE OF THIS SOFTWARE, ITS DOCUMENTATION, OR ANY\n# DERIVATIVES THEREOF, EVEN IF THE AUTHORS HAVE BEEN ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n#\n# THE AUTHORS AND DISTRIBUTORS SPECIFICALLY DISCLAIM ANY WARRANTIES,\n# INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY,\n# FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT.  THIS SOFTWARE\n# IS PROVIDED ON AN \"AS IS\" BASIS, AND THE AUTHORS AND DISTRIBUTORS HAVE\n# NO OBLIGATION TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR\n# MODIFICATIONS.\n#\n\npackage require Tk\n\nnamespace eval ::tkdnd {\n  variable _topw \".drag\"\n  variable _tabops\n  variable _state\n  variable _x0\n  variable _y0\n  variable _platform_namespace\n  variable _drop_file_temp_dir\n  variable _auto_update 1\n  variable _dx 3 ;# The difference in pixels before a drag is initiated.\n  variable _dy 3 ;# The difference in pixels before a drag is initiated.\n\n  variable _windowingsystem\n\n  bind TkDND_Drag1 <ButtonPress-1> {tkdnd::_begin_drag press  1 %W %s %X %Y %x %y}\n  bind TkDND_Drag1 <B1-Motion>     {tkdnd::_begin_drag motion 1 %W %s %X %Y %x %y}\n  bind TkDND_Drag2 <ButtonPress-2> {tkdnd::_begin_drag press  2 %W %s %X %Y %x %y}\n  bind TkDND_Drag2 <B2-Motion>     {tkdnd::_begin_drag motion 2 %W %s %X %Y %x %y}\n  bind TkDND_Drag3 <ButtonPress-3> {tkdnd::_begin_drag press  3 %W %s %X %Y %x %y}\n  bind TkDND_Drag3 <B3-Motion>     {tkdnd::_begin_drag motion 3 %W %s %X %Y %x %y}\n\n  # ----------------------------------------------------------------------------\n  #  Command tkdnd::initialise: Initialise the TkDND package.\n  # ----------------------------------------------------------------------------\n  proc initialise { dir PKG_LIB_FILE PACKAGE_NAME} {\n    variable _platform_namespace\n    variable _drop_file_temp_dir\n    variable _windowingsystem\n    global env\n\n    switch [tk windowingsystem] {\n      x11 {\n        set _windowingsystem x11\n      }\n      win32 -\n      windows {\n        set _windowingsystem windows\n      }\n      aqua  {\n        set _windowingsystem aqua\n      }\n      default {\n        error \"unknown Tk windowing system\"\n      }\n    }\n\n    ## Get User's home directory: We try to locate the proper path from a set of\n    ## environmental variables...\n    foreach var {HOME HOMEPATH USERPROFILE ALLUSERSPROFILE APPDATA} {\n      if {[info exists env($var)]} {\n        if {[file isdirectory $env($var)]} {\n          set UserHomeDir $env($var)\n          break\n        }\n      }\n    }\n\n    ## Should use [tk windowingsystem] instead of tcl platform array:\n    ## OS X returns \"unix,\" but that's not useful because it has its own\n    ## windowing system, aqua\n    ## Under windows we have to also combine HOMEDRIVE & HOMEPATH...\n    if {![info exists UserHomeDir] &&\n        [string equal $_windowingsystem windows] &&\n        [info exists env(HOMEDRIVE)] && [info exists env(HOMEPATH)]} {\n      if {[file isdirectory $env(HOMEDRIVE)$env(HOMEPATH)]} {\n        set UserHomeDir $env(HOMEDRIVE)$env(HOMEPATH)\n      }\n    }\n    ## Have we located the needed path?\n    if {![info exists UserHomeDir]} {\n      set UserHomeDir [pwd]\n    }\n    set UserHomeDir [file normalize $UserHomeDir]\n\n    ## Try to locate a temporary directory...\n    foreach var {TKDND_TEMP_DIR TEMP TMP} {\n      if {[info exists env($var)]} {\n        if {[file isdirectory $env($var)] && [file writable $env($var)]} {\n          set _drop_file_temp_dir $env($var)\n          break\n        }\n      }\n    }\n    if {![info exists _drop_file_temp_dir]} {\n      foreach _dir [list \"$UserHomeDir/Local Settings/Temp\" \\\n                         \"$UserHomeDir/AppData/Local/Temp\" \\\n                         /tmp \\\n                         C:/WINDOWS/Temp C:/Temp C:/tmp \\\n                         D:/WINDOWS/Temp D:/Temp D:/tmp] {\n        if {[file isdirectory $_dir] && [file writable $_dir]} {\n          set _drop_file_temp_dir $_dir\n          break\n        }\n      }\n    }\n    if {![info exists _drop_file_temp_dir]} {\n      set _drop_file_temp_dir $UserHomeDir\n    }\n    set _drop_file_temp_dir [file native $_drop_file_temp_dir]\n\n    source $dir/tkdnd_generic.tcl\n    switch $_windowingsystem {\n      x11 {\n        source $dir/tkdnd_unix.tcl\n        set _platform_namespace xdnd\n      }\n      win32 -\n      windows {\n        source $dir/tkdnd_windows.tcl\n        set _platform_namespace olednd\n      }\n      aqua  {\n        source $dir/tkdnd_macosx.tcl\n        set _platform_namespace macdnd\n      }\n      default {\n        error \"unknown Tk windowing system\"\n      }\n    }\n    load $dir/$PKG_LIB_FILE $PACKAGE_NAME\n    source $dir/tkdnd_compat.tcl\n    ${_platform_namespace}::initialise\n  };# initialise\n\n  proc GetDropFileTempDirectory { } {\n    variable _drop_file_temp_dir\n    return $_drop_file_temp_dir\n  }\n  proc SetDropFileTempDirectory { dir } {\n    variable _drop_file_temp_dir\n    set _drop_file_temp_dir $dir\n  }\n\n};# namespace ::tkdnd\n\n# ----------------------------------------------------------------------------\n#  Command tkdnd::drag_source\n# ----------------------------------------------------------------------------\nproc ::tkdnd::drag_source { mode path { types {} } { event 1 }\n                                      { tagprefix TkDND_Drag } } {\n  set tags [bindtags $path]\n  set idx  [lsearch $tags ${tagprefix}$event]\n  switch -- $mode {\n    register {\n      if { $idx != -1 } {\n        ## No need to do anything!\n        # bindtags $path [lreplace $tags $idx $idx ${tagprefix}$event]\n      } else {\n        bindtags $path [linsert $tags 1 ${tagprefix}$event]\n      }\n      _drag_source_update_types $path $types\n    }\n    unregister {\n      if { $idx != -1 } {\n        bindtags $path [lreplace $tags $idx $idx]\n      }\n    }\n  }\n};# tkdnd::drag_source\n\nproc ::tkdnd::_drag_source_update_types { path types } {\n  set types [platform_specific_types $types]\n  set old_types [bind $path <<DragSourceTypes>>]\n  foreach type $types {\n    if {[lsearch $old_types $type] < 0} {lappend old_types $type}\n  }\n  bind $path <<DragSourceTypes>> $old_types\n};# ::tkdnd::_drag_source_update_types\n\n# ----------------------------------------------------------------------------\n#  Command tkdnd::drop_target\n# ----------------------------------------------------------------------------\nproc ::tkdnd::drop_target { mode path { types {} } } {\n  variable _windowingsystem\n  set types [platform_specific_types $types]\n  switch -- $mode {\n    register {\n      switch $_windowingsystem {\n        x11 {\n          _register_types $path [winfo toplevel $path] $types\n        }\n        win32 -\n        windows {\n          _RegisterDragDrop $path\n          bind <Destroy> $path {+ tkdnd::_RevokeDragDrop %W}\n        }\n        aqua {\n          macdnd::registerdragwidget [winfo toplevel $path] $types\n        }\n        default {\n          error \"unknown Tk windowing system\"\n        }\n      }\n      set old_types [bind $path <<DropTargetTypes>>]\n      set new_types {}\n      foreach type $types {\n        if {[lsearch -exact $old_types $type] < 0} {lappend new_types $type}\n      }\n      if {[llength $new_types]} {\n        bind $path <<DropTargetTypes>> [concat $old_types $new_types]\n      }\n    }\n    unregister {\n      switch $_windowingsystem {\n        x11 {\n        }\n        win32 -\n        windows {\n          _RevokeDragDrop $path\n        }\n        aqua {\n          error todo\n        }\n        default {\n          error \"unknown Tk windowing system\"\n        }\n      }\n      bind $path <<DropTargetTypes>> {}\n    }\n  }\n};# tkdnd::drop_target\n\n# ----------------------------------------------------------------------------\n#  Command tkdnd::_begin_drag\n# ----------------------------------------------------------------------------\nproc ::tkdnd::_begin_drag { event button source state X Y x y } {\n  variable _x0\n  variable _y0\n  variable _state\n\n  switch -- $event {\n    press {\n      set _x0    $X\n      set _y0    $Y\n      set _state \"press\"\n    }\n    motion {\n      if { ![info exists _state] } {\n        # This is just extra protection. There seem to be\n        # rare cases where the motion comes before the press.\n        return\n      }\n      if { [string equal $_state \"press\"] } {\n        variable _dx\n        variable _dy\n        if { abs($_x0-$X) > ${_dx} || abs($_y0-$Y) > ${_dy} } {\n          set _state \"done\"\n          _init_drag $button $source $state $X $Y $x $y\n        }\n      }\n    }\n  }\n};# tkdnd::_begin_drag\n\n# ----------------------------------------------------------------------------\n#  Command tkdnd::_init_drag\n# ----------------------------------------------------------------------------\nproc ::tkdnd::_init_drag { button source state rootX rootY X Y } {\n  # Call the <<DragInitCmd>> binding.\n  set cmd [bind $source <<DragInitCmd>>]\n  # puts \"CMD: $cmd\"\n  if {[string length $cmd]} {\n    set cmd [string map [list %W $source %X $rootX %Y $rootY %x $X %y $Y \\\n                              %S $state  %e <<DragInitCmd>> %A \\{\\} %% % \\\n                              %t [bind $source <<DragSourceTypes>>]] $cmd]\n    set code [catch {uplevel \\#0 $cmd} info options]\n    # puts \"CODE: $code ---- $info\"\n    switch -exact -- $code {\n      0 {}\n      3 - 4 {\n        # FRINK: nocheck\n        return\n      }\n      default {\n        return -options $options $info\n      }\n    }\n\n    set len [llength $info]\n    if {$len == 3} {\n      foreach { actions types _data } $info { break }\n      set types [platform_specific_types $types]\n      set data [list]\n      foreach type $types {\n        lappend data $_data\n      }\n      unset _data\n    } elseif {$len == 2} {\n      foreach { actions _data } $info { break }\n      set data [list]; set types [list]\n      foreach {t d} $_data {\n        foreach t [platform_specific_types $t] {\n          lappend types $t; lappend data $d\n        }\n      }\n      unset _data t d\n    } else {\n      if {$len == 1 && [string equal [lindex $actions 0] \"refuse_drop\"]} {\n        return\n      }\n      error \"not enough items in the result of the <<DragInitCmd>>\\\n             event binding. Either 2 or 3 items are expected. The command\n             executed was: \\\"$cmd\\\"\\nResult was: \\\"$info\\\"\"\n    }\n    set action refuse_drop\n    variable _windowingsystem\n    # puts \"Source:   \\\"$source\\\"\"\n    # puts \"Types:    \\\"[join $types {\", \"}]\\\"\"\n    # puts \"Actions:  \\\"[join $actions {\", \"}]\\\"\"\n    # puts \"Button:   \\\"$button\\\"\"\n    # puts \"Data:     \\\"[string range $data 0 100]\\\"\"\n    switch $_windowingsystem {\n      x11 {\n        set action [xdnd::_dodragdrop $source $actions $types $data $button]\n      }\n      win32 -\n      windows {\n        set action [_DoDragDrop $source $actions $types $data $button]\n      }\n      aqua {\n        set action [macdnd::dodragdrop $source $actions $types $data $button]\n      }\n      default {\n        error \"unknown Tk windowing system\"\n      }\n    }\n    ## Call _end_drag to notify the widget of the result of the drag\n    ## operation...\n    _end_drag $button $source {} $action {} $data {} $state $rootX $rootY $X $Y\n  }\n};# tkdnd::_init_drag\n\n# ----------------------------------------------------------------------------\n#  Command tkdnd::_end_drag\n# ----------------------------------------------------------------------------\nproc ::tkdnd::_end_drag { button source target action type data result\n                          state rootX rootY X Y } {\n  set rootX 0\n  set rootY 0\n  # Call the <<DragEndCmd>> binding.\n  set cmd [bind $source <<DragEndCmd>>]\n  if {[string length $cmd]} {\n    set cmd [string map [list %W $source %X $rootX %Y $rootY %x $X %y $Y %% % \\\n                              %S $state %e <<DragEndCmd>> %A \\{$action\\}] $cmd]\n    set info [uplevel \\#0 $cmd]\n    # if { $info != \"\" } {\n    #   variable _windowingsystem\n    #   foreach { actions types data } $info { break }\n    #   set types [platform_specific_types $types]\n    #   switch $_windowingsystem {\n    #     x11 {\n    #       error \"dragging from Tk widgets not yet supported\"\n    #     }\n    #     win32 -\n    #     windows {\n    #       set action [_DoDragDrop $source $actions $types $data $button]\n    #     }\n    #     aqua {\n    #       macdnd::dodragdrop $source $actions $types $data\n    #     }\n    #     default {\n    #       error \"unknown Tk windowing system\"\n    #     }\n    #   }\n    #   ## Call _end_drag to notify the widget of the result of the drag\n    #   ## operation...\n    #   _end_drag $button $source {} $action {} $data {} $state $rootX $rootY\n    # }\n  }\n};# tkdnd::_end_drag\n\n# ----------------------------------------------------------------------------\n#  Command tkdnd::platform_specific_types\n# ----------------------------------------------------------------------------\nproc ::tkdnd::platform_specific_types { types } {\n  variable _platform_namespace\n  ${_platform_namespace}::platform_specific_types $types\n}; # tkdnd::platform_specific_types\n\n# ----------------------------------------------------------------------------\n#  Command tkdnd::platform_independent_types\n# ----------------------------------------------------------------------------\nproc ::tkdnd::platform_independent_types { types } {\n  variable _platform_namespace\n  ${_platform_namespace}::platform_independent_types $types\n}; # tkdnd::platform_independent_types\n\n# ----------------------------------------------------------------------------\n#  Command tkdnd::platform_specific_type\n# ----------------------------------------------------------------------------\nproc ::tkdnd::platform_specific_type { type } {\n  variable _platform_namespace\n  ${_platform_namespace}::platform_specific_type $type\n}; # tkdnd::platform_specific_type\n\n# ----------------------------------------------------------------------------\n#  Command tkdnd::platform_independent_type\n# ----------------------------------------------------------------------------\nproc ::tkdnd::platform_independent_type { type } {\n  variable _platform_namespace\n  ${_platform_namespace}::platform_independent_type $type\n}; # tkdnd::platform_independent_type\n\n# ----------------------------------------------------------------------------\n#  Command tkdnd::bytes_to_string\n# ----------------------------------------------------------------------------\nproc ::tkdnd::bytes_to_string { bytes } {\n  set string {}\n  foreach byte $bytes {\n    append string [binary format c $byte]\n  }\n  return $string\n};# tkdnd::bytes_to_string\n\n# ----------------------------------------------------------------------------\n#  Command tkdnd::urn_unquote\n# ----------------------------------------------------------------------------\nproc ::tkdnd::urn_unquote {url} {\n  set result \"\"\n  set start 0\n  while {[regexp -start $start -indices {%[0-9a-fA-F]{2}} $url match]} {\n    foreach {first last} $match break\n    append result [string range $url $start [expr {$first - 1}]]\n    append result [format %c 0x[string range $url [incr first] $last]]\n    set start [incr last]\n  }\n  append result [string range $url $start end]\n  return [encoding convertfrom utf-8 $result]\n};# tkdnd::urn_unquote\n"
  },
  {
    "path": "build_files/tkdnd2.9.2/tkdnd_compat.tcl",
    "content": "#\n# tkdnd_compat.tcl --\n# \n#    This file implements some utility procedures, to support older versions\n#    of the TkDND package.\n#\n# This software is copyrighted by:\n# George Petasis, National Centre for Scientific Research \"Demokritos\",\n# Aghia Paraskevi, Athens, Greece.\n# e-mail: petasis@iit.demokritos.gr\n#\n# The following terms apply to all files associated\n# with the software unless explicitly disclaimed in individual files.\n#\n# The authors hereby grant permission to use, copy, modify, distribute,\n# and license this software and its documentation for any purpose, provided\n# that existing copyright notices are retained in all copies and that this\n# notice is included verbatim in any distributions. No written agreement,\n# license, or royalty fee is required for any of the authorized uses.\n# Modifications to this software may be copyrighted by their authors\n# and need not follow the licensing terms described here, provided that\n# the new terms are clearly indicated on the first page of each file where\n# they apply.\n# \n# IN NO EVENT SHALL THE AUTHORS OR DISTRIBUTORS BE LIABLE TO ANY PARTY\n# FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES\n# ARISING OUT OF THE USE OF THIS SOFTWARE, ITS DOCUMENTATION, OR ANY\n# DERIVATIVES THEREOF, EVEN IF THE AUTHORS HAVE BEEN ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n# \n# THE AUTHORS AND DISTRIBUTORS SPECIFICALLY DISCLAIM ANY WARRANTIES,\n# INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY,\n# FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT.  THIS SOFTWARE\n# IS PROVIDED ON AN \"AS IS\" BASIS, AND THE AUTHORS AND DISTRIBUTORS HAVE\n# NO OBLIGATION TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR\n# MODIFICATIONS.\n#\n\nnamespace eval compat {\n\n};# namespace compat\n\n# ----------------------------------------------------------------------------\n#  Command ::dnd\n# ----------------------------------------------------------------------------\nproc ::dnd {method window args} {\n  switch $method {\n    bindtarget {\n      switch [llength $args] {\n        0 {return [tkdnd::compat::bindtarget0 $window]}\n        1 {return [tkdnd::compat::bindtarget1 $window [lindex $args 0]]}\n        2 {return [tkdnd::compat::bindtarget2 $window [lindex $args 0] \\\n                                                      [lindex $args 1]]}\n        3 {return [tkdnd::compat::bindtarget3 $window [lindex $args 0] \\\n                                     [lindex $args 1] [lindex $args 2]]}\n        4 {return [tkdnd::compat::bindtarget4 $window [lindex $args 0] \\\n                    [lindex $args 1] [lindex $args 2] [lindex $args 3]]}\n      }\n    }\n    cleartarget {\n      return [tkdnd::compat::cleartarget $window]\n    }\n    bindsource {\n      switch [llength $args] {\n        0 {return [tkdnd::compat::bindsource0 $window]}\n        1 {return [tkdnd::compat::bindsource1 $window [lindex $args 0]]}\n        2 {return [tkdnd::compat::bindsource2 $window [lindex $args 0] \\\n                                                      [lindex $args 1]]}\n        3 {return [tkdnd::compat::bindsource3 $window [lindex $args 0] \\\n                                     [lindex $args 1] [lindex $args 2]]}\n      }\n    }\n    clearsource {\n      return [tkdnd::compat::clearsource $window]\n    }\n    drag {\n      return [tkdnd::_init_drag 1 $window \"press\" 0 0 0 0]\n    }\n  }\n  error \"invalid number of arguments!\"\n};# ::dnd\n\n# ----------------------------------------------------------------------------\n#  Command compat::bindtarget\n# ----------------------------------------------------------------------------\nproc compat::bindtarget0 {window} {\n  return [bind $window <<DropTargetTypes>>]\n};# compat::bindtarget0\n\nproc compat::bindtarget1 {window type} {\n  return [bindtarget2 $window $type <Drop>]\n};# compat::bindtarget1\n\nproc compat::bindtarget2 {window type event} {\n  switch $event {\n    <DragEnter> {return [bind $window <<DropEnter>>]}\n    <Drag>      {return [bind $window <<DropPosition>>]}\n    <DragLeave> {return [bind $window <<DropLeave>>]}\n    <Drop>      {return [bind $window <<Drop>>]}\n  }\n};# compat::bindtarget2\n\nproc compat::bindtarget3 {window type event script} {\n  set type [normalise_type $type]\n  ::tkdnd::drop_target register $window [list $type]\n  switch $event {\n    <DragEnter> {return [bind $window <<DropEnter>> $script]}\n    <Drag>      {return [bind $window <<DropPosition>> $script]}\n    <DragLeave> {return [bind $window <<DropLeave>> $script]}\n    <Drop>      {return [bind $window <<Drop>> $script]}\n  }\n};# compat::bindtarget3\n\nproc compat::bindtarget4 {window type event script priority} {\n  return [bindtarget3 $window $type $event $script]\n};# compat::bindtarget4\n\nproc compat::normalise_type { type } {\n  switch $type {\n    text/plain -\n    {text/plain;charset=UTF-8} -\n    Text                       {return DND_Text}\n    text/uri-list -\n    Files                      {return DND_Files}\n    default                    {return $type}\n  }\n};# compat::normalise_type\n\n# ----------------------------------------------------------------------------\n#  Command compat::bindsource\n# ----------------------------------------------------------------------------\nproc compat::bindsource0 {window} {\n  return [bind $window <<DropTargetTypes>>]\n};# compat::bindsource0\n\nproc compat::bindsource1 {window type} {\n  return [bindsource2 $window $type <Drop>]\n};# compat::bindsource1\n\nproc compat::bindsource2 {window type script} {\n  set type [normalise_type $type]\n  ::tkdnd::drag_source register $window $type\n  bind $window <<DragInitCmd>> \"list {copy} {%t} \\[$script\\]\"\n};# compat::bindsource2\n\nproc compat::bindsource3 {window type script priority} {\n  return [bindsource2 $window $type $script]\n};# compat::bindsource3\n\n# ----------------------------------------------------------------------------\n#  Command compat::cleartarget\n# ----------------------------------------------------------------------------\nproc compat::cleartarget {window} {\n};# compat::cleartarget\n\n# ----------------------------------------------------------------------------\n#  Command compat::clearsource\n# ----------------------------------------------------------------------------\nproc compat::clearsource {window} {\n};# compat::clearsource\n"
  },
  {
    "path": "build_files/tkdnd2.9.2/tkdnd_generic.tcl",
    "content": "#\n# tkdnd_generic.tcl --\n#\n#    This file implements some utility procedures that are used by the TkDND\n#    package.\n#\n# This software is copyrighted by:\n# George Petasis, National Centre for Scientific Research \"Demokritos\",\n# Aghia Paraskevi, Athens, Greece.\n# e-mail: petasis@iit.demokritos.gr\n#\n# The following terms apply to all files associated\n# with the software unless explicitly disclaimed in individual files.\n#\n# The authors hereby grant permission to use, copy, modify, distribute,\n# and license this software and its documentation for any purpose, provided\n# that existing copyright notices are retained in all copies and that this\n# notice is included verbatim in any distributions. No written agreement,\n# license, or royalty fee is required for any of the authorized uses.\n# Modifications to this software may be copyrighted by their authors\n# and need not follow the licensing terms described here, provided that\n# the new terms are clearly indicated on the first page of each file where\n# they apply.\n#\n# IN NO EVENT SHALL THE AUTHORS OR DISTRIBUTORS BE LIABLE TO ANY PARTY\n# FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES\n# ARISING OUT OF THE USE OF THIS SOFTWARE, ITS DOCUMENTATION, OR ANY\n# DERIVATIVES THEREOF, EVEN IF THE AUTHORS HAVE BEEN ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n#\n# THE AUTHORS AND DISTRIBUTORS SPECIFICALLY DISCLAIM ANY WARRANTIES,\n# INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY,\n# FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT.  THIS SOFTWARE\n# IS PROVIDED ON AN \"AS IS\" BASIS, AND THE AUTHORS AND DISTRIBUTORS HAVE\n# NO OBLIGATION TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR\n# MODIFICATIONS.\n#\n\nnamespace eval generic {\n  variable _types {}\n  variable _typelist {}\n  variable _codelist {}\n  variable _actionlist {}\n  variable _pressedkeys {}\n  variable _action {}\n  variable _common_drag_source_types {}\n  variable _common_drop_target_types {}\n  variable _drag_source {}\n  variable _drop_target {}\n\n  variable _last_mouse_root_x 0\n  variable _last_mouse_root_y 0\n\n  variable _tkdnd2platform\n  variable _platform2tkdnd\n\n  proc debug {msg} {\n    puts $msg\n  };# debug\n\n  proc initialise { } {\n  };# initialise\n\n  proc initialise_platform_to_tkdnd_types { types } {\n    variable _platform2tkdnd\n    variable _tkdnd2platform\n    set _platform2tkdnd [dict create {*}$types]\n    set _tkdnd2platform [dict create]\n    foreach type [dict keys $_platform2tkdnd] {\n      dict lappend _tkdnd2platform [dict get $_platform2tkdnd $type] $type\n    }\n  };# initialise_platform_to_tkdnd_types\n\n  proc initialise_tkdnd_to_platform_types { types } {\n    variable _tkdnd2platform\n    set _tkdnd2platform [dict create {*}$types]\n  };# initialise_tkdnd_to_platform_types\n\n};# namespace generic\n\n# ----------------------------------------------------------------------------\n#  Command generic::HandleEnter\n# ----------------------------------------------------------------------------\nproc generic::HandleEnter { drop_target drag_source typelist codelist\n                            actionlist pressedkeys } {\n  variable _typelist;                 set _typelist    $typelist\n  variable _pressedkeys;              set _pressedkeys $pressedkeys\n  variable _action;                   set _action      refuse_drop\n  variable _common_drag_source_types; set _common_drag_source_types {}\n  variable _common_drop_target_types; set _common_drop_target_types {}\n  variable _actionlist\n  variable _drag_source;              set _drag_source $drag_source\n  variable _drop_target;              set _drop_target {}\n  variable _actionlist;               set _actionlist  $actionlist\n  variable _codelist                  set _codelist    $codelist\n\n  variable _last_mouse_root_x;        set _last_mouse_root_x 0\n  variable _last_mouse_root_y;        set _last_mouse_root_y 0\n  # debug \"\\n===============================================================\"\n  # debug \"generic::HandleEnter: drop_target=$drop_target,\\\n  #        drag_source=$drag_source,\\\n  #        typelist=$typelist\"\n  # debug \"generic::HandleEnter: ACTION: default\"\n  return default\n};# generic::HandleEnter\n\n# ----------------------------------------------------------------------------\n#  Command generic::HandlePosition\n# ----------------------------------------------------------------------------\nproc generic::HandlePosition { drop_target drag_source pressedkeys\n                               rootX rootY { time 0 } } {\n  variable _types\n  variable _typelist\n  variable _codelist\n  variable _actionlist\n  variable _pressedkeys\n  variable _action\n  variable _common_drag_source_types\n  variable _common_drop_target_types\n  variable _drag_source\n  variable _drop_target\n\n  variable _last_mouse_root_x;        set _last_mouse_root_x $rootX\n  variable _last_mouse_root_y;        set _last_mouse_root_y $rootY\n\n  # debug \"generic::HandlePosition: drop_target=$drop_target,\\\n  #            _drop_target=$_drop_target, rootX=$rootX, rootY=$rootY\"\n\n  if {![info exists _drag_source] && ![string length $_drag_source]} {\n    # debug \"generic::HandlePosition: no or empty _drag_source:\\\n    #               return refuse_drop\"\n    return refuse_drop\n  }\n\n  if {$drag_source ne \"\" && $drag_source ne $_drag_source} {\n    debug \"generic position event from unexpected source: $_drag_source\\\n           != $drag_source\"\n    return refuse_drop\n  }\n\n  set _pressedkeys $pressedkeys\n\n  ## Does the new drop target support any of our new types?\n  # foreach {common_drag_source_types common_drop_target_types} \\\n  #         [GetWindowCommonTypes $drop_target $_typelist] {break}\n  foreach {drop_target common_drag_source_types common_drop_target_types} \\\n          [FindWindowWithCommonTypes $drop_target $_typelist] {break}\n  set data [GetDroppedData $time]\n\n  # debug \"\\t($_drop_target) -> ($drop_target)\"\n  if {$drop_target != $_drop_target} {\n    if {[string length $_drop_target]} {\n      ## Call the <<DropLeave>> event.\n      # debug \"\\t<<DropLeave>> on $_drop_target\"\n      set cmd [bind $_drop_target <<DropLeave>>]\n      if {[string length $cmd]} {\n        set cmd [string map [list %W $_drop_target %X $rootX %Y $rootY \\\n          %CST \\{$_common_drag_source_types\\} \\\n          %CTT \\{$_common_drop_target_types\\} \\\n          %CPT \\{[lindex [platform_independent_type [lindex $_common_drag_source_types 0]] 0]\\} \\\n          %ST  \\{$_typelist\\}    %TT \\{$_types\\} \\\n          %A   \\{$_action\\}      %a \\{$_actionlist\\} \\\n          %b   \\{$_pressedkeys\\} %m \\{$_pressedkeys\\} \\\n          %D   \\{\\}              %e <<DropLeave>> \\\n          %L   \\{$_typelist\\}    %% % \\\n          %t   \\{$_typelist\\}    %T  \\{[lindex $_common_drag_source_types 0]\\} \\\n          %c   \\{$_codelist\\}    %C  \\{[lindex $_codelist 0]\\} \\\n          ] $cmd]\n        uplevel \\#0 $cmd\n      }\n    }\n    set _drop_target $drop_target\n    set _action      refuse_drop\n\n    if {[llength $common_drag_source_types]} {\n      set _action [lindex $_actionlist 0]\n      set _common_drag_source_types $common_drag_source_types\n      set _common_drop_target_types $common_drop_target_types\n      ## Drop target supports at least one type. Send a <<DropEnter>>.\n      # puts \"<<DropEnter>> -> $drop_target\"\n      set cmd [bind $drop_target <<DropEnter>>]\n      if {[string length $cmd]} {\n        focus $drop_target\n        set cmd [string map [list %W $drop_target %X $rootX %Y $rootY \\\n          %CST \\{$_common_drag_source_types\\} \\\n          %CTT \\{$_common_drop_target_types\\} \\\n          %CPT \\{[lindex [platform_independent_type [lindex $_common_drag_source_types 0]] 0]\\} \\\n          %ST  \\{$_typelist\\}    %TT \\{$_types\\} \\\n          %A   $_action          %a  \\{$_actionlist\\} \\\n          %b   \\{$_pressedkeys\\} %m  \\{$_pressedkeys\\} \\\n          %D   [list $data]      %e  <<DropEnter>> \\\n          %L   \\{$_typelist\\}    %%  % \\\n          %t   \\{$_typelist\\}    %T  \\{[lindex $_common_drag_source_types 0]\\} \\\n          %c   \\{$_codelist\\}    %C  \\{[lindex $_codelist 0]\\} \\\n          ] $cmd]\n        set _action [uplevel \\#0 $cmd]\n        switch -exact -- $_action {\n          copy - move - link - ask - private - refuse_drop - default {}\n          default {set _action copy}\n        }\n      }\n    }\n  }\n\n  set _drop_target {}\n  if {[llength $common_drag_source_types]} {\n    set _common_drag_source_types $common_drag_source_types\n    set _common_drop_target_types $common_drop_target_types\n    set _drop_target $drop_target\n    ## Drop target supports at least one type. Send a <<DropPosition>>.\n    set cmd [bind $drop_target <<DropPosition>>]\n    if {[string length $cmd]} {\n      set cmd [string map [list %W $drop_target %X $rootX %Y $rootY \\\n        %CST \\{$_common_drag_source_types\\} \\\n        %CTT \\{$_common_drop_target_types\\} \\\n        %CPT \\{[lindex [platform_independent_type [lindex $_common_drag_source_types 0]] 0]\\} \\\n        %ST  \\{$_typelist\\}    %TT \\{$_types\\} \\\n        %A   $_action          %a  \\{$_actionlist\\} \\\n        %b   \\{$_pressedkeys\\} %m  \\{$_pressedkeys\\} \\\n        %D   [list $data]      %e  <<DropPosition>> \\\n        %L   \\{$_typelist\\}    %%  % \\\n        %t   \\{$_typelist\\}    %T  \\{[lindex $_common_drag_source_types 0]\\} \\\n        %c   \\{$_codelist\\}    %C  \\{[lindex $_codelist 0]\\} \\\n        ] $cmd]\n      set _action [uplevel \\#0 $cmd]\n    }\n  }\n  # Return values: copy, move, link, ask, private, refuse_drop, default\n  # debug \"generic::HandlePosition: ACTION: $_action\"\n  switch -exact -- $_action {\n    copy - move - link - ask - private - refuse_drop - default {}\n    default {set _action copy}\n  }\n  return $_action\n};# generic::HandlePosition\n\n# ----------------------------------------------------------------------------\n#  Command generic::HandleLeave\n# ----------------------------------------------------------------------------\nproc generic::HandleLeave { } {\n  variable _types\n  variable _typelist\n  variable _codelist\n  variable _actionlist\n  variable _pressedkeys\n  variable _action\n  variable _common_drag_source_types\n  variable _common_drop_target_types\n  variable _drag_source\n  variable _drop_target\n  variable _last_mouse_root_x\n  variable _last_mouse_root_y\n  if {![info exists _drop_target]} {set _drop_target {}}\n  # debug \"generic::HandleLeave: _drop_target=$_drop_target\"\n  if {[info exists _drop_target] && [string length $_drop_target]} {\n    set cmd [bind $_drop_target <<DropLeave>>]\n    if {[string length $cmd]} {\n      set cmd [string map [list %W $_drop_target \\\n        %X $_last_mouse_root_x %Y $_last_mouse_root_y \\\n        %CST \\{$_common_drag_source_types\\} \\\n        %CTT \\{$_common_drop_target_types\\} \\\n        %CPT \\{[lindex [platform_independent_type [lindex $_common_drag_source_types 0]] 0]\\} \\\n        %ST  \\{$_typelist\\}    %TT \\{$_types\\} \\\n        %A   \\{$_action\\}      %a  \\{$_actionlist\\} \\\n        %b   \\{$_pressedkeys\\} %m  \\{$_pressedkeys\\} \\\n        %D   \\{\\}              %e  <<DropLeave>> \\\n        %L   \\{$_typelist\\}    %%  % \\\n        %t   \\{$_typelist\\}    %T  \\{[lindex $_common_drag_source_types 0]\\} \\\n        %c   \\{$_codelist\\}    %C  \\{[lindex $_codelist 0]\\} \\\n        ] $cmd]\n      set _action [uplevel \\#0 $cmd]\n    }\n  }\n  foreach var {_types _typelist _actionlist _pressedkeys _action\n               _common_drag_source_types _common_drop_target_types\n               _drag_source _drop_target} {\n    set $var {}\n  }\n};# generic::HandleLeave\n\n# ----------------------------------------------------------------------------\n#  Command generic::HandleDrop\n# ----------------------------------------------------------------------------\nproc generic::HandleDrop {drop_target drag_source pressedkeys rootX rootY time } {\n  variable _types\n  variable _typelist\n  variable _codelist\n  variable _actionlist\n  variable _pressedkeys\n  variable _action\n  variable _common_drag_source_types\n  variable _common_drop_target_types\n  variable _drag_source\n  variable _drop_target\n  variable _last_mouse_root_x\n  variable _last_mouse_root_y\n  variable _last_mouse_root_x;        set _last_mouse_root_x $rootX\n  variable _last_mouse_root_y;        set _last_mouse_root_y $rootY\n\n  set _pressedkeys $pressedkeys\n\n  # puts \"generic::HandleDrop: $time\"\n\n  if {![info exists _drag_source] && ![string length $_drag_source]} {\n    return refuse_drop\n  }\n  if {![info exists _drop_target] && ![string length $_drop_target]} {\n    return refuse_drop\n  }\n  if {![llength $_common_drag_source_types]} {return refuse_drop}\n  ## Get the dropped data.\n  set data [GetDroppedData $time]\n  ## Try to select the most specific <<Drop>> event.\n  foreach type [concat $_common_drag_source_types $_common_drop_target_types] {\n    set type [platform_independent_type $type]\n    set cmd [bind $_drop_target <<Drop:$type>>]\n    if {[string length $cmd]} {\n      set cmd [string map [list %W $_drop_target %X $rootX %Y $rootY \\\n        %CST \\{$_common_drag_source_types\\} \\\n        %CTT \\{$_common_drop_target_types\\} \\\n        %CPT \\{[lindex [platform_independent_type [lindex $_common_drag_source_types 0]] 0]\\} \\\n        %ST  \\{$_typelist\\}    %TT \\{$_types\\} \\\n        %A   $_action          %a \\{$_actionlist\\} \\\n        %b   \\{$_pressedkeys\\} %m \\{$_pressedkeys\\} \\\n        %D   [list $data]      %e <<Drop:$type>> \\\n        %L   \\{$_typelist\\}    %% % \\\n        %t   \\{$_typelist\\}    %T  \\{[lindex $_common_drag_source_types 0]\\} \\\n        %c   \\{$_codelist\\}    %C  \\{[lindex $_codelist 0]\\} \\\n        ] $cmd]\n      set _action [uplevel \\#0 $cmd]\n      # Return values: copy, move, link, ask, private, refuse_drop\n      switch -exact -- $_action {\n        copy - move - link - ask - private - refuse_drop - default {}\n        default {set _action copy}\n      }\n      return $_action\n    }\n  }\n  set cmd [bind $_drop_target <<Drop>>]\n  if {[string length $cmd]} {\n    set cmd [string map [list %W $_drop_target %X $rootX %Y $rootY \\\n      %CST \\{$_common_drag_source_types\\} \\\n      %CTT \\{$_common_drop_target_types\\} \\\n      %CPT \\{[lindex [platform_independent_type [lindex $_common_drag_source_types 0]] 0]\\} \\\n      %ST  \\{$_typelist\\}    %TT \\{$_types\\} \\\n      %A   $_action          %a \\{$_actionlist\\} \\\n      %b   \\{$_pressedkeys\\} %m \\{$_pressedkeys\\} \\\n      %D   [list $data]      %e <<Drop>> \\\n      %L   \\{$_typelist\\}    %% % \\\n      %t   \\{$_typelist\\}    %T  \\{[lindex $_common_drag_source_types 0]\\} \\\n      %c   \\{$_codelist\\}    %C  \\{[lindex $_codelist 0]\\} \\\n      ] $cmd]\n    set _action [uplevel \\#0 $cmd]\n  }\n  # Return values: copy, move, link, ask, private, refuse_drop\n  switch -exact -- $_action {\n    copy - move - link - ask - private - refuse_drop - default {}\n    default {set _action copy}\n  }\n  return $_action\n};# generic::HandleDrop\n\n# ----------------------------------------------------------------------------\n#  Command generic::GetWindowCommonTypes\n# ----------------------------------------------------------------------------\nproc generic::GetWindowCommonTypes { win typelist } {\n  set types [bind $win <<DropTargetTypes>>]\n  # debug \">> Accepted types: $win $_types\"\n  set common_drag_source_types {}\n  set common_drop_target_types {}\n  if {[llength $types]} {\n    ## Examine the drop target types, to find at least one match with the drag\n    ## source types...\n    set supported_types [supported_types $typelist]\n    foreach type $types {\n      foreach matched [lsearch -glob -all -inline $supported_types $type] {\n        ## Drop target supports this type.\n        lappend common_drag_source_types $matched\n        lappend common_drop_target_types $type\n      }\n    }\n  }\n  list $common_drag_source_types $common_drop_target_types\n};# generic::GetWindowCommonTypes\n\n# ----------------------------------------------------------------------------\n#  Command generic::FindWindowWithCommonTypes\n# ----------------------------------------------------------------------------\nproc generic::FindWindowWithCommonTypes { win typelist } {\n  set toplevel [winfo toplevel $win]\n  while {![string equal $win $toplevel]} {\n    foreach {common_drag_source_types common_drop_target_types} \\\n            [GetWindowCommonTypes $win $typelist] {break}\n    if {[llength $common_drag_source_types]} {\n      return [list $win $common_drag_source_types $common_drop_target_types]\n    }\n    set win [winfo parent $win]\n  }\n  ## We have reached the toplevel, which may be also a target (SF Bug #30)\n  foreach {common_drag_source_types common_drop_target_types} \\\n          [GetWindowCommonTypes $win $typelist] {break}\n  if {[llength $common_drag_source_types]} {\n    return [list $win $common_drag_source_types $common_drop_target_types]\n  }\n  return { {} {} {} }\n};# generic::FindWindowWithCommonTypes\n\n# ----------------------------------------------------------------------------\n#  Command generic::GetDroppedData\n# ----------------------------------------------------------------------------\nproc generic::GetDroppedData { time } {\n  variable _dropped_data\n  return  $_dropped_data\n};# generic::GetDroppedData\n\n# ----------------------------------------------------------------------------\n#  Command generic::SetDroppedData\n# ----------------------------------------------------------------------------\nproc generic::SetDroppedData { data } {\n  variable _dropped_data\n  set _dropped_data $data\n};# generic::SetDroppedData\n\n# ----------------------------------------------------------------------------\n#  Command generic::GetDragSource\n# ----------------------------------------------------------------------------\nproc generic::GetDragSource { } {\n  variable _drag_source\n  return  $_drag_source\n};# generic::GetDragSource\n\n# ----------------------------------------------------------------------------\n#  Command generic::GetDropTarget\n# ----------------------------------------------------------------------------\nproc generic::GetDropTarget { } {\n  variable _drop_target\n  return $_drop_target\n};# generic::GetDropTarget\n\n# ----------------------------------------------------------------------------\n#  Command generic::GetDragSourceCommonTypes\n# ----------------------------------------------------------------------------\nproc generic::GetDragSourceCommonTypes { } {\n  variable _common_drag_source_types\n  return  $_common_drag_source_types\n};# generic::GetDragSourceCommonTypes\n\n# ----------------------------------------------------------------------------\n#  Command generic::GetDropTargetCommonTypes\n# ----------------------------------------------------------------------------\nproc generic::GetDropTargetCommonTypes { } {\n  variable _common_drag_source_types\n  return  $_common_drag_source_types\n};# generic::GetDropTargetCommonTypes\n\n# ----------------------------------------------------------------------------\n#  Command generic::platform_specific_types\n# ----------------------------------------------------------------------------\nproc generic::platform_specific_types { types } {\n  set new_types {}\n  foreach type $types {\n    set new_types [concat $new_types [platform_specific_type $type]]\n  }\n  return $new_types\n}; # generic::platform_specific_types\n\n# ----------------------------------------------------------------------------\n#  Command generic::platform_specific_type\n# ----------------------------------------------------------------------------\nproc generic::platform_specific_type { type } {\n  variable _tkdnd2platform\n  if {[dict exists $_tkdnd2platform $type]} {\n    return [dict get $_tkdnd2platform $type]\n  }\n  list $type\n}; # generic::platform_specific_type\n\n# ----------------------------------------------------------------------------\n#  Command tkdnd::platform_independent_types\n# ----------------------------------------------------------------------------\nproc ::tkdnd::platform_independent_types { types } {\n  set new_types {}\n  foreach type $types {\n    set new_types [concat $new_types [platform_independent_type $type]]\n  }\n  return $new_types\n}; # tkdnd::platform_independent_types\n\n# ----------------------------------------------------------------------------\n#  Command generic::platform_independent_type\n# ----------------------------------------------------------------------------\nproc generic::platform_independent_type { type } {\n  variable _platform2tkdnd\n  if {[dict exists $_platform2tkdnd $type]} {\n    return [dict get $_platform2tkdnd $type]\n  }\n  return $type\n}; # generic::platform_independent_type\n\n# ----------------------------------------------------------------------------\n#  Command generic::supported_types\n# ----------------------------------------------------------------------------\nproc generic::supported_types { types } {\n  set new_types {}\n  foreach type $types {\n    if {[supported_type $type]} {lappend new_types $type}\n  }\n  return $new_types\n}; # generic::supported_types\n\n# ----------------------------------------------------------------------------\n#  Command generic::supported_type\n# ----------------------------------------------------------------------------\nproc generic::supported_type { type } {\n  variable _platform2tkdnd\n  if {[dict exists $_platform2tkdnd $type]} {\n    return 1\n  }\n  return 0\n}; # generic::supported_type\n"
  },
  {
    "path": "build_files/tkdnd2.9.2/tkdnd_macosx.tcl",
    "content": "#\n# tkdnd_macosx.tcl --\n#\n#    This file implements some utility procedures that are used by the TkDND\n#    package.\n\n#   This software is copyrighted by:\n#   Georgios Petasis, Athens, Greece.\n#   e-mail: petasisg@yahoo.gr, petasis@iit.demokritos.gr\n#\n#   Mac portions (c) 2009 Kevin Walzer/WordTech Communications LLC,\n#   kw@codebykevin.com\n#\n#\n# The following terms apply to all files associated\n# with the software unless explicitly disclaimed in individual files.\n#\n# The authors hereby grant permission to use, copy, modify, distribute,\n# and license this software and its documentation for any purpose, provided\n# that existing copyright notices are retained in all copies and that this\n# notice is included verbatim in any distributions. No written agreement,\n# license, or royalty fee is required for any of the authorized uses.\n# Modifications to this software may be copyrighted by their authors\n# and need not follow the licensing terms described here, provided that\n# the new terms are clearly indicated on the first page of each file where\n# they apply.\n#\n# IN NO EVENT SHALL THE AUTHORS OR DISTRIBUTORS BE LIABLE TO ANY PARTY\n# FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES\n# ARISING OUT OF THE USE OF THIS SOFTWARE, ITS DOCUMENTATION, OR ANY\n# DERIVATIVES THEREOF, EVEN IF THE AUTHORS HAVE BEEN ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n#\n# THE AUTHORS AND DISTRIBUTORS SPECIFICALLY DISCLAIM ANY WARRANTIES,\n# INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY,\n# FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT.  THIS SOFTWARE\n# IS PROVIDED ON AN \"AS IS\" BASIS, AND THE AUTHORS AND DISTRIBUTORS HAVE\n# NO OBLIGATION TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR\n# MODIFICATIONS.\n#\n\n#basic API for Mac Drag and Drop\n\n#two data types supported: strings and file paths\n\n#two commands at C level: ::tkdnd::macdnd::registerdragwidget, ::tkdnd::macdnd::unregisterdragwidget\n\n#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\n\nif {[tk windowingsystem] eq \"aqua\" && \"AppKit\" ni [winfo server .]} {\n  error {TkAqua Cocoa required}\n}\n\nnamespace eval macdnd {\n\n  proc initialise { } {\n     ## Mapping from platform types to TkDND types...\n    ::tkdnd::generic::initialise_platform_to_tkdnd_types [list \\\n       NSPasteboardTypeString  DND_Text  \\\n       NSFilenamesPboardType   DND_Files \\\n       NSPasteboardTypeHTML    DND_HTML  \\\n    ]\n  };# initialise\n\n};# namespace macdnd\n\n# ----------------------------------------------------------------------------\n#  Command macdnd::HandleEnter\n# ----------------------------------------------------------------------------\nproc macdnd::HandleEnter { path drag_source typelist { data {} } } {\n  variable _pressedkeys\n  variable _actionlist\n  set _pressedkeys 1\n  set _actionlist  { copy move link ask private }\n  ::tkdnd::generic::SetDroppedData $data\n  ::tkdnd::generic::HandleEnter $path $drag_source $typelist $typelist \\\n           $_actionlist $_pressedkeys\n};# macdnd::HandleEnter\n\n# ----------------------------------------------------------------------------\n#  Command macdnd::HandlePosition\n# ----------------------------------------------------------------------------\nproc macdnd::HandlePosition { drop_target rootX rootY {drag_source {}} } {\n  variable _pressedkeys\n  variable _last_mouse_root_x; set _last_mouse_root_x $rootX\n  variable _last_mouse_root_y; set _last_mouse_root_y $rootY\n  ::tkdnd::generic::HandlePosition $drop_target $drag_source \\\n                                   $_pressedkeys $rootX $rootY\n};# macdnd::HandlePosition\n\n# ----------------------------------------------------------------------------\n#  Command macdnd::HandleLeave\n# ----------------------------------------------------------------------------\nproc macdnd::HandleLeave { args } {\n  ::tkdnd::generic::HandleLeave\n};# macdnd::HandleLeave\n\n# ----------------------------------------------------------------------------\n#  Command macdnd::HandleDrop\n# ----------------------------------------------------------------------------\nproc macdnd::HandleDrop { drop_target data args } {\n  variable _pressedkeys\n  variable _last_mouse_root_x\n  variable _last_mouse_root_y\n  ## Get the dropped data...\n  ::tkdnd::generic::SetDroppedData $data\n  ::tkdnd::generic::HandleDrop {} {} $_pressedkeys \\\n                               $_last_mouse_root_x $_last_mouse_root_y 0\n};# macdnd::HandleDrop\n\n# ----------------------------------------------------------------------------\n#  Command macdnd::GetDragSourceCommonTypes\n# ----------------------------------------------------------------------------\nproc macdnd::GetDragSourceCommonTypes { } {\n  ::tkdnd::generic::GetDragSourceCommonTypes\n};# macdnd::GetDragSourceCommonTypes\n\n# ----------------------------------------------------------------------------\n#  Command macdnd::platform_specific_types\n# ----------------------------------------------------------------------------\nproc macdnd::platform_specific_types { types } {\n  ::tkdnd::generic::platform_specific_types $types\n}; # macdnd::platform_specific_types\n\n# ----------------------------------------------------------------------------\n#  Command macdnd::platform_specific_type\n# ----------------------------------------------------------------------------\nproc macdnd::platform_specific_type { type } {\n  ::tkdnd::generic::platform_specific_type $type\n}; # macdnd::platform_specific_type\n\n# ----------------------------------------------------------------------------\n#  Command tkdnd::platform_independent_types\n# ----------------------------------------------------------------------------\nproc ::tkdnd::platform_independent_types { types } {\n  ::tkdnd::generic::platform_independent_types $types\n}; # tkdnd::platform_independent_types\n\n# ----------------------------------------------------------------------------\n#  Command macdnd::platform_independent_type\n# ----------------------------------------------------------------------------\nproc macdnd::platform_independent_type { type } {\n  ::tkdnd::generic::platform_independent_type $type\n}; # macdnd::platform_independent_type\n"
  },
  {
    "path": "build_files/tkdnd2.9.2/tkdnd_unix.tcl",
    "content": "#\n# tkdnd_unix.tcl --\n#\n#    This file implements some utility procedures that are used by the TkDND\n#    package.\n#\n# This software is copyrighted by:\n# George Petasis, National Centre for Scientific Research \"Demokritos\",\n# Aghia Paraskevi, Athens, Greece.\n# e-mail: petasis@iit.demokritos.gr\n#\n# The following terms apply to all files associated\n# with the software unless explicitly disclaimed in individual files.\n#\n# The authors hereby grant permission to use, copy, modify, distribute,\n# and license this software and its documentation for any purpose, provided\n# that existing copyright notices are retained in all copies and that this\n# notice is included verbatim in any distributions. No written agreement,\n# license, or royalty fee is required for any of the authorized uses.\n# Modifications to this software may be copyrighted by their authors\n# and need not follow the licensing terms described here, provided that\n# the new terms are clearly indicated on the first page of each file where\n# they apply.\n#\n# IN NO EVENT SHALL THE AUTHORS OR DISTRIBUTORS BE LIABLE TO ANY PARTY\n# FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES\n# ARISING OUT OF THE USE OF THIS SOFTWARE, ITS DOCUMENTATION, OR ANY\n# DERIVATIVES THEREOF, EVEN IF THE AUTHORS HAVE BEEN ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n#\n# THE AUTHORS AND DISTRIBUTORS SPECIFICALLY DISCLAIM ANY WARRANTIES,\n# INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY,\n# FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT.  THIS SOFTWARE\n# IS PROVIDED ON AN \"AS IS\" BASIS, AND THE AUTHORS AND DISTRIBUTORS HAVE\n# NO OBLIGATION TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR\n# MODIFICATIONS.\n#\n\nnamespace eval xdnd {\n  variable _dragging 0\n\n  proc initialise { } {\n    ## Mapping from platform types to TkDND types...\n    ::tkdnd::generic::initialise_platform_to_tkdnd_types [list \\\n       text/plain\\;charset=utf-8     DND_Text  \\\n       UTF8_STRING                   DND_Text  \\\n       text/plain                    DND_Text  \\\n       STRING                        DND_Text  \\\n       TEXT                          DND_Text  \\\n       COMPOUND_TEXT                 DND_Text  \\\n       text/uri-list                 DND_Files \\\n       text/html\\;charset=utf-8      DND_HTML  \\\n       text/html                     DND_HTML  \\\n       application/x-color           DND_Color \\\n    ]\n  };# initialise\n\n};# namespace xdnd\n\n# ----------------------------------------------------------------------------\n#  Command xdnd::HandleXdndEnter\n# ----------------------------------------------------------------------------\nproc xdnd::HandleXdndEnter { path drag_source typelist time { data {} } } {\n  variable _pressedkeys\n  variable _actionlist\n  variable _typelist\n  set _pressedkeys 1\n  set _actionlist  { copy move link ask private }\n  set _typelist    $typelist\n  # puts \"xdnd::HandleXdndEnter: $time\"\n  ::tkdnd::generic::SetDroppedData $data\n  ::tkdnd::generic::HandleEnter $path $drag_source $typelist $typelist \\\n           $_actionlist $_pressedkeys\n};# xdnd::HandleXdndEnter\n\n# ----------------------------------------------------------------------------\n#  Command xdnd::HandleXdndPosition\n# ----------------------------------------------------------------------------\nproc xdnd::HandleXdndPosition { drop_target rootX rootY time {drag_source {}} } {\n  variable _pressedkeys\n  variable _typelist\n  variable _last_mouse_root_x; set _last_mouse_root_x $rootX\n  variable _last_mouse_root_y; set _last_mouse_root_y $rootY\n  # puts \"xdnd::HandleXdndPosition: $time\"\n  ## Get the dropped data...\n  catch {\n    ::tkdnd::generic::SetDroppedData [GetPositionData $drop_target $_typelist $time]\n  }\n  ::tkdnd::generic::HandlePosition $drop_target $drag_source \\\n                                   $_pressedkeys $rootX $rootY\n};# xdnd::HandleXdndPosition\n\n# ----------------------------------------------------------------------------\n#  Command xdnd::HandleXdndLeave\n# ----------------------------------------------------------------------------\nproc xdnd::HandleXdndLeave { } {\n  ::tkdnd::generic::HandleLeave\n};# xdnd::HandleXdndLeave\n\n# ----------------------------------------------------------------------------\n#  Command xdnd::_HandleXdndDrop\n# ----------------------------------------------------------------------------\nproc xdnd::HandleXdndDrop { time } {\n  variable _pressedkeys\n  variable _last_mouse_root_x\n  variable _last_mouse_root_y\n  ## Get the dropped data...\n  ::tkdnd::generic::SetDroppedData [GetDroppedData \\\n    [::tkdnd::generic::GetDragSource] [::tkdnd::generic::GetDropTarget] \\\n    [::tkdnd::generic::GetDragSourceCommonTypes] $time]\n  ::tkdnd::generic::HandleDrop {} {} $_pressedkeys \\\n                               $_last_mouse_root_x $_last_mouse_root_y $time\n};# xdnd::HandleXdndDrop\n\n# ----------------------------------------------------------------------------\n#  Command xdnd::GetPositionData\n# ----------------------------------------------------------------------------\nproc xdnd::GetPositionData { drop_target typelist time } {\n  foreach {drop_target common_drag_source_types common_drop_target_types} \\\n    [::tkdnd::generic::FindWindowWithCommonTypes $drop_target $typelist] {break}\n  GetDroppedData [::tkdnd::generic::GetDragSource] $drop_target \\\n    $common_drag_source_types $time\n};# xdnd::GetPositionData\n\n# ----------------------------------------------------------------------------\n#  Command xdnd::GetDroppedData\n# ----------------------------------------------------------------------------\nproc xdnd::GetDroppedData { _drag_source _drop_target _common_drag_source_types time } {\n  if {![llength $_common_drag_source_types]} {\n    error \"no common data types between the drag source and drop target widgets\"\n  }\n  ## Is drag source in this application?\n  if {[catch {winfo pathname -displayof $_drop_target $_drag_source} p]} {\n    set _use_tk_selection 0\n  } else {\n    set _use_tk_selection 1\n  }\n  foreach type $_common_drag_source_types {\n    # puts \"TYPE: $type ($_drop_target)\"\n    # _get_selection $_drop_target $time $type\n    if {$_use_tk_selection} {\n      if {![catch {\n        selection get -displayof $_drop_target -selection XdndSelection \\\n                      -type $type\n                                              } result options]} {\n        return [normalise_data $type $result]\n      }\n    } else {\n      # puts \"_selection_get -displayof $_drop_target -selection XdndSelection \\\n      #                 -type $type -time $time\"\n      #after 100 [list focus -force $_drop_target]\n      #after 50 [list raise [winfo toplevel $_drop_target]]\n      if {![catch {\n        _selection_get -displayof $_drop_target -selection XdndSelection \\\n                      -type $type -time $time\n                                              } result options]} {\n        return [normalise_data $type $result]\n      }\n    }\n  }\n  return -options $options $result\n};# xdnd::GetDroppedData\n\n# ----------------------------------------------------------------------------\n#  Command xdnd::platform_specific_types\n# ----------------------------------------------------------------------------\nproc xdnd::platform_specific_types { types } {\n  ::tkdnd::generic::platform_specific_types $types\n}; # xdnd::platform_specific_types\n\n# ----------------------------------------------------------------------------\n#  Command xdnd::platform_specific_type\n# ----------------------------------------------------------------------------\nproc xdnd::platform_specific_type { type } {\n  ::tkdnd::generic::platform_specific_type $type\n}; # xdnd::platform_specific_type\n\n# ----------------------------------------------------------------------------\n#  Command tkdnd::platform_independent_types\n# ----------------------------------------------------------------------------\nproc ::tkdnd::platform_independent_types { types } {\n  ::tkdnd::generic::platform_independent_types $types\n}; # tkdnd::platform_independent_types\n\n# ----------------------------------------------------------------------------\n#  Command xdnd::platform_independent_type\n# ----------------------------------------------------------------------------\nproc xdnd::platform_independent_type { type } {\n  ::tkdnd::generic::platform_independent_type $type\n}; # xdnd::platform_independent_type\n\n# ----------------------------------------------------------------------------\n#  Command xdnd::_normalise_data\n# ----------------------------------------------------------------------------\nproc xdnd::normalise_data { type data } {\n  # Tk knows how to interpret the following types:\n  #    STRING, TEXT, COMPOUND_TEXT\n  #    UTF8_STRING\n  # Else, it returns a list of 8 or 32 bit numbers...\n  switch -glob $type {\n    STRING - UTF8_STRING - TEXT - COMPOUND_TEXT {return $data}\n    text/html {\n      if {[catch {\n            encoding convertfrom unicode $data\n           } string]} {\n        set string $data\n      }\n      return [string map {\\r\\n \\n} $string]\n    }\n    text/html\\;charset=utf-8  -\n    text/plain\\;charset=utf-8 -\n    text/plain {\n      if {[catch {\n            encoding convertfrom utf-8 [tkdnd::bytes_to_string $data]\n           } string]} {\n        set string $data\n      }\n      return [string map {\\r\\n \\n} $string]\n    }\n    text/uri-list* {\n      if {[catch {\n            encoding convertfrom utf-8 [tkdnd::bytes_to_string $data]\n          } string]} {\n        set string $data\n      }\n      ## Get rid of \\r\\n\n      set string [string trim [string map {\\r\\n \\n} $string]]\n      set files {}\n      foreach quoted_file [split $string] {\n        set file [tkdnd::urn_unquote $quoted_file]\n        switch -glob $file {\n          \\#*       {}\n          file://*  {lappend files [string range $file 7 end]}\n          ftp://*   -\n          https://* -\n          http://*  {lappend files $quoted_file}\n          default   {lappend files $file}\n        }\n      }\n      return $files\n    }\n    application/x-color {\n      return $data\n    }\n    text/x-moz-url -\n    application/q-iconlist -\n    default    {return $data}\n  }\n}; # xdnd::normalise_data\n\n#############################################################################\n##\n##  XDND drag implementation\n##\n#############################################################################\n\n# ----------------------------------------------------------------------------\n#  Command xdnd::_selection_ownership_lost\n# ----------------------------------------------------------------------------\nproc xdnd::_selection_ownership_lost {} {\n  variable _dragging\n  set _dragging 0\n};# _selection_ownership_lost\n\n# ----------------------------------------------------------------------------\n#  Command xdnd::_dodragdrop\n# ----------------------------------------------------------------------------\nproc xdnd::_dodragdrop { source actions types data button } {\n  variable _dragging\n\n  # puts \"xdnd::_dodragdrop: source: $source, actions: $actions, types: $types,\\\n  #       data: \\\"$data\\\", button: $button\"\n  if {$_dragging} {\n    ## We are in the middle of another drag operation...\n    error \"another drag operation in progress\"\n  }\n\n  variable _dodragdrop_drag_source                $source\n  variable _dodragdrop_drop_target                0\n  variable _dodragdrop_drop_target_proxy          0\n  variable _dodragdrop_actions                    $actions\n  variable _dodragdrop_action_descriptions        $actions\n  variable _dodragdrop_actions_len                [llength $actions]\n  variable _dodragdrop_types                      $types\n  variable _dodragdrop_types_len                  [llength $types]\n  variable _dodragdrop_data                       $data\n  variable _dodragdrop_transfer_data              {}\n  variable _dodragdrop_button                     $button\n  variable _dodragdrop_time                       0\n  variable _dodragdrop_default_action             refuse_drop\n  variable _dodragdrop_waiting_status             0\n  variable _dodragdrop_drop_target_accepts_drop   0\n  variable _dodragdrop_drop_target_accepts_action refuse_drop\n  variable _dodragdrop_current_cursor             $_dodragdrop_default_action\n  variable _dodragdrop_drop_occured               0\n  variable _dodragdrop_selection_requestor        0\n\n  ##\n  ## If we have more than 3 types, the property XdndTypeList must be set on\n  ## the drag source widget...\n  ##\n  if {$_dodragdrop_types_len > 3} {\n    _announce_type_list $_dodragdrop_drag_source $_dodragdrop_types\n  }\n\n  ##\n  ## Announce the actions & their descriptions on the XdndActionList &\n  ## XdndActionDescription properties...\n  ##\n  _announce_action_list $_dodragdrop_drag_source $_dodragdrop_actions \\\n                        $_dodragdrop_action_descriptions\n\n  ##\n  ## Arrange selection handlers for our drag source, and all the supported types\n  ##\n  registerSelectionHandler $source $types\n\n  ##\n  ## Step 1: When a drag begins, the source takes ownership of XdndSelection.\n  ##\n  selection own -command ::tkdnd::xdnd::_selection_ownership_lost \\\n                -selection XdndSelection $source\n  set _dragging 1\n\n  ## Grab the mouse pointer...\n  _grab_pointer $source $_dodragdrop_default_action\n\n  ## Register our generic event handler...\n  #  The generic event callback will report events by modifying variable\n  #  ::xdnd::_dodragdrop_event: a dict with event information will be set as\n  #  the value of the variable...\n  _register_generic_event_handler\n\n  ## Set a timeout for debugging purposes...\n  #  after 60000 {set ::tkdnd::xdnd::_dragging 0}\n\n  tkwait variable ::tkdnd::xdnd::_dragging\n  _SendXdndLeave\n\n  set _dragging 0\n  _ungrab_pointer $source\n  _unregister_generic_event_handler\n  catch {selection clear -selection XdndSelection}\n  unregisterSelectionHandler $source $types\n  return $_dodragdrop_drop_target_accepts_action\n};# xdnd::_dodragdrop\n\n# ----------------------------------------------------------------------------\n#  Command xdnd::_process_drag_events\n# ----------------------------------------------------------------------------\nproc xdnd::_process_drag_events {event} {\n  # The return value from proc is normally 0. A non-zero return value indicates\n  # that the event is not to be handled further; that is, proc has done all\n  # processing that is to be allowed for the event\n  variable _dragging\n  if {!$_dragging} {return 0}\n  # puts $event\n\n  variable _dodragdrop_time\n  set time [dict get $event time]\n  set type [dict get $event type]\n  if {$time < $_dodragdrop_time && ![string equal $type SelectionRequest]} {\n    return 0\n  }\n  set _dodragdrop_time $time\n\n  variable _dodragdrop_drag_source\n  variable _dodragdrop_drop_target\n  variable _dodragdrop_drop_target_proxy\n  variable _dodragdrop_default_action\n  switch $type {\n    MotionNotify {\n      set rootx  [dict get $event x_root]\n      set rooty  [dict get $event y_root]\n      set window [_find_drop_target_window $_dodragdrop_drag_source \\\n                                           $rootx $rooty]\n      if {[string length $window]} {\n        ## Examine the modifiers to suggest an action...\n        set _dodragdrop_default_action [_default_action $event]\n        ## Is it a Tk widget?\n        # set path [winfo containing $rootx $rooty]\n        # puts \"Window under mouse: $window ($path)\"\n        if {$_dodragdrop_drop_target != $window} {\n          ## Send XdndLeave to $_dodragdrop_drop_target\n          _SendXdndLeave\n          ## Is there a proxy? If not, _find_drop_target_proxy returns the\n          ## target window, so we always get a valid \"proxy\".\n          set proxy [_find_drop_target_proxy $_dodragdrop_drag_source $window]\n          ## Send XdndEnter to $window\n          _SendXdndEnter $window $proxy\n          ## Send XdndPosition to $_dodragdrop_drop_target\n          _SendXdndPosition $rootx $rooty $_dodragdrop_default_action\n        } else {\n          ## Send XdndPosition to $_dodragdrop_drop_target\n          _SendXdndPosition $rootx $rooty $_dodragdrop_default_action\n        }\n      } else {\n        ## No window under the mouse. Send XdndLeave to $_dodragdrop_drop_target\n        _SendXdndLeave\n      }\n    }\n    ButtonPress {\n    }\n    ButtonRelease {\n      variable _dodragdrop_button\n      set button [dict get $event button]\n      if {$button == $_dodragdrop_button} {\n        ## The button that initiated the drag was released. Trigger drop...\n        _SendXdndDrop\n      }\n      return 1\n    }\n    KeyPress {\n    }\n    KeyRelease {\n      set keysym [dict get $event keysym]\n      switch $keysym {\n        Escape {\n          ## The user has pressed escape. Abort...\n          if {$_dragging} {set _dragging 0}\n        }\n      }\n    }\n    SelectionRequest {\n      variable _dodragdrop_selection_requestor\n      variable _dodragdrop_selection_property\n      variable _dodragdrop_selection_selection\n      variable _dodragdrop_selection_target\n      variable _dodragdrop_selection_time\n      set _dodragdrop_selection_requestor [dict get $event requestor]\n      set _dodragdrop_selection_property  [dict get $event property]\n      set _dodragdrop_selection_selection [dict get $event selection]\n      set _dodragdrop_selection_target    [dict get $event target]\n      set _dodragdrop_selection_time      $time\n      return 0\n    }\n    default {\n      return 0\n    }\n  }\n  return 0\n};# _process_drag_events\n\n# ----------------------------------------------------------------------------\n#  Command xdnd::_SendXdndEnter\n# ----------------------------------------------------------------------------\nproc xdnd::_SendXdndEnter {window proxy} {\n  variable _dodragdrop_drag_source\n  variable _dodragdrop_drop_target\n  variable _dodragdrop_drop_target_proxy\n  variable _dodragdrop_types\n  variable _dodragdrop_waiting_status\n  variable _dodragdrop_drop_occured\n  if {$_dodragdrop_drop_target > 0} _SendXdndLeave\n  if {$_dodragdrop_drop_occured} return\n  set _dodragdrop_drop_target       $window\n  set _dodragdrop_drop_target_proxy $proxy\n  set _dodragdrop_waiting_status    0\n  if {$_dodragdrop_drop_target < 1} return\n  # puts \"XdndEnter: $_dodragdrop_drop_target $_dodragdrop_drop_target_proxy\"\n  _send_XdndEnter $_dodragdrop_drag_source $_dodragdrop_drop_target \\\n                  $_dodragdrop_drop_target_proxy $_dodragdrop_types\n};# xdnd::_SendXdndEnter\n\n# ----------------------------------------------------------------------------\n#  Command xdnd::_SendXdndPosition\n# ----------------------------------------------------------------------------\nproc xdnd::_SendXdndPosition {rootx rooty action} {\n  variable _dodragdrop_drag_source\n  variable _dodragdrop_drop_target\n  if {$_dodragdrop_drop_target < 1} return\n  variable _dodragdrop_drop_occured\n  if {$_dodragdrop_drop_occured} return\n  variable _dodragdrop_drop_target_proxy\n  variable _dodragdrop_waiting_status\n  ## Arrange a new XdndPosition, to be send periodically...\n  variable _dodragdrop_xdnd_position_heartbeat\n  catch {after cancel $_dodragdrop_xdnd_position_heartbeat}\n  set _dodragdrop_xdnd_position_heartbeat [after 200 \\\n    [list ::tkdnd::xdnd::_SendXdndPosition $rootx $rooty $action]]\n  if {$_dodragdrop_waiting_status} {return}\n  # puts \"XdndPosition: $_dodragdrop_drop_target $rootx $rooty $action\"\n  _send_XdndPosition $_dodragdrop_drag_source $_dodragdrop_drop_target \\\n                     $_dodragdrop_drop_target_proxy $rootx $rooty $action\n  set _dodragdrop_waiting_status 1\n};# xdnd::_SendXdndPosition\n\n# ----------------------------------------------------------------------------\n#  Command xdnd::_HandleXdndStatus\n# ----------------------------------------------------------------------------\nproc xdnd::_HandleXdndStatus {event} {\n  variable _dodragdrop_drop_target\n  variable _dodragdrop_waiting_status\n\n  variable _dodragdrop_drop_target_accepts_drop\n  variable _dodragdrop_drop_target_accepts_action\n  set _dodragdrop_waiting_status 0\n  foreach key {target accept want_position action x y w h} {\n    set $key [dict get $event $key]\n  }\n  set _dodragdrop_drop_target_accepts_drop   $accept\n  set _dodragdrop_drop_target_accepts_action $action\n  if {$_dodragdrop_drop_target < 1} return\n  variable _dodragdrop_drop_occured\n  if {$_dodragdrop_drop_occured} return\n  _update_cursor\n  # puts \"XdndStatus: $event\"\n};# xdnd::_HandleXdndStatus\n\n# ----------------------------------------------------------------------------\n#  Command xdnd::_HandleXdndFinished\n# ----------------------------------------------------------------------------\nproc xdnd::_HandleXdndFinished {event} {\n  variable _dodragdrop_xdnd_finished_event_after_id\n  catch {after cancel $_dodragdrop_xdnd_finished_event_after_id}\n  set _dodragdrop_xdnd_finished_event_after_id {}\n  variable _dodragdrop_drop_target\n  set _dodragdrop_drop_target 0\n  variable _dragging\n  if {$_dragging} {set _dragging 0}\n\n  variable _dodragdrop_drop_target_accepts_drop\n  variable _dodragdrop_drop_target_accepts_action\n  if {[dict size $event]} {\n    foreach key {target accept action} {\n      set $key [dict get $event $key]\n    }\n    set _dodragdrop_drop_target_accepts_drop   $accept\n    set _dodragdrop_drop_target_accepts_action $action\n  } else {\n    set _dodragdrop_drop_target_accepts_drop 0\n  }\n  if {!$_dodragdrop_drop_target_accepts_drop} {\n    set _dodragdrop_drop_target_accepts_action refuse_drop\n  }\n  # puts \"XdndFinished: $event\"\n};# xdnd::_HandleXdndFinished\n\n# ----------------------------------------------------------------------------\n#  Command xdnd::_SendXdndLeave\n# ----------------------------------------------------------------------------\nproc xdnd::_SendXdndLeave {} {\n  variable _dodragdrop_drag_source\n  variable _dodragdrop_drop_target\n  if {$_dodragdrop_drop_target < 1} return\n  variable _dodragdrop_drop_target_proxy\n  # puts \"XdndLeave: $_dodragdrop_drop_target\"\n  _send_XdndLeave $_dodragdrop_drag_source $_dodragdrop_drop_target \\\n                  $_dodragdrop_drop_target_proxy\n  set _dodragdrop_drop_target 0\n  variable _dodragdrop_drop_target_accepts_drop\n  variable _dodragdrop_drop_target_accepts_action\n  set _dodragdrop_drop_target_accepts_drop   0\n  set _dodragdrop_drop_target_accepts_action refuse_drop\n  variable _dodragdrop_drop_occured\n  if {$_dodragdrop_drop_occured} return\n  _update_cursor\n};# xdnd::_SendXdndLeave\n\n# ----------------------------------------------------------------------------\n#  Command xdnd::_SendXdndDrop\n# ----------------------------------------------------------------------------\nproc xdnd::_SendXdndDrop {} {\n  variable _dodragdrop_drag_source\n  variable _dodragdrop_drop_target\n  if {$_dodragdrop_drop_target < 1} {\n    ## The mouse has been released over a widget that does not accept drops.\n    _HandleXdndFinished {}\n    return\n  }\n  variable _dodragdrop_drop_occured\n  if {$_dodragdrop_drop_occured} {return}\n  variable _dodragdrop_drop_target_proxy\n  variable _dodragdrop_drop_target_accepts_drop\n  variable _dodragdrop_drop_target_accepts_action\n\n  set _dodragdrop_drop_occured 1\n  _update_cursor clock\n\n  if {!$_dodragdrop_drop_target_accepts_drop} {\n    _SendXdndLeave\n    _HandleXdndFinished {}\n    return\n  }\n  # puts \"XdndDrop: $_dodragdrop_drop_target\"\n  variable _dodragdrop_drop_timestamp\n  set _dodragdrop_drop_timestamp [_send_XdndDrop \\\n                 $_dodragdrop_drag_source $_dodragdrop_drop_target \\\n                 $_dodragdrop_drop_target_proxy]\n  set _dodragdrop_drop_target 0\n  # puts \"XdndDrop: $_dodragdrop_drop_target\"\n  ## Arrange a timeout for receiving XdndFinished...\n  variable _dodragdrop_xdnd_finished_event_after_id\n  set _dodragdrop_xdnd_finished_event_after_id \\\n    [after 10000 [list ::tkdnd::xdnd::_HandleXdndFinished {}]]\n};# xdnd::_SendXdndDrop\n\n# ----------------------------------------------------------------------------\n#  Command xdnd::_update_cursor\n# ----------------------------------------------------------------------------\nproc xdnd::_update_cursor { {cursor {}}} {\n  # puts \"_update_cursor $cursor\"\n  variable _dodragdrop_current_cursor\n  variable _dodragdrop_drag_source\n  variable _dodragdrop_drop_target_accepts_drop\n  variable _dodragdrop_drop_target_accepts_action\n\n  if {![string length $cursor]} {\n    set cursor refuse_drop\n    if {$_dodragdrop_drop_target_accepts_drop} {\n      set cursor $_dodragdrop_drop_target_accepts_action\n    }\n  }\n  if {![string equal $cursor $_dodragdrop_current_cursor]} {\n    _set_pointer_cursor $_dodragdrop_drag_source $cursor\n    set _dodragdrop_current_cursor $cursor\n  }\n};# xdnd::_update_cursor\n\n# ----------------------------------------------------------------------------\n#  Command xdnd::_default_action\n# ----------------------------------------------------------------------------\nproc xdnd::_default_action {event} {\n  variable _dodragdrop_actions\n  variable _dodragdrop_actions_len\n  if {$_dodragdrop_actions_len == 1} {return [lindex $_dodragdrop_actions 0]}\n\n  set alt     [dict get $event Alt]\n  set shift   [dict get $event Shift]\n  set control [dict get $event Control]\n\n  if {$shift && $control && [lsearch $_dodragdrop_actions link] != -1} {\n    return link\n  } elseif {$control && [lsearch $_dodragdrop_actions copy] != -1} {\n    return copy\n  } elseif {$shift && [lsearch $_dodragdrop_actions move] != -1} {\n    return move\n  } elseif {$alt && [lsearch $_dodragdrop_actions link] != -1} {\n    return link\n  }\n  return default\n};# xdnd::_default_action\n\n# ----------------------------------------------------------------------------\n#  Command xdnd::getFormatForType\n# ----------------------------------------------------------------------------\nproc xdnd::getFormatForType {type} {\n  switch -glob [string tolower $type] {\n    text/plain\\;charset=utf-8 -\n    text/html\\;charset=utf-8  -\n    utf8_string               {set format UTF8_STRING}\n    text/html                 -\n    text/plain                -\n    string                    -\n    text                      -\n    compound_text             {set format STRING}\n    text/uri-list*            {set format UTF8_STRING}\n    application/x-color       {set format $type}\n    default                   {set format $type}\n  }\n  return $format\n};# xdnd::getFormatForType\n\n# ----------------------------------------------------------------------------\n#  Command xdnd::registerSelectionHandler\n# ----------------------------------------------------------------------------\nproc xdnd::registerSelectionHandler {source types} {\n  foreach type $types {\n    selection handle -selection XdndSelection \\\n                     -type $type \\\n                     -format [getFormatForType $type] \\\n                     $source [list ::tkdnd::xdnd::_SendData $type]\n  }\n};# xdnd::registerSelectionHandler\n\n# ----------------------------------------------------------------------------\n#  Command xdnd::unregisterSelectionHandler\n# ----------------------------------------------------------------------------\nproc xdnd::unregisterSelectionHandler {source types} {\n  foreach type $types {\n    catch {\n      selection handle -selection XdndSelection \\\n                       -type $type \\\n                       -format [getFormatForType $type] \\\n                       $source {}\n    }\n  }\n};# xdnd::unregisterSelectionHandler\n\n# ----------------------------------------------------------------------------\n#  Command xdnd::_convert_to_unsigned\n# ----------------------------------------------------------------------------\nproc xdnd::_convert_to_unsigned {data format} {\n  switch $format {\n    8  { set mask 0xff }\n    16 { set mask 0xffff }\n    32 { set mask 0xffffff }\n    default {error \"unsupported format $format\"}\n  }\n  ## Convert signed integer into unsigned...\n  set d [list]\n  foreach num $data {\n    lappend d [expr { $num & $mask }]\n  }\n  return $d\n};# xdnd::_convert_to_unsigned\n\n# ----------------------------------------------------------------------------\n#  Command xdnd::_SendData\n# ----------------------------------------------------------------------------\nproc xdnd::_SendData {type offset bytes args} {\n  variable _dodragdrop_drag_source\n  variable _dodragdrop_types\n  variable _dodragdrop_data\n  variable _dodragdrop_transfer_data\n\n  ## The variable _dodragdrop_data contains a list of data, one for each\n  ## type in the _dodragdrop_types variable. We have to search types, and find\n  ## the corresponding entry in the _dodragdrop_data list.\n  set index [lsearch $_dodragdrop_types $type]\n  if {$index < 0} {\n    error \"unable to locate data suitable for type \\\"$type\\\"\"\n  }\n  set typed_data [lindex $_dodragdrop_data $index]\n  set format 8\n  if {$offset == 0} {\n    ## Prepare the data to be transferred...\n    switch -glob $type {\n      text/plain* - UTF8_STRING - STRING - TEXT - COMPOUND_TEXT {\n        binary scan [encoding convertto utf-8 $typed_data] \\\n                    c* _dodragdrop_transfer_data\n        set _dodragdrop_transfer_data \\\n           [_convert_to_unsigned $_dodragdrop_transfer_data $format]\n      }\n      text/uri-list* {\n        set files [list]\n        foreach file $typed_data {\n          switch -glob $file {\n            *://*     {lappend files $file}\n            default   {lappend files file://$file}\n          }\n        }\n        binary scan [encoding convertto utf-8 \"[join $files \\r\\n]\\r\\n\"] \\\n                    c* _dodragdrop_transfer_data\n        set _dodragdrop_transfer_data \\\n           [_convert_to_unsigned $_dodragdrop_transfer_data $format]\n      }\n      application/x-color {\n        set format 16\n        ## Try to understand the provided data: we accept a standard Tk colour,\n        ## or a list of 3 values (red green blue) or a list of 4 values\n        ## (red green blue opacity).\n        switch [llength $typed_data] {\n          1 { set color [winfo rgb $_dodragdrop_drag_source $typed_data]\n              lappend color 65535 }\n          3 { set color $typed_data; lappend color 65535 }\n          4 { set color $typed_data }\n          default {error \"unknown color data: \\\"$typed_data\\\"\"}\n        }\n        ## Convert the 4 elements into 16 bit values...\n        set _dodragdrop_transfer_data [list]\n        foreach c $color {\n          lappend _dodragdrop_transfer_data [format 0x%04X $c]\n        }\n      }\n      default {\n        set format 32\n        binary scan $typed_data c* _dodragdrop_transfer_data\n      }\n    }\n  }\n\n  ##\n  ## Data has been split into bytes. Count the bytes requested, and return them\n  ##\n  set data [lrange $_dodragdrop_transfer_data $offset [expr {$offset+$bytes-1}]]\n  switch $format {\n    8  {\n      set data [encoding convertfrom utf-8 [binary format c* $data]]\n    }\n    16 {\n      variable _dodragdrop_selection_requestor\n      if {$_dodragdrop_selection_requestor} {\n        ## Tk selection cannot process this format (only 8 & 32 supported).\n        ## Call our XChangeProperty...\n        set numItems [llength $data]\n        variable _dodragdrop_selection_property\n        variable _dodragdrop_selection_selection\n        variable _dodragdrop_selection_target\n        variable _dodragdrop_selection_time\n        XChangeProperty $_dodragdrop_drag_source \\\n                        $_dodragdrop_selection_requestor \\\n                        $_dodragdrop_selection_property \\\n                        $_dodragdrop_selection_target \\\n                        $format \\\n                        $_dodragdrop_selection_time \\\n                        $data $numItems\n        return -code break\n      }\n    }\n    32 {\n    }\n    default {\n      error \"unsupported format $format\"\n    }\n  }\n  # puts \"SendData: $type $offset $bytes $args ($typed_data)\"\n  # puts \"          $data\"\n  return $data\n};# xdnd::_SendData\n"
  },
  {
    "path": "build_files/tkdnd2.9.2/tkdnd_utils.tcl",
    "content": "#\n# tkdnd_utils.tcl --\n#\n#    This file implements some utility procedures that are used by the TkDND\n#    package.\n#\n# This software is copyrighted by:\n# George Petasis, National Centre for Scientific Research \"Demokritos\",\n# Aghia Paraskevi, Athens, Greece.\n# e-mail: petasis@iit.demokritos.gr\n#\n# The following terms apply to all files associated\n# with the software unless explicitly disclaimed in individual files.\n#\n# The authors hereby grant permission to use, copy, modify, distribute,\n# and license this software and its documentation for any purpose, provided\n# that existing copyright notices are retained in all copies and that this\n# notice is included verbatim in any distributions. No written agreement,\n# license, or royalty fee is required for any of the authorized uses.\n# Modifications to this software may be copyrighted by their authors\n# and need not follow the licensing terms described here, provided that\n# the new terms are clearly indicated on the first page of each file where\n# they apply.\n#\n# IN NO EVENT SHALL THE AUTHORS OR DISTRIBUTORS BE LIABLE TO ANY PARTY\n# FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES\n# ARISING OUT OF THE USE OF THIS SOFTWARE, ITS DOCUMENTATION, OR ANY\n# DERIVATIVES THEREOF, EVEN IF THE AUTHORS HAVE BEEN ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n#\n# THE AUTHORS AND DISTRIBUTORS SPECIFICALLY DISCLAIM ANY WARRANTIES,\n# INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY,\n# FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT.  THIS SOFTWARE\n# IS PROVIDED ON AN \"AS IS\" BASIS, AND THE AUTHORS AND DISTRIBUTORS HAVE\n# NO OBLIGATION TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR\n# MODIFICATIONS.\n#\n\npackage require tkdnd\nnamespace eval ::tkdnd {\n  namespace eval utils {\n  };# namespace ::tkdnd::utils\n  namespace eval text {\n    variable _drag_tag           tkdnd::drag::selection::tag\n    variable _state              {}\n    variable _drag_source_widget {}\n    variable _drop_target_widget {}\n    variable _now_dragging       0\n  };# namespace ::tkdnd::text\n};# namespace ::tkdnd\n\nbind TkDND_Drag_Text1 <ButtonPress-1>   {tkdnd::text::_begin_drag clear  1 %W %s %X %Y %x %y}\nbind TkDND_Drag_Text1 <B1-Motion>       {tkdnd::text::_begin_drag motion 1 %W %s %X %Y %x %y}\nbind TkDND_Drag_Text1 <B1-Leave>        {tkdnd::text::_TextAutoScan %W %x %y}\nbind TkDND_Drag_Text1 <ButtonRelease-1> {tkdnd::text::_begin_drag reset  1 %W %s %X %Y %x %y}\nbind TkDND_Drag_Text2 <ButtonPress-2>   {tkdnd::text::_begin_drag clear  2 %W %s %X %Y %x %y}\nbind TkDND_Drag_Text2 <B2-Motion>       {tkdnd::text::_begin_drag motion 2 %W %s %X %Y %x %y}\nbind TkDND_Drag_Text2 <ButtonRelease-2> {tkdnd::text::_begin_drag reset  2 %W %s %X %Y %x %y}\nbind TkDND_Drag_Text3 <ButtonPress-3>   {tkdnd::text::_begin_drag clear  3 %W %s %X %Y %x %y}\nbind TkDND_Drag_Text3 <B3-Motion>       {tkdnd::text::_begin_drag motion 3 %W %s %X %Y %x %y}\nbind TkDND_Drag_Text3 <ButtonRelease-3> {tkdnd::text::_begin_drag reset  3 %W %s %X %Y %x %y}\n\n# ----------------------------------------------------------------------------\n#  Command tkdnd::text::drag_source\n# ----------------------------------------------------------------------------\nproc ::tkdnd::text::drag_source { mode path { types DND_Text } { event 1 } { tagprefix TkDND_Drag_Text } { tag sel } } {\n  switch -exact -- $mode {\n    register {\n      $path tag bind $tag <ButtonPress-${event}> \\\n        \"tkdnd::text::_begin_drag press ${event} %W %s %X %Y %x %y\"\n      ## Set a binding to the widget, to put selection as data...\n      bind $path <<DragInitCmd>> \"::tkdnd::text::DragInitCmd $path {%t} $tag\"\n      ## Set a binding to the widget, to remove selection if action is move...\n      bind $path <<DragEndCmd>> \"::tkdnd::text::DragEndCmd $path %A $tag\"\n    }\n    unregister {\n      $path tag bind $tag <ButtonPress-${event}> {}\n      bind $path <<DragInitCmd>> {}\n      bind $path <<DragEndCmd>>  {}\n    }\n  }\n  ::tkdnd::drag_source $mode $path $types $event $tagprefix\n};# ::tkdnd::text::drag_source\n\n# ----------------------------------------------------------------------------\n#  Command tkdnd::text::drop_target\n# ----------------------------------------------------------------------------\nproc ::tkdnd::text::drop_target { mode path { types DND_Text } } {\n  switch -exact -- $mode {\n    register {\n      bind $path <<DropPosition>>   \"::tkdnd::text::DropPosition $path %X %Y %A %a %m\"\n      bind $path <<Drop>>           \"::tkdnd::text::Drop $path %D %X %Y %A %a %m\"\n    }\n    unregister {\n      bind $path <<DropEnter>>      {}\n      bind $path <<DropPosition>>   {}\n      bind $path <<DropLeave>>      {}\n      bind $path <<Drop>>           {}\n    }\n  }\n  ::tkdnd::drop_target $mode $path $types\n};# ::tkdnd::text::drop_target\n\n# ----------------------------------------------------------------------------\n#  Command tkdnd::text::DragInitCmd\n# ----------------------------------------------------------------------------\nproc ::tkdnd::text::DragInitCmd { path { types DND_Text } { tag sel } { actions { copy move } } } {\n  ## Save the selection indices...\n  variable _drag_source_widget\n  variable _drop_target_widget\n  set _drag_source_widget $path\n  set _drop_target_widget {}\n  _save_selection $path $tag\n  list $actions $types [$path get $tag.first $tag.last]\n};# ::tkdnd::text::DragInitCmd\n\n# ----------------------------------------------------------------------------\n#  Command tkdnd::text::DragEndCmd\n# ----------------------------------------------------------------------------\nproc ::tkdnd::text::DragEndCmd { path action { tag sel } } {\n  variable _drag_source_widget\n  variable _drop_target_widget\n  set _drag_source_widget {}\n  set _drop_target_widget {}\n  _restore_selection $path $tag\n  switch -exact -- $action {\n    move {\n      ## Delete the original selected text...\n      variable _selection_first\n      variable _selection_last\n      $path delete $_selection_first $_selection_last\n    }\n  }\n};# ::tkdnd::text::DragEndCmd\n\n# ----------------------------------------------------------------------------\n#  Command tkdnd::text::DropPosition\n# ----------------------------------------------------------------------------\nproc ::tkdnd::text::DropPosition { path X Y action actions keys} {\n  variable _drag_source_widget\n  variable _drop_target_widget\n  set _drop_target_widget $path\n  ## This check is primitive, a more accurate one is needed!\n  if {$path eq $_drag_source_widget} {\n    ## This is a drag within the same widget! Set action to move...\n    if {\"move\" in $actions} {set action move}\n  }\n  incr X -[winfo rootx $path]\n  incr Y -[winfo rooty $path]\n  $path mark set insert @$X,$Y; update\n  return $action\n};# ::tkdnd::text::DropPosition\n\n# ----------------------------------------------------------------------------\n#  Command tkdnd::text::Drop\n# ----------------------------------------------------------------------------\nproc ::tkdnd::text::Drop { path data X Y action actions keys } {\n  incr X -[winfo rootx $path]\n  incr Y -[winfo rooty $path]\n  $path mark set insert @$X,$Y\n  $path insert [$path index insert] $data\n  return $action\n};# ::tkdnd::text::Drop\n\n# ----------------------------------------------------------------------------\n#  Command tkdnd::text::_save_selection\n# ----------------------------------------------------------------------------\nproc ::tkdnd::text::_save_selection { path tag} {\n  variable _drag_tag\n  variable _selection_first\n  variable _selection_last\n  variable _selection_tag $tag\n  set _selection_first [$path index $tag.first]\n  set _selection_last  [$path index $tag.last]\n  $path tag add $_drag_tag $_selection_first $_selection_last\n  $path tag configure $_drag_tag \\\n    -background [$path tag cget $tag -background] \\\n    -foreground [$path tag cget $tag -foreground]\n};# tkdnd::text::_save_selection\n\n# ----------------------------------------------------------------------------\n#  Command tkdnd::text::_restore_selection\n# ----------------------------------------------------------------------------\nproc ::tkdnd::text::_restore_selection { path tag} {\n  variable _drag_tag\n  variable _selection_first\n  variable _selection_last\n  $path tag delete $_drag_tag\n  $path tag remove $tag 0.0 end\n  #$path tag add $tag $_selection_first $_selection_last\n};# tkdnd::text::_restore_selection\n\n# ----------------------------------------------------------------------------\n#  Command tkdnd::text::_begin_drag\n# ----------------------------------------------------------------------------\nproc ::tkdnd::text::_begin_drag { event button source state X Y x y } {\n  variable _drop_target_widget\n  variable _state\n  # puts \"::tkdnd::text::_begin_drag $event $button $source $state $X $Y $x $y\"\n\n  switch -exact -- $event {\n    clear {\n      switch -exact -- $_state {\n         press {\n           ## Do not execute other bindings, as they will erase selection...\n           return -code break\n         }\n      }\n      set _state clear\n    }\n    motion {\n      variable _now_dragging\n      if {$_now_dragging} {return -code break}\n      if { [string equal $_state \"press\"] } {\n        variable _x0; variable _y0\n        if { abs($_x0-$X) > ${::tkdnd::_dx} || abs($_y0-$Y) > ${::tkdnd::_dy} } {\n          set _state \"done\"\n          set _drop_target_widget {}\n          set _now_dragging       1\n          set code [catch {\n            ::tkdnd::_init_drag $button $source $state $X $Y $x $y\n          } info options]\n          set _drop_target_widget {}\n          set _now_dragging       0\n          if {$code != 0} {\n            ## Something strange occurred...\n            return -options $options $info\n          }\n        }\n        return -code break\n      }\n      set _state clear\n    }\n    press {\n      variable _x0; variable _y0\n      set _x0    $X\n      set _y0    $Y\n      set _state \"press\"\n    }\n    reset {\n      set _state {}\n    }\n  }\n  if {$source eq $_drop_target_widget} {return -code break}\n  return -code continue\n};# tkdnd::text::_begin_drag\n\nproc tkdnd::text::_TextAutoScan {w x y} {\n  variable _now_dragging\n  if {$_now_dragging} {return -code break}\n  return -code continue\n};# tkdnd::text::_TextAutoScan\n"
  },
  {
    "path": "build_files/tkdnd2.9.2/tkdnd_windows.tcl",
    "content": "#\n# tkdnd_windows.tcl --\n#\n#    This file implements some utility procedures that are used by the TkDND\n#    package.\n#\n# This software is copyrighted by:\n# George Petasis, National Centre for Scientific Research \"Demokritos\",\n# Aghia Paraskevi, Athens, Greece.\n# e-mail: petasis@iit.demokritos.gr\n#\n# The following terms apply to all files associated\n# with the software unless explicitly disclaimed in individual files.\n#\n# The authors hereby grant permission to use, copy, modify, distribute,\n# and license this software and its documentation for any purpose, provided\n# that existing copyright notices are retained in all copies and that this\n# notice is included verbatim in any distributions. No written agreement,\n# license, or royalty fee is required for any of the authorized uses.\n# Modifications to this software may be copyrighted by their authors\n# and need not follow the licensing terms described here, provided that\n# the new terms are clearly indicated on the first page of each file where\n# they apply.\n#\n# IN NO EVENT SHALL THE AUTHORS OR DISTRIBUTORS BE LIABLE TO ANY PARTY\n# FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES\n# ARISING OUT OF THE USE OF THIS SOFTWARE, ITS DOCUMENTATION, OR ANY\n# DERIVATIVES THEREOF, EVEN IF THE AUTHORS HAVE BEEN ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n#\n# THE AUTHORS AND DISTRIBUTORS SPECIFICALLY DISCLAIM ANY WARRANTIES,\n# INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY,\n# FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT.  THIS SOFTWARE\n# IS PROVIDED ON AN \"AS IS\" BASIS, AND THE AUTHORS AND DISTRIBUTORS HAVE\n# NO OBLIGATION TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR\n# MODIFICATIONS.\n#\n\nnamespace eval olednd {\n\n  proc initialise { } {\n    ## Mapping from platform types to TkDND types...\n    ::tkdnd::generic::initialise_platform_to_tkdnd_types [list \\\n       CF_UNICODETEXT          DND_Text  \\\n       CF_TEXT                 DND_Text  \\\n       CF_HDROP                DND_Files \\\n       UniformResourceLocator  DND_URL   \\\n       CF_HTML                 DND_HTML  \\\n       {HTML Format}           DND_HTML  \\\n       CF_RTF                  DND_RTF   \\\n       CF_RTFTEXT              DND_RTF   \\\n       {Rich Text Format}      DND_RTF   \\\n    ]\n    # FileGroupDescriptorW    DND_Files \\\n    # FileGroupDescriptor     DND_Files \\\n\n    ## Mapping from TkDND types to platform types...\n    ::tkdnd::generic::initialise_tkdnd_to_platform_types [list \\\n       DND_Text  {CF_UNICODETEXT CF_TEXT}               \\\n       DND_Files {CF_HDROP}                             \\\n       DND_URL   {UniformResourceLocator UniformResourceLocatorW} \\\n       DND_HTML  {CF_HTML {HTML Format}}                \\\n       DND_RTF   {CF_RTF CF_RTFTEXT {Rich Text Format}} \\\n    ]\n  };# initialise\n\n};# namespace olednd\n\n# ----------------------------------------------------------------------------\n#  Command olednd::HandleDragEnter\n# ----------------------------------------------------------------------------\nproc olednd::HandleDragEnter { drop_target typelist actionlist pressedkeys\n                               rootX rootY codelist { data {} } } {\n  ::tkdnd::generic::SetDroppedData $data\n  focus $drop_target\n  ::tkdnd::generic::HandleEnter $drop_target 0 $typelist \\\n                                $codelist $actionlist $pressedkeys\n  set action [::tkdnd::generic::HandlePosition $drop_target {} \\\n                                               $pressedkeys $rootX $rootY]\n  if {$::tkdnd::_auto_update} {update idletasks}\n  return $action\n};# olednd::HandleDragEnter\n\n# ----------------------------------------------------------------------------\n#  Command olednd::HandleDragOver\n# ----------------------------------------------------------------------------\nproc olednd::HandleDragOver { drop_target pressedkeys rootX rootY } {\n  set action [::tkdnd::generic::HandlePosition $drop_target {} \\\n                                               $pressedkeys $rootX $rootY]\n  if {$::tkdnd::_auto_update} {update idletasks}\n  return $action\n};# olednd::HandleDragOver\n\n# ----------------------------------------------------------------------------\n#  Command olednd::HandleDragLeave\n# ----------------------------------------------------------------------------\nproc olednd::HandleDragLeave { drop_target } {\n  ::tkdnd::generic::HandleLeave\n  if {$::tkdnd::_auto_update} {update idletasks}\n};# olednd::HandleDragLeave\n\n# ----------------------------------------------------------------------------\n#  Command olednd::HandleDrop\n# ----------------------------------------------------------------------------\nproc olednd::HandleDrop { drop_target pressedkeys rootX rootY type data } {\n  ::tkdnd::generic::SetDroppedData [normalise_data $type $data]\n  set action [::tkdnd::generic::HandleDrop $drop_target {} \\\n                                           $pressedkeys $rootX $rootY 0]\n  if {$::tkdnd::_auto_update} {update idletasks}\n  return $action\n};# olednd::HandleDrop\n\n# ----------------------------------------------------------------------------\n#  Command olednd::GetDataType\n# ----------------------------------------------------------------------------\nproc olednd::GetDataType { drop_target typelist } {\n  foreach {drop_target common_drag_source_types common_drop_target_types} \\\n    [::tkdnd::generic::FindWindowWithCommonTypes $drop_target $typelist] {break}\n  lindex $common_drag_source_types 0\n};# olednd::GetDataType\n\n# ----------------------------------------------------------------------------\n#  Command olednd::GetDragSourceCommonTypes\n# ----------------------------------------------------------------------------\nproc olednd::GetDragSourceCommonTypes { drop_target } {\n  ::tkdnd::generic::GetDragSourceCommonTypes\n};# olednd::GetDragSourceCommonTypes\n\n# ----------------------------------------------------------------------------\n#  Command olednd::platform_specific_types\n# ----------------------------------------------------------------------------\nproc olednd::platform_specific_types { types } {\n  ::tkdnd::generic::platform_specific_types $types\n}; # olednd::platform_specific_types\n\n# ----------------------------------------------------------------------------\n#  Command olednd::platform_specific_type\n# ----------------------------------------------------------------------------\nproc olednd::platform_specific_type { type } {\n  ::tkdnd::generic::platform_specific_type $type\n}; # olednd::platform_specific_type\n\n# ----------------------------------------------------------------------------\n#  Command tkdnd::platform_independent_types\n# ----------------------------------------------------------------------------\nproc ::tkdnd::platform_independent_types { types } {\n  ::tkdnd::generic::platform_independent_types $types\n}; # tkdnd::platform_independent_types\n\n# ----------------------------------------------------------------------------\n#  Command olednd::platform_independent_type\n# ----------------------------------------------------------------------------\nproc olednd::platform_independent_type { type } {\n  ::tkdnd::generic::platform_independent_type $type\n}; # olednd::platform_independent_type\n\n# ----------------------------------------------------------------------------\n#  Command olednd::normalise_data\n# ----------------------------------------------------------------------------\nproc olednd::normalise_data { type data } {\n  switch [lindex [::tkdnd::generic::platform_independent_type $type] 0] {\n    DND_Text   {return $data}\n    DND_Files  {return $data}\n    DND_HTML   {return [encoding convertfrom utf-8 $data]}\n    default    {return $data}\n  }\n}; # olednd::normalise_data\n"
  },
  {
    "path": "build_files/updater.spec",
    "content": "# -*- mode: python ; coding: utf-8 -*-\nimport os\nimport sys\nfrom PyInstaller.building.api import PYZ, EXE\nfrom PyInstaller.building.build_main import Analysis\nfrom PyInstaller.config import CONF\n\nCONF['distpath'] = './dist' # type: ignore\nCONF['workpath'] = './_build' # type: ignore\nblock_cipher = None\nsys.modules['FixTk'] = None # type: ignore\na = Analysis([f'{os.getcwd()}/updater.py'],\n             pathex=[os.getcwd()],\n             binaries=[],\n             datas=[],\n             hiddenimports=['pkg_resources.py2_warn'],\n             hookspath=[],\n             runtime_hooks=[],\n             excludes=['pandas', 'numpy', 'cryptography', 'simplejson', 'PySide2', 'FixTk', 'tcl', 'tk', '_tkinter',\n                       'tkinter', 'Tkinter'],\n             win_no_prefer_redirects=False,\n             win_private_assemblies=False,\n             cipher=block_cipher,\n             noarchive=False)\npyz = PYZ(a.pure, a.zipped_data,\n          cipher=block_cipher)\nexe = EXE(pyz,\n          a.scripts,\n          a.binaries,\n          a.zipfiles,\n          a.datas,\n          [],\n          name='Updater',\n          debug=False,\n          manifest='build_files/Updater.exe.MANIFEST',\n          bootloader_ignore_signals=False,\n          strip=False,\n          upx=False,\n          upx_exclude=['vcruntime140.dll', 'msvcp140.dll', 'python36.dll', 'python37.dll', 'python38.dll'],\n          runtime_tmpdir=None,\n          console=False,\n          icon=os.path.abspath('resources/Updater.ico'),\n          version='mcu_version_info.txt')\n# ONLY USE FOR DEBUGGING\n# coll = COLLECT(exe,\n#                a.binaries - TOC([('libcrypto-1_1.dll', None, None)]),\n#                a.zipfiles,\n#                a.datas,\n#                strip=False,\n#                upx=False,\n#                upx_exclude=['vcruntime140.dll', 'msvcp140.dll', 'python36.dll', 'python37.dll', 'python38.dll'],\n#                name='updater')\n"
  },
  {
    "path": "conftest.py",
    "content": "import pytest\n\n\ndef pytest_addoption(parser):\n    parser.addoption('--ci', action='store_true', default=False)\n    parser.addoption('--upload', action='store_true', default=False)\n    parser.addoption('--test-auto-update', action='store_true', default=False)\n\n\n@pytest.fixture(scope='session')\ndef ci(pytestconfig):\n    return pytestconfig.getoption('ci')\n\n\ndef pytest_collection_modifyitems(config, items):\n    if config.getoption('--ci'):\n        skip_non_ci = pytest.mark.skip(reason='test not for CI')\n        for item in items:\n            if 'no_ci' in item.keywords:\n                item.add_marker(skip_non_ci)\n    else:\n        skip_ci = pytest.mark.skip(reason='test only for CI')\n        for item in items:\n            if 'ci_only' in item.keywords:\n                item.add_marker(skip_ci)\n"
  },
  {
    "path": "linux_install.py",
    "content": "#!/usr/bin/env python3\nimport os\nfrom pathlib import Path\nINSTALL_DIR = Path('$HOME/bin/music-caster').expanduser()\n\nos.chdir(INSTALL_DIR)\n# TODO\nraise NotImplemented\n"
  },
  {
    "path": "linux_install.sh",
    "content": "#!/usr/bin/env bash\nset -ex\n\necho \"(music-caster) Updating...\"\n# git fetch\n# git reset --hard \"@{u}\"\n\nPYTHON=python3.14\n./scripts/pre-req.sh $PYTHON\n\necho \"(music-caster) Creating $PYTHON virtual environment\"\n# if .venv DNE or has wrong Python version, delete old .venv and install new .venv\nif [ ! -d .venv ] || [ \"$(.venv/bin/python -V)\" != \"$($PYTHON -V)\" ]; then\n    rm -rf .venv src/.venv src/venv venv\n    $PYTHON -m venv .venv\nfi\n. .venv/bin/activate\n\necho \"(music-caster) Installing dependencies\"\npython -m pip install --upgrade -r requirements.txt\n# restore\ncd ~/bin/music-caster\n\n# copy icons\nmkdir -p ~/Downloads/music-caster-tmp\nmkdir -p ~/.local/share/icons/hicolor/32x32/apps\nmkdir -p ~/.local/share/icons/hicolor/128x128/apps\nmkdir -p ~/.local/share/icons/hicolor/256x256/apps\nmkdir -p ~/.local/share/icons/hicolor/512x512/apps\n\n# 32x32\ncp -rf resources/icons/32x32.png ~/Downloads/music-caster-tmp\nmv -f ~/Downloads/music-caster-tmp/32x32.png ~/.local/share/icons/hicolor/32x32/apps/music_caster.png\n# 128x128\ncp -rf resources/icons/128x128.png ~/Downloads/music-caster-tmp\nmv -f ~/Downloads/music-caster-tmp/128x128.png ~/.local/share/icons/hicolor/128x128/apps/music_caster.png\n# 256x256\ncp -rf resources/icons/128x128@2x.png ~/Downloads/music-caster-tmp\nmv -f ~/Downloads/music-caster-tmp/128x128@2x.png ~/.local/share/icons/hicolor/256x256/apps/music_caster.png\n# 512x512\ncp -rf resources/icons/icon.png ~/Downloads/music-caster-tmp\nmv -f ~/Downloads/music-caster-tmp/icon.png ~/.local/share/icons/hicolor/512x512/apps/music_caster.png\n\nrm -rf ~/Downloads/music-caster-tmp\n\n# install .desktop file\necho \"(music-caster) Registering as desktop application\"\ncp -rf music_caster.desktop ~/.local/share/applications\n\n# delete old files\nrm -rf ~/.icons/music_caster.png\n"
  },
  {
    "path": "music_caster.desktop",
    "content": "# See README.md for instructions on how to use\n[Desktop Entry]\nType=Application\nName=Music Caster\nComment=A modern music player that can cast audio files and urls to Google Chromecasts, Home minis, etc.\nExec=sh -c \"$HOME/bin/music-caster/.venv/bin/python $HOME/bin/music-caster/src/music_caster.py\"\nIcon=music_caster.png\nTerminal=false\nMimeType=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;\nCategories=Audio;Player;\nKeywords=Audio;Song;Track;MP3;Playlist;YouTube;Cast;URL;\nActions=PlayPause;Next;Previous;StopQuit;\nX-GNOME-UsesNotifications=true\n\n# [Desktop Action PlayPause]\n# Name=Play/Pause\n# Exec=XXX --play-pause\n\n# [Desktop Action Next]\n# Name=Next\n# Exec=XXX --next\n\n# [Desktop Action Previous]\n# Name=Previous\n# Exec=XXX --previous\n\n# [Desktop Action StopQuit]\n# Name=Stop & Quit\n# Exec=XXX --quit\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[tool.ruff.format]\n# Like Black, use double quotes for strings.\nquote-style = \"single\"\n\n# pyproject.toml\n[tool.pytest.ini_options]\nminversion = \"8.0\"\naddopts = \"-s\"\ntestpaths = [\n    \"src/test_harness.py\",\n    \"tests\",\n]\nmarkers = [\n    \"no_ci: marks tests as unable to run in CI environment\",\n    \"ci_only: marks tests as only able to run in CI environment\",\n]\n"
  },
  {
    "path": "requirements-dev.txt",
    "content": "GitPython~=3.1\nautopep8\nrequirements-parser\n# pyinstaller dependencies\nsetuptools>=65.5.1\naltgraph\npyinstaller-hooks-contrib >= 2020.11\npefile; sys_platform == 'win32'\npywin32-ctypes; sys_platform == 'win32'\npypiwin32; sys_platform == 'win32'\nmacholib; sys_platform == 'darwin'\nbuild_files/pyinstaller-6.16.0-py3-none-any.whl\npipdeptree\npyoxidizer\npytest\nruff\n"
  },
  {
    "path": "requirements.txt",
    "content": "audioop-lts; python_version>='3.13'\ncomtypes; sys_platform == 'win32'\nwheel\nbuild_files/pyaudio-0.2.14-cp314-cp314-win_amd64.whl; sys_platform == 'win32'\npyaudio; sys_platform == 'darwin'\npywin32; sys_platform == 'win32'\nwinrt-Windows.Media; sys_platform == 'win32'\nwinrt-Windows.Media.Playback; sys_platform == 'win32'\nwinrt-Windows.Media.Control; sys_platform == 'win32'\nwinrt-Windows.Storage.Streams; sys_platform == 'win32'\nwinrt-Windows.Foundation; sys_platform == 'win32'\nwinrt-runtime; sys_platform == 'win32'\npgi; sys_platform == 'linux'\ntestresources; sys_platform == 'linux'\nujson~=5.5\nmutagen~=1.45\nPillow~=11.3.0\nPyChromecast~=14.0\nzeroconf~=0.146\npynput~=1.4.5\npypng~=0.0.20\n# use zip for faster install than git\nhttps://github.com/elibroftw/pypresence/archive/master.zip\n# https://github.com/qwertyquerty/pypresence/archive/master.zip\nhttps://github.com/yt-dlp/yt-dlp/archive/master.zip\nhttps://github.com/elibroftw/youtube-comment-downloader/archive/master.zip\n# PySimpleGUI 4.60.5 no longer available on pypi, therefore use FreeSimpleGUI commit hash\nhttps://github.com/spyoungtech/FreeSimpleGUI/archive/a0634800bca051c824d879d31e14ae3b201e5667.zip\npyqrcode~=1.2.1\npystray~=0.19.1\nrequests~=2.28\nurllib3~=2.5\nFreeSimpleGUI\nwaitress~=3.0\nwavinfo~=4.0\nscrapetube\npyperclip~=1.8\nwerkzeug~=3.0\npython-vlc==3.0.21203\n# why was this fixed to 3.13?\nlz4~=4.4.4; sys_platform == 'win32'\nlz4; sys_platform != 'win32'\nbrowser_cookie3\nbeautifulsoup4~=4.10\nflask~=2.0\npycparser~=2.14  # used by deemix\ndeezer-py~=1.2\ndeemix~=3.5\nsix~=1.16\nportalocker~=2.4\npython-tkdnd; sys_platform != 'win32'\n# 1.2.2+ was causing issues\ndateparser==1.2.1\n# experiments\n# numpy~=1.21\n# matplotlib~=3.6\n# soundfile~=0.11\nappdirs\n"
  },
  {
    "path": "resources/favicons/browserconfig.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<browserconfig>\n    <msapplication>\n        <tile>\n            <square150x150logo src=\"https://raw.githubusercontent.com/elibroftw/music-caster/master/resources/favicons/mstile-150x150.png\"/>\n            <TileColor>#ededed</TileColor>\n        </tile>\n    </msapplication>\n</browserconfig>\n"
  },
  {
    "path": "resources/favicons/site.webmanifest",
    "content": "{\n    \"name\": \"\",\n    \"short_name\": \"\",\n    \"icons\": [\n        {\n            \"src\": \"https://raw.githubusercontent.com/elibroftw/music-caster/master/resources/favicons/android-chrome-192x192.png\",\n            \"sizes\": \"192x192\",\n            \"type\": \"image/png\"\n        },\n        {\n            \"src\": \"https://raw.githubusercontent.com/elibroftw/music-caster/master/resources/favicons/android-chrome-256x256.png\",\n            \"sizes\": \"256x256\",\n            \"type\": \"image/png\"\n        }\n    ],\n    \"theme_color\": \"#eeeeee\",\n    \"background_color\": \"#eeeeee\",\n    \"display\": \"standalone\"\n}\n"
  },
  {
    "path": "resources/gude-2023-11-11.log",
    "content": "19:31:21:330 [ALWAYS]\tGUDE Logging Started\n19:31:21:331 [INFO]\tSqliteResumeCache::SqliteResumeCache basedirectory is null/empty\n19:31:21:331 [ERROR]\tSqliteResumeCache::CreateSqliteResumeCache creating resume cache pointer failure\n19:31:21:331 [ERROR]\tInitialize resume cache pointer failure\n19:31:21:331 [INFO]\tgude policy POLICY_USER limit (2,2,1) chunk 2097152, chunking (UL 0 DL 1), resXfer 1, adapt 0, NSURL 0, timeout 86400 \n19:31:21:331 [INFO]\tInitialized -- gude-lib version: v0.12.1 app: gude\n19:31:24:337 [WARN]\tIgnoring attempt to reinit logging to \\gude at level 4 retaining 3 days of logs\n19:31:24:337 [INFO]\tSqliteResumeCache::SqliteResumeCache basedirectory is null/empty\n19:31:24:337 [ERROR]\tSqliteResumeCache::CreateSqliteResumeCache creating resume cache pointer failure\n19:31:24:337 [ERROR]\tInitialize resume cache pointer failure\n19:31:24:338 [INFO]\tgude policy POLICY_USER limit (4,4,1) chunk 2097152, chunking (UL 0 DL 1), resXfer 1, adapt 0, NSURL 1, timeout 86400 \n19:31:24:338 [INFO]\tInitialized -- gude-lib version: v0.12.1 app: gude\n"
  },
  {
    "path": "scripts/arch-install.sh",
    "content": "#!/usr/bin/env bash\nPYTHON=$1\n\necho \"Installing Arch system dependencies\"\nsudo pacman -Sy --noconfirm python-pip $PYTHON \"$PYTHON-dev\" \"$PYTHON-virtualenv\" \"$PYTHON-tk\" python3-pyaudio\n"
  },
  {
    "path": "scripts/debian-install.sh",
    "content": "#!/usr/bin/env bash\necho \"debian-based distro detected\"\nPYTHON=$1\n# sudo apt install -y software-properties-common\n# Check for python3.14\nif ! $PYTHON --version &> /dev/null; then\n  echo \"Python 3.14 not found. Installing...\"\n  sudo add-apt-repository -y ppa:deadsnakes/ppa\n  sudo apt update\n  sudo apt install -y python3.14\nfi\n\necho \"Installing system dependencies\"\nsudo apt update\nsudo apt install -y python3-pip \"${PYTHON}-venv\" \"${PYTHON}-tk\" python3-pyaudio \"${PYTHON}-venv\"\n"
  },
  {
    "path": "scripts/fedora-install.sh",
    "content": "#!/usr/bin/env bash\nPYTHON=$1\n\necho \"Installing Fedora system dependencies\"\nsudo dnf install -y \"$PYTHON\" python-pip \"$PYTHON-devel\" \"$PYTHON-virtualenv\" \"$PYTHON-tkinter\" portaudio-devel redhat-rpm-config\n"
  },
  {
    "path": "scripts/pre-req.sh",
    "content": "#!/usr/bin/env bash\n# Define script locations (change paths if needed)\nDEBIAN_INSTALL=\"./scripts/debian-install.sh\"\nARCH_INSTALL=\"./scripts/arch-install.sh\"\nFEDORA_INSTALL=\"./scripts/fedora-install.sh\"\nSUSE_INSTALL=\"./scripts/suse-install.sh\"\n# Check for /etc/os-release (preferred method)\nif [ -f /etc/os-release ]; then\n  . /etc/os-release\n  case \"$ID\" in\n    debian|ubuntu|mint|linuxmint)\n      if [ -f \"$DEBIAN_INSTALL\" ]; then\n        \"$DEBIAN_INSTALL\" \"$1\"\n        exit 0\n      fi\n      echo \"Error: $DEBIAN_INSTALL not found!\"\n      exit 1\n      ;;\n    arch)\n      if [ -f \"$ARCH_INSTALL\" ]; then\n        \"$ARCH_INSTALL\" \"$1\"\n        exit 0\n      fi\n      echo \"Error: $ARCH_INSTALL not found!\"\n      exit 1\n      ;;\n    fedora)\n      if [ -f \"$FEDORA_INSTALL\" ]; then\n        \"$FEDORA_INSTALL\" \"$1\"\n        exit 0\n      fi\n      echo \"Error: $FEDORA_INSTALL not found!\"\n      exit 1\n      ;;\n    opensuse|sles)\n      if [ -f \"$SUSE_INSTALL\" ]; then\n        \"$SUSE_INSTALL\" \"$1\"\n        exit 0\n      fi\n      echo \"Error: $SUSE_INSTALL not found!\"\n      exit 1\n      ;;\n  esac\nfi\n\n# No match, display error\necho \"Error: Unsupported distribution. Only Debian, Arch, Fedora, and SUSE are supported.\"\nexit 1\n"
  },
  {
    "path": "scripts/suse-install.sh",
    "content": "#!/usr/bin/env bash\nPYTHON=$1\n\necho \"Installing SUSE system dependencies\"\nsudo zypper install -y \"$PYTHON\" \"$PYTHON-devel\" \"$PYTHON-tk\" \"$PYTHON-virtualenv\" python3-pyaudio\n"
  },
  {
    "path": "src/audio_player.py",
    "content": "\"\"\"\nAudioPlayer v2.3.7\nAuthor: Elijah Lopez\nEnsure VLC shared library files (*.dll, *.so) are located in \"vlc_lib/\"\n\"\"\"\nimport math\nimport os\nimport platform\nimport sys\nimport time\nfrom enum import IntEnum\nfrom pathlib import Path\n\nIS_FROZEN = getattr(sys, 'frozen', False)  # pyinstaller generated executable\ntry:\n    app_path = sys._MEIPASS\nexcept AttributeError:\n    app_path = os.path.dirname(sys.executable if IS_FROZEN else __file__)\nvlc_ext = 'dll' if platform.system() == 'Windows' else 'so'\nif platform.system() != 'Windows':\n    os.environ['PYTHON_VLC_MODULE_PATH'] = f'{app_path}/vlc_lib/plugins'\nvlc_lib_path = Path(f'{app_path}/vlc_lib/libvlc.{vlc_ext}')\nos.environ['PYTHON_VLC_LIB_PATH'] = str(vlc_lib_path)\ncwd = os.getcwd()\nif platform.system() == 'Linux':\n    os.chdir(f'{app_path}/vlc_lib')\nimport vlc\n\nos.chdir(cwd)\n\n\nclass AudioPlayerUnit(IntEnum):\n    MILLI_SECOND = 1\n    SECOND = 1000\n\n\nclass AudioPlayer:\n    __slots__ = 'vlc_instance', 'player', 'is_url'\n\n    def __init__(self, skip_vlc=False):\n        if not skip_vlc:\n            self.vlc_instance = vlc.Instance()\n            self.player: vlc.MediaPlayer = self.vlc_instance.media_player_new()\n        self.is_url = False\n\n    def has_media(self):\n        return self.player.get_media() is not None\n\n    def is_busy(self):\n        \"\"\"Returns whether player is playing or is paused\"\"\"\n        return self.has_media()\n\n    def play(self, media_path, start_playing=True, volume=None, start_from=0):\n        \"\"\"\n        :param media_path: str\n        :param start_playing: bool\n        :param volume: float[0, 1]\n        :param start_from: time to start from in seconds\n        \"\"\"\n        self.is_url = media_path.startswith('http')\n        self.player.set_mrl(media_path)\n        self.player.play()\n        if volume is not None:\n            self.set_volume(volume)\n        block_until = time.time() + 1\n        while not self.player.is_playing() and time.time() < block_until:\n            pass\n        self.set_pos(start_from)\n        if not start_playing:\n            self.pause()\n\n    def load(self, file_path):\n        self.play(file_path, start_playing=False)\n\n    def pause(self):\n        if self.is_playing():\n            self.player.pause()\n            block_until = time.time() + 1\n            while self.player.is_playing() and time.time() < block_until:\n                pass\n            return True\n        return False\n\n    def resume(self):\n        \"\"\"\n        Resumes playback if paused and has media\n        Also used to start playing audio after load was used\n        \"\"\"\n        if not self.is_playing() and self.has_media():\n            if self.player.get_length() - self.player.get_time() > 0.5:\n                self.player.audio_set_volume(self.player.audio_get_volume())\n                self.player.pause()\n                block_until = time.time() + 1\n                while not self.player.is_playing() and time.time() < block_until:\n                    pass\n                return True\n        return False\n\n    def stop(self):\n        \"\"\"Stop the playback of any audio and return the current position in seconds\"\"\"\n        if self.is_busy():\n            position = self.player.get_time() / 1000\n            self.player.stop()\n            self.player.set_media(None)\n            return position\n        return 0\n\n    @staticmethod\n    def percent_to_db_percent(percent: float):\n        \"\"\"\n        :param percent: float [0, 1]\n        \"\"\"\n        try:\n            return round(20 * math.log(percent * 100, 10), 3) / 40\n        except ValueError:\n            return 0\n\n    @staticmethod\n    def db_percent_to_percent(db: float):\n        \"\"\":param db: float [0, 40]\"\"\"\n        return 0 if db == 0 else round((10 ** (2 * db)) / 120, 2)\n\n    def set_volume(self, volume):\n        \"\"\"\n        Sets the output volume and not the program volume\n        :param volume: float[0, 1]\n        \"\"\"\n        self.player.audio_set_volume(int(volume * 100))\n\n    def get_volume(self):\n        \"\"\"\n        get the volume of the output\n        :return float [0, 1]\n        \"\"\"\n        return self.player.audio_get_volume() / 100\n\n    def set_pos(self, position, unit=AudioPlayerUnit.SECOND):\n        \"\"\"position is in seconds from start\"\"\"\n        self.player.set_time(int(position * unit))\n\n    def get_pos(self, unit=AudioPlayerUnit.SECOND) -> float:\n        \"\"\"\n        returns the position of the audio playing\n        position meaning the time in seconds from the start of the audio data/file\n        \"\"\"\n        return self.player.get_time() / unit\n\n    def is_playing(self):\n        \"\"\"returns strictly whether the player is playing audio\"\"\"\n        return self.player.is_playing()\n\n    def is_paused(self):\n        return not self.is_playing() and self.has_media()\n\n    def is_idle(self):\n        \"\"\"Whether audio player is in stopped state: audio was never loaded, finished/stopped playing\"\"\"\n        return not self.is_busy()\n\n    def toggle_mute(self):\n        self.player.audio_toggle_mute()\n\n    def mute(self):\n        self.player.audio_set_mute(True)\n\n    def unmute(self):\n        self.player.audio_set_mute(False)\n\n    def get_length(self, unit=AudioPlayerUnit.SECOND) -> float:\n        return self.player.get_length() / unit\n\n    def get_sample_rate(self):\n        return self.player.get_rate()\n"
  },
  {
    "path": "src/b64_images.py",
    "content": "WINDOW_ICON = b'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'\nUNFILLED_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=='\nFILLED_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='\nNEXT_BUTTON_IMG = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAASBJREFUSInt1L8rRWEcx/HXEamruKNY/BnKZJJBmQzKaCGLTHfBZDeSRRlMZLGalEFRCjODReRHBuUx3HvqdBzHuSe37nDfdTrf8z2f5/t5PvX00KHdiNKNEMIO1nDXxJxJTCDgI4qiWq461HkPIcyGEBR8tkOC9MyuDJ9nVLCHfQwWSPKQqF+KmCR7M7jGXAGjX8kySVPFLg4w3CqTmGncYr6VJtCHLRxhJNHv/k+TmCncYAk9eMsT5+7gD3qxiUfc5wnLJonZwDGG8kRlk5xhEeeN74E8cZkkqxhNGMBn3oJmkpxiAZfN7qpokhWMlTEgO8lXoj5RP6ZXZYbHZCWpNt7LGC9oUEnU/emfWUkOsY6LAsNjnvDaqH/cwh3aj2+H8WMIDwPNtQAAAABJRU5ErkJggg=='\nPREVIOUS_BUTTON_IMG = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAARJJREFUSInt1LtKA0EUgOFPsRMvIAjiCyg+iGhjHbAQCx/Azs7SyqSxCiiYFwiCIIKFj6GFVRo7DSJCxGORDSzL4u4GFy3yw8Awe+b8O7fDhH9PRPQiop+0ZkSo0BYj4rSMJE2ngmA7Ip4jIrI5p3M8/VT/rcTi59HGNZbxmg2YKZHkJ3ZwhtXU2FQ2KG8lZVjABboZQS7jSBp4wF7ZCUXblf6+hBZ2q/5VkeQ9iWmgmYgqUyTpGR7u5TjJRxSdyQrucISvuiRzeMEJ1nFbh+Qz1X/EJg4x+E1JHk2s4aZOCTxhCwf4qEsyoo0NXKXGii9Ipgp3K1Th/YgY5FXhvHfSwaxhoSu97zjHPY4rzJnwR3wDDhTHcFM0dNgAAAAASUVORK5CYII='\nPLAY_BUTTON_IMG = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAAH9JREFUSIntlUEOgCAMBNE/6Mv0kfozXjGe5CBEW+weTNyEG2E6JWlT+hMRYAMmIHmPBwKQgUUNOeOy6oW4rN5AzFYRkEerKMitVTSkaaWAVFZKSLG6vjXaseYM5psdFjswq9qVgRXhx5fqFZCq+miIdKzIB6R01MuXln79fi4Hzn7a/EcS7E0AAAAASUVORK5CYII='\nPAUSE_BUTTON_IMG = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAAD5JREFUSIljYBgFJABGbIL/////jyYkwMDA8BGbUhTDGBmxmsdEltNIBKOWjFoyasmoJaOWjFoyZCwZBSQBABSVBiYy06UHAAAAAElFTkSuQmCC'\nVOLUME_IMG = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAAV1JREFUSIntlL1LA0EQxX8RtU0i2ChIIGAhRAW/OkGxC2JpFbBSbLT3TxHE0los7NRKUAJG7NKIlbWIIoJ5FjeHx3ofeweCRR4MzOzuzJvdnRnoIwdK7oIkH78VoAYcJwYu/YQeyJ8XdeACWPJ1yEtSA9qm3/8FyThwC1TMHnb2V4EbYKIoSR24A0ZTznwAi8CZD0kFqJqUgQWCJ3IJwgrZBE6Aa9OngbXUlCU9S3ozeVUy9iQhadvsltmS1M4i8cW+BUXSo6RP00/l9EHcc72kZhGPI2DI9Et3s0ifxOErovd8SMoFSLYi+rK7ORjj8GRE4btWMwhawCSwY/YGTqPGkUwRzLSQZAa44nfzhegB58Ah0LSYB6lpRSomKrNW0knVhaR5W++6Q9b34zvAHPCecmYE6ALrmdESbhJKw/pBknbTzha5SYgHgvkE0PB1ivv4LHQIKmisgG8f/wTfaEggzd7YFiAAAAAASUVORK5CYII='\nVOLUME_MUTED_IMG = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAAVhJREFUSIntlM0qRVEUx38XSZR8voBMTJSPkZGUjJQ8gChewMTQQ9w3uRkYKCnKABkzcUsZiG5Xt5j4GZwtx+nc7RwyUH61219rr397r7UX/PNd1CKtX91Wh2J2aSpZkQKcAWPAUMyoUvlw3VH0loE9YAa4LXOoq4TtPrAUxq+/IXIIzJdxnCb7XAMkbz0IdIb+LEfgPXiLwGRmbwWYaquo3pnQUp/UF/OpqeNhXFe7Q1athrXjmEizjdMsJ8FpNcwP1I3U/nJMpFFQ5NKPP7GeWq8b/k+asimcx0Nq3AIaWYOsSFHRl9DvADXgHNgFJoAmMNv2pHoTrv2o3pskQB7pwDfUkfB0W2HtKCbSq/aFHnVUvcoROQ/7a+qcn+vWproQE8lrPepFJPBfFsgiMXgGpoHTAra5lMmuOeD9k1Vihlm+U+qvSUrPcNTxD0o9JDeqktS5f/4ob0mm8DiCKHHOAAAAAElFTkSuQmCC'\nREPEAT_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='\nREPEAT_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=='\nREPEAT_OFF_IMG = b'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'\nDEFAULT_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/LHJnEcvtgnaf8AZb2pp2InHmR69rekzaJqcmny5IU5jc/xIeh/z3FVK7rx7on9paX/AGhAmZrUFuByyfxD+v4VwvXpVIcZcyCiiimUFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAVV1vW9K8N6TPrmuXyW1pbJvmmkPCj+ZJPAA5J4o1vW9K8OaVPrmt3yW1rbJummkPAHp7k9AByTxXzL8Yfi/qvxO1URIr22lWzk2VkTyT08yTHVz+SjgdyZlKxUY3ZH8Y/jFq3xP1Xy4w9tpNs5NlZE8k9PNkx1cjt0UHA7k1/gb8Mpfi38SrHwpKrCyUm41WRf4LZCNwz2LEhB7v7Vycrdh1r60/Yu+GQ8JfDNvGmo222/wDEbCZCw+ZLRCREv/Ajuk/4EvpWe7NKjVOF0euJbw28Sw20KxxxoFjjQYVFAwFA7AAY/CkZc1MQQaYy9wKo4SFlzwaYy44NTMuaYy9iKAIWXHIpjLmpmXHBpjLjkUAQsueDUbLjg1Oy56UxlzwaAIGXH0qNlx9KnZccEUxlx9KAIGXHIFRMuPpVhlx9KYy9wKAKzp6Dio3TH0qwy46Dio3T24oArunY9KidOx/CrLpj6VG6e3FAFV07HrUbp+dWXTsfzqN0oDYqunt9RUToPwq06e3NROnt9RQWncqun/1qidParToPwqJ0/wDrUDKrp7fUVC6f/Wq26e1QugPT8qAKxBBwaY6AinaneWGkQG61e+gtIgMmW6mWNQPXLECuC8U/tRfs+eDmaLWvizpBkRtrQ2cxuXBx6RBqBpN7HbOn/wBaonSvBvFP/BRz4LaZlPDPhzXdXbnDeQlsmfrISf8Ax2vOfE//AAUo8d3oaPwl8OdKsAQQsl9cyXDj0OBsWldFqlN9D65dAODUF48VnCbi8lSGNeskzBFH1J4r4K8T/tl/tG+KNyyfEOTT42x+60m1jtwP+BAFv1rz7XPFfinxPMbjxJ4l1DUHY5LXt7JLz/wIkUuYtUJdWfo9aeNvBOq6h/ZOl+MdJubvJH2W31KJ5CR1wobJq/JHX5iWkktjdR3lhI0E0ThopoTtdGByCCOQRX6I/AXxVq3jv4MeG/FmvS+Ze3umI11J/wA9HBZSx9ztyfc007kzpunqdJJHUTp7fUVbkjqGSP2pi3Kjp/8AWqJ09qtunt+FQun/AOugCo6flUTp/wDWq26e1Qun5UAVHT2+oqJ09qtvH/8ArqF09qAKsif/AK6idPzq06e1RSR0AVXT2+oqGSMMCCMg1bdM9uaidPb6igD6h/Zx+Ip8feAI7TUp/M1DSsWt4WOTIuP3ch/3lGD7q1QeJ9IOiazLaKuI2+eH/dPb8DkfhXi3wC8dnwD8R7Se5m22OoEWl9k8KGPyOf8AdfH4Fq+kfiHpP2zSRqMa/vLVstjuh6/lwfwqkzP4Z+pw9FFFUaBRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAVV1vW9K8O6VPret3qW9rbpummkPAHp7kngAck0a1rWl+HdLm1rWr1Le1t03Syv0A9B6k9AByTXzd8XPizqfxI1PYoe30y3cmzsy3OenmPjq5/JRwO5Kckiox5mRfGD4uap8S9UEaB7fS7dybOyLck9PMkx1cj8FHA7k8NK5Xr1qaY4+aqk7EisXqzdJWNr4Y+B7r4nfELSfA1tkDULtUuHH/ACzgX5pX/BA344r7+t7O1sLWKysLdYoII1jhiUcIigBVHsAAK+bv2BfAPn3mt/E28gyIFGm2DEfxMBJMw/4D5a/8CNfS9XFaHHiJ3nbsREAimkEGpWXHIphAIo2MtyJlxyKYy5qYgg0xl7gUAQsueDTGXHBqZlzTGXsRQBCy45FMZc1My44NMZccigCFlzwajZccGp2XPI60xlzwaAIGXH0qNlx9KnZccEUxlx9KAIGXHIFRMuPpVhlx9KYy9wKAKzoPwqN0x9KsMuPpUbp7cUAV3TselROnY/hVl0x9KjdPyoArNGWO0Ak9sCs/W9b0Lw7CbnxBrdlYIASXvrtIRgf75FfIX/BRf9pn4iaH8Q/+FJeB9fudIsbKwhn1S4sZTHNdyTJuCbxyqKhHAPJJz0FfJWpalqOszm61nUbi8lPWW7naVj+LEmk3Y6KdByV2z9KvFv7Y/wCzL4Q3pqPxc025kUAmDSw905z2HlqR+teYeLP+Cnfwh07fF4R8Da9qzhiFkn8u1jPvyWbH4V8PDgYHA9BRS5mbKjBH0v4p/wCCnXxV1ENH4T8AaFpaknbJcvLdSAdupVc/hXmvin9sf9pTxcrRah8Vb61iddrRaUiWq/8AkMA5/GvMqKV2WoQXQt6xr+veIrhrvxBrl5fysctJe3Tyk/8AfRNVBhRhRj2FFFIsKKKKACiiigBU++PrX6B/soLn9nHwicf8wrg/9tXr8/E++PrX6D/smp/xjd4Q/wCwV/7VeqjuYV/hR3Tp7fWonjq26f8A1qheP2qjnTsVHjqJ09vqKtvHUMkftQXuVHT/AOtUTp7VbdPb8KheP/8AXQBUdPyqJ0/+tVt09qhdPyoAqOnt9RUTp7VbdP8A9dQuntQBVkT/APXUTp19atOntUTx/wD1jQBUkjBBBHB619Y/BLxivxD+GNneX8nmXEUZs9Qz1MiDaSf95drf8Cr5UdM9ua9X/ZJ8WnS/F154PnlxFqdv50CntNH1H4oT/wB8U1uRNXidpqFlJpt9Np8nWGQrn1HY/lioa6P4kacINTi1FF4uI9rn/aX/AOsR+Vc5VlRd1cKKKKBhRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFVdZ1nTPD+lzazrN4lvbW6bpZX6AegHcnoAOSaXWNY03QNMm1jWLxILaBN0sr9APQepPQAck188/FP4o6n8Q9Sxh7fTbdz9jsyefTzHx1cj8FHA7kpuxUVci+LHxV1P4i6ltAe302Bz9jsyef+uj46ufyUcDuTw8596s3LZ+7VOZgBjP41jdvc2SsiCeQ9M1TncKjSN0AJPHYVPMck81sfCjwmfH/xP0DwcULR3+qxJOB/zyU75P8AxxWo6lbeh9pfs7eCP+FffBnQfD80Wy5ezF3ejHPnzfvGz9NwX/gNdmy55FPZR95FwOwHYelJWyVkeVJuTuyOmsuORUjLnkU2hq4J2IiARTSCDUrLjkUwgEVGxe5Ey9xTGXNTEEGmMvcCgCFl7GmMuODUzLmmMvYigCFlxyKYy5qZlxwaYy45FAELLng1Gy44NTsueR1pjLng0AQMuPpUbLj6VOy44IpjLj6UAQMuORUTLjntVhlx9KYy9wKAKzp37VG6Y+lWGXH0qN09uKAPzY/4KNDH7Weuj/qGab/6SrXhte6f8FHRj9rXXQP+gZpv/pKteF1D3PRp/AgooopFhRRRQAUUUUAFFFFABRQAT0FAIPQ0AKn3x9a/Qz9kpQf2bPB5A/5hP/tR6/PNPvj61+iH7Iy5/Zp8Hf8AYJ/9qvVRMa/wo7x0qJ0/+tVt09qhdPyqjlKjp7fUVE8dW3T/AOtULx+1A07FR46idPb61beOoZI/agvcqOn/ANaonQ56VbdPb8KheP8A/XQBUdPyqJ0/+tVt09qhdPyoAqOnt9RUTpVt0/8A11C6e31FAFWSOrnhPX5vCXirT/E8BO6wu0mYD+JQfmH4qWH41A6e1RPGD1HFAH1541gh1Xws17bMHWMLPEw7r6/98muDro/gXrY8W/CLTkupN8kNu1lcE+seUH/ju0/jXOyRPBI0Eg+aNirfUHFWjOGl0JRRRTNAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKravq+m6Fps2r6tdpBbwJuklft7AdyegA5JpdW1bTtD06XVdVu1gt4V3SSP29APUnoB3rwf4mfEfUvHmoBfmg0+Bj9ltSf/AB9/Vj+Q6DuSm7FRjdlb4pfE3U/H2pBMNBp0DH7JaZ79N7+rn9Og7k8bO2AeeatTvk47etU7lyOCc1m3c2SS2Ksr9c9v1qpcMc/LVm5PHt9apSNk8mpAglbaCK9k/YP8MjWfjHd+I5I8po2jyMjY4EkzCJf/AB3zK8YuG5yOlfUv/BPXw8IPBfiLxQyYa81WK2jbH8EMW4/+PSn8qcdWTWfLSZ9B01lxyKcRg4orY80jprLnkVIy45FNoAjprLjkVIydxTaTVxp2IiARTSCDUrLjkUwgEVGxe5Ey9xTGXNTEEGmMncUAQsvY0xlxwamZc0xl7EUAQsuORTGXPTrUzLjg0xlxyKAIWXPBqNlxwanZQaYy54NAEDLj6VGy4+lTsuODTGXH0oAgZccionXHParDLj6Ux17igD80v+CkAx+1vroH/QL03/0lWvCq92/4KRjH7XOugf8AQL0z/wBJVrwnpyTUPc9Gn8CCir2geF/E3iu4Fp4W8O3+pSs21Y9Ps5JiT1x8gNeneD/2Ff2p/GYSW2+FVzp0LgET63cx2igH2Y7v/HaVmU5RW7PI6K+sfB//AASm8c3eybx/8VdLsFJBeDSbOS5fHcbn2Ln3wRXp/hH/AIJl/s96AqP4mvNd16VQN32m+FvEx/3IQDj/AIFTszJ16aPz/ZlUgOwGemTjNb3hb4W/Erxw4j8HfD/WdT3dGs9NkdfT72No/Ov0y8H/ALOfwK+HxDeEPhNoVpIoGJzYLLIcdDvk3HPvmuu2COPyohtQfwKMAfgKfKR9Yvsj87fCn/BPv9pjxIVfUfC9lokbDO7WNSRWH/AI97Zr03wt/wAEuVXbJ46+LhJ53waPpuPp88p/9lr7AeMAcDionT/61OyJdabPDfC/7AP7NvhrbJfeGb7WpF6tq+pOyn/gEe1axP2uf2afg5b/AAI1nxR4c8D6fo+oaBZi6srnTLYRFgrKGjcD76sD1OSCAQa+iHT2rzb9rhcfs0eNiP8AoBvn/v4lDWhMZSclqfm0vEgHvX6KfsiJn9mfwcf+oT/7VevzsX/WD/er9Fv2Qhj9mbwbkddI/wDaslKO5vX+FHoDp/8AWqJ09qtOgIqJ0/8A11RylR0/KonT/wCtVt09qhdKAKjp7fUVE8dW3T/61QyR+1A07FR46idPb61beOoZI6C9yo6VE6HPSrbp7fhULx//AK6AKjp+VROn/wBarbx81C6flQBUdPb6ionSrbx//rqF09qAPaP2RNbJsdZ8NSSf6uaO6iXPZgUb9VX866DxdbfZPEl3GBw0gcf8CAP8815t+zHqh034opZbvlvrCaIjPdcSD/0E16x8SbcR61DcAf623wfqCf8AEVSI2qHPUUUVRYUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAVX1XVdP0TT5dU1S6WGCFcySN29AB3J6Ad6XU9TsNGsJdT1O5WGCFcySN29gO5PQDvXi3xE8e6h4zvwMNDZQsfs1sT/AOPtjqx/ToO+U3YqMbsr/Eb4haj441DOGhsYGP2W1J/8fb1Y/oOB3J5G6bPTrVuckCqM7ZPJqGzVaKxUnOB+FU5j1zVq4I6VTmbjBP41LZS00Kty2OD36VUnzjirExJJzVWc7R1pMLalWUljivtv9i/RV0j9nrR5tuGv57q7f33TMo/8dQV8RytjLenNfoD8ANLXRvgh4T09Rjb4ftmbjuyBz+rVUNzDEv3DrmXNMIxwakpGXNanCMprLjkU4jBxRQBHTWXPIqRlxyKbQBHTWXHIqRlzyKbSauNOxEQCKaQQalZccimEAio2L3ImXuKYy5qYgg0xl7igCFl7GmMuODUzLmmMvY0AQsuORTGXPTrUzLjg0xlxyKAIWXPBqNlxwanZc0xlzwaAIGXH0qNlx9KnZccGmMuPpQB+Zv8AwUmGP2u9ex/0C9M/9JVrM/YK+HPhH4n/ALSuk6B430mO/sLaxur5rKZd0c0kSAorj+JctkjvgA8Vqf8ABSkY/a914D/oF6Z/6SrVj/gmWM/tXWP/AGANR/8ARaVP2juvahp2P0UstOsNJhFvpNjBaxD/AJZ2sKxr6dFAFK6DrirDLj6VG6elUcJXdOx6VE6dj+FWXTH0qN09aAKzp2PWonQ596sunY1G6du9AbFV09vqKidPyq06VE6eg+tBadyq6f8A1q81/a8Q/wDDM3jb/sBP/wChpXp7px7V5p+16uP2ZPG+R/zAnwf+2iUFR+JH5pr/AKwf71fo1+yEpP7MfgzP/QI4/wC/slfnKv8ArB/vV+jv7HyZ/Zh8Fn/qD8/9/ZKmO50V/hR6CRjg0x0BFTun/wBaoyCDg1RzFd0/+tUTp7VadARUTp/+ugCo6flUTp/9arbpULp+VAFR09vqKieOrbp/9aoZI/agadio6VE6e31q28dQvHQXuVHSonT2q26e31FQyJ/9agCo6flUTp/9arboc1C6flQBrfCq+/sv4maHebtoGpRo5Po+UP8A6FX0F8Tof3VlOOzOpP1AP9K+adOnax1O1vVODDcxyAj/AGXB/pX098SlD6JDMO10MfirU0Q/iRxVFFFWWFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAVBqWpWOkWUmo6lcrFDEuXdv5Adyew70uoahZ6XZyX9/OIoo1y7t/L3PtXk3jnxle+LLzBzFaRMfs9uT/AOPN6t/LoKTdhpXK/j3xxe+MLzkNFZxE/ZrfP/jzerH9Og9+VuWwetXbjpzVGfG7BqDW2hUuGI6HtVGc4ycVbumI61TnzSKRTnJLYNVLk44NWpuufeqdy3Y0uo1vZlSU9aqTkEYzVqYjkAVTnPNIaZVvCRbyn/pm2D+Br9IfBVmLHwXo9mqgCHSbZMDtiFBX5vXI3QuPVD/Kv0v0qMRaXbRAcLbRgD/gAq4bnNivhRJRTmXHIptaHEIy5phGODUlIy5oAZTWXHIpxGODRQBHTWXPIqRlxyKbQBHTWXHIqRlzyKbSauNOxEQCKaQQalZccimEAio2L3ImXuKYy5qYgg0xl7igCFl7GmMuODUzLmmMvY0AQsuORTGXNTMuODTGXHIoAhZc8Go2XHBqdlzTGXPBoA/Mj/gpWMftfa8P+oXpn/pKtWP+CZHP7WFjn/oX9R/9ASoP+ClwI/bA14H/AKBWmf8ApKtWP+CY3/J2Nh/2L+o/+i0qftHc/wCB8j9H2XHIqNlxyOlWGXH0pjJjkVRwlZ071G6Y+lWGXH0qN09KAK7p2qJ07H8DVl0x9KjdOx6UAVnTtUTpz71ZdOxqN07d6A2Krp7fUV5n+1+gH7MPjg4/5gT/APoxK9RdOfevMv2wkx+y/wCOSO2gvn/v4lBrB3kj8y1/1g/3q/SH9jtM/sv+C+Of7H/9qyV+by/6wf71fpN+xyhP7L3grP8A0B+P+/slTHc6K/wo9CdPaoXT/wCtVt0qJ09vqKo5iqRjg0x0BFTun/1qjIIODQBXdKidParToCKidP8A61AFR0qJ0/8ArVbdKhdPyoAqOnt9RUTx1bdP/rVDJH7UDTsVJI6idP8A69W3jqGSOgvcqOn/AOuonT2q26e31FQyJ/8AWoApzrtjY+ik/Tivp7xdL9p8D21yTncIH/NR/jXzNPGTG4x/Cf5V9J6u+/4ZafI3e2tSf++VpoiW6OToooqywooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKivr60020e+vphHFGuWY/y9z7UXt7a6dave3swjijGWY/y9z7V5x4s8UXfiO6+YGO2jP7iHPT/AGj6n+VJuw0rlbxn4tu/E93yDHbRk+RBn/x5vVv5Vzk3zHFXbkevWqU2B+NQaq1inde1UJjnJNXro5zVGfp70BrcpXZ6jtVG6yPxq5cHJOKp3I65pFFObJPNUrgg1cl9M8Yqnc9aWhS1KkpyDmqsw96tzYAxVSYEnpSBOy1Kt0P3Tc4+U/yr9L9McSabbyA53W8Zz/wEV+aF6D9mkK9fLbH5Gv0k8GXQvfB+k3oORNpdu4PrmJT/AFq4bnNil7qNKmsuORTqK0OIjopzLjkU2gBGXNMIxwakpGXNADKay45FOIxwaKAI6ay55FSMuORTaAI6ay45FSMueRTaTVxp2IiARTSCDUrLjkUwgEVGxe5Ey9xTGXNTEEGmMvcUAQsueDTGXHBqZlzTGXsaAIWXHIpjLnpUzLjg0xlxyKAPzE/4KYf8ng6+P+oVpn/pKtWP+CYfP7WdgD/0L+o/+i0qD/gpn/yeHr//AGCtL/8ASRas/wDBMEZ/a0sB/wBS/qP/AKLSp+0dv/MP8j9JGXH0qNlx9KnZccGmMuPpVHEQOuORUTLjkdKsMuPpTGTHIoArOneo3TH0qwy4+lRsnp0oArunaonTsfwNWXTH0qN07HpQBWdO1eY/tirj9l3x0f8AqAP/AOjEr1J07H8DXmP7ZC4/Zb8dZ/6AD/8AoxKCofGj8w1/1g/3q/Sr9jdc/st+Cc9P7H/9qyV+aq/6wf71fpd+xon/ABi14JP/AFBuf+/slTHc66/wo9EdP/rVE6e1WnTj2qJ0/wDrVRzFV09qhdP/AK1W3SonT2+ooAqkY4NMdARU7p/9aoyCDg0AV3SonT2q06AionT/AOtQBUdPyqJ0/wDrVbdDmoXT8qAKjp7fUVE8dW3T/wCtULp7fWgadipJHUTp/wDXq28dQyR0F7lOdMRsf9k8/hX0Xrf7v4aabGf+fe1GP+2Y/wAK+eZ48xsAOqnj8K+iPGQ+z+ENOsyMYMQx/ux00RLdHJ0UUVZYUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAVHd3dtYWzXd3KEjQZZj/AJ5NF1dQWVu11dSBI0GWY1xPiTX7nWp8kFIEJ8qLP6n3/lSbsNK7K3inxHda/c/NmO3jP7mLPT/aPqf5ViS/dzVqdRj8KqyjjBqDRWKdyc5qjKMZyKu3HHU1UnGFwaA6mfde1UrgZHv61dueGwapXPXj8aClqyhPyxB4qlcnqCavTjJ4qjdZbg9aB7FOXGKp3ByeauTcjA9Kpzbealj+yVZwSOlVZgcEirc3IwaqyAhsUug07orSguCpHBGK/Qf4D6oNZ+CvhPUg2TJ4ftAxPcrGFP6rX59Oe49a+4f2NtXXVv2eNCTdl7Jrm0cZ6bJ3x/46y1UNznxK/do9QooorU4QprLjkU6igCOinMuORTaAEZc0wjHBqSkZc0AMprLjkU4jHBooAjprLnkVIy45FNoAjprLjkVIy55FNpNXGnYiIBFNIINSsuORTCARUbF7kTLjkUxlzUxBBpjL3FAELLng0xlxwamZc0xl7GgD8wP+CmoA/bE1/H/QK0v/ANJVqz/wS+Gf2tbAf9S/qX/otKr/APBTcEfti6+D/wBArS//AElWrP8AwS8/5O2sP+xe1L/0WlR1O7/mH+R+lLLng1Gy44NTsuaYy54NWcJAy4+lRsuPpU7Ljg0xlx9KAIHXHIqJlx9KsMuPpUbqBz2oArun5VG6Y+lWJl8hPMlGxf7z8D8zWbc+JfDFtI0Nx4m02NlOGR9RiBB9wWoAmdOx6V5b+2jJHb/sreOnlcAHQiqknqTLGAK9BvPHPgWxjM17420aJB1aXVYVA/EtXy9/wUA/av8Ahdqnwou/g78O/FtprWpavPCuoS6dJ5sNrbowkYGQfKWYqoAUnAyTQXTi3NHxEBiXH+1X6ZfsYrn9ljwQQP8AmDf+1ZK/Mxfvg+9fpt+xcuf2VvA//YG/9qyVMdzpxGkUejunt9RUTp+VWnT86idPb6iqOZO5VdP/AK1ROntVp0H4VE6f/WoGVXT2qF0/+tVt09qidPb6igCqRjg0x0BFTun/ANaoyCDg0AV3T/8AXUTp7VadARUTp/8AWoAqOn5VE6f/AFqtulQun5UAVHT2+oqJ46tun/1qhdPb60DTsQ2to11ew2q9ZZ0QfiwH9a97+JsiqtlaKMYLsR9MCvHfAOnjUPHOkWpXIbUI2I9lO4/oteq/Ea5EviCO3B/1VsMj3JJ/wprcL3kjBoooqywooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACmXFxDaQtcXDhUUck0XFxDawtcTvtRRya5jV9Vm1SbJ+WND+7T+p96TdhpXK+v61catOScrCh/dR/1PvWROD61clBIxjvVScEHHvxUD2Kc/XHpVecAj8KtSgbunWqtyu3JFBa1KFz6Gqkw4xV25AP1qnOvBPoKBrVmfdDk5qjc5Bq9ckHIPWqN1g9RQC0ZRnPPFUbnBzg81emAB696pXXQk0ti0U5BjPrVSdQTVxxnr9KqXHLZFJgtipMM8c81VmHUn8KtyA4z6VWlHGBS3Q1qis45+lfWH/BPXXxe/D7XvDDN81hrKzoM9EmiX/wBmjavlB1IOa9y/YB8TLpPxY1LwzNJtTV9HLRr/AHpIHDD/AMcd/wAqcdzKquak0fXxBBpKeQCKaQQa2POEooooAKay45FOooAjopzLjkU2gBGXNMII61JSMuaAGU1lxyKcRjg0UAR01l7ipGXHIptAEdNZccipGXPIptJq407ERAIppBBqR1289qaQGFRsXuRMncUxlzUpGDiodQnt9NsZdT1C4S3toYy89zcOEjjUDJZmPAA9aAPy/wD+CnH/ACeN4gGOmlaX/wCkiVZ/4Jdf8ncWH/Yval/6LSuC/bB+Jeh/F39pXxb488Mz+dpt1qQisJweJooUWJZB7NtJHsRXf/8ABLcA/tc2AP8A0L2pf+i0qN5Hc1ahbyP0tZccimMuelTMuODTGXHIqzhIWXPBqNlxwanZc0xlzwaAMLx5438K/DTwlfeOfG2rx2Ol6dAZbq5k5wOygDlmJwAByScV8DfHj/gpr8XvHGpXGlfB5v8AhFdGDFYrgRrJfzL/AHmcgiLP91Rketdl/wAFbPirqDa94c+CthdMlrFZnVtTjR+JZGZkhDD0UK7D3bPavjOpbOujSjy8zNjxF8Q/H/i+5N34q8caxqUjDBa91OWTj0wWxWKYomO5olJ9SoJp1FSdOw3yYf8Anin/AHwKcMAYAwOwFFFACp98fWv06/YsTP7KfgfI/wCYN/7Vkr8xU++PrX6ffsUrn9lLwNn/AKA3/tWSqjuc+J+BHpLpUTp7c1ZdOx/ConTsetUcexWdPb6ionT8qtOntzUTp7fUUFp3Krp/9aonT2q06D8KidP/AK1Ayq6e1Qun/wBarbp7VE6e31FAFUjHBpjoCKndP/rVGQQcGgCu6f8A66idParToCKidP8A61AFR09qidP/AK1W3SoXT8qAOm+BumNe/EKG525W0tpZWyO5Gwf+hV03ii7F74lu5wcgSlFPso2/0qv8BrNbCz1nxPOvyxRrEjH2Bdh+q1X3vI5lc/MzZb6nrVRHFa3H0UUVRYUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFMnnitomnmfaqjk0s0scEZllbCjqaw9Tv3v2OflQH5Fz+v1pN2Glcg1fUZdSkBJKop/dp/U+9UGHBBqeVdpHpUMnXj0qBleUZHvVScc4Iq5IOQDVacZ5FA9yhKCGqvccg5q3OCe1VrlcrjvQCvczrobeRVS4Py1duByQapzDK0FdTOuVPb+VUrkZyD2q/c9TVG6X0FBXUz5hyciqdzhc1enBziqU45w1IZSk6k56VUm7nHarkgxkE1VnoaKVrFSTnNV5l5yB26VblA9O9V5xySPSpuJFNxhSa6b4I+Lx4D+L3hzxVJLsit9VjS6bP/LGT90//AI65P4VzkgyMGoJo9wKZ6jGfSjqNq7P01xjgnpxSEAiuR+A3jkfEb4Q6D4rkk3TzWCx3nOcTx/u5P/HlJ/Guvrdao8qScZWGEEGkp5AIppBBoEJRRRQAU1lxyKdRQBHRTmXHIptACMuaYQR1qSkZc0AMprLjkU4jHBooAjryf9pz9sH4RfsuaVHJ4yvZL3WLuMtp/h/TiDcTj++xPEUeeNzfgDXofxB8X6X8O/A2sePdZz9k0bTJ724A6ssaFsfjgD8a/GP4o/EzxX8Y/H2qfEvxtftcajq1000pY8RKfuRKOyIuFA9veplKxvQpe0d3sfQfxL/4Ky/tIeLLmSLwDY6P4VszkRiC0F3cY7EyS/KD9FAry/Uv22v2uNVkMl1+0N4mUnqLe8WIfkiivLaKyu2dyp047I9Fb9rz9qh/vftFeMv/AAeSVzniz4u/Fbx5C9v43+Jmv6vHI26SLUdXmlRj6lS20/lXO0UrspRitkFfRX/BLUZ/a6sB/wBS9qX/AKLSvnWvov8A4JZDP7Xdhj/oXdS/9FpTW5NT+Gz9MWXsRTGXHBqZlzTGXPBqzziFlxyKYy56VMy44NMZccigD80/+CpEF1F+1fcSToQsvhrTmhJHBULIDj8Qa+da+7P+Cs3wR1LWtD0T48aFZtKujxNp2u+WuTFA77oZjj+EOWQntvXpXwn061D3PQpNOmgooopGgUUUUAKvDDPrX6ifsW2s9t+yn4FjuYWRjoattcYODJIQfxBBr86vgV8FfFXx9+JFj8O/C1uw+0OG1G92nZZWwPzzOe2BwB3YgCv1d8P+HdK8J+HrHwvodv5Vlp1nFa2sf92ONQq/jgfmaqJzYmSskSOntxUTp2P51ZdMfSo3TselUchWdPWonT25qy6dj+BqJ07HrQGxWdPb6ionQfhVp09uaidPb6igtO5VdP8A61ROntVp0H4VE6f/AFqBlV09qhdP/rVbdKidPb6igCqRjg0x0BFTun/1qjIxwaAK7p/+uonSrToCKfp2ly6vqdvpMH3rmZYlPpk4J/LJoA7/AEW2Hh74R2tsV2y6nIZGHfDHd/6CFH41kgHI4rf8d3MP9pQ6PaDENjAsaqOxwP6Baw6tbFR2CiiimUFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFNlljhQySsAB60SypChkkbAFZl3O90+5ug+6uelJuw7NkeoXkl5Jg8ID8q/wBapyDAwfWpn+8aZKAVxUD32K8gBwDUEgw2KsSgggGoXHPNAnqV5V3H8KqSjnB44q7KpBFVJ1z+VBRSmHOarXGcfzq3OM9eoqtOO9BSM+5UEmqUwwpFaFyvJI7VRmUFOaAM26GSTVK5HetC6Ug+1UbhQR1oKvqZ9woNU7lc8HtV64U8iqlwM0ikjPlXBOarToP1q3OO1V5RknPrQwWjKMo65HeoZgQCatTKM8+tV5V4IqeodSrMuRkVBIOeasyjAx71BKuTxQ0PWx9Nf8E+PH4e01v4YXk/zRONSsFJ/hbEcwH0YRt/wI19LV+enwV+IUnws+KOj+NWci3trry79R/FbSfJKPwU7vqor9CYpY5o1mhkV0dQyOpyGB5BHsRWkHocOIj73N3HUhAIpaKs5xhBBpKeQCKaQQaAEooooAKay45FOooAjopzLjkU2gBGXNMIxwakpGXNAHj37fM0sH7G/wAQpIZGRv7AK7lODgyxgj8QSK/IpuGOPWv2o+Ovw4T4u/BzxP8ADJtgfW9FntYDJ0WUrmMn6OFr8XdU0vU9E1K40XWrOS3vLOd4Lu3lXDRSoxV1I7EMCKyqbndhWuVogoooqDqCiiigAr6N/wCCV6O/7XdiyLkL4c1It7DYgr5y69K+7v8AgkT8CtT02x1z9oHX7F4otSg/svQPMXHmxK4aeYf7JZVQHvtamtzOs0qbPtRl7gUxlzUxBBpjL3FWeeQsvYimMuODUzLmmMvY0AUtW0nTda0640jV7CG6tLqForq2uIw8csbDDKynggjtXxd+0B/wShN9qU/iP9nbxHbWsUrFz4b1mVlSIk/dhnwcL6K44/vV9tsuODTGXHIosiozlB6H5L+L/wBjD9qbwRMya18ENclROTcabALuIjP96ItXITfCf4q28hin+F/iVGBwVbQLkEH/AL91+yhX+JeD6ikaa4IwbiTj/poanlRusTLqj8gfDv7OXx/8WyrF4e+Cvii5LHAP9iyxr1x1kCivavhL/wAEtfjX4ru4rz4qapZeFdP3ZliWRbq8YZ5Coh2Ifdm/Cv0QlaR+JZGb/eYn+dRMu3jtT5US8RN7HC/BX4A/DL9n7wv/AMIv8N9C8hZCGvr6dt9zeOP45X7+wGFHYV1zLj6VYZcfSmMuORTMW23dlZ09uKjdMfSrDLjntUbp37UCK7p2PSonTsfzqy6Y+lRunr0oArOnrUTofTmrLp2P4VG6dj1oDYqunt9RUToPwq06H8aidO+PwoLTuVXT/wCtUTp7VadPyqJ0/wDrUDKrp7VC6f8A1qtuhzUTp7UAVSMcGuq+EumRHW7jxJdr+40y2Z8n++wIH6bvzFczIgA56etdzDanw14GtdGI23Oon7TdjuF42r+W38jQG+hSurmW9uZLyY/PK5dvqTmo6KK0NAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigApskixIXc8CiSRIkLucAVRuJ2nfceAOgpN2BDbqZ7h9zcAfdFQ1JTWQk5FTuVsQSrliRUbj5amf7xpkq5UkUgt1RWlAYCoJAQ3P4VYkHFRSjJx7UBvqQSDOAaqTrg47dquSgjrVaYAnB96Bp3KM655FVZwSPerk6EZ+lVZF5PtQF3coXA5OaozjqAK0bpc5NUplzmgpmbcr1zVC4HatO5XjBqhOmCQfSgNNzOuB1qpMuRn0NXbhCDjtVSZTgg0FplC4TOc9arSr8vTpVy4XB6cVUkHOKW4+pUmXIP1qtKpBIq7MvJzVaVcnmp2B3Kky5HTvUEi45q3Ku5feq8q8cinuik7kDoDkEV9p/sY/FH/hP/hLDoOoXW/UfDpWyuNzZZ4cZgc/8AGz6xmvi8rng/nXd/s0fFg/CD4r2esX9wU0u/8A9C1cHosTMNsh/wBx8N/u7vWiLszKvBSgfedFIrBlDKQQRwQcg0tbHmhSEAilooAYQQaSnkAimkEGgBKKKKACmsuORTqKAI6Kcy45FNoARlDCvlD9uD/gnFZ/HnV5/iv8ILy00vxVKudTsbo7LbVSBgOWH+qmxwWwVbAzg819YUjLmk0mVCcoO6PxW+JvwF+M3wb1B9N+Jnw01jSSjYE89mzQPyRlZkyjDjqDXHG4t1JVriMEdQZACK/daWNJYmt5kV42GHjdQVYe4PBrnr74Q/CbU5zdan8LfDVzKesk+g2zN+ZSo9mdSxfdH4lfabUdbqL/AL+j/Gtnwh4D8b/EHU00bwJ4P1PWbqRgEg0yxeYknpnaCB9SQK/ZX/hSPwYjYSR/CDwqpByCPDtrkH/v3W9pumabo1t9j0fTrezh/wCeVpAsS/koAo9mDxa6I+Af2Yv+CUni7XdQtfFv7SUq6VpiESDw1aThru55yFmdTthQ9wpLnp8tfeuk6JpPh3SbbQtA0yCzsrOBYbS0towkcMajCqoHQAVeZO4FNqlFJHPOrObuyIgEU0gg1Ky45FMIBFTsG5Ey9wKYy5qYgg0xl7igCFl7EUxlxwamZc0xl7GgCFlxyKYy55HWpmXHBpjLjkUAQsueDUbLjgip2XPTrTGXPBoAgZcfSo2XH0qdlxwaYy4+lAEDL3AqJlx9KsMuPpTGXHIoArOntxUbpj6VYZcfSo3Tv2oArunY9KidOx/A1ZdMfSo3TselAFZ09aidD6c1ZdOx/Co3TsetAbFV09vqKidB+FWnQ596idO+PwoLTuVXT/61ROntVp0/KonT1/OgZb8H6DHruvxW9yALaLM12x6CNeTn68D8a19b1R9Z1SXUGGFdsRr/AHUHAH5fzp1nB/wj/hgWxG271QCSb1SAfdX8eT9Kp1UUVFdQoooqigooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACkd1jXc5od1RdzVTnleR8t07Ck3YAmmaZsnp2FRMuORTqKguyaI6Kcy45FNoFe2jIpU+bg1GwypqaQZP4VG68cUD2K8q5XI61BIMYqyw4NQyoCMjrQLzRBIAy8iqs6lTj3q233TUE6ggige5RnXkgiqkq9j+dXZ1IOD61VlAz0oC5RuFqjcLyTWjcoOao3CkUF7mfcJuOAPpWfcLg1q3K87sVQuo85xQG6My4QZxiqU64BArSuF+XBFUbhCDigEUJ1x1qpMg3VfnjJGKpzLzmkW9UVJlzx6iq0qng1blGPzqvKvWgad0VGGCRioZU3L71adcnpionQngip2ZK0dioy+oqORA2QwHI6eoqd0/Oo3U+nNN7F76H2N+xd8Zf+FgfD//AIQrW7wvq/h6NYiXbLT2nSKT3K42Mf8AZU/xV7PX54fCv4jaz8KPHdh440UF3tX23NtuwLmBuJIj9R0PZgp7V9/+FPE+i+NPDll4r8OXguLG/t1mtpR3U9iOxByCOxBFXB3Vjgr03CVzQoooqznCkIBFLRQAwgg0lPIBFNIINACUUUUAFNZccinUUAR0U5lxyKbQAjLmmEYOKkpGXNADKay45FOIxwaKAI6ay55FSMuORTaAI6ay45FSMueRTaTVxp2IiARTSCDUrLjkUwgEVGxe5Ey9xTGXNTEEGmMvcUAQsvY0xlxwamZc0xl7GgCFlxyKYy5qZlxwaYy45FAELLng1Gy44NTsuenWmMueDQBAy4+lRsuPpU7Ljg0xlx9KAIGTHIqJlxz2qwy4+lMZccigCs6elRumPpVhlx9Kjde+OKAK7p61E6djVl0x9KjdOx6UAVnTt3qJ0OferLp2P4Go3TsetAbFV09B9aueHtKt769a5vwfsdqvm3OP4hnhB7sePpmoRBJNIsUMZZ2YKqqOST0FaV40VlapolqylYn33Mi9JJeh/wCAr0H4nvTSuaLUj1C+n1O9kv7jAeRs7R0UdlHsBxUNFFWaBRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFI7qg3MaGYKMmq0sjSNk9OwpN2AJJGkbJ6dhTCMjFLRUF6WGEEGkp5APWmkEGgWwlMZccin0UDsmQydqaRnrUkqcZAqOgSdtGQyIckVA/ANWnHzVBInJA/CgexXZA1V5QcYq1UMyZzigXmilOoI5qlcKQea0JlwT9aqXCAgige5SnU4qjcoCDWjMvGD+dUrhaClqjOnXjmqNwmO1aU6ZOKpXEZOaBozZk3A+1UbhOxFaUybWINVLmPcMjrQNGbOtU7iM9jWhOhzz1qpOhHagaM+VTUDpnrVyZAD0qvIuDgUmwTtKxTlXH4VC4wc+tW5ow341Xdf4T2pPUpq5XkTn+VROmeDVhhkcVGy54xSEmVWQrXun7GHx6XwR4gHww8VXwXSdWuM6fNK3y2l23G0nskhwPZ8H+I14hKmeSOlRMnGMcULRjlFVI2Z+l9FeI/shftEf8LG0Rfh74wvt2vabB+4nlbm/t14357yLwG9Rhv72Pbq2TTR5c4OErMKKKKZIUhAIpaKAGEEGkp5AIppBBoASiiigAprLjkU6igCOinMuORTaAEZc0wjBxUlIy5oAZTWXHIpxGODRQBHTWXPIqRlxyKbQBHTWXHIqRlzyKbSauNOxEQCKaQQalZccimEAio2L3InXuKYy5qYgg0xl7igCFl7GmMuODUzLmmMvY0AQsuORTGXNTMuODTGXHIoAhZc8Go2XHBqdlzTGXPBoAgZcfSo2XH0qdlxwaYy4+lAEDJjkVEy4+lWGXH0pjrjkUAVnT0qN0x9KsMuOR0qN070AV3TselROnY1ZdMfSkSOGMefcLuQH5Uzje3p9PX/69AJXYtsP7Mt/th/4+ZlIg9Y0PBf6noPbJ9KrAADAp00sk8rTzNlmOWOP84FNq0rG6VkFFFFMYUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFIzBRk0MwUZNQu5c5P4CgAdy5yfwFMZM8inUUWuBHRTmUk5FNqGrAnyhSEAilopGm4wjBxSU8gEU0gg0E7DWGVIqFlxyKnPIxUZHY0D0ZC45zUUg5qeVcDI6VC44zQJO2jK8i4bFRyD9asSDvUUqkrn0oHsUp481TmU960JlqrcR55oDzRnzLzVWeMnI/Kr06c1WnXIzQCd2Zk6YbBqncR85rSuYwegqlcJQV1My5jB+YVTnQjtWlcJ6CqU8eTQMzbiMHJFVJowRjFaM8eKqTJQUu5m3EeD7VVlWtGeMHPFU5oyvWk9QauVJF4+lQSoCc1akX2+tQSJxxR0KTuiq6YNRsuOtWXXjNROpPNSLYgZQahkQg1YYHOTTWQNwaBrQNG1nVvDer23iDQr+S0vbOZZrW5iPzRuOhHr6EHggkHg19w/s7fHzR/jf4V82TyrbXLFFXVtPU8A9BNHnkxt/wCOnKnsT8MOhU4NaPgrxn4k+Hnie18XeEtQa2vbRso3VXU/ejcfxIw4I/kQDTjKxNWmqsfM/RqiuH+Bfx08MfG7wwNU0wrbajbBV1TS3fL27nuP70Z52t+BwQRXcVqmmjzJRcXZhRRRTEFIQCKWigBhBBpKeQCKaQQaAEooooAKay45FOooAjopzLjkU2gBGXNMIxwakpGXNADKay45FOIxwaKAI6ay55FSMuORTaAI6ay45FSMueRTaTVxp2IiARTSCDUrLjkUwgEVGxe5Ey9xTGXNTEEGmMvcUAQsvY0xlxwamZc0xlzwaAIWXHIpjLmpmXHBpjLjkUAQsueDUbLjg1Oy56UxlzwaAIGXH0qNlx9KnZccGmMuPpQBAyY5FRMuPpVhlx9KgupUt03t36D1oBK5DKUiXe549O5qrJI0rbm4wMKB0A9KJZXlbe5+gHam1aVjaMbBRRRTKCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigApGYKOaUkAZNMJJNADJGZm5/AU2nsOOtMPBxU7AFFFFUAU1l7inUUWuBHRTmXPIptQ1YE+UKCARg0UUjTcYQQajb7xqYgEYqKVSpoJ2GkZGKgkTAOKnpjjmgejKrjK0wjIwamlTaSe1REYOKBJ20ZWmTA6dDVWZOMVfkXP41Wmj56fWgexn3CEjgVUmTI6VoTIfSqkydaBO6d0Z9wntVK4StK4jOeapXEftQX8S0M2eP1FUriLBzWnNFkEEc1UnjBBUjpQNO5mTxBxnvVKZOcYrTmj2nB71UuYtwJHWgaM2VOaqzxDoavzxkHB61Xmj3DkcigpGZNHtJyKhZR0NXpowcgiqskZU4P50MGVZU2nI6GoWXBq3IgIwehqCSMjg/gancejRXdKjZeKnZexqN07UhbELoHGM1C6EHDcVZIxTHjDj+VGw9i74K8b+J/h14mt/FnhDU2tb22PyuBlZEPWN16Oh7g/UYIBH2x8Bf2hfCvxv0TNuUstatYwdR0l5MsnbzIyfvxk9+o6Ng9fhRkweas6JruteFtZt/EPh3VJrK9tJN9vdW77XRv8D0IOQRwQRTTaZFSlGqvM/SOivF/2dv2t9B+J6QeEfHDwab4hICRNnbBqB9YyfuSHvGev8JPQe0VqmmjzpwlB2YUUUUyQpCARS0UAMIINJTyARTSCDQAlFFFABTWXHIp1FAEdFOZccim0AIy5phGODUlIy5oAZTWXHIpxGODRQBHTWXPIqRlxyKbQBHTWXHIqR17im0mrjTsREAimkEGpWXHIphAIqNi9yJl7imMuamIINMZccigCFl7GmMuODUzLmmMueDQBCy45FMZc/WpmXHBpjLjkUAQsueDUbLjg1OygjNc34j8cQWmrN4R8PRx3mrqge5jYnyrCM9JJyOhP8MY+d/8AZXLAGk29DUvbuGzGJDlmGUQHk+/096ypppJ5DJIeew9PaooInjBae4eaVzmWeTG5z64HAHoBwBwKfVpWNYx5QoooplBRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUEgDJoJAGTTGYk0ADMSaSiigApGXPQUtFAEZGOKKey0w9aWwBRRRTQBTWXPIp1FAEdFOZM8im1DVgT5Qpsihlp1BGRikabkBGDimOOM1My54NRup6UE6ohccZqCZMHcKskZ4NRsuQQaB6MrMMioZVzzVlkx0qGRe35UCTs9SlPHnkDmqc6D8K0pVqpPF6Cgoz5o9y4PaqU8WQfUVpSoVPIqtPFzkCgS0ZlSoM81UuIucgVp3MOTlRVSaPI6cigrfVGXPGD2qnPHjnFalxF7VTnjoHfqZlxCDmqciYPvWpPEeTiqc8OeQOaBpmfPFnkVUmiz2rSlTrkVWmiz2FBe5nOnrUUiA8Vcli5xioHTsfwqXoTez8inJHjio2XsatvHnqKgkjx1o3L3IGTFRkHuKnZexFMdCOhpbE7EDqGGCKidCpqwRjimsuetFrD2KpGDkHnPb17V778A/21NY8KrB4U+Lck+o6cuEg1hQXubYdhIOsyD1++P9qvB3iqN1x1FCbWwSjGorM/SLQPEOh+KtIg1/w5q1vfWVym6C6tZQ6OPYjv6jqO9XK/Pf4X/GT4gfB/VTqXgrWjHHI4a70+cF7a5x/fTPX/aXDD17V9XfBj9r74c/FDydF1uZdB1p8KLK9mHkzt/0ylOA2f7rYb2PWtFNPc4KlCUHpsetUUe1FWYBSEAilooAYQQaSnkAimkEGgBKKKKACmsuORTqKAI6Kcy45FNoARlzTCMcGpKRlzQAymsuORTiCOtFAEdNZc8ipGXHIptAEdNZccipGXPIptJq407ERAIppBBqVlxyKYQCKjYvciZe4pjLmpiCDTGTJ+UdewoAhZc8Gq97dWmnWst9qF1FBBCheaeaQIkajqzMeAPc1xnxc/aN+HPwnWSwvb3+0tXUfLpFg4Mint5rfdiH1y3oprwG58UfFz9qzxSNGu7wWekQOJJrW2Vha2admbvNIe245J6BRmlc0jSlLV6I9R1/46a98UNfk8BfAhzFbx/8hTxbND8luh4/cKerHnazcnqAAN1dL4T8J6P4M0ddG0aN9pcyXE8zl5bmVvvSyOeXdu5P0HApvg3wboHgPQYvDvh208qCP5nduXmc9ZHPdj+nQcCtWrStuaWitEFFFFUAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFBIAyaCQBk0wsTQAMxJpKKKACiiigAooooAKRlB60tFAEZGKKey55phBB5qbWHcKKKKpO4gprL3FOooAjopzL3AptQ1YE+UY45ppGRipHGRTKRpuQyLtao3HOasyKGXH5VA6EcGgnYgcYOcdailTjIqdhkYpn1oHoypKuelVpk71emix0FVpVzQJO2jKE8Q657VUkQ8qeorRlT2qrPEc5FAzOnj9qpzxYJIH1rTmjzziqk8VBSMyeKqc8XXitSeL2qnPF7UD3MuaP2qrPDjkD6Vpzxe1VZIx0I4oEjLmizziq8kfY1pTwbfp61Vmi9qRa8jOlh7gc1VkiIOMVpSx9iKrzQ57cimPdGe6ev51E8e4YNXJYscYqB0wcGpsLVehUkjwcEfSo2TH0q26A8EZqF4ivTkUblaNFdkz0qNh/k1ZZAelROhB5pC1RAy54qN489RzVgoPpTHjwM4/KgEyq0eOlMeMOCrKCO4IyKsOmfrTCD0oK0aPR/hJ+1n8VPhUItLmvv7b0mPAGnanIxaNfSOXlk9gdy+wr6Y+Fn7WPwi+J/lWC6z/Y+pyYH9m6uyxlm9Ekzsk/Ag+1fDzKD1qOSJWUqyA+oI4NNSaMZ0IyVz9Ms0V8DfDb9pH4wfC0x22geK5Lixj4GmaoDcQAei7juT/gLD6V7v8P8A9v7wVqqpZ/Ebw1daRMcBruyzc25PqQAJF/JvrVqa6nLLDzW2p9BUhAIrF8HfEnwD8QbYXXgrxfp+prjLLa3Ks6/7yfeX8QK28jOKvcxaaeowgg0lPIBFNIINAhKKKKACmsuORTqKAI6Kcy45FNoARlzTCMcGpKRlzQAymsuORTiCOtFAEdNZc8inuuOazPFHjHwn4JszqHjDxLY6XCBnff3SxZ+gJy34A0DSbeheppQk4QEk9ABzXifj39ur4Z6FvtvA2kXuvXAyFmKm2ts+u5xvYfRfxrw/4h/tSfGX4krJZXHiL+yrCTIaw0YGFWHo8mfMf8WA9qiUom0KFR76H1F8Tf2h/hT8LA9p4g8Qrc6go40rTMTXGfRgDtj/AOBkfSvnX4pftffEnx6suleGD/wj2myZUx2cpNzKvo83BXPogX6mvJ0twAeMZOfqa6b4ZfDLXfiXrw0nSV8qCLDX1665S3Q/zY9l7+wBNRds6lRhBX3G/DL4Ya/8TvEH9l6WpjgQh7+/dcpApPU/3nPZep78ZNfU3g3wboHgPQYfDnhy08qCLlmbl5XPV3Pdj+nQYAo8HeDtB8CaDF4d8O2nlQRcszcvK56u57sf06DgVqVpGNiZTcgoooqiAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKCcUUjDIxQA0kk5pKUgikqNUydUFFFFUncoKKKKYBRRRQAUUUUAFIy56GlooAjPHUUU9lz0pnQ9anVDCiiiqWogprJ3FOoo3AjpjDBxUrL3FRuO9Q1YE7MbTJFyafSMMikaFaRCpzUbjBzVl0DDBqB0I+VhQTsRMMiq80WeQKskYOKZIufx60DeqKEqZqtNHV+eLGSKrSx57UCTtoZ80eKqzxd60Zo/aq0sXYigq9mZk8VVJ4fatSaEc1Umh9qCvNGXPD7VUnhx2rVmhz2qpPB7UBuZkkf8LDiqs8G0+1aU0P5VXkj7MOKBJmZLFVeSPsa0Z4MHgcfSq8sJ6gUi0zOmgz2qtLD7VpSR+oqCWDPI60yr32M2SNl4P4VGy8YIq7LADwVqCSEqcYpWFbsVHjGf61G8RI4q0Ux0/I0xox6YoGmmU3jGcHtTSjCrckO7t+NRNHg4qbA1crugPDConiB9jVpk9ajZRnBFBN2nqVWi569KYyE/WrbRK3Tio2i56/pT0Zadyq0RPNN2H1FWWix3pjQ9wf0pWFZXuQxPPazreW0zxSocpNE5V1PswwR+Fd94P8A2pfj14LCRWHxBuLyBAALbV0F0uPTL/OPwauFMfbNMMYB65ouxOPNufRPhf8A4KHeILYLF40+G9rc8YafSr1oj9dkgYf+PCu/8P8A7d/wM1gKmrtq+kuev2vTjIo/4FCX/lXxqyYPTPpSFTjgU1Jmbo0pOzVj770X9o34E6+Qum/FfRNx6JcXggb8pQtdRp3iXw5rADaT4hsLoHoba9jkz/3yxr82mjDDDHI9DTFtokbckKA+oUA01NmbwsOjP022t2Rj9FJo2v8A3G/75Nfmpb6trdmc2euX0Jx/yxvZE/kwq2vjjx5HxH451tR2C6xcf/F0+cX1TzP0g2sf4G/75NMaNxyI2/75NfnE3jrx3Jw3jnWz9dYuP/i6qXGt6/eH/Tdf1Cb/AK7X8r/zajnB4VfzH6OahruiaSpfVdas7ZR1NxdpH/6ERXM63+0D8EPDwP8AavxW0JGHVItQWVvyj3Gvz9kt4pm3yxK57lxn+dOWMrwi49gMUc7GsLHufZ/iL9uL4C6RldM1HU9WcdBYaYyqf+BSlBXAeJ/+Chd46tF4K+GKIf4Z9X1Dd/5DiA/9Cr5xEBPOM+tPSEAc+tTzSZaoUlueh+LP2tPj54vDQN41OmQNn9xotutvx6b+X/8AHq8+vLi81K8bUNTvJrq4c5ee5laSQn3ZiTSrFxkCpEgyc4pXNUox2RXWAsc4qVIduMjp0qcQAdBmuk+G3wy1v4l64NL01fKt4sNe3rLlIEP82PZe/wBMmiwN6Efw0+GWu/EvXRpWmL5cEWGvb11yluh/mx7L39gCa+oPB/g/QfAuhReHvDtp5UEXLM3Lyuerue7H/wCsMAUvhDwhoXgfQovD3h608qCLlmbl5XPV3Pdj/wDWHFadaxjYwlK4UUUVRIUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAjDI4FMIxUlIy5pNBa4yiggg4NFTqidgoooqk7lBRRRTAKKKKACiiigAprLnkCnUUAR9OKKewzTCMVOqYBRRRVAFNdARwPyp1FAEDDBxSVLKgI3AfWoqhqw07DXGDmo5E3DNTEZGKjIwcGkWVpEI5phGRirEiDoahkQoaCdUQyJ2/Kqs0OOQOP5VdZciopEyOR9aBvVGdLH7VWlj9q0ZoT1qrLFnjFAk7aMoSR84/Kqk0J54rSli9qryRr3FBSdjLmh9qqzQ7vrWpNBxzVWaGgrzRlzQ+1VZofatWaHPUc1Vmg68UBuZkkeBhhxVaaDbyBxWlNB1wKryQ8HigSZmTQZ5AwarvGQfetOWDHQVXmtwe1BSfUzpIQ3bBqCWDsRV+SAioniJFBSZnSQYOMfnUTREf8A160JIARgioZICD04pWE1cpNHjtUboD2q48R6Y4qFoj0z+tJXBNoqtEPQio3i9D+lXDEen86Y0WewPpTaLTTKbRHGMUxojnlauND22kUxoQfWp2JsU2hB5wRTWiz2P51aMXYn9KYY/ehXHqVmg/2aY0J6gVbMfHWmlMnlaLivYqGLPBNM8oHjNXGiXOQKQw574o0He5UMRB6Cm+V3xVwwAjkU02/tRZBoyoYjmgwnv/OrXk8cjFHkr3osJNLRlRYMcYp3k8cj61aEPHt7UoiBHSjQd1sVRBg9PpTvI5zVkQnoRmnLb88imGpWEeOAPqTTxbZ7cVZFt0xT1i9BSZLK6W4xyKcIPQVaW3PXHFdD8OvhtrPxD1kabpy+XBHhry8ZcpAv9WPZe/0oWoX01Ifhv8NNa+I2tDTdNXy4I8NeXjplYEP82PZe/wBBX0t4S8JaH4J0OLw/4ftPKgi5Zjy8rnq7nux/+sOKd4U8KaJ4M0SLQdAtPKgi5JPLyP3dz3Y//WHFaNaxjYxlLmCiiiqJCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAQgnoKZUlIy570mrgMopSMGkqdmTswoozRVJ3KCiiimAUUUUAFFFFABSMuaWigCMjBxRT2UdTTKnYLhRRRVAFRyJjoKkpGXIpNXGiA9aR1yMipGTJzTCCKgexEwyMVG6Bhtap3XHIpjLkZoKKrqUODTXXPIqw6Bxg1CylTg0E7FeSMHtVaaHHQVedc8gVDJH3FA9GZ0sWetVpYvatKeHqQKrSxZoF5Mz5ItwwfwqrNBjjFaUsVV5YyeP1oKTsZk0PtVaWD2rUlgPQCq0sHtQV5oy5oPaq0sHtWpLB7VXlt/agNzLkg9qgkg9q05YOelQSQZFALUzJbcN0qtJD2IrUkhKnBH0qGWAOMjrQO+pmPCfSonhHpWg9vjjH4VFJb55FA79jNeDHb8aiktwe3NaDwc4x+FRtB2xQFzPaAjpUbRe2PwrQe3HpUbQEGlYLJlAxk8EU0xAetXWtz12/Xio2gz2FA9Sm0ZJwc/lTWhHcnNWzDg8j9aa8eeDRYV2imYM8c0wwkfwmrhix1H60hj54X9KLFXVimYM8gCkMLY4H4VcKGkMWTnFSK6KQhHSjyD0I+lXPJzzijyx6dadg90p/ZyOCKBb56CrnldsUvkk8AY+lFg0sVBbcc/wA6VbYdCOtWjD/nFKImPNCuF+5VWFR1FOEHbH6VaW3PYU9YMDpRqgvbcqrbnPIpwtwO30q0sOeMfjW/4B+HesePdY+wWI8uCMhru7ZcrCv9WPYf0p2FdIr/AA++Hes/EHWRp2nDyoIsG8u2XKwr/Vj2Xv8ASvovwt4W0XwbosWhaDa+VBHySeWkY9XY92P/ANYcUvhjwxo/hDR4tD0O18qGPkk8tI3d2Pdj6/gOK0KuMbGMpXCiiiqJCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigBGXPQUzFSUhUHmk1cBlFKRg0lTsydmFFFFUncoKKKKYBRRRQAUUUUAFNK8YFOooAjopzJnoKaRg1OqAKKKKpagNde4pjDvmpetMZcGpaKWqsRMO1MZdpqZlzzTGXjBFSGxC69xUboHFTkEGmMuORQUVmUqcGmOgIyBVh0DComUqcGgnYrSR9xVeWDHSrzJnkVFJGCKB6MzZYvWoJYfUVozQelV5IqBJ20ZnyRdiKrywdTitGSGoHixnPT6UFJtGbLB7VXmgI6D8K05YPaoJIPagZlvCD0qCW3/StKa2J5HWoJIj0IoHvqjNlgB4YVWltyprUlhBHI+lQSQHOKATTMySDPUVE0HtWlJb9SahktsjK0DM6S23cgVC9v2rReIj7y1HJCrDpQK5mSQEHBphg9v0rRa3B4NRPbkdRQF0UGiA9fwpjQg9DV57cj3phgBPNBSZRa3z0HNMa2zwRV5oAD/hTTBntSHuyh9nIOCKa1vg1eMGODSND2waYrlE2vcCk+zbu3NXjAR2/MUeSSckUDuUPsxHsfSl+zY5Iq75Oe1AgJONtIRSNscdOtKLUqeelXPs5J5GPalFseuOKNAKgtwDwKBAByFNXBaEHnmni2HXFAFIQlu1OFr3P5VcFuF5H5VueCPAmqeNtVFjYr5cEZBurplysS/1Y9h/SmK6W5V8CfD7VfHOrfYLFTHBHg3d0y5WJf6sew/pXvnhvw3pHhPSI9F0W28uGPkk8s7HqzHux/8ArdKXw74d0rwtpUej6Pb+XDHySeWdu7Me5NXqtKxlKVwooopkhRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFACMuegppGKfSEUmrgMopSMGkqdmTswoooqkygooopgFFFFABRRRQAU1lGOB+VOooAjIIODRT2HemEYqdhhQRkYoop3uIYQQcU0jNSMMimEEHBqWrMtWZGygioyMcGp2GefamMoI5pC2IWXHIqN4w4qcgg0xk7igorMpU4NMZAeRVl0DjB61CylDg0E7EDxgjGPwqvLBjkdP5VdZQ1MeP1FA2rmdJF6CoZIavywAdKheLPagSdtzPlhPVageIHgjFaMkPqKglt8jOKB27GfJAetV5YFOcDBrRaIiopIQ/GMGgL2Mx7f2qF7ftWlJbnriont/agrSWpmvb+1Qvbc8CtOS2yOKheFgcMKA1MyS3B6rUTW3tWlJDkfMPxqN7celA7mY9rnoKia3x1FajWx7Cont93YUBZMzWg5wVqNrfjgVpvbBuCKjNr7UC1M5rUelM+zf7NaDWrA9P0pptWxwKCk2UDan+7TTbAjkVoG2PQimm1PZaAuyh9m7Un2Yg8ir/2btik+znpQTdoo/Zj1xR9nB7Yq79nbvR9mOc0FXdil9nHUUoi9B+dXRaj/wDXSi0OelAtUykIiOMUotznpV1bcdxWt4R8Fah4u1IWloNkKYNxcsuRGv8AUnsKAdtyr4M8C6n4y1T7HZjy4Y8G5uWX5Yl/qx7D+le2eH9A0vwzpcekaRbiOGPrnlnbuzHuTS6FoWm+HNNj0rSoNkSdSfvO3dmPcmrlWlYzlJsKKKKZIUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFACFc0wipKRhmk1cGrjKKUjFJU7E7BRRRVJ3KCiiimAUUUUAFFFFABSMoPNLRQBGeDiinsvB4phGDip2C4UjLmlop7hsRkEHBpGXPSpCoNNIIPNS1YvRkRXI6UxlK1My55FMIHSkLYiZc9KjdAw2sKnZCORTCoNBRWdChppAPWrDp6/nUTxkdKCdmQtHketQSwdxyKtEY4NIVBoHoyi8dRPF6Velhxzjj1qFovb8qBJ20ZRlgByageDitF4vaopLcHoPyoKM54e2KieEHtV94faongzQTbqig0APSopYB0IxV54CDzUbRHpQO73M97cdxUb2oPQVotAOhFMa3HpQVdMzGtyvFMe3J6itKS3HcVEbfPage+xntCMdKjaAHnFaL2vcCmG37GgRntApGM0024HWr7W3cL9aY1uCOlAa2KLW6nkUn2cY6VdNv2NNNsVPSgNSmbYZ6UhhUjBH0q79nyOlN+znPNAXbKZt1xg0hhUcbc1dNv6ig2vYigNSl5Qz93p6UvlZ4GBVw2xxg1o+GPCN94mvvs1uNkSEGecjhB/UnsKB30KvhXwbqHivUfstsPLiTBuLhlyIx/UnsK9b0XRdO8P6cmmaXAI4k6+rnuxPcml0jSLDQ7BNN02AJGn5se7E9yas1aVjJu4UUUUxBRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAhUHtTCKkpGXPSk1cBlFGKKnZk7BRRRVJ3KCiiimAUUUUAFFFFABSMuec0tFAEeMUU5lGKacjtU2aAKCAetFFPcNhjKRTWXPIqUjPBprJ3FJoq6e5CRzSMgPIqRlz2phFSGxGRjgimPGCOKnIB601kI6UFFV0wdrCmFCOlWmRX6ionjZTQTsQ/Wo3iA5WpygNNKEe9A9GVnj9RUbRVaZMcimFAaBJ20KkkGTyKhaEVfeLI5Gaikh4/oaB7FF7ckcCoWg/2a0GhPp+VRvBnnAzQHmjPe3BqNoGU/wD1qvtDjqD+NMaAEYoFuUDGehFNaHjkVdaAjgimNAOhAoAotF6CmmHd1P61ceDbwB+NNaDjoaBpu5TMHtTDbjqB3q55PqDSGH2oHe5Ta2z3pv2YHhhVsw88A01omB6UBcqG22np+lBtwe1WjGTxTTCc9/yoHuVTAOh/lR5K96tGAEc1d0Dwzda9d+RD8kaYMspHCj+p9qAZX8O+GL3xFe/ZoF2RJzNMRwg/qT2FekaXpdjo1kmn6fDsjT82Pck9zS6bptnpFmtjYxbEX8ye5J7mp6tKxm3cKKKKYgooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigBCMnNNKkU+kZRjik1cBlFKRg0lTsTqgoooqk7lbhRRRTAKKKKACiiigAprLxTqKAI6KeVB5zTCCOtTZoaCiiiqEIVBqN4yO1S0EZ4NJq47lcjBxRUrxZ5WoyCOtS1YE7DSoNMaP1qSikWV2QZ5H40woR05qy0at1qNo2WgnZkBUHqKjeEjleRVgqD1FIU9DQOyZVII6iggHqKmeIjnHFMKDtQJO2jIXiXqOKjaPPYVYMZ+tMdGHJFMNiu8APO01EYgat0141bnHNIGuqKjQA9v1qNoCDg5/KrhQUjRAjGaB7lJoR0wPypjQY7CrrRkHBammLIwQKA3KLQA/w/rTTD6j9autAQegprQZ7CgCmYAeQBmmGAHgj9KumL1Apphz6CgCkYCvGPpxSGDjIB/OrhiHRjVnSNCn1a58qPKxr/rJMcKP8aBFbRPD1zrVz5MQ2Rr/AK2UjO0f4+1dxp+n2umWq2dnHtRfzJ9T6mlsrK20+2W1tI9qL+ZPqfepatKwm7hRRRTEFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAhXIwKay4NPpGBPek1cBlFKRikqdifhCiiiqTuUFFFFMAooooAKKKKACkZAeaWigCPGKKey55FM71OwBRRRTTuAU10B5p1FMCFk9KaQR1FTMvcU2oasCbW5HSEAjmpCgPSmlGFI03ImQA8immP0NTMueDTCCDQTsyMqfSo3iA6Cp6QqD1FA9GVjH6GkKH0qd4gOVpuw0CTtoyu8YJ+7TCi1aKt3FRvGv8AdxTDYrtDnkH9KYYyO4qzsWmvCDyDSBrqiuYyeophjYHGKsGMjvSGIkdRQG5XKnoRTGQjtVgxMDgkUhjJ4OKBWuVzGTyVpNoHG2pmiYd6n0/SZdSm2KdqL998dP8A69AyLTNJl1Sfy4xtRfvyY6D/ABrqLS0gsoFtrZNqr+vufei1tYLOBbe3Taq/r7mpKtKwm7hRRRTEFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFACEA9RTSMdqfSFQaTQWuMooIIODRU7E7MKKKKpO5QUUUUwCiiigAooooAKRlBFLRQBH9KKey5HFMIxxU7MYUUUU07iCmsvcU6imBHRTmXPIptQ1YE+UCAeoprRqwx0p1FI03IWiZT60m0jtUxAIppBHWgnYiprLjkVNgelIUU0DsmQUEA9RUjw91pvlnsaBJ20ZE0ak9AKbsX0qYxn2prRN2FA9iFolPIFNMY9TU2xvSmvGTyBzQLzRCYgepppiwcE1Ntb0NS2thLePtAwo+8x7UBvqRWWmvfSbFyFH3n9K3Le3itYRBAmFX/OaIII7eMRRLgCn1aVhN3CiiimIKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAay4GQaQjHWn0jLmk1cBlFGKKnYn4WFFFFUncoKKKKYBRRRQAUUUUAFIy56UtFAEZBBwaKewyOBTCMVOzAKKKKpAFNdc8inUUbgR9KKcy55FNqGrAnYKQjIxS0UjTcYQQaSnkAimkEdaCdhKay45FOooHoyOilZccikoEnbRjWXvTakp1vaPcPxwo6tT3HsNt7N7p+OFH3mrSiijhQRxrgCiONIkCIMAU6qSsQ9QooopgFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFFFFABRRRQAjLmmGpKRlz0pNAMooPXg0VOxOwUUUVSdygooopgFFFFABRRRQAUjLmlooAjIwcE0U9lzTCCODU2sO4UUUVQgprr3FOoo3AjopzL3FNqGrAnYKQgEUtFI03GEEGkp5AIppBBoJ2EprLjmnU+GBpmwOnc0DdmhlvbtO2Bwo6mr8aLGgRBgChEWNQiDAFLVpWIuFFFFMAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooopJ3EncKKKKYwooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAEZc80wgjrUlIwyKTVwtcZRQRziip1RO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'\nRESTORE_WINDOW = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAAPlJREFUSIntk79OAkEQhz8QcheTayyxEQobExNrCuND3CtY8wzG1qcAWirfwc5En8FAcRZIgRZefhQM0az3Z1mj1f2SyczuzN232dmBRo0AJLk2krSSlJuvMkl6dv/Z8eCeA4nFSVWh6TQEstMH8AJ0S/I5MAAOQiAt8/dAWlP7CFz4QMbAFZDZ+sR87HGgQhVBUiACjp39digk+MPfQiJgCfSAPjCz/fdQSFnjP4GFxW/mh8CUr2ccA4cGX7N9fWdeVBuo7NswXkuay18PIZAyu7PaiZtz9S+NL+vJEXBr+R8TDLwClxbne1MlPe1x/5J0U3ddjRr9nTawKgnkexlHFAAAAABJRU5ErkJggg=='\nLOCATE_FILE = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAAXNJREFUSIntlbEuRFEQhr+Zg4qgoqD1BBpERKIlolN4BxIUtJ5BJR5AIdErNmwkCiLbSHgENhqJZu/5Fc7dIHd3ryURiT85yZl7Z+Y//5yZHPjHn4GkQ0l1tUZD0q2kWUnkqwjWhkTANXDawqUX2Ej7OeAMwKxlymISSWvvT/lpuaR9ScfJd6aVEu/ANdrm3zQwCKwAl0AVmOqGpImCmt8CM0AFeErfdotie8qSxBgBCCEgCTOrA5PACPACXADDXZPEGIkxEkIYAnbM7C7LsgMze3T3x+SWAeFbSkIIQ+5eS6ftd/eJGON2UgVvpc+KYkvdibvj7kvAuKQBYNXMtkIIpVq2lJJ04TUzw8x2gQWg9k7F90kS0Y2ZXQF7yZ4vG/vVFl5P5r2kStnYUkryksQYq+5+AhzlM/Oj5XL3vJWX8+Tu5QrRieQh35hZcxBz+xMaQF83JIu8TXMTbcozBtQ75PsISedt3pIiPEva/BLJP34Nr3F5AGzDIVXfAAAAAElFTkSuQmCC'\nCLEAR_QUEUE = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAATRJREFUSIntkk1uwjAQhd+MDUEISrbZlQu0PUJ7ELbsco3cgAUL1hyDTS/QK8ASRY3aSKDEHnfTIJzwE3VZ5ZOske2ZeX4aAx0dHfeg802SJAAAEUGe55jP55vpdPr6h77fRPRQbfT5jXPOi0VReLHVq4kgIt6ZJ1KWJQDAWgsAWK/Xb71er1F0CeccmJkHg8GEiNxVkSp5NBqNtdbB4XDIrLWGiOppDZgZ1lpJ0/STma87YWYYYzCbzd6jKHq627lGlmW7xWLxWJalZ92TrGbR5uXXuFTrOSmKAsYYLJfLZ6XUSfQWv7NQw+EwFBFbd9EQCcNwAoCPx+OXiNi2szDG2P1+n2qtoZS67SSO4x0zj9s4qOj3+9hutx+r1epFKQWtG3/JFwmCoHXzuhARnVZHxz/jB11ofrsXPqlsAAAAAElFTkSuQmCC'\nSAVE_IMG = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAATlJREFUSInt1T1KBEEQBeBvF38QDRQ8hEZeQK8ggicwMDYQRBADEQTRwEgDf/AEG65eQMETCGJg6BGUVcvAXVhne3ZmRNBgHzQz1d31Xr3u6R4G+G+oZTsiAqaxgUm898kPtLD2jbTWQ9sjMhMRx1EN6xGh08o4ucAKntBAvY+LVUy0403sl3Vy2q7usru6nPaQcDSV5UxV+dZ+TiTGshjOxIc4yE4aKiAYlljSLqSK7NmUlEjna1rESx+RyBF5y3b0cyKHpIOC3S1H8muoKnKOZexUSSparm7sYav93sAdmmUSqzjZzcRXeP5tkfFE3+hPRfKW8CwTb6PndKc4U4QjOSJLeMQ15jCfM6/4poiI2Yg4qXgLd3AbEQtlnNzjCK8Yw0fXWM3XPrR8/8/UfZ30Jm4KnQzw5/gEo7Tit/WlmmYAAAAASUVORK5CYII='\nSHUFFLE_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'\nSHUFFLE_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=='\nPLAY_NEXT_ICON = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAAOxJREFUSIntkzFqAkEUhn8WLUMKBcHGTi08Qa5hk2aLnMQLWAQXUsVKLLyDNxAiGEgbcoOQUovPwlkY13Gz485W7gePXXjwvjdv3kg1IQDyIgISTrzZuZCSNN6NKAkt6QI9oAM8AGsjei0rGQIz4IdLfq3/latWo4AvljSX1PRrswDmBGOryw/gGRiEHFfLEiyruviJKfDtKB5shXemyMsVyQh4zOZ8JSmDK6NyhovIT30beZJP832qzP7PxXuNK0/SLrDCpSXZx7jl/DH2OD3I0hIBMXDAzSbUdi0k9SVNJX1J2lu5P7/Wa+6KIxwRWqnKaEkqAAAAAElFTkSuQmCC'\nQUEUE_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='\nNEW_PLAYLIST = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAASBJREFUSInt078uBFEYBfDfLPWqNBS20ognoBCFB1BJlArPoPQGKhWVUqLSrkInCgkaFZ1GIRLE30+xNzHGzs7YUNmTTGbunXPPd79z7mWAAf4KWX4QEUuYxVvFuiHs4KhUOMu6/4iI7aiPlYhQ9uQxXKjziPtih932g+cKzmdXhU7qrqsWztlV7GQKE+plcoJrzKOJfbxWVo+I4x9kspb8f0rjkbqZbGBGvUza6fsKk3gvI/9GJqeYLmr1ymQVC75n0sAZ1rGI5cQJtBJnDy9Js43NrluKiMMeGdwmv7dq5PXlkhbtamG0xJY7XGAM47n5XZ0TOYeHNHeTZdllWZES/Z441zn6pZk0+lEtYCy9m2WEYvD94EDHruqLOMD/wgcLDgX8PuKpagAAAABJRU5ErkJggg=='\nDELETE_ICON = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAANZJREFUSInt1D8uxEEYxvHP+HMBS0HlAEqNam+jcwQHEI5A7yAkIoqNQuMGIlQaibyKVVhm3p0NxRa/bzLFzPvO87x55w8Dy0ZpBSLi59IRtr7N33AyI1bqcmtJAQfYwwd2cVzJ2cY9VvGA60TvNxFxFYtx19LK2rWPTbwjvkZtf8E6Xkspt4uawCHGpi1rsYIJzlpn0iQiRMRNZ6seKxdlpoqM586anrLgPJN/YTAZTKr0vq40b57JRqfJ6C8mF50ml1kw++rh3PSb30lyXnDaWczAkvAJ+zaCCsNSzjYAAAAASUVORK5CYII='\nPLAY_ICON = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAAHFJREFUSIntlMENgCAQBE970Mq0SO2MKsaXfAQhl9uHhk14wjBHWLORTwU4gAWwnuWFACRgU0PuNK0iIE2rKMirVTSkaKWAPKyUkGxVO2f24YuZXLs6LU5gVY0rATvCh8+3V0Dkn1FaK/KC1Ff9yH9zAR0obmDa9niBAAAAAElFTkSuQmCC'\nEXPORT_PL = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAAPNJREFUSIntlDFKxEAUhr9xI55CvYignY02YmejoM2CpbfwAF5CxDtsYeMdFC9gJQjJfhZ5yqhJzLoRFPeH4WXeG/4vw8wbWOhPSO0au+qorT4EZNNaBz8F2fC99oeGbIfxoTqJqHo0FGQnDI9jXqlJ3Yr8yRCQJ3WczafqanzvBajogqQ2SKYCqIDX5BRYBx6yevlmmD5bFj02Vs5ZZ6kHZG79b0jjZfkOZAxcAKOG2hnw2JA/B057k6OrVVe+eCzzoXozy06qD7GPyrb1bX1yH/EWeO74GaibNIXX3SyQa2AZWMuM2pSoz+4SuOpYt9Av0gvtRmhPGjsRlgAAAABJRU5ErkJggg=='\nX_ICON = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAAR9JREFUSIntlLFKA0EQhodUJp0IgYBoBEmZKLHxCfTtfIE8gF0KbRMjlikENaAQCFjepRc94bPILewNe5dZLWzuh7+4ZWa+nZ3dE6lVywkQYBcYA/38e5tHwAAwQ3rAKxulwOEWwCyPXQN9K2RMUesK0J2KXVkhx8CLSk4CoImK+QQurRABdoBnVSQFWiUdZMAJETNxbgY6egRuAoChy4uFSL5zDdJHdObn/AbifBsALIGOjtVq2LAiIvIRWMtEJImoUdmJHrKvBOj+9bimqug7MFdrhQcbC9EdfHkz0JchBQ5iIJ0SwCnFW/cUAJ1bIVcq+RvvHXhuAgsVe2+F7AMPeVJWAvA7cqA34MgKcb4GLioAzntsfvVt80xq/at+ANFMQcxleDtAAAAAAElFTkSuQmCC'\nUP_ICON = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAALxJREFUSIntk7ERwjAMRRUXbkmbEWADpggD0DIAx2IMAWyQFdKmTsE9GhlyieNznND53flsS7K+fmGRTOZfFKEk4Asf9d1r0qzwt0sRuet+ihUJAozXHnjrOozzc5iFujd9Y/S8ntGkFdDzo9fYpk6uImIHd6uxdQym3AEdUzqg3MrJRURKT7zUXDo6oQVajwtHqzWzfWKcnEWkCuQrrUkDMEATcOFogKXf4StSRwg46lSRxwKRZ5JIJrOKD5gaOr3a2gWhAAAAAElFTkSuQmCC'\nDOWN_ICON = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAALdJREFUSIntjzEOglAQBUdirG29gpfhXiZEe1tu4B08AkfQlhYTHZstiBGEj8aGSUhg3/7/GJiZ+Qvq2eGcU0vyESV5akmmVgMKKjXruqczCB5AMeB/itgdj4q6Ui89FpfY6bznkwnADTj05PvYSSNMUNdq/caijoypJgA1cHwzP0aWTssEdaM2LYsmZnzLBOAKlK3vMmbTeDFB3ar3eLaveRfLkb0VcGq9D2JsCcAOWCScm5n5AU8R/D5fdm9FrQAAAABJRU5ErkJggg=='\nPLUS_ICON = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAAMNJREFUSIntkzEKAjEURN8u2gh2YqEXsLK28TJexht4j+28hI1gI4iohdZ2IutY7F8IYmATSJcHn0lCkskkBDKZFkm+Wkt6Sqok9f/N+UcZ6D8HhsAiZG2oSW0qqyQmUYSayNFkSdrr+oSY9DzjG2AJPKwv4EXz4AAjYAu8nT3GwA5YdXKWtFccx5AkZ2AG3JyxGpgCA2tfTdsrnwCXTiksia8qO/E95WfUj3Yi1KR0tEhlEkVskoKESU6mB5oPmcn4+QIW0bX+FmU7ggAAAABJRU5ErkJggg=='\nCOPY_ICON = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAARZJREFUSInt1LsuRFEUxvHfdpsgMlFpRESvoRYdLyBKjRfQ6lQeQTyBF1AoVPQegAbFSFwamonrVsyemHD2MUcUJPMlOznrrG+v/zo5WYue/ppC0csYY2e4gnXU8JqpM4RLbIYQGl2RY4ztMxJjPI/da+snXzKHE6ziKHVcpCb2MRlCmPqcHMhcWsI9rlN8hpuMt61bjBclcpA9NDCf4vo3AEr+WV/mwpVW57kmKikHeUvnV5SD/Kp6kP8HaW+Euy5qNTFYlCibg1cfw7WQvIVF8IgZDFeF1PGEY+yW+Dq1UQVSx0t6Xky+6OuABoxhWmvXXVSBnGIZh6nwg+KNXcMBdjJ1SiFr2MYsnjGa8fVjogzQU2W9A9hjZp3X4thrAAAAAElFTkSuQmCC'\nEDIT_ICON = b'iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAABS2lUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDIgNzkuMTYwOTI0LCAyMDE3LzA3LzEzLTAxOjA2OjM5ICAgICAgICAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+nhxg7wAAAQRJREFUSIntk8FKAlEYhQ9SoZCIKx9BhN7AFylaFEkhlqu2rl34CK16nnDhKorCnqBN7Vz4ufCMjkNOc3VcCB4YmDPcOd9/z9yRDtoLAaHXJTCI/C4gZ8Avc13sClJnVf1kXiGkxoTKkl4k/UiqxZ4f57WTEjCJTV8BmsBjXnWVHDy2H9kv1mwLqTrw1b5lfw8U8oBUHPhufxUDrKzdFHLiwE+W/wXA3V8DbQI5deBboqL2ulpDIWUHfthf23fTDkcIJAJEpyiq6CENEAIpOvAr8ZFv/gOEQL6BoV9qG3CbBRACAegB577vZAWEQMYslamiNMjRGs6TpIakqaTnbKMdtE+aAQHeA941GTukAAAAAElFTkSuQmCC' # pencil\n"
  },
  {
    "path": "src/ca.elijahlopez.MusicCaster.yml",
    "content": "app-id: ca.elijahlopez.MusicCaster\nruntime: org.freedesktop.Platform\nruntime-version: '21.08'\nsdk: org.freedesktop.Sdk\nfinish-args:\n  - --share=network\n  - --share=ipc\n  - --socket=x11\n  - --socket=wayland\n  - --device=all\n  - --filesystem=host\ncommand: python3 music_caster.py\nmodules:\n  - name: music-caster\n    buildsystem: simple\n    build-commands:\n      - pip3 install -r requirements.txt\n# flatpak-builder dist/flatpak ca.elijahlopez.MusicCaster.yml --force-clean"
  },
  {
    "path": "src/experiments.py",
    "content": "from base64 import b64decode\nfrom pathlib import Path\nimport urllib.parse\nimport soundfile as sf\nimport numpy as np\nimport matplotlib.pyplot as plt\nimport io\nfrom PIL import Image\nimport urllib\n\nfrom b64_images import DEFAULT_ART\nfrom utils import get_album_art, get_ipv4\n\n\ndef get_audio_wave(file):\n    data, samplerate = sf.read(file)\n    n = len(data)\n    time_axis = np.linspace(0, n / samplerate, n, endpoint=False)\n\n    ch1, ch2 = data.transpose()\n\n    sound_axis = ch1 + ch2\n    accent_color = '#00bfff'\n    bg = '#121212'\n    buf = io.BytesIO()\n    fig = plt.figure(figsize=(4.5 * 60, 0.75 * 60), dpi=5)\n    plt.plot(time_axis, sound_axis, color=accent_color)\n    plt.axis('off')\n    plt.margins(x=0)\n    fig.patch.set_facecolor(bg)\n    plt.savefig(buf, format='png', bbox_inches='tight', transparent=True)\n    im = Image.open(buf)\n    # return im.resize((int(im.size[0] / 3), int(im.size[1] / 3)))\n\n\n# test_file = 'C:/Users/maste/MEGA/Music/No Mana - Memories of Nothing.flac'\n# get_audio_wave(test_file)\n\nimg_file = r\"C:\\Users\\maste\\Documents\\MEGA\\Music\\02 - Under Your Spell.flac\"\nimg_file = r\"C:\\Users\\maste\\Downloads\\TheMagicFluteOverture.mp3\"\nmime_type, img_data = get_album_art(img_file, False)\nimg = Image.open(io.BytesIO(b64decode(img_data)))\ndata = io.BytesIO()\nimg.convert('RGB').save(data, format='JPEG')\nimg.save('dist/test.jpg', format='JPEG')\nassert data.getvalue()\nurl_args = urllib.parse.urlencode({'path': Path(img_file).as_posix()})\nurl = f'http://{get_ipv4()}:2001/file?{url_args}&thumbnail_only=true'\nprint(url)\n\nimg = Image.open(io.BytesIO(b64decode(DEFAULT_ART)))\ndata = io.BytesIO()\nimg.convert('RGB').save(data, format='JPEG')\nprint(len(data.getvalue()))\nprint(len(data.getvalue()))\n"
  },
  {
    "path": "src/go.mod",
    "content": "module Updater\n\ngo 1.17\n"
  },
  {
    "path": "src/go.sum",
    "content": "github.com/akavel/rsrc v0.10.2 h1:Zxm8V5eI1hW4gGaYsJQUhxpjkENuG91ki8B4zCrvEsw=\ngithub.com/akavel/rsrc v0.10.2/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=\n"
  },
  {
    "path": "src/gui/__init__.py",
    "content": "import FreeSimpleGUI as Sg\nimport platform\nfrom .views import *\nimport ctypes\nimport ctypes.wintypes\nimport sys\n\nALT_KEY, EXTENDED_KEY, KEY_UP = 0x12, 0x0001, 0x0002\n\n\ndef focus_window(window: Sg.Window, is_frozen=getattr(sys, 'frozen', False)):\n    # raises TclError [window_is_foreground]\n    # use bring_to_front when frozen and in Python use other method\n    if platform.system() == 'Windows':\n        keybd_event = ctypes.windll.user32.keybd_event\n        if is_frozen and window_is_foreground(window):\n            window.bring_to_front()\n        else:\n            keybd_event(ALT_KEY, 0, EXTENDED_KEY | 0, 0)\n            ctypes.windll.user32.SetForegroundWindow.argtypes = (ctypes.wintypes.HWND,)\n            ctypes.windll.user32.SetForegroundWindow(window.TKroot.winfo_id())\n            keybd_event(ALT_KEY, 0, EXTENDED_KEY | KEY_UP, 0)\n        if window.TKroot.state() == 'iconic':\n            window.normal()\n        window.force_focus()\n    else:\n        window.force_focus()\n        window.bring_to_front()\n\n\ndef window_is_foreground(window: Sg.Window):\n    # raises TclError\n    width, height = window.TKroot.winfo_width(), window.TKroot.winfo_height()\n    x, y = window.TKroot.winfo_rootx(), window.TKroot.winfo_rooty()\n    if (width, height, x, y) != (1, 1, 0, 0):\n        return window.TKroot.winfo_containing(x + (width // 2), y + (height // 2)) is not None\n    return False\n"
  },
  {
    "path": "src/gui/components.py",
    "content": "import base64\nimport io\nimport platform\n\nimport pyqrcode\nimport FreeSimpleGUI as Sg\nfrom meta import FONT_NORMAL, State\nfrom PIL import Image, ImageDraw\n\n\ndef get_styled_button_font():\n    if platform.system() == 'Windows':\n        return 'Segoe UI Variable', 12\n\n\ndef StyledButton(button_text, fill, text_color, tooltip=None, key=None, visible=True,\n              pad=None, bind_return_key=False, button_width=None, blend_color=None, outline=None):\n    if State.using_tcl_theme:\n        return Sg.Button(button_text, use_ttk_buttons=True, key=key, visible=visible,\n                         bind_return_key=bind_return_key, size=(button_width, 1), pad=pad)\n    multi = 4\n    btn_w = ((len(button_text) if button_width is None else button_width) * 5 + 20) * multi\n    height = 18 * multi\n    btn_img = Image.new('RGBA', (btn_w, height), (0, 0, 0, 0))\n    d = ImageDraw.Draw(btn_img)\n    x0 = y0 = 0\n    if outline is None:\n        outline = fill\n    d.rounded_rectangle((x0, y0, btn_w, height), fill=fill, outline=outline, width=5, radius=10)\n    data = io.BytesIO()\n    btn_img.thumbnail((btn_w // 3, height // 3), resample=Image.Resampling.LANCZOS)\n    btn_img.save(data, format='png', quality=100)\n    btn_img = base64.b64encode(data.getvalue())\n    btn_color = (text_color, blend_color)\n    if blend_color is None:\n        blend_color = text_color\n        mouseover_colors = (None, None)\n        highlight_colors = None\n    else:\n        mouseover_colors = btn_color if platform.system() == 'Windows' else None\n        highlight_colors = btn_color\n    return Sg.Button(button_text=button_text, image_data=btn_img, button_color=(text_color, blend_color),\n                     tooltip=tooltip, key=key, pad=pad, enable_events=False, size=(button_width, 1),\n                     bind_return_key=bind_return_key, font=get_styled_button_font(), visible=visible,\n                     mouseover_colors=mouseover_colors, highlight_colors=highlight_colors)\n\n\ndef IconButton(image_data, key, tooltip, bg):\n    return Sg.Button(image_data=image_data, key=key, tooltip=tooltip, enable_events=True, button_color=(bg, bg))\n\n\ndef Checkbox(name, key, settings, on_right=False, tooltip=None):\n    # fix for languages that are too long to fit into the UI\n    if tooltip is None:\n        tooltip = name\n    bg = settings['theme']['background']\n    size = (23, 5) if on_right else (23, 5)\n    checkbox = {'background_color': bg, 'font': FONT_NORMAL, 'enable_events': True, 'pad': ((0, 5), (5, 5))}\n    return Sg.Checkbox(name, default=settings[key], key=key, tooltip=tooltip, size=size, **checkbox)\n\n\ndef QRCode(text_to_encode):\n    try:\n        qr_code = pyqrcode.create(text_to_encode)\n        return qr_code.png_as_base64_str(scale=3, module_color=(255, 255, 255, 255), background=(18, 18, 18, 255))\n    except OSError:\n        # Failed?\n        return None\n"
  },
  {
    "path": "src/gui/views.py",
    "content": "import platform\nimport time\nfrom datetime import datetime\nfrom math import ceil, floor\n\nimport FreeSimpleGUI as Sg\nfrom b64_images import (\n    CLEAR_QUEUE,\n    COPY_ICON,\n    DELETE_ICON,\n    DOWN_ICON,\n    EDIT_ICON,\n    EXPORT_PL,\n    LOCATE_FILE,\n    NEXT_BUTTON_IMG,\n    PAUSE_BUTTON_IMG,\n    PLAY_BUTTON_IMG,\n    PLAY_ICON,\n    PLAY_NEXT_ICON,\n    PLUS_ICON,\n    PREVIOUS_BUTTON_IMG,\n    QUEUE_ICON,\n    RESTORE_WINDOW,\n    SAVE_IMG,\n    SHUFFLE_OFF,\n    SHUFFLE_ON,\n    UP_ICON,\n    VOLUME_IMG,\n    VOLUME_MUTED_IMG,\n    X_ICON,\n)\nfrom meta import (\n    CONTACT_INFO,\n    COVER_NORMAL,\n    EMAIL,\n    FONT_LINK,\n    FONT_MED,\n    FONT_NORMAL,\n    FONT_TAB,\n    FONT_TITLE,\n    LINK_COLOR,\n    PL_COMBO_W,\n    VERSION,\n    State,\n)\nfrom modules.playing_status import PlayingStatus\nfrom modules.resolution_switcher import fmt_res, get_all_resolutions\nfrom utils import (\n    Device,\n    create_progress_bar_texts,\n    get_first_artist,\n    get_languages,\n    repeat_img_tooltip,\n    t,\n    truncate_title,\n)\n\nfrom gui.components import Checkbox, IconButton, QRCode, StyledButton\n\n\nclass GuiContext:\n    text_color = fg = None\n    background_color = bg = None\n    accent_color = None\n    experimental = None\n\n    @classmethod\n    def update(cls, text_color, background_colour, accent_color, experimental):\n        cls.text_color = cls.fg = text_color\n        cls.background_color = cls.bg = background_colour\n        cls.accent_color = accent_color\n        cls.experimental = experimental\n\n\ndef MiniPlayerWindow(playing_status, settings, title: str, artist: str, album_art_data: bytes,\n               track_length: float | int, track_position: float | int):\n    # album_art_data is 125 x 125\n    album_art = Sg.Column([[Sg.Image(data=album_art_data, key='artwork', pad=(0, 0))]],\n                          element_justification='left', pad=(0, 0))\n    music_controls = MusicControls(settings, playing_status, prev_button_pad=((10, 5, None)))\n    progress_bar_layout = ProgressBar(settings, track_position, track_length, playing_status)\n    title = truncate_title(title)\n    right_side = Sg.Column([\n        [Sg.Text(title, font=FONT_TITLE, key='title', pad=((10, 0), 0), size=(28, 1))],\n        [Sg.Text(artist, font=FONT_MED, key='artist', pad=((10, 0), 0), size=(28, 2))],\n        music_controls, progress_bar_layout], pad=(0, 0))\n    return [[album_art, right_side] if settings['show_album_art'] else [right_side]]\n\n\ndef MainWindow(playing_status, settings, title: str, artist: str, album: str, album_art_data: bytes,\n               track_length: float | int, track_position: float | int,\n               queue, listbox_selected, timer, music_lib, devices, web_ui_url: str):\n    # devices: device_names list of (name, device_key)\n    accent_color, text_color, background_color = settings['theme']['accent'], settings['theme']['text'], settings['theme']['background']\n    alternate_bg = settings['theme']['alternate_background']\n    vertical_gui, show_album_art = settings['vertical_gui'], settings['show_album_art']\n    music_controls = MusicControls(settings, playing_status)\n    progress_bar_layout = ProgressBar(settings, track_position, track_length, playing_status)\n    if not show_album_art:\n        album_art_data = b''\n    info_top_pad = 10 + 60 * (not album_art_data) - 30 * (vertical_gui and not album_art_data)\n    # 10, 110, or 0\n    info_bot_pad = 10 + 40 * (not album_art_data) - 20 * (vertical_gui and not album_art_data)\n    # 10 or 30\n    # default_device = []\n    default_device = next(filter(lambda device: device.id == settings['device'], devices), Device())\n    combo_devices = [Sg.Combo(devices, key='devices', readonly=True, background_color=background_color, expand_x=True,\n                              default_value=default_device, enable_events=True, pad=((5, 10), 10))]\n    left_pad = settings['vertical_gui'] * 95 + 5\n    playing_section = Sg.Column([\n        [Sg.Image(data=album_art_data, pad=(0, 0), size=COVER_NORMAL, key='artwork')] if album_art_data else [],\n        [Sg.Text(album, font=FONT_MED, key='album', pad=((0, 0), (info_top_pad, 0)), enable_events=True,\n                 size=(30, 2), justification='center')],\n        [Sg.Text(title, font=FONT_TITLE, key='title', pad=((0, 0), 4), enable_events=True,\n                 size=(30, 2), justification='center')],\n        [Sg.Text(artist, font=FONT_MED, key='artist', pad=((0, 0), (0, info_bot_pad)), enable_events=True,\n                 size=(30, 0), justification='center')],\n        music_controls, progress_bar_layout, combo_devices], element_justification='center',\n        pad=((left_pad, 5), 5 * vertical_gui))\n\n    LISTBOX_HEIGHT = 21 - 7 * (vertical_gui or not show_album_art)\n    # do not allow casting to a music device\n    video_devices = list(filter(lambda device: device.id != settings['device'] or playing_status == PlayingStatus.NOT_PLAYING, devices))\n    tabs = [\n        QueueTab(queue, listbox_selected, LISTBOX_HEIGHT),\n        URLTab(accent_color, background_color),\n    ]\n    if settings['experimental_features']:\n        tabs.append(VideoTab(video_devices))\n    tabs.extend((\n        LibraryTab(music_lib, LISTBOX_HEIGHT, alternate_bg, vertical_gui, show_album_art),\n        PlaylistsTab(settings['playlists'], vertical_gui, show_album_art),\n        TimerTab(timer, settings['timer_shut_down'], settings['timer_hibernate'], settings['timer_sleep']),\n        MetadataTab(),\n        SettingsTab(settings, web_ui_url)\n    ))\n    tabs_section = Sg.TabGroup([tabs], font=FONT_TAB, border_width=0, title_color=text_color, key='tab_group',\n                            selected_background_color=accent_color, enable_events=True,\n                            tab_background_color=background_color, selected_title_color=background_color, background_color=background_color)\n    if vertical_gui:\n        return [[playing_section], [tabs_section]]\n    return [[playing_section, tabs_section]] if settings['flip_main_window'] else [[tabs_section, playing_section]]\n\n\ndef MusicControls(settings, playing_status: PlayingStatus, prev_button_pad=None):\n    btn_color = (GuiContext.bg, GuiContext.bg)\n    is_muted = settings['muted']\n    volume = 0 if is_muted else settings['volume']\n    v_slider_img = VOLUME_MUTED_IMG if is_muted else VOLUME_IMG\n    p_r_img = PAUSE_BUTTON_IMG if playing_status.playing() else PLAY_BUTTON_IMG\n    repeat_img, repeat_tooltip = repeat_img_tooltip(settings['repeat'])\n    repeat_button = {'button_color': btn_color, 'tooltip': repeat_tooltip, 'metadata': settings['repeat']}\n    shuffle_button = {'button_color': btn_color, 'image_data': SHUFFLE_ON if settings['shuffle'] else SHUFFLE_OFF}\n    mute_tooltip = t('unmute') if is_muted else t('mute')\n    return [Sg.Button(key='prev', image_data=PREVIOUS_BUTTON_IMG, button_color=btn_color, tooltip=t('previous track'), pad=prev_button_pad),\n            Sg.Button(key='pause/resume', image_data=p_r_img, button_color=btn_color),\n            Sg.Button(key='next', image_data=NEXT_BUTTON_IMG, button_color=btn_color, tooltip=t('next track')),\n            Sg.Button(key='repeat', image_data=repeat_img, **repeat_button),\n            Sg.Button(key='shuffle', **shuffle_button, tooltip=t('shuffle')),\n            Sg.Button(key='mute', image_data=v_slider_img, button_color=btn_color, tooltip=mute_tooltip),\n            Sg.Slider((0, 100), default_value=volume, orientation='h', key='volume_slider',\n                      disable_number_display=True, enable_events=True, background_color=GuiContext.accent_color,\n                      text_color='#000000', size=(10, 10), tooltip=t('scroll mousewheel'), resolution=1)]\n\n\ndef ProgressBar(settings, track_position, track_length, playing_status: PlayingStatus):\n    time_elapsed, time_left = create_progress_bar_texts(track_position, track_length)\n    text_size = (5, 1)\n    bot_pad = (settings['vertical_gui'] and not settings['show_album_art']) * 30\n    mini_mode = settings['mini_mode']\n    time_elapsed_pad = ((2, 0), (0, 0)) if mini_mode else ((0, 5), (10, bot_pad))\n    time_left_pad = ((0, 0), (0, 0)) if mini_mode else ((5, 0), (10, bot_pad))\n    progress_layout = [Sg.Text(time_elapsed, key='time_elapsed', pad=time_elapsed_pad, justification='center',\n                               size=text_size, font=FONT_NORMAL),\n                       Sg.Slider(range=(0, 1 if track_length is None else track_length),\n                                 default_value=1 if track_length is None else floor(track_position),\n                                 orientation='h', size=(20 if mini_mode else 30, 10), key='progress_bar',\n                                 enable_events=True, relief=Sg.RELIEF_FLAT, background_color=GuiContext.accent_color,\n                                 disable_number_display=True, disabled=playing_status.stopped() or track_length is None,\n                                 tooltip=t('scroll mousewheel'),\n                                 pad=((2, 10), (0, 0)) if mini_mode else ((8, 8), (10, bot_pad))),\n                       Sg.Text(time_left, key='time_left', pad=time_left_pad, justification='left',\n                               size=text_size, font=FONT_NORMAL)]\n    if mini_mode:\n        progress_layout.append(Sg.Button(key='mini_mode', image_data=RESTORE_WINDOW, size=(1, 1), enable_events=True,\n                                         button_color=(GuiContext.bg, GuiContext.bg), tooltip=t('restore window'), pad=(0, 0)))\n    return progress_layout\n\n\ndef URLTab(accent_color, bg):\n    layout = [[Sg.Text(t('Enter URL'), font=FONT_NORMAL)],\n              [Sg.Radio(t('Play Immediately'), 'url_option', key='url_play', default=True),\n               Sg.Radio(t('Queue'), 'url_option', key='url_queue'),\n               Sg.Radio(t('Play Next'), 'url_option', key='url_play_next')],\n              [Sg.Input(key='url_input', font=FONT_NORMAL, enable_events=True, border_width=1),\n               StyledButton(t('Submit'), accent_color, bg, key='url_submit', bind_return_key=True)],\n              [Sg.Text('', key='url_msg', size=(20, 1))]]\n    return Sg.Tab(t('URL'), [[Sg.Column(layout, pad=(5, 20))]], key='tab_url')\n\n\ndef QueueTab(queue, listbox_selected, listbox_height):\n    select_file_values = [t('Play'), t('Queue'), t('Play Next')]\n    select_files = t('Select Files')\n    select_folder = t('Select Folder')\n    install_update_text = t('Install Update')\n    biggest_word = len(max(*select_file_values, select_files, select_folder, key=len))\n    combo_w = ceil(biggest_word * 0.95)\n    btn_color = (GuiContext.bg, GuiContext.bg)\n    queue_controls = [Sg.Column([[\n        # fs stands for file system here\n        Sg.Combo(select_file_values, default_value=select_file_values[0], key='fs_action', size=(combo_w, 5),\n                 enable_events=False, pad=(5, (6, 4)), readonly=True),\n        StyledButton(select_files, GuiContext.accent_color, GuiContext.bg, key='select_files',\n                  button_width=biggest_word, pad=(5, (7, 5))),\n        StyledButton(select_folder, GuiContext.accent_color, GuiContext.bg, key='select_folders',\n                  button_width=biggest_word),\n        StyledButton(install_update_text, '#1f3139', '#b3edc9', outline='#1b583b', blend_color=GuiContext.bg, key='install_update',\n                  button_width=biggest_word, visible=State.update_available and not State.installing_update),\n    ]], justification='left')]\n    move_to_next_up = {'image_data': PLAY_NEXT_ICON, 'button_color': btn_color, 'tooltip': t('Move to next up')}\n    listbox_controls = [\n        [Sg.Button(key='mini_mode', image_data=RESTORE_WINDOW, button_color=btn_color, tooltip=t('Launch mini mode'))],\n        [Sg.Button(key='queue_all', image_data=QUEUE_ICON, button_color=btn_color, tooltip=t('queue all'))],\n        [Sg.Button(key='clear_queue', image_data=CLEAR_QUEUE, button_color=btn_color, tooltip=t('Clear the queue'))],\n        [Sg.Button(key='save_to_pl', image_data=SAVE_IMG, button_color=btn_color, tooltip=t('Save to playlist'))],\n        [Sg.Button(key='locate_uri', image_data=LOCATE_FILE, button_color=btn_color, tooltip=t('locate track'))],\n        [Sg.Button(key='copy_uri', image_data=COPY_ICON, button_color=btn_color, tooltip=t('copy uris'))],\n        [Sg.Button(key='edit_metadata', image_data=EDIT_ICON, button_color=btn_color, tooltip=t('edit metadata'))],\n        [Sg.Button(key='move_to_next_up', **move_to_next_up)],\n        [IconButton(UP_ICON, 'move_up', t('move up'), GuiContext.bg)],\n        [IconButton(X_ICON, 'remove_track', t('remove'), GuiContext.bg)],\n        [IconButton(DOWN_ICON, 'move_down', t('move down'), GuiContext.bg)]\n    ]\n    queue_tab_layout = [[\n        Sg.Column([[Sg.Listbox(queue, default_values=listbox_selected, size=(64, listbox_height),\n                               select_mode=Sg.SELECT_MODE_EXTENDED,\n                               text_color=GuiContext.fg, key='queue', font=FONT_NORMAL,\n                               bind_return_key=True)], queue_controls]),\n        Sg.Column(listbox_controls, pad=(0, (5, 0)), vertical_alignment='top')]]\n    return Sg.Tab(t('Queue'), queue_tab_layout, key='tab_queue')\n\n\ndef LibraryTab(music_lib, listbox_height, alternate_bg, vertical_gui: bool, show_album_art: bool):\n    try:\n        lib_data = [[track['title'], get_first_artist(track['artist']), track['album'], uri] for uri, track in\n                    music_lib.items()]\n    except RuntimeError:\n        lib_data = []\n    lib_headings = ['title', 'artist', 'album']\n    if State.using_tcl_theme:\n        library_height = listbox_height\n        col_widths = [25, 12, 15]\n    else:\n        library_height = 15 - 4 * (vertical_gui or not show_album_art)\n        col_widths = [20, 15, 15]\n\n    library_layout = [[Sg.Table(values=lib_data, headings=lib_headings, row_height=30, auto_size_columns=False,\n                                col_widths=col_widths, bind_return_key=True, justification='right',\n                                size=(10, 1), selected_row_colors=(GuiContext.bg, GuiContext.accent_color), num_rows=library_height,\n                                right_click_menu=['', ['Play::library', 'Play Next::library',\n                                                       'Queue::library', 'Locate::library']],\n                                header_text_color=GuiContext.fg, header_background_color=GuiContext.bg,\n                                alternating_row_color=alternate_bg, key='library')]]\n    return Sg.Tab(t('Library'), library_layout, key='tab_library')\n\n\ndef PlaylistsTab(playlists, vertical_gui: bool, show_album_art: bool):\n    playlists_names = list(playlists.keys())\n    default_pl_name = playlists_names[0] if playlists_names else None\n    btn_color = (GuiContext.bg, GuiContext.bg)\n    playlist_selector = [\n        [IconButton(PLUS_ICON, 'new_pl', t('new playlist'), GuiContext.bg),\n         Sg.Button(image_data=EXPORT_PL, key='export_pl', tooltip=t('export playlist'), button_color=btn_color),\n         Sg.Button(image_data=DELETE_ICON, key='delete_pl', tooltip=t('delete playlist'), button_color=btn_color),\n         Sg.Button(image_data=PLAY_ICON, key='play_pl', tooltip=t('play playlist'), button_color=btn_color),\n         Sg.Button(image_data=QUEUE_ICON, key='queue_pl', tooltip=t('queue playlist'), button_color=btn_color),\n         Sg.Button(image_data=PLAY_NEXT_ICON, key='add_next_pl', tooltip=t('add to next up'), button_color=btn_color),\n         Sg.Combo(values=playlists_names, size=(PL_COMBO_W, 1), key='playlist_combo', font=FONT_NORMAL,\n                  enable_events=True, default_value=default_pl_name, readonly=True)]]\n    playlist_name = playlists_names[0] if playlists_names else ''\n    pl_length_txt = [Sg.Text('', font=FONT_NORMAL, key='pl_length')]\n    add_tracks_btn = [StyledButton(t('Add files'), GuiContext.accent_color, GuiContext.bg, key='pl_add_tracks', button_width=14)]\n    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)))]\n    add_url_btn = [StyledButton(t('Add URL'), GuiContext.accent_color, GuiContext.bg, key='pl_add_url', button_width=14)]\n    pl_saved_txt = [Sg.Text(t('Playlist saved'), key='pl_saved', font=FONT_NORMAL, visible=False, text_color='green')]\n    lb_height = 17 - 6 * (vertical_gui or not show_album_art)\n    pl_name_text = t('Playlist name')\n    name_text_w = max(13, len(pl_name_text))\n    layout = [[Sg.Column(playlist_selector, pad=(5, 20))],\n              [Sg.Text(pl_name_text, font=FONT_NORMAL, size=(name_text_w, 1), justification='center', pad=(4, (5, 10))),\n               Sg.Input(playlist_name, key='pl_name', size=(60 - name_text_w, 1), font=FONT_NORMAL,\n                        pad=((22, 5), (5, 10)), border_width=1),\n               Sg.Button(key='pl_save', image_data=SAVE_IMG, tooltip='Ctrl + S', button_color=btn_color)],\n              [Sg.Column([pl_length_txt, add_tracks_btn, url_input_btn, add_url_btn, pl_saved_txt],\n                         vertical_alignment='top'),\n               Sg.Listbox([], size=(45, lb_height), select_mode=Sg.SELECT_MODE_EXTENDED, text_color=GuiContext.fg,\n                          key='pl_tracks', background_color=GuiContext.bg, font=FONT_NORMAL, bind_return_key=True),\n               Sg.Column(\n                   [[IconButton(UP_ICON, 'pl_move_up', t('move up'), GuiContext.bg)],\n                    [IconButton(X_ICON, 'pl_rm_items', t('remove'), GuiContext.bg)],\n                    [IconButton(DOWN_ICON, 'pl_move_down', t('move down'), GuiContext.bg)],\n                    [Sg.Button(image_data=PLAY_ICON, key='play_pl_selected', tooltip=t('play selected'),\n                               button_color=btn_color)],\n                    [Sg.Button(image_data=QUEUE_ICON, key='queue_pl_selected', tooltip=t('queue selected'),\n                               button_color=btn_color)],\n                    [Sg.Button(image_data=PLAY_NEXT_ICON, key='add_next_pl_selected',\n                               tooltip=t('add selected to next up'), button_color=btn_color)],\n                    [Sg.Button(image_data=LOCATE_FILE, key='pl_locate_selected', button_color=btn_color,\n                               tooltip=t('locate selected'), size=(2, 1))],\n                    [Sg.Button(image_data=COPY_ICON, key='pl_copy_selected', button_color=btn_color,\n                               tooltip=t('copy URIs'), size=(2, 1))]\n                    ],\n                   background_color=GuiContext.bg)]]\n    return Sg.Tab(t('Playlists'), layout, key='tab_playlists')\n\n\ndef TimerTab(timer, is_shut_down: bool, is_hibernate: bool, is_sleep: bool):\n    do_nothing = not (is_shut_down or is_hibernate or is_sleep)\n    # if timer is valid\n    if time.time() < timer:\n        timer_date = datetime.fromtimestamp(timer)\n        timer_date = timer_date.strftime('%#I:%M %p')\n        timer_text = t('Timer set for $TIME').replace('$TIME', timer_date)\n    else:\n        timer_text = t('No Timer Set')\n    # wait for last track to finish setting\n    cancel_button = StyledButton(t('Cancel Timer'), GuiContext.accent_color, GuiContext.bg, key='cancel_timer', visible=timer != 0)\n    defaults = {'text_color': GuiContext.fg, 'background_color': GuiContext.bg, 'font': FONT_NORMAL, 'enable_events': True}\n    layout = [\n        [Sg.Radio(t('Shut down when timer runs out'), 'TIMER', default=is_shut_down, key='shut_down', **defaults)],\n        [Sg.Radio(t('Sleep when timer runs out'), 'TIMER', default=is_sleep, key='sleep', **defaults)],\n        [Sg.Radio(t('Hibernate when timer runs out'), 'TIMER', default=is_hibernate, key='hibernate', **defaults)],\n        [Sg.Radio(t('Only Stop Playback').capitalize(), 'TIMER', default=do_nothing, key='timer_stop', **defaults)],\n        [Sg.Text(t('Enter minutes or HH:MM'), font=FONT_NORMAL),\n         Sg.Input(key='timer_input', size=(11, 1), border_width=1),\n         StyledButton(t('Submit'), GuiContext.accent_color, GuiContext.bg, key='timer_submit')],\n        [Sg.Text(t('Invalid Input (enter minutes or HH:MM)'), font=FONT_NORMAL, visible=False, key='timer_error')],\n        [Sg.Text(timer_text, font=FONT_NORMAL, key='timer_text', size=(20, 1), metadata=timer != 0), cancel_button]\n    ]\n    return Sg.Tab(t('Timer'), [[Sg.Column(layout, pad=(0, (50, 0)), justification='center')]], key='tab_timer')\n\n\ndef MetadataTab():\n    layout = [[Sg.Column([\n        [StyledButton(t('Select File'), GuiContext.accent_color, GuiContext.bg, key='metadata_browse'),\n         StyledButton(t('Save'), GuiContext.accent_color, GuiContext.bg, key='metadata_save'),\n         Sg.Text('', size=(45, 1), key='metadata_file', border_width=1, relief='sunken', click_submits=True)]],\n        pad=(0, (20, 10)))],\n        [Sg.Column([[Sg.Text(t(text), size=(20, 1)), Sg.Input(key=f'metadata_{key}', border_width=1, size=(25, 1))]\n                    for (text, key) in\n                    (('Title', 'title'), ('Artist', 'artist'), ('Album', 'album'), ('Track Number', 'track_num'))]),\n         Sg.Image(key='metadata_art')],\n        [Sg.Checkbox(t('Explicit'), key='metadata_explicit', enable_events=True),\n         StyledButton(t('Select artwork'), GuiContext.accent_color, GuiContext.bg, key='metadata_select_art', pad=(5, 10)),\n         StyledButton(t('Search artwork'), GuiContext.accent_color, GuiContext.bg, key='metadata_search_art', pad=(5, 10)),\n         StyledButton(t('Remove artwork'), GuiContext.accent_color, GuiContext.bg, key='metadata_remove_art', pad=(5, 10))],\n        [Sg.Text('', key='metadata_msg', text_color='green', size=(60, 1))]]\n    return Sg.Tab(t('Metadata'), [[Sg.Column(layout, pad=(5, 5))]], key='tab_metadata')\n\n\ndef SettingsTab(settings, web_ui_url):\n    qr_code = QRCode(web_ui_url)\n    general_tab = Sg.Tab(t('General'), [\n        [Sg.Text('🌐' if platform.system() == 'Windows' else 'g', tooltip=t('language', True)),\n         Sg.Combo(values=get_languages(), size=(3, 1), default_value=settings['lang'], key='lang',\n                  readonly=True, enable_events=True, tooltip=t('language'))],\n        [Checkbox(t('Auto update'), 'auto_update', settings),\n         Checkbox(t('Discord presence'), 'discord_rpc', settings, True)],\n        [Checkbox(t('Notifications'), 'notifications', settings),\n         Checkbox(t('Run on startup'), 'run_on_startup', settings, True)],\n        [Checkbox(t('Folder context menu'), 'folder_context_menu', settings),\n         Checkbox(t('Scan folders'), 'scan_folders', settings, True)],\n        [Checkbox(t('Remember last folder'), 'use_last_folder', settings),\n         Checkbox(t('Exit app on GUI close'), 'gui_exits_app', settings, True)],\n        [Sg.Text(t('System Audio Delay:')),\n         Sg.Input(settings['sys_audio_delay'], size=(10, 1), key='sys_audio_delay', tooltip=t('seconds'),\n                  border_width=1, pad=(70, 1), enable_events=True)]\n    ], background_color=GuiContext.bg)\n    queuing_tab = Sg.Tab(t('Queueing'), [\n        [Checkbox(t('Reversed play next'), 'reversed_play_next', settings),\n         Checkbox(t('Always queue library'), 'queue_library', settings, True)],\n        [Checkbox(t('Populate queue on startup'), 'populate_queue_startup', settings),\n         Checkbox(t('Persistent queue'), 'persistent_queue', settings, True)],\n        [Checkbox(t('Smart queue'), 'smart_queue', settings)]\n    ])\n    ui_tab = Sg.Tab(t('UI'), [\n        [Checkbox(t('Save window positions'), 'save_window_positions', settings),\n         Checkbox(t('Show track number'), 'show_track_number', settings, True)],\n        [Checkbox(t('Left-side music controls'), 'flip_main_window', settings),\n         Checkbox(t('Vertical GUI'), 'vertical_gui', settings, True)],\n        [Checkbox(t('Show album art'), 'show_album_art', settings),\n         Checkbox(t('Mini mode on top'), 'mini_on_top', settings, True)],\n        [Checkbox(t('Use cover.* for album art'), 'folder_cover_override', settings),\n         Checkbox(t('Show index in queue'), 'show_queue_index', settings, True)],\n        [Sg.Text(t('Track Format:'), tooltip='&alb, &trck, &artist, &title'),\n         Sg.Input(settings['track_format'], size=(30, 1), key='track_format', enable_events=True,\n                  border_width=1, pad=(70, 1), tooltip='&alb, &trck, &artist, &title')]\n    ], background_color=GuiContext.bg)\n    tabs = [general_tab, queuing_tab, ui_tab]\n\n    if platform.system() == 'Windows':\n        res_values = list(get_all_resolutions().keys())\n        on_battery_res = None if settings['on_battery_res'] is None else fmt_res(*settings['on_battery_res'])\n        plugged_in_res = None if settings['plugged_in_res'] is None else fmt_res(*settings['plugged_in_res'])\n        misc_tab = Sg.Tab(t('Misc'),[\n            [Sg.Text(t('On battery resolution')),\n             Sg.Combo(values=res_values, size=(6, 1), default_value=on_battery_res,\n                       key='on_battery_res', readonly=True, enable_events=True)],\n            [Sg.Text(t('Plugged in resolution')),\n             Sg.Combo(values=res_values, size=(6, 1), default_value=plugged_in_res,\n                        key='plugged_in_res', readonly=True, enable_events=True)],\n            [Checkbox(t('Experimental features'), 'experimental_features', settings)]\n            ],\n            background_color=GuiContext.bg)\n        tabs.append(misc_tab)\n    settings_tab_group = Sg.TabGroup([tabs], title_color=GuiContext.fg,\n                                     border_width=0, selected_background_color=GuiContext.accent_color, font=FONT_TAB,\n                                     tab_background_color=GuiContext.bg, selected_title_color=GuiContext.bg, background_color=GuiContext.bg)\n    checkbox_col = Sg.Column([[settings_tab_group]], pad=((0, 0), (5, 0)))\n    qr_code_params = {'tooltip': t('Open Web GUI'), 'button_color': (GuiContext.bg, GuiContext.bg)}\n    right_settings_col = Sg.Column([\n        [Sg.Button(key='web_gui', image_data=qr_code, **qr_code_params)],\n        [StyledButton('settings.json', GuiContext.accent_color, GuiContext.bg, key='settings_file', pad=((15, 0), 5), button_width=12)],\n        [StyledButton('Changelog', GuiContext.accent_color, GuiContext.bg, key='changelog_file', pad=((15, 0), 5), button_width=12)]\n    ], pad=(0, 0))\n    link_params = {'text_color': LINK_COLOR, 'font': FONT_LINK, 'click_submits': True}\n    layout = [\n        [Sg.Text(f'Music Caster v{VERSION}', font=FONT_NORMAL),\n         Sg.Text(CONTACT_INFO, tooltip=t('Send me an email'), key='open_email', **link_params),\n         Sg.Text('GitHub', **link_params, key='open_github')],\n        [checkbox_col, right_settings_col] if qr_code else [checkbox_col],\n        [Sg.Listbox(settings['music_folders'], size=(62, 5), select_mode=Sg.SELECT_MODE_EXTENDED, text_color=GuiContext.fg,\n                    key='music_folders', background_color=GuiContext.bg, font=FONT_NORMAL, bind_return_key=True,\n                    no_scrollbar=True),\n         Sg.Column([\n             [IconButton(X_ICON, 'remove_music_folder', t('remove selected folder'), GuiContext.bg)],\n             [IconButton(PLUS_ICON, 'add_music_folder', t('add folder'), GuiContext.bg)]])]]\n    return Sg.Tab(t('Settings'), layout, key='tab_settings')\n\n\ndef VideoTab(devices):\n    select_files = t('Select Files')\n    layout = [\n        [Sg.Text('Warning this is highly experimental and might not even work')],\n        [Sg.Combo(devices, key='video_cast_device', readonly=True, background_color=GuiContext.bg, expand_x=True, enable_events=True, pad=((5, 10), 10))],\n        [StyledButton(select_files, GuiContext.accent_color, GuiContext.bg, key='video_select_file',\n                  button_width=len(select_files), pad=(5, (7, 5)))],\n        [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')],\n    ]\n    return Sg.Tab(t('Video'), layout)\n"
  },
  {
    "path": "src/knownpaths.py",
    "content": "'''\nThe MIT License (MIT)\n\nCopyright (c) 2014 Michael Kropat\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n'''\n# [1] http://msdn.microsoft.com/en-us/library/windows/desktop/aa373931.aspx\n# [2] http://msdn.microsoft.com/en-us/library/windows/desktop/dd378457.aspx\n# [3] http://msdn.microsoft.com/en-us/library/windows/desktop/bb762188.aspx\n# [4] http://msdn.microsoft.com/en-us/library/windows/desktop/ms680722.aspx\n# [5] http://www.themacaque.com/?p=954\n\n\nimport ctypes, sys\nfrom ctypes import windll, wintypes\nfrom uuid import UUID\nfrom enum import Enum\n\n\nclass KNOWN_FOLDER_FLAG:\n    KF_FLAG_DEFAULT = 0x00000000\n    KF_FLAG_FORCE_APP_DATA_REDIRECTION = 0x00080000\n    KF_FLAG_RETURN_FILTER_REDIRECTION_TARGET = 0x00040000\n    KF_FLAG_FORCE_PACKAGE_REDIRECTION = 0x00020000\n    KF_FLAG_NO_PACKAGE_REDIRECTION = 0x00010000\n    KF_FLAG_FORCE_APPCONTAINER_REDIRECTION = 0x00020000\n    KF_FLAG_NO_APPCONTAINER_REDIRECTION = 0x00010000\n    KF_FLAG_CREATE = 0x00008000\n    KF_FLAG_DONT_VERIFY = 0x00004000\n    KF_FLAG_DONT_UNEXPAND = 0x00002000\n    KF_FLAG_NO_ALIAS = 0x00001000\n    KF_FLAG_INIT = 0x00000800\n    KF_FLAG_DEFAULT_PATH = 0x00000400\n    KF_FLAG_NOT_PARENT_RELATIVE = 0x00000200\n    KF_FLAG_SIMPLE_IDLIST = 0x00000100\n    KF_FLAG_ALIAS_ONLY = 0x80000000\n\n\nclass GUID(ctypes.Structure):  # [1]\n    _fields_ = [\n        (\"Data1\", wintypes.DWORD),\n        (\"Data2\", wintypes.WORD),\n        (\"Data3\", wintypes.WORD),\n        (\"Data4\", wintypes.BYTE * 8),\n    ]\n\n    def __init__(self, uuid_):\n        ctypes.Structure.__init__(self)\n        (\n            self.Data1,\n            self.Data2,\n            self.Data3,\n            self.Data4[0],\n            self.Data4[1],\n            rest,\n        ) = uuid_.fields\n        for i in range(2, 8):\n            self.Data4[i] = rest >> (8 - i - 1) * 8 & 0xFF\n\n\nclass FOLDERID:  # [2]\n    AccountPictures = UUID('{008ca0b1-55b4-4c56-b8a8-4de4b299d3be}')\n    AdminTools = UUID('{724EF170-A42D-4FEF-9F26-B60E846FBA4F}')\n    ApplicationShortcuts = UUID('{A3918781-E5F2-4890-B3D9-A7E54332328C}')\n    CameraRoll = UUID('{AB5FB87B-7CE2-4F83-915D-550846C9537B}')\n    CDBurning = UUID('{9E52AB10-F80D-49DF-ACB8-4330F5687855}')\n    CommonAdminTools = UUID('{D0384E7D-BAC3-4797-8F14-CBA229B392B5}')\n    CommonOEMLinks = UUID('{C1BAE2D0-10DF-4334-BEDD-7AA20B227A9D}')\n    CommonPrograms = UUID('{0139D44E-6AFE-49F2-8690-3DAFCAE6FFB8}')\n    CommonStartMenu = UUID('{A4115719-D62E-491D-AA7C-E74B8BE3B067}')\n    CommonStartup = UUID('{82A5EA35-D9CD-47C5-9629-E15D2F714E6E}')\n    CommonTemplates = UUID('{B94237E7-57AC-4347-9151-B08C6C32D1F7}')\n    Contacts = UUID('{56784854-C6CB-462b-8169-88E350ACB882}')\n    Cookies = UUID('{2B0F765D-C0E9-4171-908E-08A611B84FF6}')\n    Desktop = UUID('{B4BFCC3A-DB2C-424C-B029-7FE99A87C641}')\n    DeviceMetadataStore = UUID('{5CE4A5E9-E4EB-479D-B89F-130C02886155}')\n    Documents = UUID('{FDD39AD0-238F-46AF-ADB4-6C85480369C7}')\n    DocumentsLibrary = UUID('{7B0DB17D-9CD2-4A93-9733-46CC89022E7C}')\n    Downloads = UUID('{374DE290-123F-4565-9164-39C4925E467B}')\n    Favorites = UUID('{1777F761-68AD-4D8A-87BD-30B759FA33DD}')\n    Fonts = UUID('{FD228CB7-AE11-4AE3-864C-16F3910AB8FE}')\n    GameTasks = UUID('{054FAE61-4DD8-4787-80B6-090220C4B700}')\n    History = UUID('{D9DC8A3B-B784-432E-A781-5A1130A75963}')\n    ImplicitAppShortcuts = UUID('{BCB5256F-79F6-4CEE-B725-DC34E402FD46}')\n    InternetCache = UUID('{352481E8-33BE-4251-BA85-6007CAEDCF9D}')\n    Libraries = UUID('{1B3EA5DC-B587-4786-B4EF-BD1DC332AEAE}')\n    Links = UUID('{bfb9d5e0-c6a9-404c-b2b2-ae6db6af4968}')\n    LocalAppData = UUID('{F1B32785-6FBA-4FCF-9D55-7B8E7F157091}')\n    LocalAppDataLow = UUID('{A520A1A4-1780-4FF6-BD18-167343C5AF16}')\n    LocalizedResourcesDir = UUID('{2A00375E-224C-49DE-B8D1-440DF7EF3DDC}')\n    Music = UUID('{4BD8D571-6D19-48D3-BE97-422220080E43}')\n    MusicLibrary = UUID('{2112AB0A-C86A-4FFE-A368-0DE96E47012E}')\n    NetHood = UUID('{C5ABBF53-E17F-4121-8900-86626FC2C973}')\n    OriginalImages = UUID('{2C36C0AA-5812-4b87-BFD0-4CD0DFB19B39}')\n    PhotoAlbums = UUID('{69D2CF90-FC33-4FB7-9A0C-EBB0F0FCB43C}')\n    PicturesLibrary = UUID('{A990AE9F-A03B-4E80-94BC-9912D7504104}')\n    Pictures = UUID('{33E28130-4E1E-4676-835A-98395C3BC3BB}')\n    Playlists = UUID('{DE92C1C7-837F-4F69-A3BB-86E631204A23}')\n    PrintHood = UUID('{9274BD8D-CFD1-41C3-B35E-B13F55A758F4}')\n    Profile = UUID('{5E6C858F-0E22-4760-9AFE-EA3317B67173}')\n    ProgramData = UUID('{62AB5D82-FDC1-4DC3-A9DD-070D1D495D97}')\n    ProgramFiles = UUID('{905e63b6-c1bf-494e-b29c-65b732d3d21a}')\n    ProgramFilesX64 = UUID('{6D809377-6AF0-444b-8957-A3773F02200E}')\n    ProgramFilesX86 = UUID('{7C5A40EF-A0FB-4BFC-874A-C0F2E0B9FA8E}')\n    ProgramFilesCommon = UUID('{F7F1ED05-9F6D-47A2-AAAE-29D317C6F066}')\n    ProgramFilesCommonX64 = UUID('{6365D5A7-0F0D-45E5-87F6-0DA56B6A4F7D}')\n    ProgramFilesCommonX86 = UUID('{DE974D24-D9C6-4D3E-BF91-F4455120B917}')\n    Programs = UUID('{A77F5D77-2E2B-44C3-A6A2-ABA601054A51}')\n    Public = UUID('{DFDF76A2-C82A-4D63-906A-5644AC457385}')\n    PublicDesktop = UUID('{C4AA340D-F20F-4863-AFEF-F87EF2E6BA25}')\n    PublicDocuments = UUID('{ED4824AF-DCE4-45A8-81E2-FC7965083634}')\n    PublicDownloads = UUID('{3D644C9B-1FB8-4f30-9B45-F670235F79C0}')\n    PublicGameTasks = UUID('{DEBF2536-E1A8-4c59-B6A2-414586476AEA}')\n    PublicLibraries = UUID('{48DAF80B-E6CF-4F4E-B800-0E69D84EE384}')\n    PublicMusic = UUID('{3214FAB5-9757-4298-BB61-92A9DEAA44FF}')\n    PublicPictures = UUID('{B6EBFB86-6907-413C-9AF7-4FC2ABF07CC5}')\n    PublicRingtones = UUID('{E555AB60-153B-4D17-9F04-A5FE99FC15EC}')\n    PublicUserTiles = UUID('{0482af6c-08f1-4c34-8c90-e17ec98b1e17}')\n    PublicVideos = UUID('{2400183A-6185-49FB-A2D8-4A392A602BA3}')\n    QuickLaunch = UUID('{52a4f021-7b75-48a9-9f6b-4b87a210bc8f}')\n    Recent = UUID('{AE50C081-EBD2-438A-8655-8A092E34987A}')\n    RecordedTVLibrary = UUID('{1A6FDBA2-F42D-4358-A798-B74D745926C5}')\n    ResourceDir = UUID('{8AD10C31-2ADB-4296-A8F7-E4701232C972}')\n    Ringtones = UUID('{C870044B-F49E-4126-A9C3-B52A1FF411E8}')\n    RoamingAppData = UUID('{3EB685DB-65F9-4CF6-A03A-E3EF65729F3D}')\n    RoamedTileImages = UUID('{AAA8D5A5-F1D6-4259-BAA8-78E7EF60835E}')\n    RoamingTiles = UUID('{00BCFC5A-ED94-4e48-96A1-3F6217F21990}')\n    SampleMusic = UUID('{B250C668-F57D-4EE1-A63C-290EE7D1AA1F}')\n    SamplePictures = UUID('{C4900540-2379-4C75-844B-64E6FAF8716B}')\n    SamplePlaylists = UUID('{15CA69B3-30EE-49C1-ACE1-6B5EC372AFB5}')\n    SampleVideos = UUID('{859EAD94-2E85-48AD-A71A-0969CB56A6CD}')\n    SavedGames = UUID('{4C5C32FF-BB9D-43b0-B5B4-2D72E54EAAA4}')\n    SavedSearches = UUID('{7d1d3a04-debb-4115-95cf-2f29da2920da}')\n    Screenshots = UUID('{b7bede81-df94-4682-a7d8-57a52620b86f}')\n    SearchHistory = UUID('{0D4C3DB6-03A3-462F-A0E6-08924C41B5D4}')\n    SearchTemplates = UUID('{7E636BFE-DFA9-4D5E-B456-D7B39851D8A9}')\n    SendTo = UUID('{8983036C-27C0-404B-8F08-102D10DCFD74}')\n    SidebarDefaultParts = UUID('{7B396E54-9EC5-4300-BE0A-2482EBAE1A26}')\n    SidebarParts = UUID('{A75D362E-50FC-4fb7-AC2C-A8BEAA314493}')\n    SkyDrive = UUID('{A52BBA46-E9E1-435f-B3D9-28DAA648C0F6}')\n    SkyDriveCameraRoll = UUID('{767E6811-49CB-4273-87C2-20F355E1085B}')\n    SkyDriveDocuments = UUID('{24D89E24-2F19-4534-9DDE-6A6671FBB8FE}')\n    SkyDrivePictures = UUID('{339719B5-8C47-4894-94C2-D8F77ADD44A6}')\n    StartMenu = UUID('{625B53C3-AB48-4EC1-BA1F-A1EF4146FC19}')\n    Startup = UUID('{B97D20BB-F46A-4C97-BA10-5E3608430854}')\n    System = UUID('{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}')\n    SystemX86 = UUID('{D65231B0-B2F1-4857-A4CE-A8E7C6EA7D27}')\n    Templates = UUID('{A63293E8-664E-48DB-A079-DF759E0509F7}')\n    UserPinned = UUID('{9E3995AB-1F9C-4F13-B827-48B24B6C7174}')\n    UserProfiles = UUID('{0762D272-C50A-4BB0-A382-697DCD729B80}')\n    UserProgramFiles = UUID('{5CD7AEE2-2219-4A67-B85D-6C9CE15660CB}')\n    UserProgramFilesCommon = UUID('{BCBD3057-CA5C-4622-B42D-BC56DB0AE516}')\n    Videos = UUID('{18989B1D-99B5-455B-841C-AB7C74E4DDFC}')\n    VideosLibrary = UUID('{491E922F-5643-4AF4-A7EB-4E7A138D8174}')\n    Windows = UUID('{F38BF404-1D43-42F2-9305-67DE0B28FC23}')\n\n\nclass UserHandle:  # [3]\n    current = wintypes.HANDLE(0)\n    common = wintypes.HANDLE(-1)\n\n\n_CoTaskMemFree = windll.ole32.CoTaskMemFree  # [4]\n_CoTaskMemFree.restype = None\n_CoTaskMemFree.argtypes = [ctypes.c_void_p]\n\n_SHGetKnownFolderPath = windll.shell32.SHGetKnownFolderPath  # [5] [3]\n_SHGetKnownFolderPath.argtypes = [\n    ctypes.POINTER(GUID),\n    wintypes.DWORD,\n    wintypes.HANDLE,\n    ctypes.POINTER(ctypes.c_wchar_p),\n]\n\n\nclass PathNotFoundException(Exception):\n    pass\n\n\ndef sh_get_known_folder_path(folderid, user_handle=UserHandle.current, flags=0x0):\n    fid = GUID(folderid)\n    pPath = ctypes.c_wchar_p()\n    S_OK = 0\n    if (\n        _SHGetKnownFolderPath(\n            ctypes.byref(fid), flags, user_handle, ctypes.byref(pPath)\n        )\n        != S_OK\n    ):\n        raise PathNotFoundException()\n    path = pPath.value\n    _CoTaskMemFree(pPath)\n    return path\n\n\nif __name__ == '__main__':\n    print(sh_get_known_folder_path(FOLDERID.Music))\n"
  },
  {
    "path": "src/languages/da.txt",
    "content": "# Language: Danish\n# Credits: Frey Clante\n# Any line starting with a # is ignored\n# If a line contains $X do not translate the $X, please use it in context\n# e.g. \"Update $VER is available\" will dynamically become \"Update v4.75.0 is available\"\n# Try to keep the same capitalization or lack of as in the English file\ntilføj mappe\nTilføj Music Caster til mappe kontekst menu\nTilføj filer\nTilføj URL\nDer er sket en fejl, genstarter nu\nDer opstod en intern Server Fejl\nAudio Selection\nAutomatiske opdateringer\nAf\nAnnuller\nAnnuller Timer\nTøm køen\nKnapper\nKunne ikke forbinde til cast device\nKunne ikke finde optagelseskilde\nKunne ikke afspille $URL\nKunne ikke oprette køen fordi mappe-scanning er slået fra\nKunne ikke indstille timer\nDiscord installeret?\nDownloader opdatering ver: $VER\nSkriv minutter eller HH:MM (Timer:Minutter)\nSkriv tid\nSkriv URL\nFEJL\nAfslut\nMappe kontekst menu\nMappen indeholder ikke lydfiler\nMapper\nGenerelt\nGå i dvale når nedtælling løber ud\nSæt computer i dvale\nINFO\nFejl i det indtastede (Skriv minutter eller timer ( 24 timers format (HH:mm) ))\nUgyldig URL. URL'er SKAL starte med http:// eller https://\nUgyldig lydfil valgt\nUgyldig lydfil $FILE\nbehold mini-tilstand i forgrunden\nStart mini-tilstand\nMusik-Knapper (venstre)\nBibliotek\nBibliotek indeksering blev afbrudt, kun scannede filer blev tilføjet\nLytter\nSystem Lyd\nDenne Computer\nfind valgte\nForbindelsen til $DEVICE blev afbrudt, skifter til denne computer\nMini-Mode i forgrunden\nMere\nflyt ned\nsæt til at afspille som næste\nFlyt indhold til venstre\nFlyt op\nMusic Caster kører i minimeret tilstand.\nmute\nNy\nnæste nummer\nDer er ikke nok plads på den valgte disk til at auto-opdatere\nDer er ikke nok plads på den valgte disk til at gemme indstillingerne\nIngen timer sat\nIkke forbundet til et 'cast device'\nIkke logget ind på deezer.com\nIntet afspilles\nNotifikationer\nStop kun afspilning\nÅben\nPause\nGemt kø\nAfspil\nAfspil Alle\nAfspil Filer\nAfspil Filer bagefter\nAfspil Øjeblikkeligt\nAfspil Næste\nAfspil URL\nAfspil URL bagefter\nAfspiller\nPlaylist navn\nPlaylists\nPlaylists Tab\nIndlæs kø under opstart\nIndlæser kø fra mapper under opstart\nforrige nummer\nKø\nSæt alle i kø\nSæt filer i kø\nSæt URL i kø\nOpdater Devices\nHusk sidste mappe\nfjern den valgte mappe\nfjern\nGentag\nGentag Alle\nGentag 'fra'\nGentag nummer\nGentag Muligheder\nScan Bibliotek igen\nScanner Bibliotek igen\nGenoptag\nReverse play next behaviour Afspil næste baglæns-opførsel\nNæste spilles baglæns\nKør ved opstart\nGem kø på tværs a sessioner\nGem i playlist\nGem Vindues positioner\nScan mapper\nscroll musehjul\nSøg efter musik...\nVælg Device\nVælg Mappe\nVælg Mapper\nVælg Filer\nVælg Fil\nVælg Lydfiler\nSend mig en mail\nsæt --> DEBUG = true i `settings.json` for at aktivere denne side\nSæt Timer\nSet\nIndstillinger\nVis Album Art\nVis album art in GUI\nVis plads-indeks i køen\nVis indeks for nummer\nVis indeks for nummer i køen\nbland\nLuk computeren ned når timeren løber ud\nLuk computeren\nGå i dvale når timeren løber ud\nGå i dvale\nStop\nSend\nTak fordi du installerer Music Caster.\nTimer\nTimer annulleret\nTimer indstillet til: $TIME\nUdfyld kunstner/titel\nBrugerflade\nunmute\nOpdatering ver: $VER er tilgængelig\nURL\nBrug cover.* til album art\nBrug cover.* til art i stedet for filens album art\ncover.* billede trumfer coverfil\nLodret Brugerflade\nVis Lydfilers kilde Links\nÅbn Brugerflade i browser\nny playlist\neksporter playlist\nslet playlist\nafspil playliste\nsæt playliste i kø\nUkendt Titel\nUkendt Kunstner\nUkendt Album\nSæt altid bibliotek i kø\nAfspil med Music Caster\nTilføj til kø i Music Caster\nAfspil næste i Music Caster\nSætter i kø\nIntelligent kø\ntilføj som næste\nMetadata\nGemmer metadata\nMetadata gemt\nGem\nTitel\nKunstner\nAlbum\nNummer indeks\nEksplicit\nVælg artwork\nSøg artwork\nfjern artwork\nArtwork fundet\nintet artwork found\nSøger efter artwork...\nIndlæser URL(s)\nTilføjede URL(s)\nsprog\nafspil valgte\nsæt valgte i kø\ntilføj valgte som næste\nSystem Lyd Forsinkelse:\nsekunder\nfind nummer\nSkifter til lokal afspilning\nNummer Format:\nKunne ikke hente lyd fra: $URL\nAfslut når den grafiske brugeflade lukkes\n'På batteri' opløsning\n'tilsluttet' opløsning\nKunne ikke indstille opløsning\nKopier URIs\nrediger metadata\nEksperimentelle funktioner\nPlaylist gemt\nOpdatering\nDownloadet $VER. Relancering...\n"
  },
  {
    "path": "src/languages/de.txt",
    "content": "# Sprache: Deutsch (German)\n# Credits: Bernd Miller\n# Jede Zeile, die mit einem # beginnt, wird ignoriert!\n# Wenn eine Zeile $X enthält, $X nicht übersetzen, sondern im Kontext behandeln.\n# z.B. \"Update $VER ist verfügbar\" wird dynamisch zu \"Update v4.75.0 ist verfügbar\"\n# Try to keep the same capitlization or lack of as in the English file\nOrdner hinzufügen\nMusic Caster zum Ordner-Kontext-Menü hinzufügen\nDateien hinzufügen\nURL hinzufügen\nEin Fehler ist aufgetreten, Neustart wird durchgeführt.\nEin interner Serverfehler ist aufgetreten.\nAudio Auswahl\nAuto Update\nvon\nabbrechen\nZeitsteuerung abbrechen\nWarteschlange löschen\nBedienelemente\nkonnte nicht mit dem Castgerät verbinden\nkonnte kein Ausgangsgerät zum Aufnehmen finden\nkonnte $URL nicht abspielen\nkonnte die Warteschlange nicht befüllen, da der Bibliotheksscan deaktiviert ist.\nkonnte Zeitsteuerung nicht setzten.\nDiscord-Präsenz\nlade Update $VER herunter\nMinuten oder HH:MM eingeben\nZeit eingeben\nURL eingeben\nFEHLER\nbeenden\nOrdner-Kontext-Menü\nOrdner enthält keine Audiodateien\nOrdner\nallgemein\nRuhezustand wenn die Zeitsteuerung abgelaufen ist.\nComputer in den Ruhezustand versetzen.\nINFO\nungültige Eingabe (Minutes or HH:MM eingeben)\nungültige URL. (URL's müssen mit http:// or https:// beginnen)\nungültige Audio Datei ausgewählt\nungültige Audio Datei $FILE\nMini Mode immer sichtbar\nMini Mode starten\nMusik-Bedienelemente links\nBibliothek\nIndizierung der Bibliothek unvollständig, nur bereits analysierte Dateien wurden hinzugefügt.\nhören\nSystem Audio\nlokales Gerät\nlokalisiere Auswahl\nVerbindung zu $DEVICE verloren, schalte auf lokales Gerät um.\nMini Mode immer oben\nmehr\nnach unten schieben\nweiter zum nächsten\nTitelinhalt nach links verschieben\nnach oben schieben\nMusic Caster läuft in der Taskleiste.\nstummschalten\nneu\nnächster Titel\nnicht genügend Speicherplatz für automatisches Update auf dem Gerät verfügbar.\nEinstellungen können nicht gespeichert werden, da nicht genügend Speicherplatz auf dem Gerät verfügbar ist.\nkeine Zeitsteuerung gesetzt\nnicht mit einem Castgerät verbunden\nnicht bei deezer.com angemeldet\nnichts wird abgespielt\nBenachrichtigungen\nNur die Wiedergabe anhalten\nöffnen\nPause\ndauerhafte Warteschlange\nabspielen\nalles abspielen\nDateien abspielen\nDateien als nächstes abspielen\nsofort abspielen\nnächstes abspielen\nURL abspielen\nURL als nächstes abspielen\nwird abgespielt\nWiedergabelistenname\nWiedergabelisten\nWiedergabelisten Tab\nWarteschlange bei Start füllen\nWarteschlange mit Ordnern beim Start befüllen\nvorheriger Track\nWarteschlange\nalle Warteschlangen\nDatei Warteschlangen\nURL Warteschlangen\nNeueinlesen der Geräte\nletzten Ordner merken\nentferne selektierten Ordner\nentfernen\nwiederhole\nwiederhole alles\nwiederholen aus\nwiederhole eins\nWiederholoptionen\nBibliothek einlesen\nlese Bibliothek ein\nfortsetzen\nkehre das Verhalten von \"spiele nächstes ab\" um\nspiele vorheriges ab\nbeim Start ausführen\nspeichere die Warteschlange zwischen Sitzungen\nspeichere die Warteschlange als Wiedergabeliste\nspeichere Fensterposition\nlese Ordner ein\nBlättern mit Mausrad\nSuche nach Musik...\nwähle Gerät\nwähle Ordner\nwähle Ordner\nwähle Dateien\nwähle Datei\nwähle Audio Dateien\nsende mir eine Email\nsetze DEBUG = true in `settings.json` um diese Seite zu aktivieren\nsetze Zeitsteuerung\nsetze\nEinstellungen\nzeige Albumcover\nzeige Albumcover im GUI\nzeige Index in Warteschlange\nzeige Titelnummer\nzeige Titelnummer in Warteschlange\nzufällige Reihenfolge\nWenn die Zeitsteuerung abgelaufen ist herunterfahren.\nComputer herunterfahren\nWenn die Zeitsteuerung abgelaufen ist Energie sparen.\nComputer Energie sparen aktivieren\nStop\nübermitteln\nVielen Dank, dass Sie Music Caster installiert haben.\nZeitsteuerung\nZeitsteuerung abgebrochen\nZeitsteuerung auf $TIME setzen\nschreibe Artist/Titel\nBenutzerschnittstelle\nStummschaltung aufheben\nUpdate $VER ist verfügbar\nURL\nbenutze cover.* für Albumcover\nbenutze cover.* für Bilder anstatt Albumcover der Datei\ncover.* Bild überschreibt Albumcover der Datei\nvertikales GUI\nzeige Audiodateiquellenverknüpfungen\nöffne Web GUI\nneue Wiedergabeliste\nexportiere Wiedergabeliste\nlösche Wiedergabeliste\nWiedergabeliste abspielen\nWiedergabeliste zur Warteschlange hinzufügen\nunbekannter Titel\nunbekannter Artist\nunbekanntes Album\nBibliothek als Warteschlange\nmit Music Caster abspielen\nzur Music Caster Warteschlange hinzufügen\nals nächstes in Music Caster abspielen\nanstehend\nintelligente Warteschlange\nals nächtes oben hinzufügen\nMetadaten\nspeichere Metadaten\nMetadaten gespeichert\nspeichern\nTitel\nArtist\nAlbum\nTrack Nummer\nexplizit\nwähle Albumcover\nsuche Albumcover\nentferne Albumcover\nAlbumcover gefunden\nkein Albumcover gefunden\nsuche nach Albumcover...\nlade URL(s)\nhinzugefügte URL(s)\nSprache\nspiele markiertes ab\nmarkiertes in die Warteschlange\nfüge markiertes als nächstes oben ein\nSystem Audio Verzögerung:\nSekunden\nTitel lokalisieren\nschalte auf lokales Gerät um\nTitelformat:\nkonnte bei $URL kein Audio finden\nbeende zusammen mit GUI\nAuflösung bei Batteriebetrieb\nAuflösung bei Netzbetrieb\nKonnte Auflösung nicht setzen\nkopiere URIs\nMetadaten beatbeiten\nexperimentelle Funktionen\nWarteschlange gespeichert\ninstalliere Update\nHeruntergeladen $VER. Relaunch...\n"
  },
  {
    "path": "src/languages/en.txt",
    "content": "# Language: English\n# Credits: Elijah Lopez\n# Any line starting with a # is ignored\n# If a line contains $X do not translate the $X, please use it in context\n# e.g. \"Update $VER is available\" will dynamically become \"Update v4.75.0 is available\"\n# Try to keep the same capitalization or lack of as in the English file\nadd folder\nAdd Music Caster to folder context menu\nAdd files\nAdd URL\nAn error occurred, restarting now\nAn Internal Server Error occurred\nAudio Selection\nAuto update\nBy\nCancel\nCancel Timer\nClear the queue\nControls\nCould not connect to cast device\nCould not find an output device to record\nCould not play $URL\nCould not populate queue because library scan is disabled\nCould not set timer\nDiscord presence\nDownloading update $VER\nEnter minutes or HH:MM\nEnter Time\nEnter URL\nERROR\nExit\nFolder context menu\nFolder does not contain audio files\nFolders\nGeneral\nHibernate when timer runs out\nHibernate Computer\nINFO\nInvalid Input (enter minutes or HH:MM)\nInvalid URL. URL's need to start with http:// or https://\nInvalid audio file selected\nInvalid audio file $FILE\nKeep mini mode on top\nLaunch mini mode\nLeft-side music controls\nLibrary\nLibrary indexing incomplete, only scanned files have been added\nListening\nSystem Audio\nLocal device\nlocate selected\nLost connection to $DEVICE, switching to local device\nMini mode on top\nMore\nmove down\nMove to next up\nMove track content to the left\nmove up\nMusic Caster is running in the tray.\nmute\nNew\nnext track\nNo space left on device to auto-update\nNo space left on device to save settings\nNo Timer Set\nNot connected to a cast device\nNot logged into deezer.com\nNothing Playing\nNotifications\nOnly Stop Playback\nOpen\nPause\nPersistent queue\nPlay\nPlay All\nPlay Files\nPlay Files Next\nPlay Immediately\nPlay Next\nPlay URL\nPlay URL Next\nPlaying\nPlaylist name\nPlaylists\nPlaylists Tab\nPopulate queue on startup\nPopulates queue from folders on startup\nprevious track\nQueue\nqueue all\nQueue Files\nQueue URL\nRefresh Devices\nRemember last folder\nremove selected folder\nremove\nRepeat\nRepeat All\nRepeat Off\nRepeat One\nRepeat Options\nRescan Library\nRescanning library\nResume\nReverse play next behaviour\nReversed play next\nRun on startup\nSave queue between sessions\nSave to playlist\nSave window positions\nScan folders\nscroll mousewheel\nSearch for music...\nSelect Device\nSelect Folder\nSelect Folders\nSelect Files\nSelect File\nSelect Audio Files\nSend me an email\nset DEBUG = true in `settings.json` to enable this page\nSet Timer\nSet\nSettings\nShow album art\nShow album art in GUI\nShow index in queue\nShow track number\nShow track number in queue\nshuffle\nShut down when timer runs out\nShut Down Computer\nSleep when timer runs out\nSleep Computer\nStop\nSubmit\nThanks for installing Music Caster.\nTimer\nTimer cancelled\nTimer set for $TIME\nType in artist/tracks\nUI\nunmute\nUpdate $VER is available\nURL\nUse cover.* for album art\nUse cover.* for art instead of file's album art\ncover.* image overrides file cover\nVertical GUI\nView Audio Files Source Links\nOpen Web GUI\nnew playlist\nexport playlist\ndelete playlist\nplay playlist\nqueue playlist\nUnknown Title\nUnknown Artist\nUnknown Album\nAlways queue library\nPlay with Music Caster\nQueue in Music Caster\nPlay next in Music Caster\nQueueing\nSmart queue\nadd to next up\nMetadata\nSaving metadata\nMetadata saved\nSave\nTitle\nArtist\nAlbum\nTrack Number\nExplicit\nSelect artwork\nSearch artwork\nRemove artwork\nArtwork found\nNo artwork found\nSearching for artwork...\nLoading URL(s)\nAdded URL(s)\nlanguage\nplay selected\nqueue selected\nadd selected to next up\nSystem Audio Delay:\nseconds\nlocate track\nSwitching to local device\nTrack Format:\nCould not fetch audio for $URL\nExit app on GUI close\nOn battery resolution\nPlugged in resolution\nCould not set resolution\ncopy URIs\nedit metadata\nExperimental features\nPlaylist saved\nInstall Update\nDownloaded $VER. Relaunching...\n"
  },
  {
    "path": "src/languages/es.txt",
    "content": "# Language: Spanish\n# Credits: Sergi github.com/Varguit\n# Any line starting with a # is ignored!\n# If a line contains $X do not translate the $X, please use it in context\n# e.g. \"Update $VER is available\" will dynamically become \"Update v4.75.0 is available\"\n# Try to keep the same capitalization or lack of as in the English file\nagregar carpeta\nAgregar Music Caster al menú contextual de la carpeta\nAgregar pistas\nAgregar URL\nOcurrió un error, reiniciando ahora\nSe produjo un error interno del servidor\nSelección de audio\nActualización auto\nPor\nCancelar\nCancelar Temporizador\nLimpiar la cola\nControles\nNo se pudo conectar al dispositivo de transmisión\nNo se pudo encontrar un dispositivo de salida para grabar\nNo se pudo reproducir $URL\nNo se pudo completar la cola porque el escaneo de la biblioteca está deshabilitado\nNo se pudo configurar el temporizador\nDiscord activo\nDescargando la actualización $VER\nIngrese minutos o HH:MM\nIngrese la hora\nIngrese la URL\nERROR\nSalida\nMenú contextual de carpeta\nCarpeta no contiene archivos de audio\nCarpetas\nGeneral\nHibernar cuando se acabe el tiempo\nHibernar computadora\nINFO\nEntrada no válida (ingrese minutos o HH: MM)\nURL invalida.  La URL debe comenzar con http:// o https://\nSe seleccionó un archivo de audio no válido\nArchivo de audio no válido $FILE\nMantenga el modo mini en la parte superior\nIniciar el modo mini\nControles de música en lado izquierdo\nBiblioteca\nIndexación de la biblioteca incompleta, solo se han agregado archivos escaneados\nEscuchando\nAudio del Sistema\nDispositivo local\nlocalizar seleccionado\nModo Mini en la parte superior\nConexión perdida a $DEVICE, cambio a dispositivo local\nMás\nmover hacia abajo\nPasar al siguiente\nMover el contenido de la pista a la izquierda\nmover hacia arriba\nMusic Caster se está ejecutando en la bandeja.\nsilencio\nNuevo\nsiguiente pista\nNo queda espacio en el dispositivo para actualizar automáticamente\nNo queda espacio en el dispositivo para guardar la configuración\nSin temporizador configurado\nNo conectado a un dispositivo de transmisión\nNo ha iniciado sesión en deezer.com\nNada Reproduciéndose\nNotificaciones\nDetener solo la Reproducción\nAbierta\nPausa\nCola persistente\nReproducir\nReproducir Todo\nReproducir archivos\nReproducir archivos siguiente\nReproduce inmediatamente\nReproduce siguiente\nReproducir URL\nReproducir URL Siguiente\nReproduciendo\nNombre lista reprod.\nListas de Reproducción\nPestaña Listas de Reproducción\nRellenar cola al inicio\nLlena la cola de las carpetas al inicio\npista anterior\nCola\nPoner en cola todo\nArchivos de cola\nURL de cola\nActualizar Dispositivos\nRecuerde última carpeta\neliminar carpeta seleccionada\nquitar\nRepetir\nRepite todo\nRepetir apagado\nRepetir uno\nOpciones de repetición\nVolver a Explorar la Biblioteca\nExplorar comenzó\nReanudar\nInvertir comportamiento de reproducir siguiente\nReproducir siguiente invertido\nReproduce al empezar\nGuardar cola entre sesiones\nGuardar en la lista de reproducción\nGuardar posiciones de ventana\nEscanear carpetas\nRueda del ratón de desplazamiento\nBuscar música...\nSeleccione el Dispositivo\nSeleccione la carpeta\nSeleccionar Carpetas\nSeleccionar Archivos\nSeleccionar Archivo\nSeleccionar Archivos de Audio\nEnvíame un correo electrónico\nestablezca DEBUG = true en `settings.json` para habilitar esta página\nConfigurar Temporizador\nAjusta\nAjustes\nMostrar la carátula del álbum\nMostrar la carátula del álbum en la GUI\nMostrar índice en cola\nMostrar número de pista\nMostrar el número de pista en la cola\nAleatorio\nActivar modo sleep cuando se acabe el tiempo\nActivar modo Sleep la computadora\nHibernar cuando se acabe el tiempo\nHibernar\nDetener\nActivar\nGracias por instalar Music Caster.\nTemporizador\nTemporizador cancelado\nTemporizador establecido a las $TIME\nEscribe artista / pistas\nUI\nactivar el sonido\nActualización $VER está disponible\nURL\nUse cover.* Para la carátula del álbum\nUse cover.* Para la carátula en lugar de la carátula del álbum del archivo\ncover.* la imagen anula la portada del archivo\nGUI vertical\nVer enlaces de origen de archivos de audio\nInterfaz GUI Web Abierta\nnueva lista de reproducción\nexportar lista de reproducción\neliminar lista de reproducción\nreproducir lista de reproducción\nlista de reproducción en cola\nTítulo Desconocido\nArtista Desconocido\nÁlbum Desconocido\nBiblioteca de cola siempre\nJugar con Music Caster\nCola en Music Caster\nReproducir a continuación en Music Caster\nHacer Cola\nCola inteligente\nagregar al siguiente\nMetadatos\nGuardar metadatos\nMetadatos guardados\nGuardar\nTítulo\nArtista\nÁlbum\nNúmero de Pista\nExplícito\nSeleccionar obra de arte\nBuscar obra de arte\nQuitar obra de arte\nObra de arte encontrada\nNo se encontraron obras de arte\nBuscando obras de arte...\nCargando URL(s)\nURL agregadas\nidioma\njugar seleccionado\ncola seleccionada\nagregar seleccionado al siguiente\nRetraso de audio del sistema:\nsegundos\nlocalizar pista\nCambiar a dispositivo local\nFormato de Pista:\nNo se pudo obtener el audio para $URL\nSalir de la aplicación al cerrar la GUI\nSobre la resolución de la batería\nResolución enchufada\nNo se pudo establecer la resolución\ncopiar URIs\neditar metadatos\ncaracteristicas experimentales\nLista de reproducción guardada\nActualizar\nDescargado $VER. Reiniciando...\n"
  },
  {
    "path": "src/languages/fr.txt",
    "content": "# Language: French\n# Credits: github.com/tasye24\n# Any line starting with a # is ignored\n# If a line contains $X do not translate the $X, please use it in context\n# e.g. \"Update $VER is available\" will dynamically become \"Update v4.75.0 is available\"\n# Try to keep the same capitalization or lack of as in the English file\najouter à partir d’un dossier\najouter Music Caster au dossier du menu contextuel\najouter à partir des fichiers\najouter à partir d’une URL\nUne erreur s’est produite, redémarrage immédiat\nUne erreur interne du serveur s’est produite\nSélection audio\nMise à jour automatique\nPar\nAnnuler\nAnnuler le chronomètre\nNettoyer la file d’attente\nContrôles\nImpossible de se connecter au périphérique cast\nImpossible de trouver un appareil de sortie pour enregistrer\nImpossible de jouer $URL\nImpossible de remplir la file d’attente car l’analyse de bibliothèque est désactivée\nImpossible de régler le chronomètre\nprésence Discord\nMise à jour vers $VER\nSaisir les minutes ou HH:MM\nSaisir un temps\nSaisir l'URL\nERREUR\nSortir\nDossier du menu contextuel\nLe dossier ne contient pas de fichiers audio\nDossiers\nGénérale\nMettre en veille lorsque le chronomètre finit\nMettre en veille l'ordinateur\nINFO\nEntrée invalide (saisir minutes ou HH:MM)\nURL. invalide l'URL doit commencer par http:// ou https://\nFichier audio sélectionné invalide\nFichier audio invalide $FILE\nGarder le mode mini au premier plan\nLauncer le mode mini\nCommandes de musique du côté gauche\nBibliothèque\nIndexation de la bibliothèque incomplète, seulement les fichiers scannés ont été ajoutés\nÉcoute\nAudio système\nPériphérique local\nlangue sélectionnée\nConnexion perdue avec $DEVICE, changement vers l’appareil local\nMode mini au premier plan\nPlus\ndescendre\nDéplacer vers le suivant\nDéplacer le contenu de la piste vers la gauche\nmonter\nMusic Caster est en cours d'exécution.\nsilencieux\nNouveau\nmorceau suivant\nIl n'y a plus d'espace libre sur l'appareil de mise à jour automatisé\nIl n'y a plus d'espace libre dans l'appareil pour sauvegarder les paramètres\nAucun chronomètre\nNon connecté à un appareil de Cast\nNon connecté à deezer.com\nJoue rien\nNotifications\nArrêter uniquement la lecture\nOuvrir\nPause\nFile d’attente permanente\nJouer\nTout jouer\nJouer les fichiers\nJouer les fichiers suivants\nJouer immédiatement\nJouer le prochain\nJouer à partir d'une URL\nJouer le prochain morceau de l'URL\nEn cours d'écoute\nNom de la playlist\nListes de lectures\nOnglet listes de lectures\nRemplir la file d’attente au démarrage\nRemplir la file d’attente depuis les dossiers au démarrage\nmorceau précédent\nFile d'attente\nTout mettre dans la file d'attente\nFichiers de la file d'attente\nURL de la file d'attente\nRafraichir les appareils\nRestaurer le dernier dossier\nsupprimer le dossier sélectionné\nsupprimer\nRépéter\nTout répéter\nDésactiver le mode repeat\nRépéter une fois\nOptions du mode répéter\nRéanalyse de la bibliothèque\nRéanalyse de la bibliothèque en cours\nRésumer\nJouer à l'envers le prochain comportement\nJouer à l'envers le prochain morceau\nDémarrer au lancement\nSauvegarder la file d'attente entre les sessions\nSauvegarder à la playlist\nSauvegarder les position de la fenêtre\nScanner les dossier\nscroll molette\nRechercher de la musique...\nSélectionner l'appareil\nSélectionner un dossier\nSélectionner des dossiers\nSélectionner des fichiers\nSélectionner un fichier\nSélectionner des fichiers audio\nEnvoyez-moi un email\nrégler DEBUG = true dans settings.json pour activer cette page\nRégler le chronomètre\nRégler\nParamètres\nAfficher la pochette d'album\nAfficher la pochette d'album dans l'interface\nAfficher l'index dans la file d'attente\nAfficher le numéro du morceau\nAfficher le numéro du morceau dans la file d'attente\naléatoire\nÉteindre à la fin du chronomètre\nÉteindre l'ordinateur\nMettre en veille à la fin du chronomètre\nMettre en veille l'ordinateur\nArrêter\nEnvoyer\nMerci d'avoir installé Music Caster.\nChronomètre\nChronomètre annulé\nChronomètre réglé pour $TIME\nRechercher dans artistes/morceaux\nInterface utilisateur\nréactiver le son\nNouvelle mise à jour $VER disponible\nURL\nUtiliser cover.* pour la pochette d'album\nUtiliser cover.* pour la pochette d'album au lieu de celui du fichier\nremplacer la bannière par cover.*\nInterface verticale\nAfficher les liens source des fichiers audio\nOuvrir l'interface Web\nnouvelle playlist\nexporter la playlist\nsupprimer la playlist\njouer la playlist\nplaylist de la file d'attente\nTitre inconnu\nArtiste inconnu\nAlbum inconnu\nToujours mettre en attente la bibliothèque\nJouer avec Music Caster\nMise en attente dans Music Caster\nJouer le prochain titre dans Music Caster\nMise en file d'attente\nFile d'attente intelligente\nAjouter au prochain\nmétadonnées\nSauvegarde des métadonnées\nMétadonnées sauvegardées\nSauvegarder\nTitre\nArtiste\nAlbum\nMorceau numéro\nExplicite\nSélectionner un morceau\nRechercher un morceau\nSupprimer le morceau\nMorceau trouvé\nAucun morceau trouvé\nRecherche de morceau...\nChargements des URL\nURL ajoutées\nlangue\nJouer les morceaux sélectionnés\nfile d’attente sélectionnée\nAjouter la sélection au prochain\nDélai du système audio:\nsecondes\nLocaliser le morceau\nPassage à l'appareil local\nFormat du morceau:\nImpossible de trouver le fichier audio à partir de $URL\nSortir de l'app quand le GUI se ferme\nRésolution sur batterie\nRésolution branchée\nImpossible de définir la résolution\ncopier les URI\nmodifier les métadonnées\nFonctionnalités expérimentales\nListe de lecture enregistrée\nMise à jour\nTéléchargé $VER. Relance...\n"
  },
  {
    "path": "src/languages/it.txt",
    "content": "# Language: Italiano\n# Credits: Antonio Negrini github.com/antonio-negrini\n# Any line starting with a # is ignored!\n# If a line contains $X do not translate the $X, please use it in context\n# e.g. \"Update $VER is available\" will dynamically become \"Update v4.75.0 is available\"\n# Try to keep the same capitalization or lack of as in the English file\naggiungi cartella\nAggiungi Music Caster al menu contestuale della cartella\nAggiungi tracce\nAggiungi URL\nSi è verificato un errore, riavviare ora\nSi è verificato un errore interno al Server\nSelezione audio\nAggiornamento automatico\nDa\nAnnulla\nAnnulla il Timer\nCancella la coda\nControlla\nImpossibile connettersi al dispositivo cast\nImpossibile trovare un dispositivo di output da registrare\nImpossibile riprodurre $URL\nImpossibile popolare la coda perché la scansione della libreria è disabilitata\nImpossibile impostare il timer\nPresenza su Discord\nDownload dell'aggiornamento $VER\nInserisci i minuti o HH:MM\nInserisci il tempo\nInserisci l'URL\nERRORE\nEsci\nMenu contestuale cartella\nCartella non contiene file audio\nCartelle\nGenerale\nIbernazione allo scadere del timer\nIbernazione del computer\nINFO\nInput non valido (inserire minuti o HH:MM)\nURL non valido. Gli URL devono iniziare con http:// o https://\nSelezionato file audio non valido\nFile audio $FILE non valido\nMantenere il modo mini in alto\nAvvia il modo mini\nControlli musicali a sinistra\nLibreria\nIndicizzazione della libreria incompleta, sono stati aggiunti solo i file scansionati\nAscolto\nAudio di Sistema\nDispositivo locale\nindividuare selezionato\nModalità mini in alto\nConnessione persa a $DEVICE, passaggio al dispositivo locale\nPiù\nSposta giù\nSposta su come successivo\nSposta il contenuto della traccia a sinistra\nSposta su\nMusic Caster è in esecuzione nel tray.\nMute\nNuovo\nbrano successivo\nNon c'è spazio sul dispositivo per l'aggiornamento automatico\nNon c'è spazio sul dispositivo per salvare le impostazioni\nTimer non impostato\nNon collegato a un dispositivo cast\nNon connesso a deezer.com\nNiente in riproduzione\nNotifiche\nFerma solo la Riproduzione\nApire\nPausa\nCoda persistente\nRiproduci\nRiproduci Tutto\nRiproduci Files\nRiproduci Files Dopo\nRiproduci Immediatamente\nRiproduci Prossimo\nRiproduci URL\nRiproduci URL Dopo\nIn riproduzione\nNome Playlist\nPlaylist\nScheda Playlist\nRiempi la coda all'avvio\nRiempi la coda dalle cartelle all'avvio\ntraccia precedente\nCoda\nMetti tutti in coda\nMetti in coda Files\nMetti in coda URL\nAggiornare i Dispositivi\nRicorda ultima cartella\nrimuovere la cartella selezionata\nRimuovi\nRipeti\nRipeti Tutto\nRipeti Off\nRipeti Uno\nOpzioni di ripetizione\nScansione della Libreria\nScansionando la libreria\nRiprendi\nInverti modalità riproduzione successiva\nRiprod. successiva invertita\nEsegui all'avvio\nSalva la coda tra le sessioni\nSalva in playlist\nSalva posizioni finestre\nScansione cartelle\nscorrere la rotella del mouse\nCerca la musica...\nSeleziona Dispositivo\nSeleziona Cartella\nSeleziona Cartellae\nSeleziona Files\nSeleziona File\nSeleziona Files Audio\nInviami un'e-mail\nimposta DEBUG = true in `settings.json` per abilitare questa pagina\nImposta Timer\nImposta\nImpostazioni\nMostra cover album\nMostra cover album in GUI\nMostra l'indice nella coda\nMostra il numero del brano\nMostra il numero del brano in coda\nRandom\nSpegni PC alla fine del timer\nSpegni il computer\nMetti in Sleep il PC alla fine del timer\nMetti in Sleep il computer\nStop\nInvia\nGrazie per aver installato Music Caster.\nTimer\nTimer cancellato\nTimer impostato per $TIME\nInserisci artista/brani\nUI\ntogli il mute\nAggiornamento $VER disponibile\nURL\nUsa cover.* per cover album\nUsa cover.* invece della cover inclusa nel file\nil file cover.* sovrascrive la cover inclusa nel file\nGUI verticale\nVisualizza i file audio Link alla fonte\nWeb GUI QR Code (clicca o scansiona)\nnuova playlist\nesporta playlist\nelimina playlist\nriproduci playlist\nplaylist in coda\nBrano Sconosciuto\nArtista Sconosciuto\nAlbum Sconosciuto\nMetti sempre in coda libreria\nGioca con Music Caster\nCoda in Music Caster\nRiproduci successivo in Music Caster\nCoda\nCoda smart\naggiungere al prossimo\nMetadati\nSalvataggio dei metadati\nMetadati salvati\nSalva\nBrano\nArtista\nAlbum\nNumero Brano\nEsplicito\nSeleziona immagine\nCerca immagine\nRimuovi immagine\nImmagine trovata\nNessuna immagine trovata\nRicerca immagini...\nCaricamento URL(s)\nURL(s) aggiunti/e\nlinguaggio\nriproduzione selezionata\ncoda selezionata\naggiungi selezionato al successivo\nRitardo audio di sistema:\nsecondi\nlocalizzare traccia\nPassaggio al dispositivo locale\nFormato Traccia:\nImpossibile recuperare l'audio per $URL\nEsci dall'app alla chiusura della GUI\nSulla risoluzione della batteria\nRisoluzione inserita\nImpossibile impostare la risoluzione\ncopia URIs\nmodificare i metadati\ncaratteristiche sperimentali\nPlaylist salvata\nAggiornamento\nScaricato $VER. Riavvio...\n"
  },
  {
    "path": "src/languages/nl.txt",
    "content": "# Language: Dutch\n# Credits: Jeffrey (PD3J) Jansen\n# Any line starting with a # is ignored!\n# If a line contains $X do not translate the $X, please use it in context\n# e.g. \"Update $VER is available\" will dynamically become \"Update v4.75.0 is available\"\n# Try to keep the same capitalization or lack of as in the English file\nmap toevoegen\nVoeg Music Caster toe aan het contextmenu van de map\nLiedjes toevoegen\nURL toevoegen\nEr is een fout opgetreden, nu aan het herstarten\nEr heeft een interne server fout plaatsgevonden\nAudio Selectie\nAutomatische update\nDoor\nAnnuleren\nAnnuleer Timer\nWis de wachtrij\nBediening\nKon niet verbinden met cast-apparaat\nKon geen uitvoerapparaat vinden om op te nemen\nKan $URL niet afspelen\nKan de wachtrij niet vullen omdat bibliotheekscan is uitgeschakeld\nKon de timer niet instellen\nDiscord aanwezig\nDownloading update $VER\nVoer het aantal minuten in of UU:MM\nVoer tijd in\nVul URL in\nFOUT\nExit\nContextmenu van de map\nDeze map bevat geen audio bestanden\nMappen\nAlgemeen\nGa in slaapstand als de timer afloopt\nSlaapstand Computer\nINFO\nVerkeerde ingave (geef aantal minuten in of UU:MM)\nOngeldige URL. URL's moeten beginnen met http:// of https://\nOngeldig audiobestand geselecteerd\nOngeldig audiobestand $FILE\nHoudt mini modus zichtbaar\nStart mini modus\nMuziekbediening aan de linkerkant\nBibliotheek\nBibliotheekindexering onvolledig, alleen gescande bestanden zijn toegevoegd\nAan het luisteren\nSysteemaudio\nLokale apparaten\nlokaliseren geselecteerd\nMini-modus bovenaan\nVerbinding met $DEVICE verbroken, overschakelen naar lokaal apparaat\nMeer\nomlaag\nverplaatsen naar volgende te spelen\nNummerinhoud naar links verplaatsen\nomhoog\nMusic Caster is geminimaliseerd in de taakbalk.\nmute\nNieuw\nvolgend nummer\nGeen ruimte over op het apparaat om automatisch te updaten\nGeen ruimte meer op het apparaat om instellingen op te slaan\nGeen timer ingesteld\nNiet verbonden met een cast-apparaat\nNiet ingelogd bij deezer.com\nNiets aan het spelen\nMeldingen\nAlleen afspelen stoppen\nOpen\nPause\nAanhoudende wachtrij\nAfspelen\nSpeel alles af\nSpeel bestanden\nSpeel volgende bestanden\nSpeel meteen\nSpeel volgende\nSpeel URL\nSpeel volgende URL\nAfspelen\nNaam afspeellijst\nAfspeellijsten\nTabblad Afspeellijsten\nWachtrij vullen bij opstarten\nVult wachtrij uit mappen bij opstarten\nvorige nummer\nWachtrij\nalles naar de wachtrij\nBestanden naar wachtrij\nURL naar wachtrij\nVervers apparaten\nOnthoud laatste map\nverwijder geselecteerde map\nverwijder\nHerhaal\nHerhaal alles\nHerhalen uit\nHerhaal een keer\nHerhaal opties\nBibliotheek opnieuw scannen\nBibliotheek aan het herscannen\nHervatten\nOmgekeerd afspelen volgende gedrag\nOmgekeerd afspelen volgende\nUitvoeren bij het starten\nWachtrij tussen sessies opslaan\nopslaan in afspeellijst\nVensterposities opslaan\nMappen scannen\nscroll muiswiel\nMuziek aan het zoeken...\nSelecteer apparaat\nSelecteer map\nSelecteer mappen\nSelecteer bestanden\nSelecteer bestand\nSelecteer Audio bestanden\nStuur een e-mail\nset DEBUG = true in `settings.json` om deze pagina in te schakelen\nStel Timer in\nInstellen\nInstellingen\nAlbumhoezen weergeven\nAlbumhoezen weergeven in Grafische gebruikers-interface\nToon index in wachtrij\nToon tracknummer\nToon tracknummer in wachtrij\nshuffle\nAfsluiten wanneer de timer afloopt\nComputer afsluiten\nGa in slaapstand als de timer afloopt\nComputer slaapstand\nStop\nIndienen\nBedankt voor het installeren van Music Caster.\nTimer\nTimer geannuleerd\nTimer gezet voor $TIME\nGeef artiest/tracks in\nGebruikersinterface\nmute opheffen\nUpdate $VER is beschikbaar\nURL\nGebruik cover.* voor albumhoezen\nGebruik cover.* voor albumhoed in plaats van de albumhoes van het bestand\ncover.* afbeelding heeft voorrang op de albumhoes van het bestand\nVerticale Grafische gebruikers-interface\nBekijk de bronlinks van de audiobestanden\nQR Code voor de webgebaseerde gebruikers-interface (Klikken of scannen)\nnieuwe afspeellijst\nafspeellijst exporteren\nafspeellijst verwijderen\nafspeellijst afspelen\nafspeellijst in de wachtrij zetten\nOnbekende titel\nOnbekende artiest\nOnbekend album\nBibliotheek altijd in wachtrij plaatsen\nAfspelen met Music Caster\nIn de wachtrij van Music Caster zetten\nVolgende afspelen in Music Caster\nIn de wachtrij zetten\nSlimme wachtrij\ntoevoegen aan volgende te spelen\nMetagegevens\nMetagegevens opslaan\nMetagegevens opgeslagen\nOpslaan\nTitel\nArtiest\nAlbum\nTracknummer\nExpliciet\nSelecteer afbeelding\nZoek afbeelding\nVerwijder afbeelding\nAfbeelding gevonden\nGeen afbeelding gevonden\nZoeken naar afbeelding...\nURL(s) laden\nToegevoegde URL(s)\nTaal\nSpeel selectie\nZet selectie in de wachtrij\nvoeg selectie to aan volgende te spelen\nSysteem Audio Vertraging:\nseconden\ntrack zoeken\nOverschakelen naar lokaal apparaat\nTrackformaat:\nKan geen audio ophalen voor $URL\nSluit app op GUI sluiten\nOp batterij resolutie\nAangesloten resolutie\nKan resolutie niet instellen\nkopieer URI\nmetagegevens bewerken\nexperimentele functies\nAfspeellijst opgeslagen\nInstalleer update\nGedownload $VER. Opnieuw starten...\n"
  },
  {
    "path": "src/languages/pt-br.txt",
    "content": "# Idioma: Português - Brazil\n# Créditos: Cleiton Salvagni\n# Qualquer linha que começar com # é ignorada\n# Se uma linha contém $X, não traduza o $X, por favor use-o no contexto\n# Exemplo: \"Update $VER is available\" se tornará dinamicamente \"Atualização v4.75.0 está disponível\"\n# Tente manter a mesma capitalização ou falta de, como no arquivo original em inglês\nadicionar pasta\nAdicionar Music Caster ao menu de contexto da pasta\nAdicionar arquivos\nAdicionar URL\nOcorreu um erro, reiniciando agora\nOcorreu um Erro Interno no Servidor\nSeleção de Áudio\nAtualização automática\nPor\nCancelar\nCancelar Timer\nLimpar a fila\nControles\nNão foi possível conectar ao dispositivo de transmissão\nNão foi possível encontrar um dispositivo de saída para gravação\nNão foi possível reproduzir $URL\nNão foi possível preencher a fila porque a varredura da biblioteca está desativada\nNão foi possível definir o timer\nPresença no Discord\nBaixando atualização $VER\nDigite minutos ou HH:MM\nDigite o Horário\nDigite o URL\nERRO\nSair\nMenu de contexto da pasta\nA pasta não contém arquivos de áudio\nPastas\nGeral\nHibernar quando o timer acabar\nHibernar o Computador\nINFO\nEntrada Inválida (insira minutos ou HH:MM)\nURL inválida. URLs devem começar com http:// ou https://\nArquivo de áudio inválido selecionado\nArquivo de áudio inválido $FILE\nManter modo mini no topo\nIniciar modo mini\nControles de música no lado esquerdo\nBiblioteca\nIndexação da biblioteca incompleta, apenas os arquivos escaneados foram adicionados\nOuvindo\nÁudio do Sistema\nDispositivo local\nlocalizar selecionado\nConexão perdida com $DEVICE, alternando para dispositivo local\nModo mini no topo\nMais\nmover para baixo\nMover para próximo\nMover conteúdo da faixa para a esquerda\nmover para cima\nMusic Caster está rodando na bandeja.\nmudo\nNovo\npróxima faixa\nSem espaço no dispositivo para atualização automática\nSem espaço no dispositivo para salvar configurações\nNenhum Timer Definido\nNão conectado a um dispositivo de transmissão\nNão logado em deezer.com\nNada Tocando\nNotificações\nApenas Parar Reprodução\nAbrir\nPausar\nFila Persistente\nReproduzir\nReproduzir Tudo\nReproduzir Arquivos\nReproduzir Arquivos a Seguir\nReproduzir Imediatamente\nReproduzir Próximo\nReproduzir URL\nReproduzir URL a Seguir\nTocando\nNome da Playlist\nPlaylists\nAba de Playlists\nPreencher fila na inicialização\nPreencher fila com pastas na inicialização\nfaixa anterior\nFila\nenfileirar tudo\nEnfileirar Arquivos\nEnfileirar URL\nAtualizar Dispositivos\nLembrar última pasta\nremover pasta selecionada\nremover\nRepetir\nRepetir Tudo\nRepetir Desativado\nRepetir Uma\nOpções de Repetição\nReescanear Biblioteca\nReescaneando biblioteca\nContinuar\nInverter comportamento de próximo\nPróximo invertido\nExecutar na inicialização\nSalvar fila entre sessões\nSalvar na playlist\nSalvar posições das janelas\nEscanear pastas\nrolar com a roda do mouse\nBuscar música...\nSelecionar Dispositivo\nSelecionar Pasta\nSelecionar Pastas\nSelecionar Arquivos\nSelecionar Arquivo\nSelecionar Arquivos de Áudio\nEnvie-me um email\ndefina DEBUG = true em `settings.json` para habilitar esta página\nDefinir Timer\nDefinir\nConfigurações\nMostrar capa do álbum\nMostrar capa do álbum na GUI\nMostrar índice na fila\nMostrar número da faixa\nMostrar número da faixa na fila\naleatório\nDesligar quando o timer acabar\nDesligar o Computador\nSuspender quando o timer acabar\nSuspender o Computador\nParar\nEnviar\nObrigado por instalar Music Caster.\nTimer\nTimer cancelado\nTimer definido para $TIME\nDigite artista/faixas\nUI\nativar som\nAtualização $VER disponível\nURL\nUsar cover.* para capa do álbum\nUsar cover.* para arte em vez da capa do arquivo\nimagem cover.* substitui capa do arquivo\nGUI Vertical\nVer Links de Fonte dos Arquivos de Áudio\nAbrir GUI Web\nnova playlist\nexportar playlist\nexcluir playlist\nreproduzir playlist\nenfileirar playlist\nTítulo Desconhecido\nArtista Desconhecido\nÁlbum Desconhecido\nSempre enfileirar biblioteca\nReproduzir com Music Caster\nEnfileirar no Music Caster\nReproduzir próximo no Music Caster\nEnfileirando\nFila inteligente\nadicionar ao próximo\nMetadados\nSalvando metadados\nMetadados salvos\nSalvar\nTítulo\nArtista\nÁlbum\nNúmero da Faixa\nExplícito\nSelecionar arte\nBuscar arte\nRemover arte\nArte encontrada\nNenhuma arte encontrada\nBuscando por arte...\nCarregando URL(s)\nURL(s) Adicionada(s)\nidioma\nreproduzir selecionado\nenfileirar selecionado\nadicionar selecionado ao próximo\nAtraso do Áudio do Sistema:\nsegundos\nlocalizar faixa\nAlternando para dispositivo local\nFormato da Faixa:\nNão foi possível buscar áudio para $URL\nSair do aplicativo ao fechar a GUI\nResolução ao usar bateria\nResolução ao usar energia\nNão foi possível definir resolução\ncopiar URIs\neditar metadados\nRecursos experimentais\nPlaylist salva\nInstalar Atualização\nBaixado $VER. Reiniciando...\n"
  },
  {
    "path": "src/languages/ru.txt",
    "content": "# Language: Russian\n# Credits: Kostiantyn Astakhov\n# Any line starting with a # is ignored\n# If a line contains $X do not translate the $X, please use it in context\n# e.g. \"Update $VER is available\" will dynamically become \"Update v4.75.0 is available\"\n# Try to keep the same capitalization or lack of as in the English file\nдобавить папку\nДобавить Music Caster в контексное меню папки\nДобавить файлы\nДобавить ссылку\nПроизошла ошибка, приложение перезагружается\nПроизошла внутренняя ошибка сервера\nВыбор аудио\nАвтоматическое обновление\nАвтор\nОтменить\nОтменить таймер\nОчистить очередь\nУправление\nНе удалось подключиться к потоковому устройству\nНе удалось найти устройство вывода для записи\nНе удалось воспроизвести $URL\nНе удалось заполнить очередь, поскольку сканирование медиатеки отключено\nНе удалось установить таймер\nОбновить статус в Discord\nЗагрузка обновления $VER\nВведите минуты или ГГ:ХХ\nВведите время\nВведите ссылку\nОШИБКА\nВыход\nКонтекстное меню папки\nПапка не содержит аудиофайлов\nПапки\nОбщие\nПеревести компьютер в режим гибернации, когда таймер закончится\nПеревести компьютер в режим гибернации\nИНФОРМАЦИЯ\nНеверный ввод (введите минуты или ЧЧ:ММ)\nСсылка недействительна. Ссылки должны начинаться с http:// или https://\nВыбран неверный аудиофайл\nНеверный аудиофайл $FILE\nОставлять мини-режим вверху\nЗапустить мини-режим\nУправление музыкой слева\nМедиатека\nИндексация медиатеки не завершена, добавлены только просканированные файлы\nСлушаю\nСистемное аудио\nЛокальное устройство\nпоказать выбранный файл\nУтрачено соединение с $DEVICE, перехожу на локальное устройство\nМини-режим вверху\nБольше\nпереместить вниз\nПерейти к следующему\nПереместить содержимое песни влево\nпереместить вверх\nMusic Caster продолжает работать в области уведомлений.\nвыключить звук\nНовый\nследующая песня\nНа устройстве недостаточно места для автоматического обновления\nНа устройстве недостаточно места для сохранения настроек\nНе установлен ни один таймер\nНе подключен к потоковому устройству\nНе вошли в систему на deezer.com\nНичего не воспроизводится\nУведомления\nТолько отключить воспроизведение\nОткрыть\nПауза\nСохраненная очередь\nВоспроизвести\nВоспроизвести все\nВоспроизвести файлы\nВоспроизвести файлы следующими\nВоспроизвести немедленно\nВоспроизвести следующим\nВоспроизвести ссылку\nВоспроизвести ссылку следующим\nВоспроизведение\nНазвание плейлиста\nПлейлисты\nВкладка плейлистов\nЗаполнить очередь при запуске\nЗаполнить очередь из папок при запуске\nпредыдущая песня\nОчередь\nдобавить все в очередь\nОчередь файлов\nОчередь ссылок\nОбновить устройства\nЗапомнить последнюю папку\nудалить выбранную папку\nУдалить\nПовторение\nПовторить все\nВыключить повторение\nПовторить один раз\nПараметры повторения\nПересканировать медиатеку\nПересканирование медиатеке\nПродолжить\nИзменить следующий режим воспроизведения\nИграть следующие в обратном порядке\nЗапускать при старте системы\nСохранить очередь между сессиями\nДобавить в плейлист\nСохранить положение окон\nСканировать папки\nПрокрутка мыши\nПоиск музыки...\nВыбрать устройство\nВыбрать папку\nВыбрать папки\nВыбрать файлы\nВыбрать файл\nВыбрать аудиофайлы\nОтправить мне электронное письмо\nустановите DEBUG = true в settings.json для включения этой страницы\nУстановить таймер\nУстановить\nНастройки\nПоказывать обложку альбома\nПоказывать обложку альбома в пользовательском интерфейсе\nПоказывать индекс в очереди\nПоказывать номер песни\nПоказывать номер песни в очереди\nперемешать\nВыключить компьютер, когда таймер закончится\nВыключить компьютер\nПеревести компьютер в спящий режим, когда таймер закончится\nПеревести компьютер в спящий режим\nОстановить\nУстановить\nСпасибо за установку Music Caster.\nТаймер\nТаймер отменен\nТаймер установлен на $TIME\nВведите исполнителя/песни\nПользовательский интерфейс\nвключить звук\nДоступное обновление до $VER\nСсылки\nИспользовать cover.* в качестве обложки альбома\nИспользовать cover.* в качестве обложки альбома вместо встроенной в файл\ncover.* изображение замещает встроенную в файл обложку\nВертикальный пользовательский интерфейс\nПросмотреть ссылку на источник аудиофайлов\nОткрыть веб-пользовательский интерфейс\nсоздать новый плейлист\nэкспортировать плейлист\nудалить плейлист\nвоспроизвести плейлист\nдобавить плейлист в очередь\nНеизвестное название\nНеизвестный исполнитель\nНеизвестный альбом\nВсегда добавлять медиатеку в очередь\nВоспроизвести с помощью Music Caster\nДобавить в очередь в Music Caster\nВоспроизвести следующим в Music Caster\nОчередь\nУмная очередь\nдобавить в следующие\nМетаданные\nСохранение метаданных\nМетаданные сохранены\nСохранить\nНазвание\nИсполнитель\nАльбом\nНомер песни\nТочно\nВыбрать обложку\nИскать обложку\nУдалить обложку\nНайден обложку\nОбложка не найдена\nПоиск обложки...\nЗагрузка ссылки(ок)\nДобавлена ссылка\nязык\nвоспроизвести избранное\nдобавить избранное в очередь\nдобавить избранное в следующие\nЗадержка системного аудио:\nсекунды\nнайти песню\nПереход на локальное устройство\nФормат названия песни:\nНе удалось получить аудио с $URL\nВыход из программы при закрытии пользовательского интерфейса\nРазрешение при работе от батареи\nРазрешение при работе от питания\nНе удалось установить разрешение\nскопировать путь\nредактировать метаданные\nЭкспериментальные функции\nПлейлист сохранен\nОбновлять\nЗагружено $VER. Перезапуск...\n"
  },
  {
    "path": "src/languages/sk.txt",
    "content": "# Language: Slovak\n# Credits: PalVac\n# Any line starting with a # is ignored\n# If a line contains $X do not translate the $X, please use it in context\n# e.g. \"Update $VER is available\" will dynamically become \"Update v4.75.0 is available\"\n# Try to keep the same capitalization or lack of as in the English file\nprida prieinok\nPrida Music Caster do kontextovej ponuky prieinka\nPrida sbory\nPrida URL\nVyskytla sa chyba, aplikcia sa retartuje\nVyskytla sa vntorn chyba servera\nVber zvuku\nAutomatick aktualizcia\nAutor\nZrui\nZrui asova\nVymaza frontu\nOvldanie\nNepodarilo sa pripoji k streamovaciemu zariadeniu\nNepodarilo sa njs vstupn zariadenie na nahrvanie\nNepodarilo sa prehra $URL\nNepodarilo sa naplni frontu, pretoe skenovanie kninice mdi je zakzan\nNepodarilo sa nastavi asova\nAktualizova stav v Discord\nSahuje sa aktualizcia $VER\nZadajte minty alebo HH:MM\nZadajte as\nZadajte URL\nCHYBA\nUkoni\nKontextov menu prieinka\nPrieinok neobsahuje zvukov sbory\nPrieinky\nVeobecn\nPrepn pota do reimu hiberncie po uplynut asovaa\nPrepn pota do reimu hiberncie\nINFORMCIA\nNeplatn vstup (zadajte minty alebo HH:MM)\nNeplatn URL. URL musia zana na http:// alebo https://\nVybrali ste neplatn zvukov sbor\nNeplatn zvukov sbor $FILE\nPonecha mini reim navrchu\nSpusti mini reim\nOvldanie hudby vavo\nKninica\nIndexovanie kninice je nepln, boli pridan iba naskenovan sbory\nPovanie\nSystmov zvuk\nMiestne zariadenie\nnjs vybran\nStratilo sa spojenie s $DEVICE, prepn na loklne zariadenie\nMini reim navrchu\nViac\nposun nadol\nPrejs na alie\nPresun obsah skladby doava\nposun hore\nMusic Caster je spusten v tray.\nstlmi\nNov\nalia skladba\nVo vaom zariaden nie je dostatok miesta na automatick aktualizcie\nVo vaom zariaden nie je dostatok miesta na uloenie nastaven\nNebol nastaven iadny asova\nNie je pripojen k streamovaciemu zariadeniu\nNie ste prihlsen/- na deezer.com\nNi sa neprehrva\nOznmenia\nIba zastavi prehrvanie\nOtvori\nPozastavi\nUloen front\nPrehra\nPrehra vetky\nPrehra sbory\nPrehra sbory nasledovne\nPrehra okamite\nPrehra aliu\nPrehra URL\nPrehra aliu URL\nPrehrvanie\nNzov playlistu\nPlaylisty\nKarta playlistov\nNaplni front pri spusten\nNaplni frontu prieinkov pri spusten\npredchdzajca skladba\nFronta\nzaradi vetky\nSbory frontu\nFronta prepojen\nObnovi zariadenia\nZapamta si posledn prieinok\nodstrni vybran prieinok\nodstrni\nOpakova\nOpakova vetky\nVypn opakovanie\nOpakova jednu\nMonosti opakovania\nPreskenova kninicu\nPreskenovanie kninice\nPokraova\nZmena nasledujceho reimu prehrvania\nPrehra nasledovn v opanom porad\nSpusti pri tarte\nUloi front medzi relciami\nUloi do playlistu\nUloi polohu okien\nSkenova prieinky\nPosvanie myou\nHada hudbu...\nVybra zariadenie\nVybra prieinok\nVybra prieinky\nVybra sbory\nVybra sbor\nVybra zvukov sbory\nPoli mi email\nnastavte DEBUG = true v `settings.json` pre aktivciu tejto strnky\nNastavi asova\nNastavi\nNastavenia\nZobrazi obal albumu\nZobrazi obalu albumu v grafickom rozhran\nZobrazi index vo fronte\nZobrazi slo skladby\nZobrazi sla skladieb vo fronte\nnhodn vber\nVypn po uplynut asovaa\nVypn pota\nUspa po vypran asovaa\nUspa pota\nZastavi\nOdosla\nakujeme za intalciu Music Caster.\nasova\nasova zruen\nasova nastaven na $TIME\nZadajte interpreta/skladby\nUI\nzapn zvuk\nDostupn aktualizcia na $VER\nURL\nPoui cover.* ako obal albumu\nPoui cover.* ako obal albumu namiesto vloenia do sboru\ncover.* obrzok nahrad obrzok obalu vloen v sbore\nVertiklne pouvatesk rozhranie\nZobrazi odkazy na zdroje zvukovch sborov\nOtvori webov grafick rozhranie\nnov playlist\nexportova playlist\nvymaza playlist\nprehra playlist\nfronta playlistu\nNeznmy nzov\nNeznmy umelec\nNeznmy album\nVdy zaraova kninicu do frontu\nPrehra v Music Caster\nPoradie v Music Caster\nPrehra alie v Music Caster\nZaraovanie do fronty\nInteligentn fronta\nprida do alej\nMetadta\nUkladaj sa metadta\nMetadta uloen\nUloi\nNzov\nUmelec\nAlbum\nslo skladby\nExplicitn\nVybra obal\nHada obal\nOdstrni obal\nObal njden\nObal nenjden\nHad sa obal...\nNatava URL\nPridan URL\njazyk\nprehra vybran\nvybran fronta\nprida vybran na alie\nOneskorenie zvuku systmu:\nseknd\nnjs skladbu\nPrepnanie na loklne zariadenie\nFormt nzvu piesne:\nNepodarilo sa nata zvuk pre $URL\nUkoni program pri zatvoren pouvateskho rozhrania\nRozlenie pri napjan z batrie\nRozlenie pri napjan zo zdroja\nNepodarilo sa nastavi rozlenie\nkoprova cestu\nupravi metadta\nExperimentlne funkcie\nPlaylist uloen\nNaintalova aktualizciu\nStiahnut $VER. Relaunching...\n"
  },
  {
    "path": "src/languages/uk.txt",
    "content": "# Language: Ukrainian\n# Credits: Kostiantyn Astakhov\n# Any line starting with a # is ignored\n# If a line contains $X do not translate the $X, please use it in context\n# e.g. \"Update $VER is available\" will dynamically become \"Update v4.75.0 is available\"\n# Try to keep the same capitalization or lack of as in the English file\nдодати каталог\nДодати Music Caster до контекстного меню каталогу\nДодати файли\nДодати посилання\nСталася помилка, додаток перезаватажується\nСталася внутрішня помилка сервера\nВибір аудіо\nАвтоматичне оновлення\nВиконавець\nСкасувати\nСкасувати таймер\nОчистити чергу\nУправління\nНе вдалося підключитися до потокового пристрою\nНе вдалося знайти пристрій виведення для запису\nНе вдалося відтворити $URL\nНе вдалося заповнити чергу, оскільки сканування медіатеки вимкнено\nНе вдалося встановити таймер\nОновити статус в Discord\nЗавантаження оновлення $VER\nВведіть хвилини або ГГ:ХХ\nВведіть час\nВведіть посилання\nПОМИЛКА\nВихід\nКонтекстне меню каталогу\nКаталог не містить аудіофайлів\nКаталоги\nЗагальні\nПеревести комп'ютер в режим гібернації, коли таймер закінчиться\nПеревести комп'ютер в режим гібернації\nІНФОРМАЦІЯ\nНевірний ввід (введіть хвилини або ГГ:ХХ)\nПосилання недійсне. Посилання повинні починатися з http:// або https://\nВибрано невірний аудіофайл\nНевірний аудіофайл $FILE\nЗалишати міні-режим вгорі\nЗапустити міні-режим\nКерування музикою зліва\nМедіатека\nІндексація медіатеки незавершена, додані лише проскановані файли\nСлухаю\nСистемне аудіо\nЛокальний пристрій\nпоказати обраний файл\nВтрачено з'єднання з $DEVICE, переходжу на локальний пристрій\nМіні-режим вгорі\nБільше\nперемістити вниз\nПерейти до наступного\nПеремістити вміст пісні вліво\nперемістити вгору\nMusic Caster продовжує працювати в області сповіщень.\nвимкнути звук\nНовий\nнаступна пісня\nНа пристрої недостатньо місця для автоматичного оновлення\nНа пристрої недостатньо місця для збереження налаштувань\nНе встановлено жодного таймера\nНе підключено до потокового пристрою\nНе ввійшли в систему на deezer.com\nНічого не відтворюється\nПовідомлення\nЛише вимкнути відтворювання\nВідкрити\nПауза\nЗбереженна черга\nВідтворити\nВідтворити все\nВідтворити файли\nВідтворити файли наступними\nВідтворити негайно\nВідтворити наступним\nВідтворити посилання\nВідтворити посилання наступним\nВідтворення\nНазва плейлисту\nПлейлисти\nВкладка плейлистів\nЗаповнити чергу при запуску\nЗаповнити чергу з папок при запуску\nпопередня пісня\nЧерга\nдодати все до черги\nЧерга файлів\nЧерга посилань\nОновити пристрої\nЗапам'ятати останній каталог\nвидалити обраний каталог\nвидалити\nПовторення\nПовторити все\nВимкнути повторення\nПовторити один раз\nПараметри повторення\nПересканувати медіатеку\nПересканування медіатеке\nПродовжити\nЗмінити наступний режим відтворення\nГрати наступні в зворотньому порядку\nЗапускати при старті системи\nЗберігати чергу між сесіями\nДодати до плейлисту\nЗберегти положення вікон\nСканувати каталоги\nПрокручування миші\nПошук музики...\nВибрати пристрій\nВибрати каталог\nВибрати каталоги\nВибрати файли\nВибрати файл\nВибрати аудіофайли\nНадіслати мені електронного листа\nвстановіть DEBUG = true в settings.json, щоб увімкнути цю сторінку\nВстановити таймер\nВстановити\nНалаштування\nПоказувати обкладинку альбому\nПоказувати обкладинку альбому в інтерфейсі користувача\nПоказувати індекс у черзі\nПоказувати номер пісні\nПоказувати номер пісні в черзі\nперемішати\nВимкнути комп'ютер, коли таймер закінчиться\nВимкнути комп'ютер\nПеревести комп'ютер в режим сну, коли таймер закінчиться\nПеревести комп'ютер в режим сну\nЗупинити\nВстановити\nДякуємо за встановлення Music Caster.\nТаймер\nТаймер скасовано\nТаймер встановлено на $TIME\nВведіть виконавця/пісні\nКористувацький інтерфейс\nувімкнути звук\nДоступне оновлення до $VER\nПосилання\nВикористовувати cover.* як обкладинку альбома\nВикористовувати cover.* як обкладинку альбома замість вбудованної в файл\ncover.* зображення заміщає вбудованну в файл обкладику\nВертикальний інтерфейс користувача\nПереглянути посилання на джерело аудіофайлів\nВідкрити веб-інтерфейс користувача\nстворити новий плейлист\nекспортувати плейлист\nвидалити плейлист\nвідтворити плейлист\nдодати плейлист до черги\nНевідома назва\nНевідомий виконавець\nНевідомий альбом\nЗавжди додавати медіатеку до черги\nВідтворити за допомогою Music Caster\nДодати до черги в Music Caster\nВідтворити наступним в Music Caster\nЧерга\nРозумна черга\nдодати до наступних\nМетадані\nЗбереження метаданих\nМетадані збережено\nЗберегти\nНазва\nВиконавець\nАльбом\nНомер пісні\nВинятково\nВибрати обкладинку\nШукати обкладинку\nВидалити обкладинку\nЗнайдено обкладинку\nОбкладинка не знайдена\nПошук обкладинки...\nЗавантаження посилання(ь)\nДодано посилання\nмова\nвідтворити вибране\nдодати вибране до черги\nдодати вибране до наступних\nЗатримка системного аудіо:\nсекунди\nзнайти пісню\nПереходжу на локальний пристрій\nФормат назви пісні:\nНе вдалося отримати аудіо з $URL\nВихід з програми при закритті інтерфейс користувача\nРоздільна здатність при роботі від батареї\nРоздільна здатність при роботі від живлення\nНе вдалось встановити роздільну здатність\nскопіювати шлях\nредагувати метадані\nЕкспериментальні функції\nСписок відтворення збережено\nОновлення\nЗавантажено $VER. Перезапуск...\n"
  },
  {
    "path": "src/meta.py",
    "content": "VERSION = latest_version = '5.25.2'\r\nUPDATE_MESSAGE = \"\"\"\r\n[NEW] Support \"System Audio\" in CLI\r\n[MSG] Language translators wanted\r\n\"\"\".strip()\r\nIMPORTANT_INFORMATION = \"\"\"\r\n\"\"\".strip()\r\n\r\n# Constants\r\nDEFAULT_THEME = {\r\n    'accent': '#00bfff',\r\n    'background': '#121212',\r\n    'text': '#d7d7d7',\r\n    'alternate_background': '#222222',\r\n}\r\nTOGGLEABLE_SETTINGS = {\r\n    'auto_update',\r\n    'notifications',\r\n    'discord_rpc',\r\n    'run_on_startup',\r\n    'folder_cover_override',\r\n    'folder_context_menu',\r\n    'save_window_positions',\r\n    'populate_queue_startup',\r\n    'lang',\r\n    'smart_queue',\r\n    'show_track_number',\r\n    'persistent_queue',\r\n    'flip_main_window',\r\n    'vertical_gui',\r\n    'use_last_folder',\r\n    'show_album_art',\r\n    'reversed_play_next',\r\n    'scan_folders',\r\n    'show_queue_index',\r\n    'queue_library',\r\n    'show_queue_length',\r\n    'show_queue_time',\r\n    'gui_exits_app',\r\n    'experimental_features',\r\n}\r\nPID_FILENAME = 'music_caster.pid'\r\nLOCK_FILENAME = 'music_caster.lock'\r\nUNINSTALLER = 'unins000.exe'\r\nWAIT_TIMEOUT = 5\r\nSTREAM_CHUNK = 1024\r\nEMAIL = 'elijahllopezz@gmail.com'\r\nCONTACT_INFO = f'Elijah Lopez <{EMAIL}>'\r\nSUBMIT_EVENTS = {'\\r', 'special 16777220', 'special 16777221', 'timer_submit'}\r\nAUDIO_EXTS = ('mp3', 'mp4', 'mpeg', 'm4a', 'flac', 'aac', 'ogg', 'opus', 'wma', 'wav', 'aiff')\r\nIMG_FILE_TYPES = (\r\n    ('Image', '*.gif *.pdf *.png *jpg *jpeg *.tiff *.webp *.' + ' *.'.join(AUDIO_EXTS)),\r\n)\r\nAUDIO_FILE_TYPES = (('Audio File', '*.' + ' *.'.join(AUDIO_EXTS) + ' *.m3u *.m3u8'),)\r\nVIDEO_FILE_TYPES = (('Media Container File', '*.' + ' *.'.join(('mp2t', 'mp3', 'mp4', 'ogg', 'wav', 'webm'))))\r\n# re-define AUDIO_EXTS\r\nAUDIO_EXTS = {f'.{ext}' for ext in AUDIO_EXTS}\r\nAUDIO_EXTS.add('.m3u')\r\nAUDIO_HANDLER_EXTS = ('mp3', 'flac', 'm4a', 'aac', 'ogg', 'opus', 'aiff', 'wma', 'wav', 'mpeg', 'm3u', 'm3u8')\r\n\r\nFONT_NORMAL = 'Segoe UI', 11\r\nFONT_SMALL = 'Segoe UI', 10\r\nFONT_LINK = 'Segoe UI', 11, 'underline'\r\nFONT_TITLE = 'Segoe UI', 14\r\nFONT_MED = 'Segoe UI', 12\r\nFONT_TAB = 'Meiryo UI', 10\r\nLINK_COLOR = '#3ea6ff'\r\nCOVER_MINI = (127, 127)\r\nCOVER_NORMAL = (255, 255)\r\nPL_COMBO_W = 37\r\nUSER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64; rv:12.0) Gecko/20100101 Firefox/591'\r\nSUN_VALLEY_TCL = 'theme/sun-valley.tcl'\r\n\r\nTKDND_ENABLED = False\r\n\r\nUSING_TAURI_FRONTEND = False\r\nBUNDLE_IDENTIFIER = 'ca.elijahlopez.music-caster'\r\n\r\nclass State:\r\n    \"\"\"\r\n    attributes in State are modified by music_caster.py\r\n    \"\"\"\r\n\r\n    lang = ''\r\n    track_format = '&title - &artist'\r\n    PORT = 2001\r\n    # experimental setting\r\n    using_tcl_theme = False\r\n    theme_sourced = False\r\n    settings = {}\r\n    update_available = False\r\n    installing_update = True\r\n"
  },
  {
    "path": "src/modules/db.py",
    "content": "import sqlite3\nfrom pathlib import Path\nimport appdirs\nfrom meta import BUNDLE_IDENTIFIER\n\nuser_data_dir = Path(appdirs.user_data_dir(roaming=True))\nif not user_data_dir.exists():\n    print('warning: roaming app dir does not exist!')\n    user_data_dir = Path.home()\n\nclass DatabaseConnection:\n    OLD_DATABASE_FILE = Path('music_caster.db').absolute()\n    DEFAULT_DATABASE_FILE = (Path(user_data_dir) / BUNDLE_IDENTIFIER / 'music_caster.db').absolute()\n    DATABASE_FILE = OLD_DATABASE_FILE\n\n    @staticmethod\n    def create_connection():\n        conn = sqlite3.connect(DatabaseConnection.DATABASE_FILE)\n        conn.row_factory = sqlite3.Row\n        return conn\n\n    def __init__(self, db_override=None):\n        if db_override is not None:\n            self.DATABASE_FILE = db_override\n\n    def __enter__(self):\n        self.conn = self.create_connection()\n        return self.conn\n\n    def __exit__(self, exc_type, exc_val, exc_tb):\n        self.conn.close()\n\n\nSCHEMA_2 = \"\"\"\nDROP TABLE IF EXISTS concert_events;\nDROP TABLE IF EXISTS url_metadata;\nCREATE TABLE IF NOT EXISTS url_metadata (\n    src TEXT PRIMARY KEY NOT NULL,\n    title TEXT,\n    artist TEXT,\n    album TEXT,\n    length REAL,\n    url TEXT,\n    audio_url TEXT,\n    ext TEXT,\n    album_cover_url TEXT,\n    expiry REAL,\n    id TEXT,\n    type TEXT,\n    playlist_url TEXT,\n    live BOOLEAN DEFAULT 0 NOT NULL CHECK (live IN (0, 1)),\n    timestamps TEXT\n);\n\"\"\"\n\nSCHEMA_1 = \"\"\"\nCREATE TABLE IF NOT EXISTS file_metadata (\n    file_path TEXT PRIMARY KEY NOT NULL,\n    title TEXT,\n    artist TEXT,\n    album TEXT,\n    length INTEGER UNSIGNED,\n    explicit BOOLEAN DEFAULT 0 NOT NULL CHECK (explicit IN (0, 1)),\n    track_number INTEGER UNSIGNED DEFAULT 1 NOT NULL,\n    sort_key TEXT DEFAULT file_path NOT NULL,\n    time_modified REAL\n);\n\nCREATE TABLE IF NOT EXISTS url_metadata (\n    src TEXT PRIMARY KEY NOT NULL,\n    title TEXT,\n    artist TEXT,\n    album TEXT,\n    length REAL,\n    url TEXT,\n    audio_url TEXT,\n    ext TEXT,\n    art TEXT,\n    expiry REAL,\n    id TEXT,\n    pl_src TEXT,\n    live BOOLEAN DEFAULT 0 NOT NULL CHECK (live IN (0, 1))\n);\n\"\"\"\n\nMIGRATIONS = [SCHEMA_1, SCHEMA_2]\n\n\ndef init_db():\n    RESET_DB = False\n    with DatabaseConnection() as connection:\n        current_version = connection.execute('PRAGMA user_version').fetchone()[0]\n\n        if RESET_DB:\n            connection.executescript(\n                'DROP TABLE IF EXISTS file_metadata;DROP TABLE IF EXISTS url_metadata;DROP TABLE IF EXISTS concert_events;'\n            )\n            connection.executescript('PRAGMA user_version = 0;')\n            current_version = 0\n\n        for i, schema_migration in enumerate(MIGRATIONS):\n            version = i + 1\n            if current_version < version:\n                connection.executescript(schema_migration)\n                connection.execute(f'PRAGMA user_version = {version};')\n        connection.commit()\n"
  },
  {
    "path": "src/modules/error_reporting.py",
    "content": "# TODO: move the following\n# app:report_album_art_buffer_error\n# app:handle_exception\n# utils:log_translation_error\n"
  },
  {
    "path": "src/modules/iph1papi.py",
    "content": "# https://gist.github.com/NyaMisty/6c69c8f5681859b3b9ceb87737fabef7\nimport ctypes\nfrom ctypes import Structure, POINTER, c_char, c_void_p, c_ulong\nfrom ctypes.wintypes import DWORD, UINT, BYTE, BOOL, ULONG, WCHAR, WORD, USHORT, BOOLEAN\nfrom winerror import NO_ERROR, ERROR_INSUFFICIENT_BUFFER\nfrom comtypes import GUID\n\nULONGLONG = ctypes.c_ulonglong\nULONG64 = ctypes.c_uint64\nUCHAR = ctypes.c_ubyte\n\n\n###########################################################################\n#region GetIfTable2\nclass NET_LUID(Structure):\n    _fields_ = [(\"Value\", ULONGLONG)]\n\nNET_IFINDEX = ULONG\nIFTYPE = ULONG\nTUNNEL_TYPE = ctypes.c_int\nNDIS_MEDIUM = ctypes.c_int\nNDIS_PHYSICAL_MEDIUM = ctypes.c_int\nNET_IF_ACCESS_TYPE = ctypes.c_int\nNET_IF_DIRECTION_TYPE = ctypes.c_int\nIF_OPER_STATUS = ctypes.c_int\nNET_IF_ADMIN_STATUS = ctypes.c_int\nNET_IF_MEDIA_CONNECT_STATE = ctypes.c_int\nNET_IF_NETWORK_GUID = GUID\nNET_IF_CONNECTION_TYPE = ctypes.c_int\n\nIF_MAX_STRING_SIZE = 256\nIF_MAX_PHYS_ADDRESS_LENGTH = 32\n\nclass _MIB_IF_ROW2(Structure):\n    pass\n\n_MIB_IF_ROW2._fields_ = [\n    (\"InterfaceLuid\", NET_LUID),\n    (\"InterfaceIndex\", NET_IFINDEX),\n    (\"InterfaceGuid\", GUID),\n    (\"Alias\", WCHAR * (IF_MAX_STRING_SIZE + 1)),\n    (\"Description\", WCHAR * (IF_MAX_STRING_SIZE + 1)),\n    (\"PhysicalAddressLength\", ULONG),\n    (\"PhysicalAddress\", UCHAR * IF_MAX_PHYS_ADDRESS_LENGTH),\n    (\"PermanentPhysicalAddress\", UCHAR * IF_MAX_PHYS_ADDRESS_LENGTH),\n    (\"Mtu\", ULONG),\n    (\"Type\", IFTYPE),\n    (\"TunnelType\", TUNNEL_TYPE),\n    (\"MediaType\", NDIS_MEDIUM),\n    (\"PhysicalMediumType\", NDIS_PHYSICAL_MEDIUM),\n    (\"AccessType\", NET_IF_ACCESS_TYPE),\n    (\"DirectionType\", NET_IF_DIRECTION_TYPE),\n    (\"InterfaceAndOperStatusFlags\", BYTE),\n    (\"OperStatus\", IF_OPER_STATUS),\n    (\"AdminStatus\", NET_IF_ADMIN_STATUS),\n    (\"MediaConnectState\", NET_IF_MEDIA_CONNECT_STATE),\n    (\"NetworkGuid\", NET_IF_NETWORK_GUID),\n    (\"ConnectionType\", NET_IF_CONNECTION_TYPE),\n\n    (\"TransmitLinkSpeed\", ULONG64),\n    (\"ReceiveLinkSpeed\", ULONG64),\n\n    (\"InOctets\", ULONG64),\n    (\"InUcastPkts\", ULONG64),\n    (\"InNUcastPkts\", ULONG64),\n    (\"InDiscards\", ULONG64),\n    (\"InErrors\", ULONG64),\n    (\"InUnknownProtos\", ULONG64),\n    (\"InUcastOctets\", ULONG64),\n    (\"InMulticastOctets\", ULONG64),\n    (\"InBroadcastOctets\", ULONG64),\n    (\"OutOctets\", ULONG64),\n    (\"OutUcastPkts\", ULONG64),\n    (\"OutNUcastPkts\", ULONG64),\n    (\"OutDiscards\", ULONG64),\n    (\"OutErrors\", ULONG64),\n    (\"OutUcastOctets\", ULONG64),\n    (\"OutMulticastOctets\", ULONG64),\n    (\"OutBroadcastOctets\", ULONG64),\n    (\"OutQLen\", ULONG64),\n]\nMIB_IF_ROW2 = _MIB_IF_ROW2\nPMIB_IF_ROW2 = POINTER(_MIB_IF_ROW2)\n\n\nclass _MIB_IF_TABLE2(Structure):\n    pass\n\n\n_MIB_IF_TABLE2._fields_ = [\n    (\"NumEntries\", ULONG),\n    (\"Table\", MIB_IF_ROW2 * 1)\n]\nMIB_IF_TABLE2 = _MIB_IF_TABLE2\nPMIB_IF_TABLE2 = POINTER(_MIB_IF_TABLE2)\n\n\ndef get_if_table2():\n    pIfTable = PMIB_IF_TABLE2()\n    if not ctypes.windll.iphlpapi.GetIfTable2(ctypes.byref(pIfTable)) == NO_ERROR:\n        logging.error('Failed calling GetAdaptersInfo')\n\n    IfTableMib = pIfTable.contents\n    Table = ctypes.cast(ctypes.pointer(IfTableMib.Table), POINTER(MIB_IF_ROW2 * IfTableMib.NumEntries)).contents\n    for i in range(IfTableMib.NumEntries):\n        tempstruc = MIB_IF_ROW2()\n        ctypes.pointer(tempstruc).contents = Table[i]\n        yield tempstruc\n    ctypes.windll.iphlpapi.FreeMibTable(pIfTable)\n\n#endregion\n###############################################################################\n#region GetIpInterfaceTable\nScopeLevelCount        = 16\nADDRESS_FAMILY = USHORT\nNL_ROUTER_DISCOVERY_BEHAVIOR = ctypes.c_int\nNL_LINK_LOCAL_ADDRESS_BEHAVIOR = ctypes.c_int\nNL_INTERFACE_OFFLOAD_ROD = BYTE\n\nclass MIB_IPINTERFACE_ROW(Structure):\n    pass\nMIB_IPINTERFACE_ROW._fields_ = [\n    (\"Family\", ADDRESS_FAMILY),\n    (\"InterfaceLuid\", NET_LUID),\n    (\"InterfaceIndex\", NET_IFINDEX),\n    (\"MaxReassemblySize\", ULONG),\n    (\"InterfaceIdentifier\", ULONG64),\n    (\"MinRouterAdvertisementInterval\", ULONG),\n    (\"MaxRouterAdvertisementInterval\", ULONG),\n    (\"AdvertisingEnabled\", BOOLEAN),\n    (\"ForwardingEnabled\", BOOLEAN),\n    (\"WeakHostSend\", BOOLEAN),\n    (\"WeakHostReceive\", BOOLEAN),\n    (\"UseAutomaticMetric\", BOOLEAN),\n    (\"UseNeighborUnreachabilityDetection\", BOOLEAN),\n    (\"ManagedAddressConfigurationSupported\", BOOLEAN),\n    (\"OtherStatefulConfigurationSupported\", BOOLEAN),\n    (\"AdvertiseDefaultRoute\", BOOLEAN),\n    (\"RouterDiscoveryBehavior\", NL_ROUTER_DISCOVERY_BEHAVIOR),\n    (\"DadTransmits\", ULONG),\n    (\"BaseReachableTime\", ULONG),\n    (\"RetransmitTime\", ULONG),\n    (\"PathMtuDiscoveryTimeout\", ULONG),\n    (\"LinkLocalAddressBehavior\", NL_LINK_LOCAL_ADDRESS_BEHAVIOR),\n    (\"LinkLocalAddressTimeout\", ULONG),\n    (\"ZoneIndices\", ULONG * ScopeLevelCount),\n    (\"SitePrefixLength\", ULONG),\n    (\"Metric\", ULONG),\n    (\"NlMtu\", ULONG),\n    (\"Connected\", BOOLEAN),\n    (\"SupportsWakeUpPatterns\", BOOLEAN),\n    (\"SupportsNeighborDiscovery\", BOOLEAN),\n    (\"SupportsRouterDiscovery\", BOOLEAN),\n    (\"ReachableTime\", ULONG),\n    (\"TransmitOffload\", NL_INTERFACE_OFFLOAD_ROD),\n    (\"ReceiveOffload\", NL_INTERFACE_OFFLOAD_ROD),\n    (\"DisableDefaultRoutes\", BOOLEAN)\n]\n\nclass _MIB_IPINTERFACE_TABLE(Structure):\n    pass\n_MIB_IPINTERFACE_TABLE._fields_ = [\n    (\"NumEntries\", ULONG),\n    (\"Table\", MIB_IPINTERFACE_ROW * 1),\n]\nMIB_IPINTERFACE_TABLE = _MIB_IPINTERFACE_TABLE\nPMIB_IPINTERFACE_TABLE = POINTER(MIB_IPINTERFACE_TABLE)\n\ndef get_ip_interface_table():\n    pIfTable = PMIB_IPINTERFACE_TABLE()\n    if not ctypes.windll.iphlpapi.GetIpInterfaceTable(socket.AF_INET, ctypes.byref(pIfTable)) == NO_ERROR:\n        logging.error('Failed calling GetIpInterfaceTable')\n\n    IfTableMib = pIfTable.contents\n\n    Table = ctypes.cast(ctypes.pointer(IfTableMib.Table), POINTER(MIB_IPINTERFACE_ROW * IfTableMib.NumEntries)).contents\n    for i in range(IfTableMib.NumEntries):\n        tempstruc = MIB_IPINTERFACE_ROW()\n        ctypes.pointer(tempstruc)[0] = Table[i]\n        yield tempstruc\n    ctypes.windll.iphlpapi.FreeMibTable(pIfTable)\n#endregion\n###############################################################################\n#region GetAdaptersInfo\n\nMAX_ADAPTER_NAME_LENGTH = 256\nMAX_ADAPTER_DESCRIPTION_LENGTH = 128\nMAX_ADAPTER_LENGTH = 8\n\nMIB_IF_TYPE_ETHERNET = 6\nMIB_IF_TYPE_LOOPBACK = 28\nIF_TYPE_IEEE80211 = 71\n\n\nclass IP_ADDRESS_STRING(Structure):\n    _fields_ = [\n        (\"String\", c_char * 16),\n    ]\n\n\nclass IP_MASK_STRING(Structure):\n    _fields_ = [\n        (\"String\", c_char * 16),\n    ]\n\n\nclass IP_ADDR_STRING(Structure):\n    pass\n\n\nIP_ADDR_STRING._fields_ = [\n    (\"Next\", POINTER(IP_ADDR_STRING)),\n    (\"IpAddress\", IP_ADDRESS_STRING),\n    (\"IpMask\", IP_MASK_STRING),\n    (\"Context\", DWORD),\n]\n\n\nclass IP_ADAPTER_INFO(Structure):\n    pass\n\n\nIP_ADAPTER_INFO._fields_ = [\n    (\"Next\", POINTER(IP_ADAPTER_INFO)),\n    (\"ComboIndex\", DWORD),\n    (\"AdapterName\", c_char * (MAX_ADAPTER_NAME_LENGTH + 4)),\n    (\"Description\", c_char * (MAX_ADAPTER_DESCRIPTION_LENGTH + 4)),\n    (\"AddressLength\", UINT),\n    (\"Address\", BYTE * MAX_ADAPTER_LENGTH),\n    (\"Index\", DWORD),\n    (\"Type\", UINT),\n    (\"DhcpEnabled\", UINT),\n    (\"CurrentIpAddress\", c_void_p),  # Not used\n    (\"IpAddressList\", IP_ADDR_STRING),\n    (\"GatewayList\", IP_ADDR_STRING),\n    (\"DhcpServer\", IP_ADDR_STRING),\n    (\"HaveWins\", BOOL),\n    (\"PrimaryWinsServer\", IP_ADDR_STRING),\n    (\"SecondaryWinsServer\", IP_ADDR_STRING),\n    (\"LeaseObtained\", c_ulong),\n    (\"LeaseExpires\", c_ulong),\n\n]\n###########################################################################\n# The GetAdaptersInfo function retrieves adapter information for the local computer.\n#\n# On Windows XP and later:  Use the GetAdaptersAddresses function instead of GetAdaptersInfo.\n#\n# DWORD GetAdaptersInfo(\n#   _Out_   PIP_ADAPTER_INFO pAdapterInfo,\n#   _Inout_ PULONG           pOutBufLen\n# );\n\ndef get_adapters_info():\n    OutBufLen = DWORD(0)\n\n    ctypes.windll.iphlpapi.GetAdaptersInfo(None, ctypes.byref(OutBufLen))\n\n    AdapterInfo = ctypes.create_string_buffer(OutBufLen.value)\n    pAdapterInfo = ctypes.cast(AdapterInfo, POINTER(IP_ADAPTER_INFO))\n\n    if not ctypes.windll.iphlpapi.GetAdaptersInfo(ctypes.byref(AdapterInfo), ctypes.byref(OutBufLen)) == NO_ERROR:\n        logging.error('Failed calling GetAdaptersInfo')\n        return\n\n    while pAdapterInfo:\n        yield pAdapterInfo.contents\n        pAdapterInfo = pAdapterInfo.contents.Next\n\n#endregion\n###########################################################################\n"
  },
  {
    "path": "src/modules/playing_status.py",
    "content": "import time\n\n\nclass PlayingStatus:\n    __slots__ = (\n        'NOT_PLAYING',\n        'PLAYING',\n        'PAUSED',\n        'BUSY',\n        'state',\n        'timer',\n        'track_position',\n        'track_start',\n        'track_end',\n        'track_length',\n        'device_is_local',\n    )\n\n    def __init__(self):\n        self.NOT_PLAYING = 0\n        self.PLAYING = 1\n        self.PAUSED = 2\n        self.BUSY = {self.PLAYING, self.PAUSED}\n        self.state = self.NOT_PLAYING\n\n    # @property\n    def busy(self):\n        return self.state in self.BUSY\n\n    # @property\n    def stopped(self):\n        return self.state == self.NOT_PLAYING\n\n    # @property\n    def playing(self):\n        return self.state == self.PLAYING\n\n    # @property\n    def paused(self):\n        return self.state == self.PAUSED\n\n    def stop(self):\n        self.state = self.NOT_PLAYING\n\n    def play(self, device_is_local: bool = True):\n        self.state = self.PLAYING\n\n    def play_uri(self, position, track_length, device_is_local: bool):\n        self.track_position = position\n        self.track_length = track_length\n        self.device_is_local = device_is_local\n        self.track_start = (time.monotonic() if device_is_local else time.time()) - position\n        if self.track_length is not None:\n            self.track_end = self.track_start + self.track_length\n\n    def pause(self):\n        self.state = self.PAUSED\n\n    def play_system_audio(self):\n        self.track_length = None\n        self.track_position = 0\n        self.track_start = time.monotonic()\n\n    def __repr__(self):\n        return ['NOT PLAYING', 'PLAYING', 'PAUSED'][self.state]\n\n    def __eq__(self, other):\n        if isinstance(other, int):\n            return self.state == other\n        if not isinstance(other, PlayingStatus):\n            return str(other) == str(self)\n        return other.state == self.state\n"
  },
  {
    "path": "src/modules/resolution_switcher.py",
    "content": "import ctypes\nimport multiprocessing as mp\nimport platform\nfrom contextlib import suppress\nfrom functools import lru_cache\n\nimport pystray\nfrom PIL import Image\nfrom pystray import MenuItem as item\n\n# for cross platform https://stackoverflow.com/a/20996948/7732434?\n\nCHANGE_DPI_SCALE = True\nMENU_SHOW_HEIGHT = False\n\n\nif platform.system() == 'Windows':\n    from ctypes import wintypes\n\n    import pywintypes\n    import win32api\n    import win32con\n\n    class SYSTEM_POWER_STATUS(ctypes.Structure):\n        _fields_ = [\n            ('ACLineStatus', ctypes.c_ubyte),\n            ('BatteryFlag', ctypes.c_ubyte),\n            ('BatteryLifePercent', ctypes.c_ubyte),\n            ('SystemStatusFlag', ctypes.c_ubyte),\n            ('BatteryLifeTime', wintypes.DWORD),\n            ('BatteryFullLifeTime', wintypes.DWORD),\n        ]\n\n    SYSTEM_POWER_STATUS_P = ctypes.POINTER(SYSTEM_POWER_STATUS)\n    GetSystemPowerStatus = ctypes.windll.kernel32.GetSystemPowerStatus\n    GetSystemPowerStatus.argtypes = [SYSTEM_POWER_STATUS_P]\n    GetSystemPowerStatus.restype = wintypes.BOOL\n    powerStatus = SYSTEM_POWER_STATUS()\n\n\ndef is_plugged_in(throw_error=True):\n    \"\"\"\n    Returns True if laptop or PC is plugged in\n    throws RuntimeError\n    \"\"\"\n    if platform.system() == 'Windows':\n        if not GetSystemPowerStatus(ctypes.pointer(powerStatus)):\n            if throw_error:\n                raise RuntimeError('could not get power status')\n            return False\n        return powerStatus.ACLineStatus == 1\n    # TODO: Linux implementation\n    return True\n\n\ndef get_aspect_ratio(width, height):\n    return round(width / height, 2)\n\n\ndef get_current_res(w=None, h=None):\n    with suppress(Exception):\n        user32 = ctypes.windll.user32\n        user32.SetProcessDPIAware()\n        res = (user32.GetSystemMetrics(0), user32.GetSystemMetrics(1))\n        if w is not None:\n            w.value = res[0]\n        if h is not None:\n            h.value = res[1]\n        return res\n    # TODO: Linux\n\n\n@lru_cache(maxsize=1)\ndef get_initial_res():\n    w = mp.Value(ctypes.c_int, 0)\n    h = mp.Value(ctypes.c_int, 0)\n    # use setProcessDPIAware in only child process\n    p = mp.Process(target=get_current_res, args=[w, h])\n    p.start()\n    p.join()\n    return w.value, h.value\n\n\n@lru_cache(maxsize=1)\ndef get_initial_dpi_scale():\n    if platform.system() == 'Windows':\n        transformed_res = (win32api.GetSystemMetrics(0), win32api.GetSystemMetrics(1))\n        raw_res = get_initial_res()\n        return raw_res[0] / transformed_res[0]  # 125% is 1.25\n    # TODO: Linux\n    return 1\n\n\n@lru_cache(maxsize=1)\ndef get_all_refresh_rates():\n    i = 0\n    refresh_rates = set()\n    with suppress(Exception):\n        if platform.system() == 'Windows':\n            while True:\n                ds = win32api.EnumDisplaySettings(None, i)\n                refresh_rates.add(ds.DisplayFrequency)\n                i += 1\n    return refresh_rates\n\n\n@lru_cache(maxsize=1)\ndef get_all_resolutions():\n    i = 0\n    resolutions = []\n    seen = set()\n    max_width = 0\n    max_height = 0\n    with suppress(Exception):\n        if platform.system() == 'Windows':\n            while True:\n                ds = win32api.EnumDisplaySettings(None, i)\n                res = (ds.PelsWidth, ds.PelsHeight)\n                if res not in seen:\n                    seen.add(res)\n                    if ds.PelsWidth > max_width:\n                        max_width = ds.PelsWidth\n                    if ds.PelsHeight > max_height:\n                        max_height = ds.PelsHeight\n                    resolutions.append((ds.PelsWidth, ds.PelsHeight))\n                i += 1\n    try:\n        aspect_ratio = get_aspect_ratio(max_width, max_height)\n    except ZeroDivisionError:\n        # no resolutions found\n        return {}\n    # return resolutions with same aspect ratio as max resolution\n    lst = sorted(\n        filter(lambda res: get_aspect_ratio(*res) == aspect_ratio, resolutions)\n    )\n    return {\n        fmt_res(*res): {'w': res[0], 'h': res[1], 'dpi_scale': calc_dpi_scale(*res)}\n        for res in lst\n    }\n\n\ndpi_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]\ndpi_vals_map = {dpi: i for i, dpi in enumerate(dpi_vals)}\n\n\ndef get_recommended_dpi_idx():\n    dpi = ctypes.c_int(0)\n    if ctypes.windll.user32.SystemParametersInfoA(0x009E, 0, ctypes.byref(dpi), 1) != 0:\n        return -1 * dpi.value\n    raise IndexError\n\n\ndef calc_dpi_scale(new_w, _):\n    # assume constant aspect ratios\n    dpi_scale = get_initial_dpi_scale()\n    initial_w = get_initial_res()[0]\n    res_change = 1 - min(new_w, initial_w) / max(new_w, initial_w)\n    dpi_scale += res_change if new_w > initial_w else -res_change\n    return dpi_scale\n\n\ndef set_resolution(width: int, height: int, dpi_scale: int, refresh_rate: int = None):\n    if platform.system() == 'Windows':\n        # adapted from Peter Wood: https://stackoverflow.com/a/54262365\n        devmode = pywintypes.DEVMODEType()\n        devmode.PelsWidth = width\n        devmode.PelsHeight = height\n        devmode.Fields = win32con.DM_PELSWIDTH | win32con.DM_PELSHEIGHT\n        if refresh_rate:\n            devmode.DisplayFrequency = refresh_rate\n            devmode.Fields |= win32con.DM_DISPLAYFREQUENCY\n\n        win32api.ChangeDisplaySettings(devmode, 0)\n\n        if CHANGE_DPI_SCALE:\n            # https://stackoverflow.com/a/62916586/7732434\n            # dpi_scale = calc_dpi_scale(width, height)\n            with suppress(KeyError, IndexError):\n                ref_idx = get_recommended_dpi_idx()\n                # dpi of 1.5 -> 2 - 1 = rel index of 1\n                # dpi of 1 -> 0 - 1 = rel index of -1\n                rel_idx = dpi_vals_map[dpi_scale] - ref_idx\n                ctypes.windll.user32.SystemParametersInfoA(0x009F, rel_idx, 0, 1)\n\n\ndef set_res_curry(width, height, dpi_scale):\n    # ensure correct values are used when lambda executes\n    return lambda: set_resolution(width, height, dpi_scale)\n\n\ndef fmt_res(width, height, show_width=False):\n    # formats either W x H or Wp\n    return f'{width} x {height}' if show_width else f'{height}p'\n\n\ndef on_exit():\n    icon.visible = False\n    icon.stop()\n\n\nif __name__ == '__main__':\n    mp.freeze_support()\n    # save cache\n    get_initial_dpi_scale()\n    image = Image.open('icon.png')\n    menu = [\n        item(k, set_res_curry(v['w'], v['h'], v['dpi_scale']))\n        for k, v in get_all_resolutions().items()\n    ]\n    menu.append(item('Exit', on_exit))\n    icon = pystray.Icon('Resolution Switcher', image, 'Resolution Switcher', menu)\n    icon.run()\n"
  },
  {
    "path": "src/modules/url_metadata.py",
    "content": "import time\nfrom pathlib import Path\nfrom typing import Self\nfrom audio_player import AudioPlayer\nimport requests\nimport hashlib\nimport appdirs\nfrom base64 import b64encode, b64decode\nfrom utils import custom_art\nimport ujson as json\nfrom utils import get_yt_id\nfrom meta import BUNDLE_IDENTIFIER\nfrom PIL import Image\nfrom io import BytesIO\n\ndef tbr_audio_key(item):\n    return (item.get('tbr', 0) or 0) * (item.get('vcodec', 'none') == 'none')\n\ndef tbr_video_key(item):\n    return (item.get('height', 0) or 0), (item.get('tbr', 0) or 0)\n\ndef ydl_get_metadata(item, duration_helper=True):\n    if 'formats' in item:\n        audio_url = max(item['formats'], key=tbr_audio_key)['url']\n        try:\n            formats = [_f for _f in item['formats'] if _f.get('acodec') != 'none' and _f.get('vcodec') != 'none']\n            selected_format = max(formats, key=tbr_video_key)\n            ext, _url = selected_format['ext'], selected_format['url']\n        except ValueError:\n            # url is audio only\n            ext, _url = item['ext'] if item['ext'] != 'unknown_video' else item['format_id'], audio_url\n    else:\n        ext = item['ext']\n        _url = audio_url = item['url']\n    if item.get('is_live', False) and 'duration' not in item and duration_helper:\n        helper_ap = AudioPlayer()\n        helper_ap.play(audio_url, False)\n        item['duration'] = helper_ap.get_length()\n    expiry_time = time.time() + max(1800, item.get('duration', 0))\n    length = item['duration'] if item.get('duration', 0) else None\n    src_url = item['webpage_url']\n    split_url = src_url.rsplit('/', 2)\n    backup_artist = split_url[-1] if split_url[-1] != '' else split_url[-2]\n    artist = item.get('artist', item.get('uploader', backup_artist))\n    album = item.get('album', item.get('playlist'))\n    if album is None:\n        album = item['extractor_key']\n    album_cover_url = item.get('thumbnail')\n    url_type = item.get('extractor_key', 'unknown')\n\n    return URLMetadata(\n        src=src_url,\n        url=_url,\n        title=item.get('track', item['title']),\n        artist=artist,\n        album=album,\n        live=item.get('is_live', False),\n        length=length,\n        audio_url=audio_url,\n        ext=ext,\n        url_type=url_type,\n        expiry=expiry_time,\n        id=item['id'],\n        album_cover_url=album_cover_url,\n    )\n\n\nclass URLMetadata:\n    __slots__ = ('title', 'artist', 'album', 'length', 'src', 'url', 'audio_url', 'ext', 'album_cover_url',\n                 'expiry', 'id', 'playlist_url', 'type', 'live', 'timestamps')\n\n    DB_COLUMNS = {'title', 'artist', 'album', 'length', 'url', 'audio_url', 'ext', 'art', 'expiry', 'id', 'pl_src', 'live', 'type'}\n    MAPPED_FIELDS = {'url': 'src', 'ytid': 'id', 'is_live': 'live', 'art': 'album_cover_url'}\n    FIELDS_TO_IGNORE = set()\n    ALBUM_COVER_CACHE_DIR = Path(appdirs.user_cache_dir()) / BUNDLE_IDENTIFIER / 'Cache' / 'Album Covers'\n\n    def __init__(self, src: str, url_type: str, title: str, artist: str, album: str, live: bool | None = None, length: float | None = None,\n                 url: str | None = None, audio_url: str | None  = None, ext: str | None = None, expiry=None, id=None, album_cover_url=None,\n                 timestamps: None | list = None, playlist_url=None):\n        self.src = src\n        # for displays\n        self.url = url\n        # for speakers\n        self.audio_url = audio_url\n        self.title = title\n        self.artist = artist\n        self.album = album\n        self.length = length\n        self.ext = ext\n        self.album_cover_url = album_cover_url\n        self.expiry = expiry\n        self.id = id\n        self.playlist_url = playlist_url\n        self.type = url_type.lower()\n        self.live = live\n        self.timestamps = [] if timestamps is None else timestamps\n\n    def __hash__(self) -> int:\n        return int(self.hash(), 16)\n\n    def __getitem__(self, key):\n        if key == 'art_data':\n            return self.get_cover_image()\n        if key == 'ytid' and self.type == 'youtube':\n            return self.id\n        attr = self.MAPPED_FIELDS.get(key, key)\n        if attr not in self.__slots__:\n            raise KeyError(key)\n        return getattr(self, attr, None)\n\n    def __setitem__(self, key, value):\n        if key == 'art_data':\n            self.image_cache_path.parent.mkdir(parents=True, exist_ok=True)\n            with open(self.image_cache_path, 'wb') as f:\n                f.write(b64decode(value))\n            return\n        attr = self.MAPPED_FIELDS.get(key, key)\n        if attr not in self.__slots__:\n            raise KeyError(key)\n        setattr(self, attr, value)\n\n    def __delitem__(self, key):\n        attr = self.MAPPED_FIELDS.get(key, key)\n        if attr not in self.__slots__:\n            raise KeyError(key)\n        setattr(self, attr, None)\n\n    def __iter__(self):\n        return iter(self.__slots__)\n\n    def __len__(self):\n        return len(self.__slots__)\n\n    def keys(self):\n        return self.__slots__\n\n    def values(self):\n        return (getattr(self, attr, None) for attr in self.__slots__)\n\n    def items(self):\n        return ((attr, getattr(self, attr, None)) for attr in self.__slots__)\n\n    def get(self, key, default=None):\n        if key not in self.__slots__:\n            return default\n        return getattr(self, key, default)\n\n    def save_to_db(self, cur):\n        \"\"\"Return SQL statement and values for database insertion.\"\"\"\n        sql = '''INSERT OR REPLACE INTO url_metadata\n                 (src, title, artist, album, length, url, audio_url, ext, album_cover_url, expiry, id, type, playlist_url, live, timestamps)\n                 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'''\n\n        values = (\n            self.src,\n            self.title,\n            self.artist,\n            self.album,\n            self.length,\n            self.url,\n            self.audio_url,\n            self.ext,\n            self.album_cover_url,\n            self.expiry,\n            self.id,\n            self.type,\n            self.playlist_url,\n            int(self.live) if isinstance(self.live, bool) else self.live,\n            json.dumps(self.timestamps, escape_forward_slashes=False)\n        )\n        cur.execute(sql, values)\n\n    @classmethod\n    def from_db(cls, conn, url) -> Self | None:\n        cur = conn.cursor()\n        ytid = get_yt_id(url)\n        if ytid is not None and not ytid.startswith('PL'):\n            url = f\"https://www.youtube.com/watch?v={ytid}\"\n        result = cur.execute('SELECT * FROM url_metadata WHERE src = ?', (url,)).fetchone()\n        if not result:\n            return None\n\n        row = dict(result)\n        return cls(\n            url=row['url'],\n            title=row['title'],\n            artist=row['artist'],\n            album=row['album'],\n            live=bool(row['live']),\n            length=row['length'],\n            audio_url=row['audio_url'],\n            ext=row['ext'],\n            url_type=row['type'],\n            expiry=row['expiry'],\n            id=row['id'],\n            album_cover_url=row.get('art'),\n            playlist_url=row.get('pl_src'),\n            src=url,\n            timestamps=json.loads(row.get('timestamps', '[]'))\n        )\n\n    @classmethod\n    def from_dict(cls, data):\n        \"\"\"Create URLMetadata instance from dictionary.\"\"\"\n        metadata = data.copy()\n        if 'live' in metadata:\n            metadata['is_live'] = bool(metadata.pop('live'))\n        return cls(**metadata)\n\n    def hash(self) -> str:\n        return hashlib.md5(self.src.encode('utf-8')).hexdigest()\n\n    @property\n    def image_cache_path(self):\n        return self.ALBUM_COVER_CACHE_DIR / f'{self.hash()}.jpg'\n\n    @property\n    def is_expired(self):\n        if self.expiry is None:\n            return False\n        return self.expiry < time.time()\n\n    def get_cover_image(self) -> bytes:\n        if not self.image_cache_path.exists():\n            if not self.album_cover_url:\n                return custom_art('URL')\n            Image.open(BytesIO(requests.get(self.album_cover_url).content)).convert('RGB').save(self.image_cache_path, 'JPEG', quality=95)\n        with open(self.image_cache_path, 'rb') as f:\n            return b64encode(f.read())\n\n\n# only run once to reduce OS calls\nURLMetadata.ALBUM_COVER_CACHE_DIR.mkdir(parents=True, exist_ok=True)\n"
  },
  {
    "path": "src/modules/win32_media_controls.py",
    "content": "# https://learn.microsoft.com/windows/uwp/audio-video-camera/system-media-transport-controls\n# https://github.com/microsoft/WindowsAppSDK/issues/127\nimport enum\nimport platform\nfrom datetime import timedelta\nfrom typing import cast\n\n\nclass SystemMediaTransportControlsButton(enum.IntEnum):\n    PLAY = 0\n    PAUSE = 1\n    STOP = 2\n    RECORD = 3\n    FAST_FORWARD = 4\n    REWIND = 5\n    NEXT = 6\n    PREVIOUS = 7\n    CHANNEL_UP = 8\n    CHANNEL_DOWN = 9\n\n\nclass SystemMediaControls:\n    def __init__(self, on_event):\n        if platform.system() != 'Windows':\n            return\n        import winrt.windows.media as media\n        import winrt.windows.media.playback as playback\n\n        self.media_player = playback.MediaPlayer()\n        self.system_media_transport_controls = cast(media.SystemMediaTransportControls, self.media_player.system_media_transport_controls)\n        assert self.system_media_transport_controls is not None\n        assert self.media_player.command_manager is not None\n        self.media_player.command_manager.is_enabled = False\n        self.system_media_transport_controls.is_play_enabled = True\n        self.system_media_transport_controls.is_pause_enabled = True\n        self.system_media_transport_controls.is_next_enabled = True\n        self.system_media_transport_controls.is_previous_enabled = True\n        self.on_event = on_event\n        self.system_media_transport_controls.add_button_pressed(self._on_btn_press)\n\n    if platform.system() == 'Windows':\n        import winrt.windows.media as media\n        def _on_btn_press(self, sender, args: media.SystemMediaTransportControlsButtonPressedEventArgs):\n            self.on_event(args.button)\n\n    def set_source(self, source):\n        if platform.system() == 'Windows':\n            from winrt.windows.foundation import Uri\n            if source.startswith('htt'):\n                self.media_player.set_uri_source(Uri(source))\n            else:\n                self.media_player.set_uri_source(Uri(f'file://{source}'))\n\n    def set_playing(self):\n        if platform.system() == 'Windows':\n            import winrt.windows.media as media\n            self.system_media_transport_controls.playback_status = media.MediaPlaybackStatus.PLAYING\n\n    def set_paused(self):\n        if platform.system() == 'Windows':\n            import winrt.windows.media as media\n            self.system_media_transport_controls.playback_status = media.MediaPlaybackStatus.PAUSED\n\n    def set_stopped(self):\n        self.set_closed()\n\n    def set_closed(self):\n        if platform.system() == 'Windows':\n            import winrt.windows.media as media\n            self.system_media_transport_controls.playback_status = media.MediaPlaybackStatus.CLOSED\n\n    def set_metadata(self, title, artist, album, thumb_uri: str):\n        if platform.system() == 'Windows':\n            import winrt.windows.media as media\n            from winrt.windows.foundation import Uri\n            _updater = cast(media.SystemMediaTransportControlsDisplayUpdater, self.system_media_transport_controls.display_updater)\n            _updater.type = media.MediaPlaybackType.MUSIC\n            _updater.music_properties.artist = artist\n            _updater.music_properties.title = title\n            if album is not None:\n                _updater.music_properties.album_title = album\n            import winrt.windows.storage.streams as streams\n            assert isinstance(thumb_uri, str)\n            assert thumb_uri.count('://', 1)\n            uri = Uri(thumb_uri)\n            _updater.thumbnail = streams.RandomAccessStreamReference.create_from_uri(uri)\n            _updater.update()\n\n    def update_time(self):\n        # TODO: add arguments\n        if platform.system() == 'windows':\n            import winrt.windows.media as media\n            timeline_properties = media.SystemMediaTransportControlsTimelineProperties()\n            timeline_properties.start_time = timedelta(0)\n            timeline_properties.min_seek_time = timedelta(0)\n            timeline_properties.position = timedelta(0)\n            timeline_properties.max_seek_time = timedelta(0)\n            timeline_properties.end_time = timedelta(100)\n            self.system_media_transport_controls.update_timeline_properties(timeline_properties)\n"
  },
  {
    "path": "src/music_caster.bat",
    "content": "pythonw \"music_caster.py\" -m"
  },
  {
    "path": "src/music_caster.py",
    "content": "from gui.views import GuiContext\nfrom meta import (\n    State,\n    SUN_VALLEY_TCL,\n    PID_FILENAME,\n    LOCK_FILENAME,\n    VERSION,\n    UNINSTALLER,\n    DEFAULT_THEME,\n    EMAIL,\n    FONT_NORMAL,\n    WAIT_TIMEOUT,\n    COVER_MINI,\n    COVER_NORMAL,\n    UPDATE_MESSAGE,\n    IMPORTANT_INFORMATION,\n    AUDIO_EXTS,\n    AUDIO_FILE_TYPES,\n    IMG_FILE_TYPES,\n    SUBMIT_EVENTS,\n    TOGGLEABLE_SETTINGS,\n    TKDND_ENABLED,\n    USING_TAURI_FRONTEND,\n    BUNDLE_IDENTIFIER\n)\nimport time\n\nstart_time = time.monotonic()\nfrom contextlib import suppress\nfrom itertools import islice, chain\nimport io\nimport multiprocessing as mp\nimport os\nimport platform\nimport threading\nfrom subprocess import Popen, PIPE, DEVNULL # noqa\nimport re\nimport sys\nfrom shutil import copy2\nfrom shared import is_already_running\n\n\ndef create_pid_file(port=None):\n    with open(PID_FILENAME, 'w', encoding='utf-8') as f:\n        f.write(str(os.getpid()))\n        if port is not None:\n            f.write(f'\\n{port}')\n\n\ndef parse_pid_file():\n    with suppress(FileNotFoundError):\n        with open(PID_FILENAME, encoding='utf-8') as f:\n            pid = int(f.readline().strip())\n            try:\n                port = int(f.readline().strip())\n            except ValueError:\n                port = 2001\n            return pid, port\n    return None, 2001\n\n\ndef ensure_single_instance(debugging=False):\n    file = open(LOCK_FILENAME, 'w+', encoding='utf-8')\n    if USING_TAURI_FRONTEND:\n        return file\n    # no old running instances found, try locking file\n    try:\n        # exclusively locked\n        portalocker.lock(file, portalocker.LockFlags.EXCLUSIVE | portalocker.LockFlags.NON_BLOCKING)\n        create_pid_file()\n        if debugging:\n            print(f'Locked {LOCK_FILENAME} pid = {os.getpid()}')\n    except LockException as e:\n        # another instance is probably running\n        # wait a bit for pid to be written to file\n        time.sleep(0.1)\n        pid, port = parse_pid_file()\n        look_for = 'Music Caster' if IS_FROZEN else Path(sys.executable).name\n        # double check if it's already running\n        # if more than one instance, there's definitely >3 processes\n        threshold = 3 if pid is None else 0\n        if is_already_running(threshold=threshold, look_for=look_for, pid=pid):\n            if debugging:\n                print('not exiting because we are DEBUGGING')\n            else:\n                try:\n                    activate_instance(port=port, default_timeout=5)\n                except Exception as activation_e:\n                    app_log.error('Failed to activate existing instance', exc_info=True)\n                    handle_exception(activation_e, restart_program=False)\n                sys.exit()\n        else:\n            app_log.error('Instance was not found. Is the lock broken?', exc_info=True)\n            handle_exception(e, restart_program=False)\n    return file\n\n\nif __name__ == '__main__':\n    mp.freeze_support()\n    import argparse\n    from inspect import currentframe\n    from pathlib import Path\n    from urllib.request import pathname2url, urlopen, Request\n    from urllib.error import URLError\n\n    import appdirs\n    import portalocker\n    from portalocker.exceptions import LockException\n    import ujson as json\n\n    from sys_tray import system_tray\n\n    parser = argparse.ArgumentParser(description='Music Caster')\n    parser.add_argument('--debug', '-d', default=False, action='store_true', help='allows more than one music caster instance and no telemetry')\n    parser.add_argument('--start-playing', default=False, action='store_true', help='resume or shuffle play all')\n    parser.add_argument('--queue', '-q', default=False, action='store_true', help='uris are queued rather than immediately played')\n    parser.add_argument('--playnext', '-n', default=False, action='store_true', help='paths are added to \"next up\"')\n    parser.add_argument('--urlprotocol', default=False, action='store_true', help='launched using uri protocol')\n    parser.add_argument('--update', '-u', default=False, action='store_true', help='allow music caster to update when other CLI args are provided')\n    parser.add_argument('--nupdate', default=False, action='store_true', help='start without auto-update')\n    parser.add_argument('--exit', '-x', default=False, action='store_true',\n                        help='exits any existing instance (including self)')\n    parser.add_argument('--minimized', '-m', default=False, action='store_true', help='start minimized to tray')\n    parser.add_argument('--version', '-v', default=False, action='store_true', help='returns the version')\n    parser.add_argument('uris', nargs='*', default=[], help='list of files/dirs/playlists/urls/\"System Audio\" to play/queue')\n    parser.add_argument('--position', default=0, help='position to start at if resume_playing')\n    parser.add_argument('--shell', default=False, action='store_true', help='if from shell/explorer')\n    parser.add_argument('--device', action='store', help='select device to use (cast UUID or \"local\")', default=None)\n    parser.add_argument('--db-path', action='store', help='path to sqlite database file', default=None)\n    parser.add_argument('--settings-path', action='store', help='path to settings.json file', default=None)\n    # freeze_support() adds the following\n    parser.add_argument('--multiprocessing-fork', default=False, action='store_true', help=argparse.SUPPRESS)\n    args = parser.parse_args()\n    # if from url protocol, re-parse arguments\n    if args.urlprotocol:\n        new_args = args.uris[0].replace('music-caster://', '', 1).replace('music-caster:', '').replace('music-caster', '')\n        if new_args:\n            new_args = new_args.split(';')\n        args = parser.parse_args(new_args)\n    if args.version:\n        print(VERSION)\n        sys.exit()\n    DEBUG = args.debug\n    print(f'DEBUG: {DEBUG}')\n    IS_FROZEN = getattr(sys, 'frozen', False)\n    working_dir = Path(sys.argv[0]).absolute().parent\n    os.chdir(working_dir)\n    OLD_SETTINGS_FILE = Path('settings.json').absolute()\n    DEFAULT_SETTINGS_FILE = Path(appdirs.user_data_dir(roaming=True)) / BUNDLE_IDENTIFIER / 'settings.json'\n    SETTINGS_FILE = OLD_SETTINGS_FILE\n    if IS_FROZEN:\n        SETTINGS_FILE = Path(args.settings_path).absolute() if args.settings_path and USING_TAURI_FRONTEND else DEFAULT_SETTINGS_FILE\n        if OLD_SETTINGS_FILE.exists():\n            SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)\n            try:\n                os.rename(OLD_SETTINGS_FILE, SETTINGS_FILE)\n            except OSError as e:\n                if e.winerror == 17:\n                    copy2(OLD_SETTINGS_FILE, SETTINGS_FILE)\n                    os.remove(OLD_SETTINGS_FILE)\n                else:\n                    raise e\n\n\n    PHANTOMJS_DIR = Path('phantomjs')\n    # c:\\Users\\maste\\AppData\\Local\\Programs\\Music Caster\\settings.json\n\n    def json_dumps(d):\n        return json.dumps(d).encode('utf-8')\n\n    def activate_instance(port=2001, default_timeout=0.5, to_port=2004):\n        # by default activates if running already\n        response, local_ipv6, local_ipv4 = '', 'http://[::1]:', 'http://127.0.0.1:'\n        try:\n            with open(SETTINGS_FILE, encoding='utf-8') as json_file:\n                api_key = json.load(json_file).get('api_key', '')\n        except (FileNotFoundError, ValueError):\n            api_key = ''\n        headers = {\n            'Content-Type': 'application/json',\n            'Accept': 'application/json',\n        }\n        data = {'api_key': api_key}\n        while port < to_port and response == '':\n            for localhost in (local_ipv4, local_ipv6):\n                timeout = default_timeout\n                with suppress(URLError):\n                    if args.exit:  # --exit argument\n                        req = Request(f'{localhost}{port}/exit/', data=json_dumps(data))\n                    elif args.uris:  # MC was supplied at least one path to a folder/file\n                        uri_data = json_dumps({**data, 'uris': args.uris, 'queue': args.queue, 'play_next': args.playnext, 'device': args.device})\n                        req = Request(f'{localhost}{port}/play/', data=uri_data, headers=headers)\n                        timeout += 0.5\n                    else:  # neither --exit nor paths was supplied\n                        req = Request(f'{localhost}{port}/action/activate', data=json_dumps(data))\n                    response = urlopen(req, timeout=timeout).read()\n                if response:\n                    return True\n            port += 1\n        return False\n\n    lock_file = ensure_single_instance(debugging=DEBUG)\n    daemon_commands, tray_process_queue = mp.Queue(), mp.Queue()\n    if args.exit:\n        sys.exit()\n    import asyncio\n    from base64 import b64encode, b64decode\n    import concurrent.futures\n    from collections import deque\n    from collections.abc import Iterable\n    import ctypes\n    import encodings.idna  # noqa # DO NOT REMOVE\n    from functools import cmp_to_key\n    import glob\n    import hashlib\n    from copy import deepcopy\n    from datetime import datetime, timedelta\n    import errno\n    from functools import lru_cache\n    import logging\n    from logging.handlers import RotatingFileHandler\n    from math import log10, floor\n    import pprint\n    from random import shuffle\n    from shutil import copyfileobj, rmtree\n    from queue import Queue\n    import secrets\n    import socket\n    from threading import Thread\n    import tkinter\n    from tkinter import filedialog as fd\n    from tkinter import TclError\n    import traceback\n    import urllib.parse\n    from urllib.parse import urlsplit\n    from uuid import UUID\n    import zipfile\n\n    from b64_images import PAUSE_BUTTON_IMG, PLAY_BUTTON_IMG, SHUFFLE_OFF, SHUFFLE_ON, VOLUME_IMG, VOLUME_MUTED_IMG, WINDOW_ICON, DEFAULT_ART\n    from audio_player import AudioPlayer\n    from modules.win32_media_controls import SystemMediaTransportControlsButton # SystemMediaControls\n    from mutagen._util import MutagenError\n    from modules.playing_status import PlayingStatus\n    from modules.url_metadata import ydl_get_metadata, URLMetadata\n    from utils import (\n        get_first_artist,\n        t,\n        SystemAudioRecorder,\n        startfile,\n        custom_art,\n        get_album_art,\n        get_lan_ip,\n        get_metadata,\n        Unknown,\n        get_file_name,\n        parse_m3u,\n        valid_audio_file,\n        valid_color_code,\n        get_mac,\n        Device,\n        natural_key_file,\n        better_shuffle,\n        truncate_title,\n        resize_img,\n        repeat_img_tooltip,\n        DiscordPresence,\n        get_ipv4,\n        ydl_extract_info,\n        parse_qs,\n        urlparse,\n        get_yt_id,\n        get_yt_urls,\n        install_phantomjs,\n        add_to_path,\n        open_in_browser,\n        get_video_timestamps,\n        get_deezer_tracks,\n        get_ipv6,\n        cmd_exists,\n        add_reg_handlers,\n        get_latest_release,\n        rm_old_startup_shortcuts,\n        start_on_login_win32,\n        create_progress_bar_texts,\n        set_metadata,\n        get_spotify_headers,\n        get_cut_text,\n        export_playlist,\n        fix_path,\n        drop_target_register,\n        dnd_bind,\n        InvalidAudioFile,\n        get_audio_length,\n        get_spotify_tracks,\n    )\n    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\n    get_initial_dpi_scale()\n    from gui import MainWindow, MiniPlayerWindow, focus_window\n    import FreeSimpleGUI as Sg\n    from modules.db import DatabaseConnection, init_db\n    if IS_FROZEN:\n        try:\n            with DatabaseConnection() as conn:\n                pass\n        except Exception:\n            sys.exit(66)\n        DatabaseConnection.DATABASE_FILE = Path(args.db_path).absolute() if args.db_path and USING_TAURI_FRONTEND else DatabaseConnection.DEFAULT_DATABASE_FILE\n        if DatabaseConnection.OLD_DATABASE_FILE.exists():\n            DatabaseConnection.DATABASE_FILE.parent.mkdir(parents=True, exist_ok=True)\n            if DatabaseConnection.DATABASE_FILE.exists():\n                print('not moving database because file already exists')\n            else:\n                try:\n                    os.rename(DatabaseConnection.OLD_DATABASE_FILE, DatabaseConnection.DATABASE_FILE)\n                except OSError as e:\n                    if e.winerror == 17:\n                        copy2(DatabaseConnection.OLD_DATABASE_FILE, DatabaseConnection.DATABASE_FILE)\n                        os.remove(DatabaseConnection.OLD_DATABASE_FILE)\n                    else:\n                        raise e\n            try:\n                with DatabaseConnection() as conn:\n                    pass\n            except Exception:\n                sys.exit(67)\n        else:\n            try:\n                with DatabaseConnection() as conn:\n                    pass\n            except Exception:\n                sys.exit(68)\n\n    # 0.5 seconds gone to 3rd party imports\n    from flask import Flask, jsonify, render_template, request, redirect, send_file, Response, make_response\n    import waitress\n    from jinja2.exceptions import TemplateNotFound\n    from werkzeug.exceptions import InternalServerError, BadRequest, UnsupportedMediaType\n    from PIL import Image\n    import pychromecast\n    from pychromecast.controllers.media import MediaStatusListener\n    from pychromecast.controllers.receiver import CastStatusListener\n    from pychromecast.error import PyChromecastError, UnsupportedNamespace, NotConnected, RequestTimeout, RequestFailed\n    from pychromecast.config import APP_MEDIA_RECEIVER\n    from pychromecast import Chromecast\n    from pychromecast.models import CastInfo\n    import pyperclip\n    import requests\n    from tempfile import NamedTemporaryFile\n    try:\n        import fcntl\n    except ImportError:\n        pass\n    import scrapetube\n    try:\n        from TkinterDnD2 import DND_FILES, DND_ALL\n    except ImportError:\n        # what about tkinterdnd2\n        import tkinterDnD\n    import zeroconf\n    TIME_TO_IMPORT = time.monotonic() - start_time\n    try:\n        sun_valley_tcl_path = f'{sys._MEIPASS}/{SUN_VALLEY_TCL}'\n    except AttributeError:\n        sun_valley_tcl_path = SUN_VALLEY_TCL\n    sun_valley_tcl_path = os.path.abspath(sun_valley_tcl_path)\n    # LOGS\n    log_format = logging.Formatter('%(asctime)s %(levelname)s (%(lineno)d) %(funcName)s(): %(message)s')\n    # max 1 MB log file\n    log_handler = RotatingFileHandler('music_caster.log', maxBytes=1000000, backupCount=1, encoding='UTF-8')\n    log_handler.setFormatter(log_format)\n    app_log = logging.getLogger('music_caster')\n    app_log.propagate = False  # disable console output\n    app_log.setLevel(logging.INFO)\n    app_log.addHandler(log_handler)\n    # LOGGING\n    logging.getLogger('pychromecast.socket_client').addHandler(log_handler)\n    logging.getLogger('pychromecast').addHandler(log_handler)\n    logging.getLogger('pychromecast').setLevel(logging.INFO)\n    logging.getLogger('werkzeug').setLevel(logging.ERROR)\n    logging.getLogger('werkzeug').addHandler(log_handler)\n    app_log.debug(f'Time to import is {TIME_TO_IMPORT:.2f} seconds')\n\n    gui_window = Sg.Window('', metadata={})\n    gui_window.close()\n\n    WELCOME_MSG = t('Thanks for installing Music Caster.') + '\\n' + t('Music Caster is running in the tray.')\n    uris_to_scan = Queue()\n    PRESSED_KEYS = set()\n    settings_file_lock = threading.Lock()\n    last_play_command = settings_last_modified = 0\n    update_last_checked = time.time()  # check every hour\n    cast: Chromecast = None  # type: ignore\n    all_tracks, all_tracks_sorted = {}, []\n    url_metadata: dict(URLMetadata) = {}\n    tray_playlists = [t('Playlists Tab')]\n    CHECK_MARK = 'âœ“'\n    music_folders, device_names = [], [(f'{CHECK_MARK} ' + t('Local device'), 'device:0')]\n    music_queue, done_queue, next_queue = deque(), deque(), deque()\n    # usage: background_thread sleep(1) if seek_queue, seek_queue.pop(), seek_queue.clear(), call set_pos\n    seek_queue = []\n    playing_url = deezer_opened = attribute_error_reported = False\n    recent_api_plays = {'play': 0, 'queue': 0, 'play_next': 0}\n    # seconds but using time()\n    playing_status = PlayingStatus()\n    track_position = timer = track_end = track_length = track_start = 0\n\n    def get_downloads_folder():\n        if platform.system() == 'Windows':\n            from knownpaths import sh_get_known_folder_path, FOLDERID\n            possible_path = sh_get_known_folder_path(FOLDERID.Downloads)\n            if possible_path is not None:\n                return Path(possible_path)\n        return Path.home() / 'Downloads'\n\n\n    def get_installer_path():\n        downloads_dir = get_downloads_folder()\n        if downloads_dir.exists():\n            return str(downloads_dir / 'music_caster_installer.exe')\n        return 'music_caster_installer.exe'\n\n\n    def get_default_music_folder():\n        if platform.system() == 'Windows':\n            from knownpaths import sh_get_known_folder_path, FOLDERID\n            return sh_get_known_folder_path(FOLDERID.Music)\n        return str(Path.home() / 'Music')\n\n    print('Installer path:', get_installer_path())\n    default_auto_update = os.path.exists(UNINSTALLER) or os.path.exists('Updater.exe')\n    settings: dict = {  # default settings\n        'device': None, 'window_locations': {}, 'smart_queue': False, 'skips': {}, 'theme': DEFAULT_THEME.copy(),\n        'auto_update': default_auto_update, 'run_on_startup': os.path.exists(UNINSTALLER), 'notifications': True,\n        'shuffle': False, 'repeat': None, 'discord_rpc': False, 'save_window_positions': True, 'mini_on_top': True,\n        'populate_queue_startup': False, 'persistent_queue': False, 'volume': 20, 'muted': False, 'volume_delta': 5,\n        'scrubbing_delta': 5, 'flip_main_window': False, 'show_track_number': False, 'folder_cover_override': True,\n        'show_album_art': True, 'folder_context_menu': True, 'vertical_gui': False, 'mini_mode': False,\n        'gui_exits_app': False, 'update_check_hours': 1, 'timer_shut_down': False, 'timer_hibernate': False,\n        'timer_sleep': False, 'show_queue_index': True, 'queue_library': False, 'lang': '', 'sys_audio_delay': 0,\n        'use_last_folder': False, 'upload_pw': '', 'last_folder': get_default_music_folder(), 'scan_folders': True,\n        'track_format': '&artist - &title', 'reversed_play_next': False, 'update_message': '', 'important_message': '',\n        'music_folders': [get_default_music_folder()], 'playlists': {}, 'queues': {'done': [], 'music': [], 'next': []},\n        'position': 0, 'plugged_in_res': None, 'on_battery_res': None, 'experimental_features': False,\n        'api_key': secrets.token_urlsafe(16), 'concert_location': 'New York'}\n    default_settings = deepcopy(settings)\n    indexing_tracks_thread = save_queue_thread = Thread()\n    sar = SystemAudioRecorder()\n    app = Flask(__name__)\n\n    app.jinja_env.lstrip_blocks = app.jinja_env.trim_blocks = True\n    os.environ['WERKZEUG_RUN_MAIN'] = 'true'\n    os.environ['FLASK_SKIP_DOTENV'] = '1'\n    # if time.time() > SYNC_WITH_CHROMECAST good to sync from chromecast\n    SYNC_WITH_CHROMECAST = 0\n    CAST_LOCK = threading.Lock()\n    OLD_CAST_VOLUME = 0\n    OLD_CAST_POS = 0\n    LAST_PLAYED = time.time()\n    init_db()\n\n    def get_line_number():\n        cf = currentframe()\n        return cf.f_back.f_lineno\n\n\n    def tray_notify(message, title='Music Caster', context=''):\n        \"\"\" A wrapper for tray_process_queue.put({ notify: {message: msg, title: title} }) \"\"\"\n        if message == 'update_available':\n            message = t('Update $VER is available').replace('$VER', f'v{context}')\n        tray_process_queue.put({'notify': {'message': message, 'title': title}})\n\n\n    def close_tray():\n        tray_process_queue.put({'close': None})\n        tray_process.join()\n\n\n    def save_settings():\n        global settings_last_modified\n        # avoid corrupting settings file if the system crashes mid-write by using temporary file + sync + atomic rename\n        with settings_file_lock:\n            try:\n                tmp_file = NamedTemporaryFile(mode='w', encoding='utf-8', prefix=SETTINGS_FILE.name, dir=SETTINGS_FILE.parent, suffix='.tmp', delete=False)\n                json.dump(settings, tmp_file, indent=2, escape_forward_slashes=False)\n                # send to kernel buffer\n                tmp_file.flush()\n                # inform OS to write to disk to avoid a situation where the file is replaced but not written to\n                if platform.system() == 'Darwin':\n                    fcntl.fcntl(tmp_file.fileno(), fcntl.F_FULLFSYNC)\n                else:\n                    os.fsync(tmp_file.fileno())\n                tmp_file.close()\n                # this atomic operation ensures that a settings.file will exist if the system crashes before/after the system call\n                os.replace(tmp_file.name, SETTINGS_FILE)\n                settings_last_modified = os.path.getmtime(SETTINGS_FILE)\n            except Exception as e:\n                handle_exception(e)\n                tray_notify(t('ERROR') + f': {e}')\n            except OSError as e:\n                if e.errno == errno.ENOSPC:\n                    tray_notify(t('ERROR') + ': ' + t('No space left on device to save settings'))\n                else:\n                    tray_notify(t('ERROR') + f': {e}')\n\n\n    def is_debug():\n        return settings.get('DEBUG', DEBUG)\n\n\n    def refresh_tray(refresh_devices=False):\n        if refresh_devices:\n            device_names.clear()\n            # account for case where user is connected to device not detectable\n            if cast is not None and cast.uuid not in cast_browser.devices:\n                cast_browser.devices[cast.uuid] = cast.cast_info\n            for device in get_devices():\n                device_names.append(device.as_tray_item(settings['device']))\n            daemon_commands.put('__UPDATE_GUI__')\n        tray_folders = [t('Select Folder')]\n        for i, folder in enumerate(music_folders):\n            folder = Path(folder)\n            folder = ('../' + '/'.join(folder.parts[-2:])) if len(folder.parts) > 2 else folder.as_posix()\n            tray_folders.append((folder, f'PF:{i}'))\n        repeat_menu = [t('Repeat All') + f' {CHECK_MARK}' * (settings['repeat'] is False),\n                       t('Repeat One') + f' {CHECK_MARK}' * (settings['repeat'] is True),\n                       t('Repeat Off') + f' {CHECK_MARK}' * (settings['repeat'] is None)]\n        tray_menu_default = [t('Settings'), t('Rescan Library'), t('Refresh Devices'),\n                             [t('Select Device'), *device_names], [t('Timer'), t('Set Timer'), t('Cancel Timer')],\n                             [t('Play'), t('System Audio'),\n                              [t('URL'), t('Play URL'), t('Queue URL'), t('Play URL Next')],\n                              [t('Folders'), *tray_folders], [t('Playlists'), *tray_playlists],\n                              [t('Select Files'), t('Play Files'), t('Queue Files'), t('Play Files Next')],\n                              t('Play All')], (t('Exit'), '__EXIT__')]\n        tray_menu_playing = [t('Settings'), t('Rescan Library'), t('Refresh Devices'),\n                             [t('Select Device'), *device_names], [t('Timer'), t('Set Timer'), t('Cancel Timer')],\n                             [t('Controls'), t('locate track', 1), [t('Repeat Options'), *repeat_menu], t('Stop'),\n                              t('previous track', 1), t('next track', 1), t('Pause')],\n                             [t('Play'), t('System Audio'),\n                              [t('URL'), t('Play URL'), t('Queue URL'), t('Play URL Next')],\n                              [t('Folders'), *tray_folders], [t('Playlists'), *tray_playlists],\n                              [t('Select Files'), t('Play Files'), t('Queue Files'), t('Play Files Next')],\n                              t('Play All')], (t('Exit'), '__EXIT__')]\n        tray_menu_paused = [t('Settings'), t('Rescan Library'), t('Refresh Devices'),\n                            [t('Select Device'), *device_names], [t('Timer'), t('Set Timer'), t('Cancel Timer')],\n                            [t('Controls'), t('locate track', 1), [t('Repeat Options'), *repeat_menu], t('Stop'),\n                             t('previous track', 1), t('next track', 1), t('Resume')],\n                            [t('Play'), t('System Audio'),\n                             [t('URL'), t('Play URL'), t('Queue URL'), t('Play URL Next')],\n                             [t('Folders'), *tray_folders],\n                             [t('Playlists'), *tray_playlists],\n                             [t('Select Files'), t('Play Files'), t('Queue Files'), t('Play Files Next')],\n                             t('Play All')], (t('Exit'), '__EXIT__')]\n        if platform.system() == 'Linux':\n            # more so for applicationindicator\n            for menu in tray_menu_default, tray_menu_paused, tray_menu_playing:\n                menu.append((t('Open'), '__ACTIVATED__'))\n        # refresh playlists\n        tray_playlists.clear()\n        tray_playlists.append(t('Playlists Tab'))\n        tray_playlists.extend([(pl.replace('&', '&&&'), f'PL:{pl}') for pl in settings['playlists']])\n        # tell tray process to update\n        # icon = FILLED_ICON if playing_status.playing() else UNFILLED_ICON\n        icon = {'filled': None} if playing_status.playing() else {'unfilled': None}\n        if playing_status.busy():\n            menu = tray_menu_playing if playing_status.playing() else tray_menu_paused\n            metadata = get_current_metadata()\n            title, artists = metadata['title'], metadata['artist']\n            _tooltip = f'{get_first_artist(artists)} - {title}'\n        else:\n            menu, _tooltip = tray_menu_default, 'Music Caster'\n        if is_debug():\n            _tooltip += ' [DEBUG]'\n        tray_process_queue.put({'menu': menu, 'tooltip': _tooltip, **icon})\n\n\n    def refresh_tray_icon():\n        icon = {'filled': None} if playing_status.playing() else {'unfilled': None}\n        tray_process_queue.put(icon)\n\n\n    def update_settings(settings_key, new_value):\n        \"\"\" returns new value and can be called from non-main thread \"\"\"\n        if settings[settings_key] != new_value:\n            settings[settings_key] = new_value\n            save_settings()\n            if settings_key == 'repeat':\n                daemon_commands.put('__UPDATE_GUI__')\n                refresh_tray()\n            elif settings_key == 'shuffle':\n                if not gui_window.is_closed():\n                    daemon_commands.put('__UPDATE_GUI__')\n                shuffle_queue() if new_value else un_shuffle_queue()\n        return new_value\n\n\n    def save_queues():\n        global save_queue_thread\n\n        def _save_queue():\n            settings['queues']['done'] = tuple(done_queue)\n            settings['queues']['music'] = tuple(music_queue)\n            settings['queues']['next'] = tuple(next_queue)\n            save_settings()\n\n        if settings['persistent_queue'] and not save_queue_thread.is_alive() and not State.installing_update:\n            save_queue_thread = Thread(target=_save_queue, name='SaveQueue')\n            save_queue_thread.start()\n\n\n    def update_volume(new_vol, _from=''):\n        \"\"\"\n        new_vol: float[0, 100]\n        AKA set_volume\n        \"\"\"\n        app_log.info(f'set to {new_vol} from {_from}')\n        gui_window.metadata['update_volume_slider'] = True\n        if not isinstance(new_vol, (float, int)):\n            new_vol = update_settings('volume', 20)\n        new_vol = new_vol / 100\n        with suppress(NameError):\n            audio_player.set_volume(new_vol)\n        if cast is not None:\n            # this was threaded because otherwise it would block for over 0.2 seconds\n            # exceptions: NotConnected, RequestTimeout, RequestFailed\n            set_volume_Thread = Thread(target=cast.set_volume, args=(new_vol,), name='CastSetVolume', daemon=True)\n            set_volume_Thread.start()\n\n\n    def cycle_repeat():\n        \"\"\" :return: new repeat value \"\"\"\n        # Repeat Off (None) becomes All (False) becomes One (True) becomes Off\n        new_repeat_setting = {None: False, True: None, False: True}[settings['repeat']]\n        return update_settings('repeat', new_repeat_setting)\n\n\n    def create_support_email_url():\n        try:\n            with open('music_caster.log', encoding='utf-8') as f:\n                log_lines = f.read().splitlines()[-10:]  # get last 10 lines of the log\n        except FileNotFoundError:\n            log_lines = []\n        log_lines = '%0D%0A'.join(log_lines)\n        email_body = f'body=%0D%0A%23%20Tail%20of%20Log%0D%0A%0D%0A{log_lines}'\n        mail_to = f'mailto:{EMAIL}?subject=Regarding%20Music%20Caster%20v{VERSION}&{email_body}'\n        return mail_to\n\n\n    def handle_exception(e: Exception, restart_program=False) -> bool:\n        current_time = str(datetime.now())\n        trace_back_msg = traceback.format_exc().replace('\\\\', '/')\n        exc_type, exc_tb = sys.exc_info()[0], sys.exc_info()[2]\n        playing_uri = 'N/A'\n        if music_queue:\n            if playing_url:\n                playing_uri = music_queue[0]\n            elif sar.alive:\n                playing_uri = 'system audio'\n            elif playing_status.busy():\n                playing_uri = music_queue[0]\n        try:\n            with open('music_caster.log', encoding='utf-8') as f:\n                log_lines = f.read().splitlines(keepends=False)[-10:]  # get last 10 lines of the log\n        except FileNotFoundError:\n            log_lines = []\n        device = 'local' if cast is None else 'cast'\n        payload = {'VERSION': VERSION, 'FATAL': restart_program, 'EXCEPTION TYPE': exc_type.__name__,\n                   'LINE': exc_tb.tb_lineno, 'TRACEBACK': trace_back_msg, 'LOG': log_lines,\n                   'MQ[0]': playing_uri, 'PLAYING_STATUS': str(playing_status), 'DEVICE': device,\n                   'CWD': os.getcwd(), 'PORTABLE': not os.path.exists(UNINSTALLER),\n                   'MAC': hashlib.md5(get_mac().encode()).hexdigest(), 'OS': platform.platform(), 'TIME': current_time}\n        if IS_FROZEN:\n            with suppress(requests.RequestException):\n                requests.post('https://lenerva.com/telemetry/music-caster/error/', json=payload, timeout=1)\n        try:\n            with open('error.log', 'r', encoding='utf-8') as _f:\n                content = _f.read()\n        except (FileNotFoundError, ValueError):\n            content = ''\n        with open('error.log', 'w', encoding='utf-8') as _f:\n            _f.write(pprint.pformat(payload))\n            _f.write('\\n')\n            _f.write(content)\n        if restart_program:\n            close_tray()\n            with suppress(Exception):\n                stop('error handling')\n            tray_notify(t('An error occurred, restarting now'))\n            # minimized = main_window.was_closed()\n            if IS_FROZEN:\n                startfile('Music Caster')\n            else:\n                raise e  # raise exception if running in script rather than executable\n            sys.exit()\n        return False\n\n    def get_current_art() -> bytes:\n        if sar.alive:\n            return custom_art('SYS')\n        if playing_status.busy() and music_queue:\n            uri = music_queue[0]\n            if uri.startswith('http'):\n                with DatabaseConnection() as conn:\n                    maybe_url_metadata = URLMetadata.from_db(conn, uri)\n                if isinstance(maybe_url_metadata, URLMetadata):\n                    return maybe_url_metadata.get_cover_image()\n                if isinstance(url_metadata.get(uri), URLMetadata):\n                    return url_metadata[uri].get_cover_image()\n                if url_metadata.get(uri, {}).get('art') in ('None', None):\n                    return custom_art('URL')\n                if 'art_data' in url_metadata[uri]:\n                    return url_metadata[uri]['art_data']\n                # use 'art_data' else download 'art' link and cache to 'art_data'\n                url_metadata[uri]['art_data'] = b64encode(requests.get(url_metadata[uri]['art']).content)\n                return url_metadata[uri]['art_data']\n            return get_album_art(uri, settings['folder_cover_override'])[1]\n        return DEFAULT_ART\n\n\n    def get_metadata_wrapped(file_path: str) -> dict:  # keys: title, artist, album, sort_key\n        try:\n            if file_path.startswith('http'):\n                raise ValueError('expected file not http...')\n            m = get_metadata(file_path)\n            return m\n        except (MutagenError, ValueError):\n            try:\n                return all_tracks[Path(file_path).as_posix()]\n            except KeyError:\n                # i forget the reason why we have the time_modified so high\n                return {'title': Unknown('Title'), 'artist': Unknown('Artist'), 'explicit': False, 'time_modified': os.path.getmtime(file_path),\n                        'album': Unknown('Title'), 'sort_key': get_file_name(file_path), 'track_number': '1'}\n\n\n    def get_uri_metadata(uri, read_file=True):\n        \"\"\" Uses cache to get metadata \"\"\"\n        # raises KeyError\n        uri = uri.replace('\\\\', '/')\n        if uri.startswith('http'):\n            with DatabaseConnection() as conn:\n                maybe_url_metadata = URLMetadata.from_db(conn, uri)\n                if maybe_url_metadata is not None:\n                    return maybe_url_metadata\n            if uri in url_metadata:\n                return url_metadata[uri]\n            return {'title': Unknown('Title'), 'artist': Unknown('Artist'), 'explicit': False,\n                    'album': Unknown('Album'), 'sort_key': uri, 'track_number': '1'}\n        if uri in all_tracks:\n            try:\n                ignore_cache = os.path.getmtime(uri) != all_tracks[uri]['time_modified'] if read_file else False\n            except FileNotFoundError:\n                ignore_cache = False\n            if not ignore_cache:\n                return all_tracks[uri]\n        # uri is probably a file that has not been cached yet\n        if read_file:\n            metadata = get_metadata_wrapped(uri)\n            all_tracks[uri] = metadata\n            return metadata\n        raise KeyError\n\n\n    def get_current_metadata() -> dict | URLMetadata:\n        if sar.alive:\n            return url_metadata['SYSTEM_AUDIO']\n        if music_queue and playing_status.busy():\n            return get_uri_metadata(music_queue[0])\n        return {'artist': '', 'title': t('Nothing Playing'), 'album': ''}\n\n\n    def get_audio_uris(uris: Iterable, scan_uris=True, ignore_m3u=False, parsed_m3us=None, ignore_dir=False):\n        \"\"\"\n        :param uris: A list of URIs (urls, folders, m3u files, files)\n        :param scan_uris: whether to add to uris_to_scan\n        :param ignore_m3u: whether to ignore .m3u(8) files\n        :param parsed_m3us: m3u files that have already been parsed. This is to avoid recursive parsing\n        :param ignore_dir: whether to scan uri if it is a dir\n        :return: generator of valid audio files\n        \"\"\"\n        if parsed_m3us is None:\n            parsed_m3us = set()\n        if isinstance(uris, str):\n            uris = (uris,)\n        for uri in uris:\n            if isinstance(uri, Iterable) and not isinstance(uri, str):\n                yield from get_audio_uris(uri, scan_uris, ignore_m3u, parsed_m3us, ignore_dir)\n            elif uri in settings['playlists']:\n                yield from get_audio_uris(settings['playlists'][uri], scan_uris=scan_uris, ignore_m3u=ignore_m3u,\n                                          parsed_m3us=parsed_m3us)\n            elif os.path.isdir(uri) and not ignore_dir:\n                # if scanning a folder,\n                #  ignore playlist files and folders that are named as files as they aren't audio files\n                yield from get_audio_uris(glob.iglob(f'{glob.escape(uri)}/**/*.*', recursive=True), ignore_dir=True,\n                                          scan_uris=scan_uris, ignore_m3u=True, parsed_m3us=parsed_m3us)\n            elif os.path.isfile(uri):\n                uri = Path(uri).absolute().as_posix()\n                if not ignore_m3u and (uri.endswith('.m3u') or uri.endswith('.m3u8')) and uri not in parsed_m3us:\n                    parsed_m3us.add(uri)\n                    yield from get_audio_uris(parse_m3u(uri), parsed_m3us=parsed_m3us)\n                elif valid_audio_file(uri):\n                    if scan_uris and uri not in all_tracks:\n                        uris_to_scan.put(uri)\n                    yield uri\n            elif uri.startswith('http'):\n                if scan_uris and uri not in url_metadata:\n                    uris_to_scan.put(uri)\n                yield uri\n\n\n    def index_all_tracks(update_global=True, ignore_files: set | None = None) -> dict:\n        \"\"\"\n        returns the music library dict if update_global is False\n        starts scanning and building the music library/database if update_global is True\n        ignore_files is a list (converted to set) of files to not include in the return value / scan\n            usually used with update_global=False (think about it)\n        \"\"\"\n        global indexing_tracks_thread, all_tracks\n        # make sure ignore_files is a set\n        if ignore_files is None:\n            ignore_files = set()\n\n        def _index_library():\n            \"\"\"\n            Scans folders provided in settings and adds them to a dictionary\n            Does not ignore the files that in ignore_files by design\n            \"\"\"\n            global all_tracks, all_tracks_sorted\n            use_temp = len(all_tracks)  # use temp if all_tracks is not empty\n            all_tracks_temp = {}\n            dict_to_use = all_tracks_temp if use_temp else all_tracks\n            # scan items in queue and library\n            file_metadata_list = []\n            urls_to_fetch = []\n            with DatabaseConnection() as conn:\n                for uri in get_audio_uris((settings['queues'].values(), music_folders), scan_uris=False, ignore_m3u=True):\n                    if uri.startswith('http'):\n                        if not URLMetadata.from_db(conn, uri):\n                            urls_to_fetch.append(uri)\n                    else:\n                        m = get_metadata_wrapped(uri)\n                        dict_to_use[uri] = m\n                        file_metadata_list.append((uri, m))\n                cur = conn.cursor()\n                # save_metadata_batch(file_metadata_list, 'file_metadata', 'file_path')\n                gui_window.metadata['update_listboxes'] = True\n\n                for url in urls_to_fetch:\n                    url_metadata_list = get_url_metadata(url)\n                    batch_to_save = []\n                    for m in url_metadata_list:\n                        batch_to_save.append((url, m))\n                        if isinstance(m, URLMetadata):\n                            m.save_to_db(cur)\n                conn.commit()\n            if use_temp:\n                all_tracks = all_tracks_temp\n            gui_window.metadata['update_listboxes'] = True\n            # TODO\n            # tracks = cur.execute('SELECT * FROM file_metadata ORDER BY sort_key').fetchall()\n            all_tracks_sorted = sorted(all_tracks.items(), key=lambda item: item[1]['sort_key'])\n            # scan items in playlists\n            for _ in get_audio_uris(settings['playlists'].values(), ignore_m3u=True):\n                # the function scans for us\n                pass\n\n        if not update_global:\n            temp_tracks = all_tracks.copy()\n            for ignore_file in ignore_files:\n                temp_tracks.pop(ignore_file, None)\n            return temp_tracks\n        if indexing_tracks_thread is None:\n            indexing_tracks_thread = Thread(target=_index_library, daemon=True, name='IndexLibrary')\n            indexing_tracks_thread.start()\n        elif not indexing_tracks_thread.is_alive():  # force reindex\n            indexing_tracks_thread = Thread(target=_index_library, daemon=True, name='IndexLibrary')\n            indexing_tracks_thread.start()\n\n\n    def download(url, outfile):\n        # throws ConnectionAbortedError\n        r = requests.get(url, stream=True)\n        if outfile.endswith('.zip'):\n            outfile = outfile.replace('.zip', '')\n            z = zipfile.ZipFile(io.BytesIO(r.content))\n            z.extractall(outfile)\n        else:\n            with open(outfile, 'wb') as _f:\n                copyfileobj(r.raw, _f)\n\n\n    def load_settings(first_load=False):  # up to 0.4 seconds\n        \"\"\"\n        load (and fix if needed) the settings file\n        calls refresh_tray(), index_all_tracks(), save_setting()\n        first_load: if true, start indexing all tracks\n        \"\"\"\n        global settings, music_folders, settings_last_modified\n        _save_settings = False\n        with settings_file_lock:\n            try:\n                attempt = 0\n                while True:\n                    try:\n                        with open(SETTINGS_FILE, encoding='utf-8') as json_file:\n                            loaded_settings = json.load(json_file)\n                            break\n                    except PermissionError:\n                        attempt += 1\n                        if attempt == 10:\n                            raise\n            except (FileNotFoundError, ValueError):\n                # if file does not exist\n                _save_settings = True\n                loaded_settings = {}\n            for setting_name, setting_value in tuple(loaded_settings.items()):\n                loaded_settings[setting_name.replace(' ', '_')] = loaded_settings.pop(setting_name)\n            for setting_name, setting_value in settings.items():\n                does_not_exist = setting_name not in loaded_settings  # setting DNE\n                # use default settings if key/value does not exist\n                if does_not_exist and setting_name in default_settings:\n                    loaded_settings[setting_name] = setting_value\n                    _save_settings = True\n                elif setting_name in {'theme', 'queues'}:\n                    # for theme key\n                    for k, v in setting_value.items():\n                        if k not in loaded_settings[setting_name]:\n                            loaded_settings[setting_name][k] = v\n                            _save_settings = True\n            settings = loaded_settings\n            # sort playlists by name\n            settings['playlists'] = {k: settings['playlists'][k] for k in sorted(settings['playlists'].keys())}\n            # if music folders were modified, re-index library\n            if music_folders != settings['music_folders'] or first_load:\n                music_folders = settings['music_folders']\n                if settings['scan_folders']:\n                    index_all_tracks()\n            refresh_tray()\n            theme = settings['theme']\n            for k, v in theme.copy().items():\n                # validate settings file color codes\n                if not valid_color_code(v):\n                    _save_settings = True\n                    theme[k] = DEFAULT_THEME[k]\n\n            # validate radio settings\n            temp = (settings['timer_shut_down'], settings['timer_hibernate'], settings['timer_sleep'])\n            if temp.count(True) > 1:  # Only one of the below can be True\n                if settings['timer_shut_down']:\n                    settings['timer_hibernate'] = False\n                settings['timer_sleep'] = False\n                _save_settings = True\n            if settings['persistent_queue'] and settings['populate_queue_startup']:  # mutually exclusive\n                settings['populate_queue_startup'] = False\n                _save_settings = True\n\n            # backwards compatible 'previous_device' -> 'device'\n            if 'previous_device' in settings:\n                settings['device'] = settings.pop('previous_device')\n            State.lang = settings['lang']\n            State.track_format = settings['track_format']\n            fg, bg, accent = theme['text'], theme['background'], theme['accent']\n\n            GuiContext.update(fg, bg, accent, settings['experimental_features'])\n\n            Sg.set_options(text_color=fg, element_text_color=fg, input_text_color=fg,\n                           button_color=(bg, accent), element_background_color=bg, scrollbar_color=bg,\n                           text_element_background_color=bg, background_color=bg,\n                           input_elements_background_color=bg, progress_meter_color=accent,\n                           titlebar_background_color=bg, titlebar_text_color=fg,\n                           # progress_meter_style=\n                           border_width=0, slider_border_width=1, progress_meter_border_depth=0, font=FONT_NORMAL)\n        if _save_settings:\n            save_settings()\n        settings_last_modified = os.path.getmtime(SETTINGS_FILE)\n\n\n    @app.errorhandler(404)\n    def page_not_found(_):\n        return redirect('/')\n\n\n    @app.post('/upload/')\n    def upload_files():  # web GUI\n        if 'files' not in request.files or not request.values.get('password'):\n            return redirect('/#more')\n        if request.values['password'] == settings['upload_pw']:\n            # only save if upload_pw is set\n            uploaded_files = request.files.getlist('files')\n            for file in uploaded_files:\n                if file.filename is not None:\n                    file.save(Path.home() / 'Downloads' / file.filename)\n        return redirect('/#more')\n\n\n    def get_request_data():\n        try:\n            return request.json\n        except (BadRequest, UnsupportedMediaType):\n            return request.values\n\n    @app.route('/action/<command>', methods=['GET', 'POST'])\n    def web_action(command):\n        request_data = get_request_data()\n        # if request_data.get('api_key') != settings['api_key']:\n        #     return {'error': 'Unauthorized, api_key=not-provided'}, 401\n        match command:\n            case 'play':\n                if resume('web'):\n                    api_msg = 'resumed playback'\n                else:\n                    if music_queue:\n                        play()\n                        api_msg = 'started playing first track in queue'\n                    else:\n                        play_all()\n                        api_msg = 'shuffled all and started playing'\n            case 'pause':\n                pause()  # resume == play\n                api_msg = 'pause called'\n            case 'next':\n                ignore_timestamps = False\n                times_to_skip = 1\n                if request_data is not None:\n                    ignore_timestamps = 'ignore_timestamps' in request_data\n                    times_to_skip = int(request_data.get('times', 1))\n                next_track(times=times_to_skip, forced=True, ignore_timestamps=ignore_timestamps)\n                api_msg = 'next track called'\n            case 'prev':\n                times_to_skip = 1\n                if request_data is not None:\n                    times_to_skip = int(request_data.get('times', 1))\n                prev_track(times=times_to_skip, forced=True)\n                api_msg = 'prev track called'\n            case 'repeat':\n                cycle_repeat()\n                api_msg = 'cycled repeat to ' + {None: 'off', True: 'one', False: 'all'}[settings['repeat']]\n            case 'shuffle':\n                shuffle_enabled = update_settings('shuffle', not settings['shuffle'])\n                api_msg = f'shuffle set to {shuffle_enabled}'\n            case 'activate':\n                daemon_commands.put('__ACTIVATED__')  # tell main thread to show GUI\n                api_msg = 'activated main window'\n            case _:\n                return f'unknown command: {command}'\n        return {'message': api_msg} if ('is_api' in request.args or request.method == 'POST') else redirect('/')\n\n\n    @app.route('/', methods=['GET', 'POST'])\n    def web_index():  # web GUI\n        request_data = get_request_data()\n        if request_data is not None:\n            for command in ('play', 'pause', 'next', 'prev', 'repeat', 'shuffle', 'activate'):\n                if command in request_data:\n                    return web_action(command)\n        api_key = settings['api_key']\n        # if request_data.get('api_key') != api_key:\n        #     return jsonify({'error': 'Unauthorized, api_key=not-provided'}), 401\n        metadata = get_current_metadata()\n        art = get_current_art()\n        if isinstance(art, bytes):\n            art = art.decode()\n        art = f'data:image/png;base64,{art}'\n        repeat_option = settings['repeat']\n        repeat_enabled = 'repeat-enabled' if settings['repeat'] is not None else ''\n        shuffle_enabled = 'shuffle-enabled' if settings['shuffle'] else ''\n        # sort by the formatted title\n        if all_tracks_sorted:\n            sorted_tracks = all_tracks_sorted\n        else:\n            sorted_tracks = sorted(\n                all_tracks.items(), key=lambda item: item[1]['sort_key']\n            )\n        list_of_tracks = [{'text': format_uri(filename),\n                           'filename': pathname2url(filename).strip('/')} for filename, _ in sorted_tracks]\n        _queue = create_track_list()\n        device_index = 0\n        for i, devices in enumerate(device_names):\n            if devices[0].startswith(CHECK_MARK):\n                device_index = i\n                break\n        formatted_devices = [('Local device', '0')]\n        stream_url, stream_time = None, track_position\n        if playing_status.playing() and music_queue:\n            metadata = get_current_metadata()\n            uri = music_queue[0]\n            if os.path.exists(uri):\n                file_path = pathname2url(uri).strip('/')\n                stream_url = f\"/file?path={file_path}&api_key={api_key}\"\n            else:\n                stream_url = metadata.get('audio_url', metadata.get('url'))\n        for cast_info in sorted(cast_browser.devices.values(), key=cast_info_sorter):\n            formatted_devices.append((cast_info.friendly_name, str(cast_info.uuid)))\n        try:\n            return render_template('index.html', device_name=platform.node(), shuffle=shuffle_enabled, version=VERSION,\n                                   repeat_enabled=repeat_enabled, playing_status=playing_status, metadata=metadata,\n                                   settings=settings, list_of_tracks=list_of_tracks, repeat_option=repeat_option, gt=t,\n                                   queue=_queue, playing_index=len(done_queue), device_index=device_index, art=art,\n                                   devices=formatted_devices, stream_url=stream_url, stream_time=stream_time)\n        except TemplateNotFound:\n            return redirect('https://github.com/elibroftw/music-caster/releases/latest')\n\n\n    @app.route('/status/')\n    @app.route('/state/')\n    def api_state():\n        _metadata = get_current_metadata()\n        now_playing = {'status': str(playing_status), 'volume': settings['volume'], 'lang': settings['lang'],\n                       'title': str(_metadata['title']), 'artist': str(_metadata['artist']),\n                       'album': str(_metadata['album']), 'gui_open': not gui_window.is_closed(),\n                       'track_position': get_track_position(), 'track_length': track_end - track_start,\n                       'queue_length': len(done_queue) + len(music_queue) + len(next_queue)}\n        return jsonify(now_playing)\n\n\n    @app.route('/play/', methods=['GET', 'POST'])\n    def api_play():\n        global last_play_command\n        merge_plays = time.monotonic() - last_play_command < 0.5\n        last_play_command = time.monotonic()\n\n        request_data = get_request_data()\n        if request_data is not None:\n            queue_only = request_data.get('queue', False)\n            if isinstance(queue_only, str):\n                queue_only = queue_only.casefold() == 'true'\n            play_next = request_data.get('play_next', False)\n            if isinstance(play_next, str):\n                play_next = play_next.casefold() == 'true'\n            device_id = request_data.get('device', None)\n            if device_id is not None:\n                change_device(device_id)\n            # reset recent_api_plays\n            if not merge_plays:\n                for opt in ('play', 'queue', 'play_next'):\n                    recent_api_plays[opt] = 0\n            if queue_only:\n                opt = 'queue'\n            elif play_next:\n                opt = 'play_next'\n            else:\n                opt = 'play'\n            merge_plays = recent_api_plays[opt]\n            recent_api_plays[opt] += 1\n            if 'uris' in request_data:\n                uris = request_data['uris'] if isinstance(request_data, dict) else request_data.getlist('uris')\n                if uris and uris[0].lower().replace(' ', '').replace('_', '') == 'systemaudio':\n                    play_system_audio()\n                else:\n                    play_uris(uris, queue_uris=queue_only,\n                            play_next=play_next, merge_tracks=merge_plays)\n                    if not queue_only and not play_next and settings['queue_library'] and merge_plays == 0:\n                        queue_all()\n            elif 'uri' in request_data:\n                if request_data['uri'].lower().replace(' ', '').replace('_', '') == 'systemaudio':\n                    play_system_audio()\n                else:\n                    play_uris([request_data['uri']], queue_uris=queue_only, play_next=play_next, merge_tracks=merge_plays)\n                    if settings['queue_library']:\n                        queue_all()\n        else:\n            recent_api_plays['play'] += 1\n        return redirect('/') if request.method == 'GET' else api_state()\n\n\n    @app.errorhandler(InternalServerError)\n    def handle_500(_e):\n        original = getattr(_e, 'original_exception', None)\n\n        if original is None:\n            # direct 500 error, such as abort(500)\n            handle_exception(_e)\n            return t('An Internal Server Error occurred') + f': {_e}'\n\n        # wrapped unhandled error\n        handle_exception(original)\n        return t('An Internal Server Error occurred') + f': {original}'\n\n\n    @app.route('/debug/')\n    def api_get_debug_info():\n        threads = [(thread.name, thread.is_alive()) for thread in threading.enumerate()]\n        if is_debug():\n            return jsonify({'pressed_keys': list(PRESSED_KEYS),\n                            'last_traceback': sys.exc_info(),\n                            'threads': threads,\n                            'mac': get_mac()})\n        return t('set DEBUG = true in `settings.json` to enable this page')\n\n\n    @app.route('/running/', methods=['GET', 'POST', 'OPTIONS'])\n    def api_running():\n        response = make_response('true')\n        http_origins = ('https://elijahlopez.herokuapp.com', 'http://elijahlopez.herokuapp.com',\n                        'https://elijahlopez.ca', 'http://elijahlopez.ca')\n        if request.environ.get('HTTP_ORIGIN') in http_origins:\n            response.headers.add('Access-Control-Allow-Origin', request.environ['HTTP_ORIGIN'])\n        return response\n\n\n    @app.route('/exit/', methods=['GET', 'POST'])\n    def api_exit():\n        daemon_commands.put('__EXIT__')\n        return api_state()\n\n\n    @app.route('/change-setting/', methods=['POST'])\n    def api_change_setting():\n        with suppress(KeyError, TypeError):\n            json_data = request.get_json(force=True, silent=True)\n            if json_data is None:\n                return 'false'\n            setting_key = json_data['setting_name']\n            if setting_key in settings or setting_key == 'timer_stop':\n                val = json_data['value']\n                update_settings(setting_key, val)\n                timer_settings = {'timer_hibernate', 'timer_sleep',\n                                  'timer_shut_down', 'timer_stop'}\n                if val and setting_key in timer_settings:\n                    for timer_setting in timer_settings.difference({setting_key, 'timer_stop'}):\n                        update_settings(timer_setting, False)\n                if setting_key == 'volume':\n                    update_volume(0 if settings['muted'] else val, 'api')\n            return 'true'\n        return 'false'\n\n\n    @app.route('/refresh-devices/')\n    def api_refresh_devices():\n        refresh_tray(True)\n        return 'true'\n\n\n    @app.route('/rescan-library/')\n    def api_rescan_library():\n        index_all_tracks()\n        return 'true'\n\n\n    @app.get('/devices/')\n    def api_get_devices():\n        request_data = get_request_data()\n        if request_data is None or ('friendly' not in request_data):\n            devices: dict | list = {'0': 'Local device'}\n            for _uuid, cast_info in cast_browser.devices.items():\n                devices[str(_uuid)] = cast_info.friendly_name\n        else:\n            devices: dict | list = ['Local device::0']\n            for cast_info in sorted(cast_browser.devices.values(), key=cast_info_sorter):\n                devices.append(f'{cast_info.friendly_name}::{cast_info.uuid}')\n        return jsonify(devices)\n\n\n    @app.post('/change-device/<_uuid>')\n    def api_change_device(_uuid):\n        return str(change_device(_uuid))\n\n\n    def cancel_timer():\n        global timer\n        timer = 0\n        if settings['notifications']:\n            tray_notify(t('Timer cancelled'))\n\n    def set_timer(val):\n        # TIMER PARSER\n        global timer\n        if val == 'cancel':\n            cancel_timer()\n            return 'timer cancelled'\n        elif val.isdigit():\n            seconds = abs(float(val)) * 60\n        elif val.count(':') == 1:\n            # parse out any PM and AM's\n            timer_value = val.strip().upper().replace(' ', '').replace('PM', '').replace('AM', '')\n            to_stop = datetime.strptime(timer_value + time.strftime(',%Y,%m,%d,%p'), '%H:%M,%Y,%m,%d,%p')\n            current_time = datetime.now()\n            current_time = current_time.replace(second=0)\n            seconds_delta = (to_stop - current_time).total_seconds()\n            seconds_delta = seconds_delta % 43200  # add 12 hours\n            seconds = seconds_delta\n        else:\n            raise ValueError('Timer input is invalid')\n        timer = time.time() + seconds\n        timer_set_to = datetime.now().replace(second=0) + timedelta(seconds=seconds)\n        if platform.system() == 'Windows':\n            timer_set_to = timer_set_to.strftime('%#I:%M %p')\n        else:\n            timer_set_to = timer_set_to.strftime('%-I:%M %p')  # Linux\n        return timer_set_to\n\n\n    @app.route('/timer/', methods=['GET', 'POST'])\n    def api_set_timer():\n        global timer\n        if request.method == 'POST':\n            val = request.data.decode()\n            try:\n                return set_timer(val.casefold())\n            except ValueError as e:\n                return str(e)\n        else:  # GET request\n            return str(timer)\n\n    @lru_cache(maxsize=12)\n    def get_cover_jpg_data(file_path) -> io.BytesIO:\n        new_img_data = io.BytesIO()\n        mime, img_data = get_album_art(file_path, settings['folder_cover_override'])\n        img_data = io.BytesIO(b64decode(img_data))\n        if mime.lower().endswith('jpeg'):\n            return img_data\n        Image.open(img_data).convert('RGB').save(new_img_data, format='JPEG')\n        return new_img_data\n\n    @lru_cache()\n    def report_album_art_buffer_error(file_path: str):\n        msg_1 = f'{Path(file_path).name} has img_data with size 0; returning DEFUALT_ART instead'\n        app_log.info(msg_1)\n        _raw_album_art_mime, _raw_album_art_data = get_album_art(file_path, settings['folder_cover_override'])\n        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)}'\n        app_log.info(msg_2)\n        handle_exception(ValueError('\\n'.join((msg_1, msg_2))))\n\n    @app.route('/file/')\n    def api_get_file():\n        if 'path' in request.args:\n            file_path = request.args['path']\n            if os.path.isfile(file_path) and valid_audio_file(file_path) or file_path == 'DEFAULT_ART':\n                if request.args.get('thumbnail_only', False) or file_path == 'DEFAULT_ART':\n                    jpeg_buffer = get_cover_jpg_data(file_path)\n                    jpeg_buffer.seek(0)\n                    if (len(jpeg_buffer.getvalue()) == 0):\n                        report_album_art_buffer_error(file_path)\n                        return send_file(io.BytesIO(DEFAULT_ART), download_name='cover.jpeg',\n                                        mimetype='image/jpeg', as_attachment=True, max_age=360000, conditional=True)\n                    return send_file(jpeg_buffer, download_name='cover.jpeg',\n                                     mimetype='image/jpeg', as_attachment=True, max_age=360000, conditional=True)\n                return send_file(file_path, conditional=True, as_attachment=True, max_age=360000)\n        return '400'\n\n    @app.route('/dz/')\n    def api_get_dz():\n        from Cryptodome.Cipher import Blowfish\n        if 'url' in request.args:\n            # TODO: cache content to prevent extra requests\n            url = request.args['url']\n            metadata = url_metadata[url]\n            file_url = metadata['file_url']\n            range_header = {'Range': request.headers.get('Range', 'bytes=0-')}\n            r = requests.get(file_url, headers=range_header, stream=True)\n            start_bytes = int(range_header['Range'].split('=', 1)[1].split('-', 1)[0])\n            blowfish_key = metadata['bf_key']\n            iv = b'\\x00\\x01\\x02\\x03\\x04\\x05\\x06\\x07'\n\n            def generate():\n                nonlocal start_bytes\n                # if start_bytes is not a multiple of 2048, first yield will be < 2048 to fix the chunks\n                extra_bytes = start_bytes % 2048\n                if extra_bytes != 0:\n                    extra_bytes = 2048 - extra_bytes\n                    chunk = next(r.iter_content(extra_bytes))\n                    if start_bytes // 2048 == 0:\n                        chunk = Blowfish.new(blowfish_key, Blowfish.MODE_CBC, iv).decrypt(chunk)\n                    yield chunk\n                    start_bytes += extra_bytes\n                for i, chunk in enumerate(r.iter_content(2048), start_bytes // 2048):\n                    if (i % 3) == 0 and len(chunk) == 2048:\n                        chunk = Blowfish.new(blowfish_key, Blowfish.MODE_CBC, iv).decrypt(chunk)\n                    yield chunk\n\n            content_type = r.headers['Content-Type']\n            rv = Response(generate(), 206, mimetype=content_type, content_type=content_type)\n            rv.headers['Content-Range'] = r.headers['Content-Range']\n            return rv\n        return '400'\n\n\n    @app.route('/system-audio/')\n    @app.route('/system-audio/<get_thumb>')\n    def api_system_audio(get_thumb=''):\n        \"\"\"\n        send system audio to chromecast\n        \"\"\"\n        if get_thumb:\n            return send_file(io.BytesIO(b64decode(custom_art('SYS'))), download_name='thumbnail.png',\n                             mimetype='image/png', as_attachment=True, max_age=360000, conditional=True)\n        return Response(sar.get_audio_data(settings['sys_audio_delay']))\n\n\n    def cast_try_reconnect(switch_twice=False):\n        global cast_browser, zconf\n        if switch_twice and cast is not None:\n            app_log.info('try changing devices to local and then back to cast')\n            cast_uuid = cast.uuid\n            if not playing_status.playing():\n                change_device()\n                change_device(cast_uuid)\n            app_log.info('try changing devices to local and then back to cast')\n        app_log.info('stop discovery')\n        cast_browser.stop_discovery()\n        zconf = zeroconf.Zeroconf()\n        cast_browser = pychromecast.discovery.CastBrowser(MyCastListener(), zconf)\n        cast_browser.start_discovery()\n        wait_until = time.monotonic() + WAIT_TIMEOUT\n        while cast is None and time.monotonic() < wait_until:\n            time.sleep(0.2)\n        if cast is None:\n            app_log.error('could not reconnect to cast')\n        return cast is not None\n\n\n    @cmp_to_key\n    def cast_info_sorter(ci1: CastInfo, ci2: CastInfo):\n        # sort by groups, then by name, then by UUID\n        if ci1.cast_type == 'group' and ci2.cast_type != 'group':\n            return -1\n        if ci1.cast_type != 'group' and ci2.cast_type == 'group':\n            return 1\n        if ci1.friendly_name < ci2.friendly_name:\n            return -1\n        if ci1.friendly_name > ci2.friendly_name:\n            return 1\n        if str(ci1.uuid) > str(ci2.uuid):\n            return 1\n        return -1\n\n\n    def get_devices():\n        lo_cis = sorted(cast_browser.devices.values(), key=cast_info_sorter)\n        lo_devices = [Device()]\n        lo_devices.extend((Device(cast_info) for cast_info in lo_cis))\n        return lo_devices\n\n\n    class UpdateFailed(Exception):\n        pass\n\n\n    class StatusCastListener(CastStatusListener):\n        \"\"\"Cast status listener\"\"\"\n\n        def __init__(self, _cast):\n            self.cast = _cast\n            self.name = _cast.name\n\n        def new_cast_status(self, status):\n            pass\n\n\n    class MediaCastListener(MediaStatusListener):\n        def __init__(self, _cast):\n            self.cast = _cast\n            self.name = _cast.name\n\n        def new_media_status(self, status):\n            pass\n\n        def load_media_failed(self, item, error_code):\n            pass\n\n    class MyCastListener(pychromecast.discovery.AbstractCastListener):\n\n        def add_cast(self, uuid, _service: str):\n            \"\"\"Called when a new cast has been discovered.\"\"\"\n            global cast\n            cast_info = cast_browser.devices[uuid]\n            if str(cast_info.uuid) == settings['device']:\n                # if currently connected to local device or another cast, change device\n                if cast is None or cast.uuid != cast_info.uuid:\n                    change_device(cast_info.uuid)\n                else:\n                    # otherwise, update the cast variable\n                    cast = pychromecast.get_chromecast_from_cast_info(cast_info, zconf=zconf)\n                    try:\n                        if cast.is_idle:\n                            cast.wait(30)\n                    except Exception as e:\n                        app_log.error('could not wait on cast', exc_info=True)\n                        handle_exception(e)\n                    # cast.register_status_listener(StatusCastListener(cast))\n                    # cast.media_controller.register_status_listener(MediaCastListener(cast))\n            refresh_tray(True)\n\n        def remove_cast(self, uuid, _service: str, cast_info):\n            \"\"\"Called when a cast has been lost (MDNS info expired or host down).\"\"\"\n            global cast\n            if cast is not None and cast.uuid == uuid:\n                # lost connection to connected device\n                app_log.info(f'Lost connection to {cast.name} ({uuid}), switching to local device')\n            refresh_tray(True)\n\n        def update_cast(self, uuid, _service: str):\n            \"\"\"Called when a cast has been updated (MDNS info renewed or changed).\"\"\"\n            global cast\n            # not entirely sure what to do if this function is called\n            # due to recent connection errors, let's experiment\n            # if we should update the cast variable?\n            if cast is not None and cast.uuid == uuid:\n                cast_info = cast_browser.devices[uuid]\n                cast_2 = pychromecast.get_chromecast_from_cast_info(cast_info, zconf=zconf)\n                try:\n                    assert cast_2 == cast\n                except AssertionError as e:\n                    handle_exception(e)\n                cast = cast_2\n            refresh_tray(True)\n\n\n    def get_device(device_uuid):\n        # UnboundLocalError is possible\n        return pychromecast.get_chromecast_from_cast_info(cast_browser.devices[device_uuid], zconf)\n\n\n    def change_device(new_uuid='local', unresponsive_cast=False):\n        \"\"\"switch_device\n        if new_uuid is invalid, then the local device is selected\n        \"\"\"\n        global cast\n        app_log.info(f'change_device({new_uuid})')\n        try:\n            if not isinstance(new_uuid, UUID):\n                new_uuid = UUID(hex=new_uuid)\n            try:\n                if cast.uuid == new_uuid:\n                    app_log.info('noop because we are already connected to device wanting to change to')\n                    return True\n                app_log.info(f'changing device from {cast.cast_info.friendly_name} ({cast.uuid})')\n            except AttributeError:\n                app_log.info('changing device from local')\n            if new_uuid not in cast_browser.devices:\n                return False\n            new_device = get_device(new_uuid)\n            app_log.info(f'new device name: {new_device.cast_info.friendly_name}')\n        except (ValueError, TypeError):\n            # local device selected (any non uuid string)\n            new_device = None\n        except UnboundLocalError:\n            app_log.error('Could not connect to cast device', exc_info=True)\n            tray_notify(t('ERROR') + ': ' + t('Could not connect to cast device'))\n            return False\n        if cast == new_device:\n            # do not change device if local device is selected again\n            return True\n        # cache information\n        current_pos = 0\n        if cast is not None and cast.app_id == APP_MEDIA_RECEIVER:\n            if not unresponsive_cast and playing_status.busy():\n                mc = cast.media_controller\n                with suppress(PyChromecastError, AssertionError):\n                    mc.update_status()  # Switch device without playback loss\n                    current_pos = mc.status.adjusted_current_time\n                    if mc.status.player_is_playing or mc.status.player_is_paused:\n                        mc.stop()\n            with suppress(PyChromecastError, AssertionError):\n                cast.quit_app(10)\n        elif cast is None and 'audio_player' in globals() and audio_player.is_busy():\n            current_pos = audio_player.stop()\n        autoplay = playing_status.playing()\n        was_busy = playing_status.busy()\n        playing_status.stop()\n        cast = new_device\n        update_settings('device', None if cast is None else str(cast.uuid))\n        refresh_tray(True)\n        if was_busy and (music_queue or sar.alive):\n            app_log.info('continuing playback on new device')\n            if sar.alive:\n                play_system_audio(switching_device=True)\n            else:\n                play(position=current_pos, autoplay=autoplay, switching_device=True, show_error=True)\n        else:\n            if cast is not None:\n                with suppress(PyChromecastError):\n                    cast.quit_app(30)\n                try:\n                    cast.wait(timeout=WAIT_TIMEOUT)\n                except RequestTimeout:\n                    tray_notify(t('ERROR') + ': ' + t('Could not connect to cast device'))\n            update_volume(0 if settings['muted'] else settings['volume'], 'change_device')\n        return True\n\n\n    def un_shuffle_queue():\n        \"\"\"\n        To be called when shuffle is toggled off\n            sorts files by natural key...\n            splits at current playing\n        Does not affect next_queue\n        Keeps currently playing the same\n        \"\"\"\n        global music_queue, done_queue\n        if music_queue:\n            # keep current playing track the same\n            track = music_queue[0]\n            temp_list = list(music_queue) + list(done_queue)\n            temp_list.sort(key=natural_key_file)\n            split_queue_at = temp_list.index(track)\n            done_queue = deque(temp_list[:split_queue_at])\n            music_queue = deque(temp_list[split_queue_at:])\n        elif done_queue:\n            # sort and set queue to first item\n            music_queue = deque(sorted(done_queue, key=natural_key_file))\n            done_queue.clear()\n        gui_window.metadata['update_listboxes'] = True\n\n\n    def shuffle_queue():\n        \"\"\"\n        To be called when shuffle is toggled  on\n            extends the music_queue with done_queue\n            and then shuffles it\n        Does not affect next_queue\n        Keeps currently playing the same\n        \"\"\"\n        global music_queue\n        # keep track the same if in the process of playing something\n        first_index = 1 if playing_status.busy() and music_queue else 0\n        music_queue.extend(done_queue)\n        done_queue.clear()\n        # shuffle is slow for a deque so use a list\n        temp_list = list(music_queue)\n        better_shuffle(temp_list, first=first_index)\n        music_queue = deque(temp_list)\n        gui_window.metadata['update_listboxes'] = True\n\n\n    def format_pl_lb(tracks):\n        \"\"\"Return (list of formatted tracks, readable playlist time length) for playlist listbox\"\"\"\n        formatted_tracks = []\n        pl_length = 0\n        for i, track in enumerate(tracks):\n            formatted_tracks.append(f\"{i + 1}. {format_uri(track, _for='pl')}\")\n            with suppress(KeyError):\n                metadata = get_uri_metadata(track, read_file=False)\n                length = metadata.get('length')\n                if length is not None:\n                    pl_length += length\n        friendly_length = ''\n        if pl_length > 3600:\n            hours = pl_length // 3600\n            friendly_length = f'{hours:.0f}h '\n            pl_length -= hours * 3600\n        if pl_length > 60:\n            minutes = pl_length // 60\n            friendly_length += f'{minutes:.0f}m '\n            pl_length -= minutes * 60\n        friendly_length += f'{pl_length:.0f}s'\n        if friendly_length == '0s':\n            friendly_length = ''\n        return formatted_tracks, friendly_length\n\n\n    def format_uri(uri: str, use_basename=False, _for=''):\n        try:\n            if use_basename:\n                raise TypeError\n            metadata = get_uri_metadata(uri, read_file=False)\n            title, artist, album = metadata['title'], metadata['artist'], metadata['album']\n            if title == Unknown('Title'):\n                title = os.path.splitext(os.path.basename(uri))[0]\n                if '-' in title:\n                    artist, title = title.split('-', maxsplit=1)\n                    artist, title = artist.strip(), title.strip()\n            else:\n                assert not isinstance(title, Unknown)\n            if uri in url_metadata and '-' in title:\n                artist, title = title.split('-', maxsplit=1)\n                artist, title = artist.strip(), title.strip()\n            formatted = settings['track_format'].replace('&artist', str(artist)).replace('&title', title)\n            formatted = formatted.replace('&alb', str(album))\n            number = metadata.get('track_number', '0').zfill(2)\n            if '&trck' in formatted:\n                formatted = formatted.replace('&trck', str(number))\n            elif settings['show_track_number'] and number != '':\n                formatted = f'[{number}] {formatted}'\n            if not _for:\n                return formatted\n            # at > ?, we need to cut characters\n            if (cut_out := len(formatted) - {'queue': 70, 'pl': 50}[_for]) > 0:\n                cut_out = (cut_out + 3) // 2  # for 3 dots\n                middle = len(formatted) // 2\n                ro = middle + cut_out\n                lo = middle - cut_out\n                formatted = formatted[:lo] + '...' + formatted[ro:]\n            return formatted\n        except (TypeError, KeyError):\n            if uri.startswith('http'):\n                return uri\n            return os.path.splitext(os.path.basename(uri))[0]\n\n\n    def create_track_list():\n        \"\"\"Return usable list for queue listbox \"\"\"\n        try:\n            max_digits = int(log10(max(len(music_queue) - 1 + len(next_queue), len(done_queue) * 10))) + 2\n        except ValueError:\n            max_digits = 0\n        i = -len(done_queue)\n        tracks = []\n        # format: Index | Artists - Title\n        try:\n            for items in (done_queue, islice(music_queue, 0, 1), next_queue, islice(music_queue, 1, None)):\n                for uri in items:\n                    formatted_track = format_uri(uri, _for='queue')\n                    if settings['show_queue_index']:\n                        if i < 0:\n                            pre = f'\\u2012{abs(i)} '.center(max_digits, '\\u2000')\n                        else:\n                            pre = f'{i} '.center(max_digits, '\\u2000')\n                        formatted_track = f'\\u2004{pre}|\\u2000{formatted_track}'\n                        i += 1\n                    tracks.append(formatted_track)\n            return tracks\n        except RuntimeError:\n            # deque mutated during iteration\n            return create_track_list()\n\n\n    def update_gui():\n        if gui_window.is_closed():\n            return\n        try:\n            if playing_status.stopped():\n                gui_window['progress_bar'].update(0, disabled=True)\n            else:\n                value, range_max = (1, 1) if track_length is None else (floor(track_position), track_length)\n                gui_window['progress_bar'].update(value, range=(0, range_max), disabled=track_length is None)\n            metadata = get_current_metadata()\n            title, artist, album = metadata['title'], get_first_artist(metadata['artist']), metadata['album']\n            if playing_status.busy() and music_queue and not sar.alive:\n                if settings['show_track_number']:\n                    with suppress(KeyError):\n                        track_number = metadata['track_number']\n                        title = f'{track_number}. {title}'\n            if settings['mini_mode']:\n                title = truncate_title(title)\n            else:\n                default_device = None if cast is None else cast.cast_info\n                gui_window['devices'].update(value=Device(default_device), values=get_devices())\n                gui_window['album'].update(album)\n            gui_window['title'].update(title)\n            gui_window['artist'].update(artist)\n            image_data = PAUSE_BUTTON_IMG if playing_status.playing() else PLAY_BUTTON_IMG\n            gui_window['pause/resume'].update(image_data=image_data)\n            if settings['show_album_art']:\n                size = COVER_MINI if settings['mini_mode'] else COVER_NORMAL\n                bg = settings['theme']['background']\n                try:\n                    album_art_data = resize_img(get_current_art(), bg, size, default_art=DEFAULT_ART)\n                except OSError as e:\n                    handle_exception(e)\n                    album_art_data = resize_img(DEFAULT_ART, bg, size)\n                gui_window['artwork'].update(data=album_art_data)\n            repeat_button: Sg.Button = gui_window['repeat']\n            repeat_img, new_tooltip = repeat_img_tooltip(settings['repeat'])\n            repeat_button.metadata = settings['repeat']\n            repeat_button.update(image_data=repeat_img)\n            repeat_button.set_tooltip(new_tooltip)\n            shuffle_image_data = SHUFFLE_ON if settings['shuffle'] else SHUFFLE_OFF\n            gui_window['shuffle'].update(image_data=shuffle_image_data)\n        except TclError as e:\n            app_log.info(f'gui_window.is_closed() = {gui_window.is_closed()}')\n            handle_exception(e)\n\n\n    def after_play(title, artists: str, album, autoplay, switching_device):\n        app_log.info(f'autoplay={autoplay}, switching_device={switching_device}')\n        # prevent Windows from going to sleep\n        if autoplay:\n            if platform.system() == 'Windows':\n                ctypes.windll.kernel32.SetThreadExecutionState(0x80000000 | 0x00000001)\n            if settings['notifications'] and not switching_device and gui_window.is_closed():\n                # artists is comma separated string\n                tray_notify(t('Playing') + f': {get_first_artist(artists)} - {title}')\n            playing_status.play()\n            # system_media_controls.set_playing()\n        else:\n            playing_status.pause()\n            # system_media_controls.set_paused()\n        refresh_tray()\n        save_queues()\n        DiscordPresence.update(t('By') + f': {artists}', title, t('Listening'), confirm_connect=settings['discord_rpc'])\n        # update metadata of the player\n        # if platform.system() == 'Windows':\n            # bg = settings['theme']['background']\n            # # base64\n            # try:\n            #     album_art_data = resize_img(get_current_art(), bg, COVER_NORMAL, default_art=DEFAULT_ART)\n            # except OSError as e:\n            #     handle_exception(e)\n            #     album_art_data = resize_img(DEFAULT_ART, bg, COVER_NORMAL)\n            # img_data = io.BytesIO(b64decode(album_art_data))\n            # album_art: Image.Image = Image.open(img_data)\n            # thumb_path = Path('thumb.jpg').absolute()\n            # TODO: convert to mode RGB in case RGBA\n            # album_art.save(thumb_path)\n            # system_media_controls.set_metadata(title, artists, album, thumb_path.as_uri())\n            # system_media_controls.update_time()\n\n        if not gui_window.is_closed():\n            gui_window.metadata['update_listboxes'] = True\n            daemon_commands.put('__UPDATE_GUI__')\n        return True\n\n\n    def play_system_audio(switching_device=False, show_error=False):\n        global track_position, track_start, track_end, track_length\n        if cast is None:\n            tray_notify(t('ERROR') + ': ' + t('Not connected to a cast device'))\n            sar.alive = False\n            return False\n        try:\n            cast.wait(timeout=WAIT_TIMEOUT)\n            cast.set_volume(0 if settings['muted'] else settings['volume'] / 100)\n            mc = cast.media_controller\n            if mc.status.player_is_playing or mc.status.player_is_paused:\n                mc.stop()\n                mc.block_until_active(WAIT_TIMEOUT)\n            title = 'System Audio'\n            artist = platform.node()\n            album = 'Music Caster'\n            metadata = {'metadataType': 3, 'albumName': album, 'title': title, 'artist': artist}\n            url_metadata['SYSTEM_AUDIO'] = {'artist': artist, 'title': title, 'album': album}\n            sar.start()  # start recording system audio BEFORE the first request for data\n            api_key = settings['api_key']\n            url = f'http://{get_ipv4()}:{State.PORT}/system-audio/?api_key={api_key}'\n            mc.play_media(url, 'audio/wav', metadata=metadata, thumb=f'{url}/thumb', stream_type='LIVE')\n            mc.block_until_active(WAIT_TIMEOUT + 1)\n            stream_start_time = time.monotonic()\n            block_until = time.monotonic() + WAIT_TIMEOUT\n            while not mc.status.player_is_playing and time.monotonic() < block_until:\n                time.sleep(0.05)\n            mc.play()\n            sar.lag = time.monotonic() - stream_start_time  # ~1 second\n            playing_status.play_system_audio()\n            track_length = None\n            track_position = 0\n            track_start = time.monotonic()\n            after_play(title, artist, album, True, switching_device)\n            return True\n        except OSError:\n            tray_notify(t('ERROR') + ': ' + t('Could not find an output device to record'))\n        except PyChromecastError as e:\n            app_log.error(f'play_sys_audio failed to cast {repr(e)}')\n            if show_error:\n                tray_notify(t('ERROR') + ': ' + t('Could not connect to cast device') + ' (psa)')\n                change_device(unresponsive_cast=True)\n                return handle_exception(e)\n            cast_try_reconnect()\n            return play_system_audio(switching_device=switching_device, show_error=True)\n        except Exception as e:\n            handle_exception(e)\n            tray_notify('ERROR: Something went wrong')\n        return False\n\n    def url_expired(uri):\n        \"\"\" Returns if URI is a URL that has expired \"\"\"\n        expiry_time = url_metadata.get(uri, {}).get('expiry', 0)\n        # if expiry_time is None, url does not have an expiry\n        if expiry_time is None:\n            return False\n        return expiry_time < time.time()\n\n    def get_url_metadata(url, fetch_art=True) -> list[dict | URLMetadata]:\n        # TODO: cache in the database for persistence\n        # TODO: move to utils.py and add parameter url_metadata_cache\n        \"\"\"\n        Tries to parse url and set url_metadata[url] to parsed metadata\n        Supports: YouTube, Soundcloud, any url ending with a valid audio extension\n        \"\"\"\n        from yt_dlp.utils import YoutubeDLError\n        global deezer_opened, attribute_error_reported\n        ytsearch = 'ytsearch1'\n        metadata_list = []\n        app_log.info('get_url_metadata: ' + url)\n        with DatabaseConnection() as conn:\n            maybe_metadata = URLMetadata.from_db(conn, url)\n            if maybe_metadata and not maybe_metadata.is_expired:\n                return [maybe_metadata]\n        if url in url_metadata and not url_expired(url):\n            return [url_metadata[url]]\n        if url.startswith('www'):\n            url = f'http://{url}'\n        # short-circuit\n        if not url.startswith('http') and not url.startswith(ytsearch):\n            return metadata_list\n        if url.startswith('http') and valid_audio_file(url):  # source url e.g. http://...radio.mp3\n            ext = url[::-1].split('.', 1)[0][::-1]\n            url_frags = urlsplit(url)\n            title, artist, album = url_frags.path.split('/')[-1], url_frags.netloc, url_frags.path[1:]\n            url_metadata[url] = metadata = {'title': title, 'artist': artist, 'length': None, 'album': album,\n                                            'src': url, 'url': url, 'ext': ext, 'expiry': None}  # never expires\n            metadata_list.append(metadata)\n        elif 'twitch.tv' in url:\n            with suppress(StopIteration, IOError):\n                r = ydl_extract_info(url, quiet=not is_debug())\n                audio_url = max(r['formats'], key=lambda item: item['tbr'] * (item['vcodec'] == 'none'))['url']\n                # for now, expire immediately\n                metadata = {'title': r['description'], 'artist': r['uploader'], 'ext': r['ext'],\n                            'expiry': 0, 'album': 'Twitch', 'length': None,\n                            'art': r['thumbnail'], 'url': r['url'], 'audio_url': audio_url, 'src': url}\n                url_metadata[url] = metadata\n                metadata_list.append(metadata)\n        elif 'soundcloud.com' in url:\n            with suppress(StopIteration, IOError):\n                r = ydl_extract_info(url, quiet=not is_debug())\n                if 'entries' in r:\n                    for entry in r['entries']:\n                        parsed_url = parse_qs(urlparse(entry['url']).query)['Policy'][0].replace('_', '=')\n                        policy = b64decode(parsed_url).decode()\n                        expiry_time = json.loads(policy)['Statement'][0]['Condition']['DateLessThan']['AWS:EpochTime']\n                        album = entry.get('album', r.get('title', 'SoundCloud'))\n                        metadata = {'title': entry['title'], 'artist': entry['uploader'], 'album': album,\n                                    'length': entry['duration'], 'art': entry['thumbnail'], 'src': entry['webpage_url'],\n                                    'url': entry['url'], 'ext': entry['ext'],\n                                    'expiry': expiry_time}\n                        url_metadata[entry['webpage_url']] = metadata\n                        metadata_list.append(metadata)\n                else:\n                    url_policy_b64 = parse_qs(urlparse(r['url']).query)['Policy'][0].replace('_', '=')\n                    policy = b64decode(url_policy_b64).decode()\n                    expiry_time = json.loads(policy)['Statement'][0]['Condition']['DateLessThan']['AWS:EpochTime']\n                    url_metadata[url] = metadata = {'title': r['title'], 'artist': r['uploader'], 'album': 'SoundCloud',\n                                                    'src': url, 'ext': r['ext'], 'expiry': expiry_time,\n                                                    'length': r['duration'], 'art': r['thumbnail'], 'url': r['url']}\n                    metadata_list.append(metadata)\n        # youtube\n        elif (ytid := get_yt_id(url)) is not None or url.startswith(f'{ytsearch}:'):\n            # lazily get videos in the playlist\n            if ytid is not None and ytid.startswith('PL'):\n                videos = scrapetube.get_playlist(ytid)\n                for i, video in enumerate(videos):\n                    _url = f'https://www.youtube.com/watch?v={video[\"videoId\"]}'\n                    src_url = f'{_url}&list={ytid}'\n                    # fetch first most URL of playlist so that play_url does not break\n                    if not metadata_list:\n                        if m_lst := get_url_metadata(_url):\n                            m = m_lst[0]\n                            m['pl_src'] = src_url\n                            metadata_list.extend(m_lst)\n                    else:\n                        metadata = URLMetadata(\n                            src=_url,\n                            url_type='YouTube',\n                            title=video['title']['runs'][0]['text'],\n                            artist=video['shortBylineText']['runs'][0]['text'],\n                            album='YouTube',\n                            id= video['videoId'],\n                            playlist_url=src_url,\n                            expiry=0,\n                            album_cover_url=f'https://img.youtube.com/vi/{ytid}/maxresdefault.jpg'\n                        )\n                        url_metadata[_url] = metadata\n                        metadata_list.append(metadata)\n            else:\n                # type error in case video was deleted or unavailable\n                try:\n                    r = ydl_extract_info(url, quiet=not is_debug())\n                    if 'entries' in r:\n                        for entry in r['entries']:\n                            metadata = ydl_get_metadata(entry, duration_helper=False)\n                            metadata['ytid'] = entry['id']\n                            # if duration > 10 minutes, try to parse out timestamps for track from comment section\n                            if entry.get('duration', 0) > 600:\n                                metadata['timestamps'] = get_video_timestamps(entry)\n                            for webpage_url in get_yt_urls(entry['id']):\n                                url_metadata[webpage_url] = metadata\n                            metadata_list.append(metadata)\n                    else:\n                        # single video\n                        metadata = ydl_get_metadata(r, duration_helper=False)\n                        metadata['ytid'] = r['id']\n                        # if duration > 10 minutes, try to parse out timestamps for track from comment section\n                        if r.get('duration', 0) > 600:\n                            metadata['timestamps'] = get_video_timestamps(r)\n                        for webpage_url in get_yt_urls(r['id']):\n                            url_metadata[webpage_url] = metadata\n                        url_metadata[url] = metadata\n                        metadata_list.append(metadata)\n                except (IOError, TypeError) as e:\n                    print('error', e)\n                except AttributeError as e:\n                    app_log.error(f'yt-dlp failed to extract {url}')\n                    trace_back_msg = traceback.format_exc().replace('\\\\', '/')\n                    if not attribute_error_reported:\n                        if 'PhantomJS' in trace_back_msg:\n                            try:\n                                install_phantomjs(PHANTOMJS_DIR)\n                                add_to_path(PHANTOMJS_DIR / 'bin')\n                            except Exception:\n                                open_in_browser('https://phantomjs.org/download.html')\n                        if 'blocked it on copyright grounds' not in trace_back_msg:\n                            attribute_error_reported = True\n                            handle_exception(e)\n        # Spotify restricted web API access\n        elif url.startswith('https://open.spotify.com') and False:\n            # spotify metadata has already been fetched, so just get youtube metadata\n            if url in url_metadata and isinstance(url_metadata[url], dict):\n                metadata = url_metadata[url]\n                if 'ytid' in metadata:\n                    youtube_metadata = get_url_metadata(f\"https://www.youtube.com/watch?v={metadata['ytid']}\", False)\n                else:\n                    query = f\"{get_first_artist(metadata['artist'])} - {metadata['title']}\"\n                    youtube_metadata = get_url_metadata(f'{ytsearch}:{query}', False)\n                    if metadata['src'] == '':\n                        metadata['src'] = youtube_metadata['src']\n                if youtube_metadata:\n                    youtube_metadata = youtube_metadata[0]\n                    # these are the only fields we need to update since they actually expire\n                    for key in ('expiry', 'url', 'audio_url', 'ext', 'ytid', 'length'):\n                        metadata[key] = youtube_metadata[key]\n                    url_metadata[metadata['src']] = url_metadata[youtube_metadata['src']] = metadata\n                    metadata_list.append(metadata)\n                else:\n                    error_msg = t('ERROR') + ': ' + t('Could not fetch audio for $URL').replace('$URL', url) + ' :('\n                    tray_notify(error_msg)\n            else:\n                # get a list of spotify tracks from the track/album/playlist Spotify URL\n                try:\n                    spotify_tracks = get_spotify_tracks(url)\n                except AttributeError:\n                    spotify_tracks = []\n                except Exception as e:\n                    handle_exception(e)\n                    spotify_tracks = []\n                if spotify_tracks:\n                    metadata = spotify_tracks[0]\n                    query = f\"{get_first_artist(metadata['artist'])} - {metadata['title']}\"\n                    youtube_metadata = get_url_metadata(f'{ytsearch}:{query}', False)\n                    if youtube_metadata:\n                        youtube_metadata = youtube_metadata[0]\n                        # expiry, url, and audio_url are not overwritten here\n                        metadata = {**youtube_metadata, **metadata}\n                        if metadata['src'] == '':\n                            metadata['src'] = youtube_metadata['src']\n                        url_metadata[metadata['src']] = url_metadata[youtube_metadata['src']] = metadata\n                        # if url is a spotify track, set its metadata\n                        if len(spotify_tracks) == 1:\n                            url_metadata[url] = metadata\n                        metadata_list.append(metadata)\n                        for spotify_track in islice(spotify_tracks, 1, None):\n                            url_metadata[spotify_track['src']] = spotify_track\n                            uris_to_scan.put(spotify_track['src'])\n                            metadata_list.append(spotify_track)\n        elif url.startswith('https://deezer.page.link') or url.startswith('https://www.deezer.com'):\n            try:\n                for metadata in get_deezer_tracks(url):\n                    url_metadata[metadata['src']] = metadata\n                    metadata_list.append(metadata)\n            except LookupError:\n                # login cookie not found\n                # first time open the browser\n                if not deezer_opened:\n                    open_in_browser('https://www.deezer.com/login')\n                    tray_notify(t('ERROR') + ': ' + t('Not logged into deezer.com'))\n                    deezer_opened = True\n                # fallback to deezer -> youtube\n                if url in url_metadata:\n                    metadata = url_metadata[url]\n                    query = f\"{get_first_artist(metadata['artist'])} - {metadata['title']}\"\n                    youtube_metadata = get_url_metadata(f'{ytsearch}:{query}', False)[0]\n                    metadata = {**youtube_metadata, **metadata}\n                    url_metadata[metadata['src']] = url_metadata[youtube_metadata['src']] = metadata\n                    metadata_list.append(metadata)\n                else:\n                    deezer_tracks = get_deezer_tracks(url, login=False)\n                    if deezer_tracks:\n                        metadata = deezer_tracks[0]\n                        query = f\"{get_first_artist(metadata['artist'])} - {metadata['title']}\"\n                        youtube_metadata = get_url_metadata(f'{ytsearch}:{query}', False)[0]\n                        metadata = {**youtube_metadata, **metadata}\n                        url_metadata[metadata['src']] = url_metadata[youtube_metadata['src']] = metadata\n                        metadata_list.append(metadata)\n                        for deezer_track in islice(deezer_tracks, 1, None):\n                            url_metadata[deezer_track['src']] = deezer_track\n                            uris_to_scan.put(deezer_track['src'])\n                            metadata_list.append(deezer_track)\n        else:\n            with suppress(IOError, TypeError, AttributeError, YoutubeDLError):\n                r = ydl_extract_info(url, quiet=not is_debug())\n                if 'entries' in r:\n                    for entry in r['entries']:\n                        url_metadata[entry['webpage_url']] = metadata = ydl_get_metadata(entry)\n                        metadata_list.append(metadata)\n                else:\n                    url_metadata[url] = url_metadata[r['webpage_url']] = metadata = ydl_get_metadata(r)\n                    metadata_list.append(metadata)\n        if metadata_list and fetch_art:\n            # fetch and cache artwork for first url\n            metadata = metadata_list[0]\n            if metadata.get('art') is not None and 'art_data' not in metadata:\n                art_url = metadata['art']\n                try:\n                    url_metadata[metadata['src']]['art_data'] = b64encode(requests.get(art_url).content)\n                except requests.RequestException as e:\n                    app_log.info(f'Could not fetch art url {art_url}')\n                    handle_exception(e)\n        return metadata_list\n\n\n    def play_url(position=0, autoplay=True, switching_device=False, show_error=False) -> bool:\n        global cast, playing_url, track_length, track_start, track_end, track_position\n        url = music_queue[0]\n        if not url.startswith('http') and not url.startswith('www') and not url.startswith('//'):\n            return False\n        metadata_list = get_url_metadata(url)\n        if not metadata_list:\n            if settings['notifications']:\n                tray_notify(\n                    t('ERROR') + ': ' + t('Could not play $URL').replace('$URL', url)\n                )\n            return False\n        if len(metadata_list) > 1:\n            # url was for multiple sources\n            with suppress(IndexError):\n                music_queue.popleft()\n            music_queue.extendleft((metadata['src'] for metadata in reversed(metadata_list)))\n        metadata = metadata_list[0]\n        title, artist, album = metadata['title'], metadata['artist'], metadata['album']\n        ext = metadata['ext']\n        url = metadata['audio_url'] if cast is None and 'audio_url' in metadata else metadata['url']\n        api_key = settings['api_key']\n        thumbnail = metadata['art'] if 'art' in metadata else f'{get_ipv4()}/file?path=DEFAULT_ART&api_key={api_key}'\n        # can be None\n        track_length = metadata['length']\n        try:\n            app_log.info(f'cast.socket_client.is_alive(): {cast.socket_client.is_alive()}')\n            cast.wait(timeout=WAIT_TIMEOUT)\n            cast.set_volume(0 if settings['muted'] else settings['volume'] / 100)\n            mc = cast.media_controller\n            _metadata = {'metadataType': 3, 'albumName': album, 'title': title, 'artist': artist}\n            stream_type = 'LIVE' if track_length is None else 'BUFFERED'\n            mc.play_media(url, f'video/{ext}', metadata=_metadata, thumb=thumbnail,\n                            current_time=position, autoplay=autoplay, stream_type=stream_type)\n            mc.block_until_active(WAIT_TIMEOUT)\n            if track_length is None:\n                mc.play()\n        except AttributeError:\n            # cast is None, so play on local\n            volume = 0 if settings['muted'] else settings['volume'] / 100\n            if autoplay or not metadata.get('is_live', False):\n                audio_player.play(\n                    url, start_playing=autoplay, start_from=position, volume=volume\n                )\n        except NotConnected:\n            app_log.error('play_url failed to cast because cast was not connected')\n            tray_notify(\n                t('ERROR')\n                + ': '\n                + t('Could not connect to cast device')\n                + ' (play_url)'\n            )\n            change_device(unresponsive_cast=True)\n            return False\n        except (PyChromecastError, OSError) as e:\n            app_log.error(f'play_url failed to cast {repr(e)}')\n            if show_error:\n                tray_notify(\n                    t('ERROR')\n                    + ': '\n                    + t('Could not connect to cast device')\n                    + ' (play_url)'\n                )\n                return handle_exception(e)\n            cast_try_reconnect()\n            return play_url(position, autoplay, switching_device, show_error=True)\n        playing_status.play_uri(position, track_length, cast is None)\n        track_position = position\n        track_start = time.monotonic() - track_position\n        if track_length is not None:\n            track_end = track_start + track_length\n        playing_url = True\n        after_play(title, artist, album, autoplay, switching_device)\n        return True\n\n    # up to 4 seconds!\n    def play(position=0, autoplay=True, switching_device=False, show_error=False, from_set_pos=False):\n        global cast, track_start, track_end, track_length, track_position, music_queue, playing_url, cast_browser, zconf, LAST_PLAYED\n        uri = music_queue[0]\n        while not os.path.exists(uri):\n            if play_url(position, autoplay, switching_device):\n                return\n            app_log.info(f'{uri} does not exist or is unplayable')\n            # it's possible that these queues are empty\n            with suppress(IndexError):\n                done_queue.append(music_queue.popleft())\n            with suppress(IndexError):\n                music_queue.appendleft(next_queue.popleft())\n            try:\n                uri, position = music_queue[0], 0\n            except IndexError:\n                return\n        uri_path = Path(uri)\n        uri = uri_path.as_posix()\n        playing_url = sar.alive = False\n        app_log.info(f'{uri_path.name} @{position}, autoplay={autoplay}, switching_device={switching_device}')\n        try:\n            track_length = get_audio_length(uri)\n        except InvalidAudioFile:\n            done_queue.append(music_queue.popleft())\n            msg = t('ERROR') + ': ' + t('Invalid audio file $FILE').replace('$FILE', uri)\n            tray_notify(msg)\n            if music_queue:\n                play()\n            return\n        metadata = get_metadata_wrapped(uri)\n        # update metadata of track in case something changed\n        all_tracks[uri] = metadata\n        volume = 0 if settings['muted'] else settings['volume'] / 100\n        if cast is None:  # play locally\n            audio_player.play(uri, volume=volume, start_playing=autoplay, start_from=position)\n            playing_status.play_uri(position, track_length, True)\n        else:\n            # track_end = time.monotonic() + WAIT_TIMEOUT * 2 + 1\n            try:\n                url_args = urllib.parse.urlencode({'path': uri, 'api_key': settings['api_key']})\n                url = f'http://{get_ipv4()}:{State.PORT}/file?{url_args}'\n                app_log.info(f'calling cast.wait on device {cast.cast_info.friendly_name} / {cast.uuid}')\n                app_log.info(f'cast.media_controller player state: {cast.media_controller.status.player_state}')\n                cast.wait(timeout=15 if show_error else WAIT_TIMEOUT)\n                if not from_set_pos:\n                    app_log.info(f'try: cast.set_volume({volume})')\n                    with suppress(RequestTimeout):\n                        cast.set_volume(volume)\n                mc = cast.media_controller\n                # https://developers.google.com/cast/docs/reference/web_receiver/cast.framework.messages#.MetadataType\n                metadata = {'title': str(metadata['title']), 'artist': str(metadata['artist']),\n                            'albumName': str(metadata['album']), 'metadataType': 3}\n                ext = uri.split('.')[-1]\n                # pychromecast.error.NotConnected: Chromecast unknown:8009 is connecting..\n                mc.play_media(url, f'audio/{ext}', current_time=position,\n                              metadata=metadata, thumb=f'{url}&thumbnail_only=true', autoplay=autoplay)\n                mc.block_until_active(WAIT_TIMEOUT)\n                playing_status.play_uri(position, track_length, False)\n                app_log.info(f'mc.status.player_state={mc.status.player_state}')\n            except (NotConnected, AttributeError) as e:\n                app_log.error('cast device is not connected', exc_info=True)\n                app_log.info(f'cast.media_controller player state: {cast.media_controller.status.player_state}')\n                r\"\"\"\n                2022-03-09 10:52:40,920 ERROR (396): [Computer room(192.168.1.9):8009]\n                Failed to connect to service HostServiceInfo(type='mdns',\n                data='Google-Home-Mini-$HASH._googlecast._tcp.local.'), retrying in 5.0s\n                Traceback (most recent call last):\n                  File \"music_caster.py\", line 1733, in play\n                  File \"pychromecast/controllers/receiver.py\", line 181, in set_volume\n                  File \"pychromecast/controllers/__init__.py\", line 95, in send_message\n                  File \"pychromecast/controllers/__init__.py\", line 99, in send_message_nocheck\n                  File \"pychromecast/socket_client.py\", line 930, in send_platform_message\n                  File \"pychromecast/socket_client.py\", line 924, in send_message\n                pychromecast.error.NotConnected: Chromecast 192.168.1.9:8009 is connecting...\n                \"\"\"\n                if not IS_FROZEN or is_debug():\n                    print(e)\n                if show_error:\n                    tray_notify(t('ERROR') + ': ' + t('Could not connect to cast device') + ' (play)')\n                    change_device(unresponsive_cast=True)\n                    return False\n                return play(position=position, autoplay=autoplay, switching_device=switching_device, show_error=True)\n            except (PyChromecastError, OSError, RuntimeError, AssertionError) as e:\n                r\"\"\"\n                Traceback (most recent call last):\n                File \"music_caster.py\", line 2137, in play\n                File \"pychromecast\\__init__.py\", line 505, in wait\n                pychromecast.error.RequestTimeout: Execution of wait timed out after 5 s.\n                \"\"\"\n                app_log.error('play failed to cast', exc_info=True)\n                app_log.info(f'cast.media_controller player state: {cast.media_controller.status.player_state}')\n                app_log.info('falling back to playing on local device')\n                if not show_error:\n                    try_reconnecting = True\n                    if cast.media_controller.status.player_state == 'UNKNOWN':\n                        try:\n                            cast.media_controller.stop()\n                            cast.quit_app(15)\n                            cast.wait(15)\n                            try_reconnecting = False\n                        except PyChromecastError as e:\n                            app_log.error('failed to stop, quit, or wait on cast device', exc_info=True)\n                            handle_exception(e)\n                    if try_reconnecting and not cast_try_reconnect():\n                        show_error = True\n                if show_error:\n                    tray_notify(t('ERROR') + ': ' + t('Could not connect to cast device') + ' (play)')\n                    change_device(unresponsive_cast=True)\n                    handle_exception(e)\n                    switching_device=True\n                return play(position=position, autoplay=autoplay, switching_device=switching_device, show_error=True)\n        track_position = position\n        track_start = time.monotonic() - track_position\n        track_end = track_start + track_length\n        app_log.info(f'track_end = {track_end:.2f}, track_start = {track_start:.2f}, track_length = {track_length:.2f}')\n        LAST_PLAYED = time.time()\n        return after_play(metadata['title'], metadata['artist'], metadata.get('album'), autoplay, switching_device)\n\n\n    def metadata_key(filename, album_sort=True):\n        \"\"\" Sort by (artist, album, track number, title) \"\"\"\n        m = get_uri_metadata(filename)\n        try:\n            tn = int(m.get('track_number'))\n        except (ValueError, TypeError):\n            tn = 1\n        return (m['album'].casefold() if album_sort else ''), tn, m['artist'].casefold(), m['title'].casefold()\n\n\n    def play_uris(uris: Iterable, return_if_empty=True, queue_uris=False,\n                  play_next=False, merge_tracks=0, natural_sort=True):\n        \"\"\"\n        TODO: make thread safe\n        Appends all music files in the provided uris (playlist names, folders, files, urls) to a temp list,\n            which is shuffled if shuffled is enabled in settings, and then extends music_queue.\n            Note: valid filesystem paths take precedence over playlist names\n        If queue_only is false, the music queue and done queue are cleared,\n            before files are added to the music_queue\n        play_next has priority over queue_uris\n        merge_tracks indicates the number of tracks that were already propogated but need to be merged\n        If sort is False, shuffle being off does not sort items\n        \"\"\"\n        temp_queue, albums_found = [], set()\n        for track in get_audio_uris(uris):\n            album_name = get_uri_metadata(track)['album']\n            if not isinstance(album_name, Unknown):\n                albums_found.add(album_name)\n            elif album_name != Unknown('Album'):\n                # NOTE: debugging purpose\n                # TODO: remove condition\n                handle_exception(Exception(f'found incorrect {album_name} instead of Unknown(\"Album\")'))\n            temp_queue.append(track)\n        if not temp_queue and return_if_empty:\n            return False\n        # fresh play condition\n        if not queue_uris and not play_next and merge_tracks == 0:\n            music_queue.clear()\n            done_queue.clear()\n        # handle merge_tracks case\n        if merge_tracks > 0:\n            with suppress(IndexError):\n                if play_next:\n                    if settings['reversed_play_next']:\n                        for _ in range(merge_tracks):\n                            temp_queue.append(next_queue.popleft())\n                    else:\n                        for _ in range(merge_tracks):\n                            temp_queue.append(next_queue.pop())\n                elif queue_uris:\n                    for _ in range(merge_tracks):\n                        temp_queue.append(music_queue.pop())\n                else:  # to play\n                    for _ in range(merge_tracks):\n                        temp_queue.append(music_queue.popleft())\n        # shuffle or sort\n        if settings['shuffle']:\n            shuffle(temp_queue)\n        elif natural_sort:\n            temp_queue.sort(key=natural_key_file)\n        else:\n            # do custom sort only if possible album was queued\n            try:\n                temp_queue.sort(key=lambda filename: metadata_key(filename, album_sort=len(albums_found) > 1))\n            except Exception as e:\n                app_log.error('could not sort temp_queue', exc_info=True)\n                handle_exception(e)\n        # add to next queue condition\n        if play_next:\n            if settings['reversed_play_next']:\n                next_queue.extendleft(reversed(temp_queue))\n            else:\n                next_queue.extend(temp_queue)\n            gui_window.metadata['update_listboxes'] = True\n            return True\n        # extend only if merge_tracks == 0 or we are queueing the tracks\n        if queue_uris or merge_tracks == 0:\n            music_queue.extend(temp_queue)\n        else:  # API play command with history (merge_tracks > 0)\n            music_queue.extendleft(reversed(temp_queue))\n        if not queue_uris:\n            if music_queue:\n                play()\n                return True\n            elif next_queue:\n                playing_status.play()\n                next_track()\n                return True\n        gui_window.metadata['update_listboxes'] = True\n        save_queues()\n        return True\n\n\n    def play_all(starting_files: Iterable = None, queue_only=False):\n        \"\"\"\n        Clears done queue, music queue, adds starting files to music queue.\n        Shuffles and queues files in the library without duplication\n        \"\"\"\n        if starting_files is None:\n            starting_files = []\n        if not queue_only:\n            music_queue.clear()\n            done_queue.clear()\n        music_queue.extend(starting_files)\n        ignore_files = set(starting_files).union(music_queue).union(done_queue).union(next_queue)\n        if indexing_tracks_thread is not None and indexing_tracks_thread.is_alive() and settings['notifications']:\n            info = t('INFO')\n            tray_notify(f'{info}: ' + t('Library indexing incomplete, only scanned files have been added'))\n        start_shuffle_from = len(music_queue)\n        music_queue.extend(index_all_tracks(False, ignore_files).keys())\n        better_shuffle(music_queue, start_shuffle_from)\n        if not queue_only:\n            if not music_queue and next_queue:\n                music_queue.append(next_queue.popleft())\n            if music_queue:\n                play()\n        gui_window.metadata['update_listboxes'] = True\n\n\n    def queue_all():\n        if not any(filter(lambda thread: thread.name == 'PlayAll', threading.enumerate())):\n            Thread(target=play_all, kwargs={'queue_only': True}, daemon=True, name='PlayAll').start()\n\n\n    def open_dialog(title, for_dir=False, filetypes=None, single_file=False):\n        if settings['use_last_folder']:\n            prev_folder = initial_folder = settings['last_folder']\n            while not os.path.exists(initial_folder):\n                initial_folder = Path(initial_folder).parent.absolute()\n                if prev_folder == initial_folder:  # prevent infinite loop\n                    initial_folder = get_default_music_folder()\n                    break\n        else:\n            initial_folder = get_default_music_folder()\n        _root = tkinter.Tk()\n        _root.withdraw()\n        if platform.system() != 'Linux':\n            _root.iconbitmap(WINDOW_ICON)\n        if for_dir:\n            paths = fd.askdirectory(title=title, initialdir=initial_folder, parent=_root)\n        elif single_file:\n            paths = [fd.askopenfilename(title=title, parent=_root, initialdir=initial_folder,\n                                        filetypes=filetypes)]\n        else:\n            paths = fd.askopenfilenames(title=title, parent=_root, initialdir=initial_folder,\n                                        filetypes=filetypes)\n        _root.destroy()\n        return paths\n\n\n    def file_action(action='pf'):\n        \"\"\"\n        action = {'pf': 'Play Files', 'pfn': 'Play Files Next', 'qf': 'Queue Files'}\n        :param action: one of {'pf': 'Play Files', 'pfn': 'Play Files Next', 'qf': 'Queue Files'}\n        :return:\n        \"\"\"\n        paths = open_dialog(t('Select Audio Files'), filetypes=AUDIO_FILE_TYPES)\n        if paths:\n            natural_sort = len(paths) > 20\n            update_settings('last_folder', os.path.dirname(paths[-1]))\n            app_log.info(f'file_action(action={action}), len(lst) is {len(paths)}')\n            if action in {t('Play'), 'pf'}:\n                if settings['queue_library']:\n                    return play_all(starting_files=paths)\n                return play_uris(paths, natural_sort=natural_sort)\n            if action in {t('Queue'), 'qf'}:\n                return play_uris(paths, queue_uris=True, natural_sort=natural_sort)\n            if action in {t('Play Next'), 'pfn'}:\n                return play_uris(paths, play_next=True, natural_sort=natural_sort)\n            gui_window.metadata['last_event'] = 'file_action'\n\n\n    def video_file_action():\n        path = open_dialog(t('Select Video Files'), filetypes=AUDIO_FILE_TYPES, single_file=True)[0]\n        device_id = gui_window['devices'].get().id\n        url_args = urllib.parse.urlencode({'path': path, 'api_key': settings['api_key']})\n        url = f'http://{get_ipv4()}:{State.PORT}/file?{url_args}'\n        video_device: Chromecast = get_device(device_id)\n        video_device.wait(timeout=WAIT_TIMEOUT)\n        mc = cast.media_controller\n        # https://developers.google.com/cast/docs/reference/web_receiver/cast.framework.messages.MovieMediaMetadata\n        metadata = {'title': Path(path).stem, 'metadataType': 1}\n        ext = path.split('.')[-1]\n        mc.play_media(url, f'video/{ext}', metadata=metadata, autoplay=True)\n        mc.block_until_active(WAIT_TIMEOUT)\n\n\n    def folder_action(action='pf'):\n        \"\"\"\n        :param action: one of {'pf': 'Play Folder', 'qf': 'Queue Folder', 'pfn': 'Play Folder Next'}\n        \"\"\"\n        directory = open_dialog(t('Select Folder'), for_dir=True)\n        if directory:\n            gui_window.metadata['last_event'] = Sg.TIMEOUT_KEY\n            update_settings('last_folder', directory)\n            app_log.info(f'folder_action: action={action}')\n            if action in {t('Play'), 'pf'}:\n                res = play_uris(directory, natural_sort=False)\n            elif action in {t('Play Next'), 'pfn'}:\n                res = play_uris(directory, play_next=True, natural_sort=False)\n            elif action in {t('Queue'), 'qf'}:\n                res = play_uris(directory, queue_uris=True, natural_sort=False)\n            else:\n                res = False\n            if res:\n                gui_window.metadata['update_listboxes'] = True\n                save_queues()\n            elif settings['notifications']:\n                tray_notify(t('ERROR') + ': ' + t('Folder does not contain audio files'))\n        else:\n            gui_window.metadata['last_event'] = 'folder_action'\n\n    def get_track_position():\n        global track_position\n        if playing_status.busy():\n            if cast is not None:\n                if playing_status.playing():\n                    track_position = time.monotonic() - track_start\n            else:\n                track_position = audio_player.get_pos()\n        return track_position\n\n    def pause(source=''):\n        \"\"\"\n        Returns true if player was playing\n        Returns false if player was not playing\n        can be called from a non-main thread\n        \"\"\"\n        global track_position, LAST_PLAYED\n        app_log.info(f'pause({source}), playing status = {playing_status}')\n        if playing_status.playing():\n            if platform.system() == 'Windows':\n                ctypes.windll.kernel32.SetThreadExecutionState(0x80000000)\n            try:\n                if cast is None:\n                    track_position = time.monotonic() - track_start\n                    if get_current_metadata().get('is_live', False):\n                        audio_player.stop()\n                    else:\n                        audio_player.pause()\n                    app_log.info('paused local audio player')\n                else:\n                    mc = cast.media_controller\n                    try:\n                        mc.pause()\n                    except (RequestTimeout, RequestFailed):\n                        try:\n                            cast.wait(30)\n                            cast.media_controller.pause()\n                        except (RequestTimeout, RequestFailed):\n                            app_log.error('failed to pause cast device', exc_info=True)\n                            return False\n                    block_until = time.monotonic() + 5\n                    while not mc.status.player_is_paused and time.monotonic() < block_until:\n                        time.sleep(0.1)\n                    if mc.status.adjusted_current_time is not None:\n                        track_position = mc.status.adjusted_current_time\n                    app_log.info('paused cast device')\n                playing_status.pause()\n                if music_queue or sar.alive:\n                    metadata = get_current_metadata()\n                    title, artist = metadata['title'], metadata['artist']\n                    DiscordPresence.update(t('By') + f': {artist}', title, 'Paused', confirm_connect=settings['discord_rpc'])\n            except UnsupportedNamespace:\n                stop('pause')\n            if not gui_window.is_closed():\n                daemon_commands.put('__UPDATE_GUI__')\n            refresh_tray()\n            LAST_PLAYED = time.time()\n            return True\n        return False\n\n\n    def resume(source=''):\n        global track_end, track_position, track_start\n        app_log.info(f'resume(source = {source}), playing status = {playing_status}')\n        if playing_status.paused():\n            if music_queue and not os.path.exists(music_queue[0]) and url_expired(music_queue[0]):\n                app_log.info('url expired, hard playing')\n                # check if the url has expired before resuming in case it has been a long time\n                play(position=track_position, autoplay=False)\n            try:\n                if cast is None:\n                    if get_current_metadata().get('is_live', False):\n                        play()\n                    else:\n                        audio_player.resume()\n                        app_log.info('resumed local audio player')\n                else:\n                    mc = cast.media_controller\n                    mc.update_status()\n                    mc.play()\n                    mc.block_until_active(WAIT_TIMEOUT)\n                    if mc.status.adjusted_current_time is not None:\n                        track_position = mc.status.adjusted_current_time\n                track_start = time.monotonic() - track_position\n                if track_length is not None:\n                    track_end = track_start + track_length\n                playing_status.play()\n                metadata = get_current_metadata()\n                title, artist = metadata['title'], get_first_artist(metadata['artist'])\n                DiscordPresence.update(t('By') + f': {artist}',title, t('Listening'), confirm_connect=settings['discord_rpc'])\n                if platform.system() == 'Windows':\n                    ctypes.windll.kernel32.SetThreadExecutionState(0x80000000 | 0x00000001)\n                if not gui_window.is_closed():\n                    daemon_commands.put('__UPDATE_GUI__')\n                refresh_tray()\n            except (PyChromecastError, AssertionError) as e:\n                print('error', e)\n                if music_queue:\n                    return play(position=track_position)\n            return True\n        return False\n\n\n    def stop(stopped_from: str, stop_cast=True):\n        \"\"\"\n        can be called from a non-main thread\n        does not check if playing_status is busy\n        \"\"\"\n        global track_start, track_end, track_position, track_length, playing_url\n        app_log.info(f'stopped from {stopped_from}, stop_cast={stop_cast}')\n        # allow Windows to go to sleep\n        if platform.system() == 'Windows':\n            # system_media_controls.set_stopped()\n            ctypes.windll.kernel32.SetThreadExecutionState(0x80000000)\n        playing_status.stop()\n        sar.alive = playing_url = False\n        DiscordPresence.clear(settings['discord_rpc'])\n        if cast is None:\n            audio_player.stop()\n        elif cast.app_id == APP_MEDIA_RECEIVER and stop_cast:\n            mc = cast.media_controller\n            with suppress(PyChromecastError):\n                mc.stop()\n                block_until = time.monotonic() + 5  # 5 seconds\n                status = mc.status\n                while (\n                    status.player_is_playing or status.player_is_paused\n                ) and time.monotonic() > block_until:\n                    time.sleep(0.1)\n                if status.player_is_playing or status.player_is_paused:\n                    try:\n                        cast.quit_app(30)\n                    except PyChromecastError as e:\n                        app_log.error('cast.quit_app failed', exc_info=True)\n                        handle_exception(e)\n        track_start = track_position = track_end = track_length = 0\n        if not gui_window.is_closed():\n            daemon_commands.put('__UPDATE_GUI__')\n        refresh_tray()\n\n\n    def set_pos(new_position: int):\n        \"\"\"\n        AKA: seeking\n        sets position of audio player or cast to new_position\n        \"\"\"\n        global track_position, track_start, track_end, SYNC_WITH_CHROMECAST\n        app_log.info('acquiring CAST_LOCK')\n        with CAST_LOCK:\n            t1 = time.time()\n            app_log.info('trying to set playback position')\n            if cast is not None:\n                SYNC_WITH_CHROMECAST = time.time() + 1\n                try:\n                    pass\n                    # cast.media_controller.update_status()\n                except (PyChromecastError, AssertionError):\n                    #   File \"C:\\Users\\maste\\Documents\\GitHub\\music-caster\\.venv\\Lib\\site-packages\\pychromecast\\socket_client.py\", line 891, in send_message\n                    #     assert self.socket is not None\n                    # AssertionError\n                    app_log.info('trying to wait on cast')\n                    cast.wait(WAIT_TIMEOUT)\n                    app_log.info(f'cast.wait took {time.time() - t1:.2f} seconds')\n                if cast.media_controller.status.player_is_idle and music_queue:\n                    app_log.info('called play instead')\n                    return play(position=new_position, autoplay=playing_status.playing(), from_set_pos=True)\n                else:\n                    for _ in range(2):\n                        try:\n                            # seek is unstable. use play instead\n                            app_log.info('call play with new position')\n                            return play(position=new_position, autoplay=playing_status.playing(), from_set_pos=True)\n                            # cast.media_controller.seek(new_position)\n                            if playing_status.paused():\n                                cast.media_controller.pause()\n                            break\n                        except (RequestFailed, RequestTimeout) as e:\n                            app_log.exception('seek \"failed\"')\n                            if not IS_FROZEN or is_debug():\n                                print(f'encountered error while seeking: {type(e)} {e}')\n                            # seeking is broken, prefer play instead\n                            return play(position=new_position, autoplay=playing_status.playing(), from_set_pos=True)\n                            break\n                        except (NotConnected):\n                            app_log.exception('seek failed')\n                            cast.wait(WAIT_TIMEOUT)\n                SYNC_WITH_CHROMECAST = time.time() + 0.5\n            else:\n                audio_player.set_pos(new_position)\n            track_position = new_position\n            track_start = time.monotonic() - track_position\n            track_end = track_start + track_length\n\n\n    def next_track(from_timeout=False, times=1, forced=False, ignore_timestamps=False):\n        \"\"\"\n        :param from_timeout: whether next track is due to the currently playing audio ending\n        :param times: number of tracks ahead\n        :param forced: if True, ignore current playing status\n        :param ignore_timestamps: whether to ignore timestamps for a track\n        :return:\n        \"\"\"\n        app_log.info(f'from_timeout={from_timeout}')\n        if music_queue:\n            app_log.info(f'current track = {Path(music_queue[0]).name}')\n        if cast is not None and cast.app_id != APP_MEDIA_RECEIVER and cast.app_id is not None and not forced:\n            # clicked next track when connected to cast and the app is not the media receiver app\n            if cast is None:\n                app_log.info('stopping internal playing_status because cast is None')\n            if cast.app_id != APP_MEDIA_RECEIVER and not forced:\n                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})')\n            playing_status.stop()\n        elif (next_queue or music_queue) and (forced or playing_status.busy() and not sar.alive):\n            # 1. there is something to play next\n            # 2. we are already playing a track (or are forcing)\n            with suppress(IndexError):\n                if track_length is not None and track_length > 600 and not ignore_timestamps:\n                    if url_metadata.get(music_queue[0], {}).get('timestamps'):\n                        # smart next track if playing a long URL with multiple tracks\n                        timestamps = url_metadata[music_queue[0]]['timestamps']\n                        new_position = next(filter(lambda seconds: seconds > get_track_position(), timestamps), 0)\n                        if new_position:\n                            return set_pos(new_position)\n            # keep track of skips (used by smart queue feature)\n            if music_queue and track_position < 5 and not from_timeout and playing_status.busy() and not forced:\n                settings['skips'][music_queue[0]] = settings['skips'].get(music_queue[0], 0) + 1\n                # save queue...\n                save_settings()\n            # if repeat all or repeat is off or empty queue or manual next\n            if settings['repeat'] in {False, None} or not music_queue or not from_timeout:\n                if settings['repeat']:\n                    update_settings('repeat', False)\n                app_log.info(f'will move the next {times} tracks from music_queue and then next_queue into the done queue')\n                for _ in range(times):\n                    if music_queue:\n                        done_queue.append(music_queue.popleft())\n                    if next_queue:\n                        music_queue.appendleft(next_queue.popleft())\n                    # if queue is empty but repeat is all AND there are tracks in the done_queue\n                    # move tracks from done_queue to music_queue\n                    if not music_queue and settings['repeat'] is False and done_queue:\n                        music_queue.extend(done_queue)\n                        done_queue.clear()\n            if music_queue:\n                if settings['smart_queue'] and from_timeout:\n                    # in the rare case all tracks will be skipped, avoid infinite loop\n                    max_skips = len(music_queue) + len(done_queue) + len(next_queue)\n                    # auto skip tracks that have been skipped a lot previously\n                    while music_queue and settings['skips'].get(music_queue[0], 0) > 5 and max_skips > 0:\n                        done_queue.append(music_queue.popleft())\n                        if next_queue:\n                            music_queue.appendleft(next_queue.popleft())\n                        # if queue is empty but repeat is all, move tracks from done_queue to music_queue\n                        if not music_queue and settings['repeat'] is False:\n                            music_queue.extend(done_queue)\n                            done_queue.clear()\n                        max_skips -= 1\n                elif times > 1:  # reset skip counter because user explicitly selected the track to play\n                    settings['skips'].pop(music_queue[0], None)\n                    save_settings()\n                return play()\n            # repeat is off (from timeout) or skip resulted in exhaustion of queue\n            stop('next track queue exhaustion', stop_cast=not from_timeout)\n\n\n    def prev_track(times=1, forced=False, ignore_timestamps=False):\n        app_log.info('')\n        if not forced and cast is not None and cast.app_id != APP_MEDIA_RECEIVER:\n            playing_status.stop()\n        elif forced or playing_status.busy() and not sar.alive:\n            with suppress(IndexError, TypeError):  # TypeError:  if track_length is None\n                timestamps = url_metadata.get(music_queue[0], {}).get('timestamps', [])\n                if track_length > 600 and timestamps and not ignore_timestamps:\n                    # smart next track if playing a long URL with multiple tracks\n                    _track_position = get_track_position()\n                    new_position = next(filter(lambda secs: secs < _track_position - 5, reversed(timestamps)), -1)\n                    if new_position != -1:\n                        return set_pos(new_position)\n            if done_queue:\n                for _ in range(times):\n                    if settings['repeat']:\n                        update_settings('repeat', False)\n                    track = done_queue.pop()\n                    # if there's a next queue, move mq[0] to top of next_queue\n                    if music_queue and next_queue:\n                        next_queue.appendleft(music_queue.popleft())\n                    music_queue.appendleft(track)\n            with suppress(IndexError):\n                settings['skips'].pop(music_queue[0], None)  # reset skip counter\n                play()\n\n    class UpdateChecker(threading.Timer):\n        latest_release = None\n        latest_version = VERSION\n        check_immediately = False\n\n        def __init__(self):\n            # check for an update every 30 minutes\n            super().__init__(1800, self.check_for_updates)\n            self.daemon = True\n            self.start()\n\n        def run(self):\n            while not self.finished.wait(self.interval):\n                self.function(*self.args, **self.kwargs)\n\n        def check_for_updates(self):\n            # avoid showing a notification for the same latest version\n            release = get_latest_release(self.latest_version, VERSION)\n            if release:\n                self.latest_release = release\n                self.latest_version = release['version']\n                State.update_available = True\n                if not gui_window.is_closed():\n                    gui_window['install_update'].update(visible=True)\n                if settings['notifications']:\n                    tray_notify('update_available', context=self.latest_version)\n\n        def auto_update(self, install_update=True, from_gui=False):\n            \"\"\" auto_start should be True when checking for updates at startup up,\n                false when checking for updates before exiting \"\"\"\n            with suppress(requests.RequestException, UpdateFailed):\n                State.installing_update = True\n                app_log.info(f'IS_FROZEN={IS_FROZEN}')\n                release = self.latest_release\n                if release is None:\n                    # since the Linux version is script, we want to force only in debug\n                    release = get_latest_release(VERSION, VERSION, force=is_debug())\n                if not release:\n                    app_log.info('no update found, or no internet, or API rate limited')\n                    raise UpdateFailed\n                State.update_available = True\n                if not install_update:\n                    State.installing_update = False\n                    return release\n                latest_ver = release['version']\n                setup_dl_link = release['setup']\n                app_log.info(f'Update found: v{latest_ver}')\n                print('Installer Link:', setup_dl_link)\n                if is_debug() or not setup_dl_link:\n                    app_log.info(f'not updating because: DEBUG={DEBUG} or not setup_dl_link={setup_dl_link}')\n                    State.update_available = False\n                    raise UpdateFailed\n                if IS_FROZEN:\n                    if platform.system() in {'Linux', 'Darwin'}:\n                        tray_notify('update_available', context=latest_ver)\n                    elif os.path.exists(UNINSTALLER):\n                        installer_path = get_installer_path()\n                        # only show message on startup to not confuse the user\n                        cmd = [installer_path, '/VERYSILENT', '/FORCECLOSEAPPLICATIONS',\n                                '/MERGETASKS=\"!desktopicon\"', '&&', 'Music Caster.exe']\n                        if not from_gui:\n                            cmd.extend(\n                                filter(\n                                    lambda arg: arg not in {'-m', '--minimized'},\n                                    sys.argv[1:],\n                                )\n                            )\n                        if gui_window.is_closed(quick_check=True):\n                            cmd.append('-m')\n                        download_update = t('Downloading update $VER').replace('$VER', latest_ver)\n                        tray_notify(download_update)\n                        tray_tooltip = download_update\n                        tray_process_queue.put({'tooltip': tray_tooltip})\n                        try:\n                            # download setup, close tray, run setup, and exit\n                            download(setup_dl_link, installer_path)\n                            tray_notify(t('Downloaded $VER. Relaunching...').replace('$VER', latest_ver))\n                            time.sleep(0.3)\n                            Popen(cmd, shell=True)\n                            daemon_commands.put('__EXIT__')  # tell main thread to exit\n                        except OSError as e:\n                            if e.errno == errno.ENOSPC:\n                                tray_notify(t('ERROR') + ': ' + t('No space left on device to auto-update'))\n                        except Exception:\n                            tray_notify('update_available', context=latest_ver)\n                    elif os.path.exists('Updater.exe'):\n                        # portable installation\n                        try:\n                            startfile('Updater')\n                            daemon_commands.put('__EXIT__')  # tell main thread to exit\n                        except OSError as e:\n                            if e == errno.ECANCELED:\n                                # user cancelled update, don't try auto-updating again\n                                # inform user what we were trying to do though\n                                update_settings('auto_update', False)\n                                tray_notify('update_available', context=latest_ver)\n                    else:\n                        # unins000.exe or updater.exe was deleted; better to inform user there is an update available\n                        tray_notify('update_available', context=latest_ver)\n            State.installing_update = False\n\n    def background_thread():\n        \"\"\"\n        Startup tasks:\n        - try to auto update\n        - sends info\n        - creates/removes shortcut\n        - starts keyboard listener\n        - connect Discord presence\n        While True tasks:\n        - scans files\n        \"\"\"\n        global SYNC_WITH_CHROMECAST\n\n        import pynput.keyboard\n        global track_position, track_start, track_end\n        app_log.info('start')\n\n        # check if update needs to be installed\n        # check for update and update if no must-run arguments were provided or if the update flag was used\n        limited_args = len(sys.argv) == 1 or ['-m'] == sys.argv[1:]\n        install_update = (\n            limited_args and settings['auto_update'] or args.update\n        ) and not args.nupdate\n        update_checker.auto_update(install_update=install_update)\n        State.installing_update = False\n\n        start_on_login_modifications()\n        p = pynput.keyboard.Listener(on_press=on_press, on_release=lambda key: PRESSED_KEYS.discard(str(key)))\n        p.name = 'pynputListener'\n        p.start()\n        while True:\n            scanned = 0\n            while not uris_to_scan.empty():\n                uri = uris_to_scan.get()\n                if uri.startswith('http'):\n                    get_url_metadata(uri)\n                else:\n                    uri = Path(uri).as_posix()\n                    all_tracks[uri] = get_metadata_wrapped(uri)\n                uris_to_scan.task_done()\n                scanned += 1\n                if scanned >= 50:\n                    scanned = 0\n                    gui_window.metadata['update_listboxes'] = True\n            if scanned:\n                gui_window.metadata['update_listboxes'] = True\n\n            if seek_queue and time.time() > SYNC_WITH_CHROMECAST:\n                time_to_seek = seek_queue.pop()\n                seek_queue.clear()\n                set_pos_thread = Thread(target=set_pos, args=(time_to_seek,), name='SetPos', daemon=False)\n                set_pos_thread.start()\n            time.sleep(0.1)\n\n    # SystemMediaTransportControls.ButtonPressed\n    def on_smtc_btn_press(event: SystemMediaTransportControlsButton):\n        match event:\n            case SystemMediaTransportControlsButton.PLAY:\n                print('play')\n            case SystemMediaTransportControlsButton.PAUSE:\n                print('pause')\n            case SystemMediaTransportControlsButton.NEXT:\n                print('next')\n            case SystemMediaTransportControlsButton.PREVIOUS:\n                print('previous')\n\n\n    def on_press(key):\n        key = str(key)\n        PRESSED_KEYS.add(key)\n        valid_shortcut = len(PRESSED_KEYS) == 4 and \"'m'\" in PRESSED_KEYS\n        ctrl_clicked = 'Key.ctrl_l' in PRESSED_KEYS or 'Key.ctrl_r' in PRESSED_KEYS\n        shift_clicked = 'Key.shift' in PRESSED_KEYS or 'Key.shift_r' in PRESSED_KEYS\n        alt_clicked = 'Key.alt_l' in PRESSED_KEYS or 'Key.alt_r' in PRESSED_KEYS\n        # Ctrl + Alt + Shift + M open up main window\n        if valid_shortcut and ctrl_clicked and shift_clicked and alt_clicked:\n            daemon_commands.put('__ACTIVATED__')\n        if key not in {'<179>', '<176>', '<177>', '<178>'}:\n            return\n        app_log.info(f'valid key press: {key}')\n        if key == '<179>' and not pause():\n            resume('keyboard')\n        elif key == '<176>' and playing_status.busy():\n            next_track()\n        elif key == '<177>' and playing_status.busy():\n            prev_track()\n        elif key == '<178>':\n            stop('keyboard shortcut')\n\n    def get_window_location():\n        if not settings['save_window_positions']:\n            return None, None\n        if settings['mini_mode']:\n            return settings['window_locations'].get('main_mini_mode', (None, None))\n        key = 'main_vertical' if settings['vertical_gui'] else 'main'\n        w, h = settings['window_locations'].get(key, (None, None))\n        if w is None or h is None:\n            return None, None\n        # clamp window size to screen size\n        if platform.system() == 'Windows':\n            from win32api import GetSystemMetrics\n            w = max(0, min(w, GetSystemMetrics(78) - 500))\n            h = max(0, min(h, GetSystemMetrics(79) - 500))\n        return w, h\n\n\n    def metadata_process_file(file: None | os.PathLike, callback_source):\n        if file is None:\n            handle_exception(TypeError(f'metadata_process_file recevied file = None. Called from {callback_source}'))\n            return False\n        if not os.path.isfile(file):\n            return False\n        try:\n            file_metadata = get_metadata_wrapped(file)\n            gui_window['metadata_file'].update(value=file)\n            gui_window['metadata_file'].set_tooltip(file)\n            gui_window['metadata_title'].update(value=file_metadata['title'])\n            gui_window['metadata_artist'].update(value=file_metadata['artist'])\n            gui_window['metadata_album'].update(value=file_metadata['album'])\n            gui_window['metadata_track_num'].update(value=file_metadata['track_number'])\n            gui_window['metadata_explicit'].update(value=file_metadata['explicit'])\n            mime, artwork = get_album_art(file)\n            artwork = None if artwork == DEFAULT_ART else artwork\n            if artwork is not None:\n                gui_window['metadata_art'].metadata = (mime, artwork)\n                with suppress(OSError):\n                    display_art = resize_img(artwork, settings['theme']['background'], COVER_MINI)\n                    gui_window['metadata_art'].update(data=display_art)\n            return True\n        except InvalidAudioFile:\n            error = t('ERROR') + ': ' + t('Invalid audio file selected')\n            gui_window['metadata_msg'].update(value=error, text_color='red')\n            gui_window.TKroot.after(2000, lambda: gui_window['metadata_msg'].update(value=''))\n            return False\n\n\n    def add_music_folder(folders):\n        added_folders = set(music_folders)\n        for folder in folders:\n            folder = folder.replace('\\\\', '/')\n            if os.path.isdir(folder) and folder not in added_folders:\n                music_folders.append(folder)\n                added_folders.add(folder)\n        gui_window['music_folders'].update(music_folders)\n        refresh_tray()\n        save_settings()\n        if settings['scan_folders']:\n            index_all_tracks()\n\n    def set_callbacks():\n        \"\"\" Set callbacks for the main window \"\"\"\n\n        def save_window_position(event):\n            if event.widget is gui_window.TKroot:\n                if settings['mini_mode']:\n                    key = 'main_mini_mode'\n                else:\n                    key = 'main_vertical' if settings['vertical_gui'] else 'main'\n                settings['window_locations'][key] = gui_window.CurrentLocation()\n                save_settings()\n\n        def library_events(event):\n            library_tree_view = gui_window['library'].TKTreeview\n            region = library_tree_view.identify('region', event.x, event.y)\n            column_index = library_tree_view.identify_column(event.x).replace('#', '')\n            gui_window.metadata['library']['region'] = region\n            gui_window.metadata['library']['column'] = int(column_index)\n\n        def dnd_pl_tracks(event):\n            # pl: playlist\n            file_paths = gui_window.TKroot.tk.splitlist(event.data)\n            pl_tracks = gui_window.metadata['pl_tracks']\n            pl_tracks.extend(get_audio_uris(file_paths))\n            update_settings('last_folder', os.path.dirname(file_paths[-1]))\n            new_values, new_length = format_pl_lb(pl_tracks)\n            new_i = len(new_values) - 1\n            gui_window['pl_length'].update(value=new_length)\n            gui_window['pl_tracks'].update(new_values, set_to_index=new_i, scroll_to_index=max(new_i - 3, 0))\n\n        def dnd_queue(event):\n            items = tk_lb.tk.splitlist(event.data)\n            files = list(filter(os.path.isfile, items))\n            dirs = filter(os.path.isdir, items)\n            # ASSUMPTION: if there are more than 20 files being queued, that's not an album\n            play_uris(files, queue_uris=True, natural_sort=len(files) > 20)\n            for directory in dirs:\n                # assume album\n                play_uris(directory, queue_uris=True, natural_sort=False)\n\n        def report_callback_exception(exc, _, __):\n            if exc == KeyboardInterrupt:\n                raise KeyboardInterrupt\n\n        gui_window.hidden_master_root.report_callback_exception = report_callback_exception\n\n        # tkdnd stopped working for some reason in python 3.14\n        if platform.system() == 'Windows' and TKDND_ENABLED:\n            gui_window.TKroot.tk.call('package', 'require', 'tkdnd')\n\n        if not settings['mini_mode']:\n            gui_window['url_input'].bind('<<Cut>>', '_cut')\n            gui_window['url_input'].bind('<<Copy>>', '_copy')\n            gui_window['pl_url_input'].bind('<<Cut>>', '_cut')\n            gui_window['pl_url_input'].bind('<<Copy>>', '_copy')\n            gui_window['library'].TKTreeview.bind('<Button-1>', library_events, add='+')\n            gui_window['library'].TKTreeview.bind('<Double-Button-1>', library_events, add='+')\n            scroll_areas = ['queue', 'pl_tracks', 'library']\n            for scroll_area in scroll_areas:\n                gui_window[scroll_area].bind('<Enter>', '_mouse_enter')\n                gui_window[scroll_area].bind('<Leave>', '_mouse_leave')\n            for input_key in {'url_input', 'pl_url_input', 'pl_name', 'timer_input',\n                              'metadata_title', 'metadata_artist', 'metadata_album', 'metadata_track_num'}:\n                gui_window[input_key].Widget.config(insertbackground=settings['theme']['text'])\n\n            if TKDND_ENABLED:\n                try:\n                    # drag and drop callbacks\n                    tk_lb = gui_window['queue'].TKListbox\n                    drop_target_register(tk_lb, DND_ALL)\n                    dnd_bind(tk_lb, '<<Drop>>', dnd_queue)\n\n                    tk_lb = gui_window['pl_tracks'].TKListbox\n                    drop_target_register(tk_lb, DND_ALL)\n                    dnd_bind(tk_lb, '<<Drop>>', dnd_pl_tracks)\n\n                    tk_frame = gui_window['tab_metadata'].TKFrame\n                    drop_target_register(tk_frame, DND_FILES)\n                    dnd_bind(tk_frame, '<<Drop>>', lambda event: metadata_process_file(tk_lb.tk.splitlist(event.data)[0], 'tkdnd'))\n\n                    tk_lb = gui_window['music_folders'].TKListbox\n                    drop_target_register(tk_lb, DND_FILES)\n                    dnd_bind(tk_lb, '<<Drop>>', lambda event: add_music_folder(tk_lb.tk.splitlist(event.data)))\n                except NameError:\n                    # https://github.com/rdbende/tkinterDnD\n                    print('TODO: DND Not Implemented')\n        elif TKDND_ENABLED:\n            try:\n                root = gui_window.TKroot\n                drop_target_register(root, DND_ALL)\n                dnd_bind(root, '<<Drop>>', lambda event: play_uris(root.tk.splitlist(event.data), queue_uris=True))\n            except NameError:\n                print('TODO: DND Not Implemented')\n\n        gui_window['volume_slider'].bind('<Enter>', '_mouse_enter')\n        gui_window['volume_slider'].bind('<Leave>', '_mouse_leave')\n        gui_window['progress_bar'].bind('<Enter>', '_mouse_enter')\n        gui_window['progress_bar'].bind('<Leave>', '_mouse_leave')\n        gui_window.TKroot.bind('<Configure> ', save_window_position, add='+')\n        gui_window.bind('<Control-braceright>', 'mini_mode')\n        gui_window.bind('<Control-Q>', 'exit_program')\n        gui_window.bind('<Control-r>', 'repeat')\n        gui_window.bind('<Control-s>', 's:83')\n        gui_window.bind('<Control-m>', 'mute')\n        gui_window.bind('<Control-e>', 'locate_uri')\n        gui_window.bind('<Control-c>', '<<Copy>>')\n        gui_window.bind('<KeyPress>', 'KeyPress')\n        for i in range(1, 10):\n            gui_window.bind(f'<Control-Key-{i}>', f'{i}:{48 + i}')\n        gui_window.TKroot.bind(\"<KeyRelease>\", lambda _: None)\n\n\n    def activate_gui(selected_tab=None, url_option='url_play'):\n        global gui_window\n        # selected_tab can be 'tab_queue', ['tab_library'], 'tab_playlists', 'tab_timer', or 'tab_settings'\n        app_log.info(f'selected_tab={selected_tab}')\n        if gui_window.is_closed():\n            State.using_tcl_theme = settings['experimental_features'] and os.path.exists(sun_valley_tcl_path)\n            # create window if window not alive\n            lb_tracks = create_track_list()\n            selected_value = lb_tracks[len(done_queue)] if lb_tracks and len(done_queue) < len(lb_tracks) else None\n            mini_mode = settings['mini_mode']\n            window_location = get_window_location()\n            if settings['show_album_art']:\n                size = COVER_MINI if mini_mode else COVER_NORMAL\n                bg = settings['theme']['background']\n                try:\n                    album_art_data = resize_img(get_current_art(), bg, size, default_art=DEFAULT_ART)\n                except OSError as e:\n                    handle_exception(e)\n                    album_art_data = resize_img(DEFAULT_ART, bg, size)\n            else:\n                album_art_data = None\n            metadata = get_current_metadata()\n            title, artist, album = metadata['title'], get_first_artist(metadata['artist']), metadata['album']\n            _track_position = get_track_position()\n            if settings['mini_mode']:\n                window_layout = MiniPlayerWindow(playing_status, settings, title, artist, album_art_data, track_length, _track_position)\n            else:\n                window_layout = MainWindow(playing_status, settings, title, artist, album, album_art_data, track_length, _track_position,\n                                           lb_tracks, selected_value, timer, all_tracks, get_devices(),\n                                           f\"http://{get_ipv4()}:{State.PORT}?api_key={settings['api_key']}\")\n            window_metadata: dict = {'last_event': None, 'update_listboxes': False, 'update_volume_slider': False,\n                                     'library': {'sort_by': 0, 'ascending': True, 'region': 'cell', 'column': 1},\n                                     'mouse_hover': '', 'url_input': '', 'pl_url_input': ''}\n            pl_name = window_metadata['pl_name'] = next(iter(settings['playlists']), '')\n            pl_tracks = window_metadata['pl_tracks'] = settings['playlists'].get(pl_name, []).copy()\n\n            gui_window = Sg.Window('Music Caster', window_layout, grab_anywhere=mini_mode, no_titlebar=mini_mode,\n                                   margins=(0, 0), finalize=True, icon=WINDOW_ICON, return_keyboard_events=True,\n                                   use_default_focus=False, keep_on_top=mini_mode and settings['mini_on_top'],\n                                   location=window_location, metadata=window_metadata, debugger_enabled=is_debug())\n            if State.using_tcl_theme:\n                Sg.TOOLTIP_BACKGROUND_COLOR = settings['theme']['background']\n                try:\n                    if not State.theme_sourced:\n                        # as per State.using_tcl_theme, sun_valley_tcl_path exists\n                        # source errors out if called more than once\n                        gui_window.TKroot.tk.call('source', sun_valley_tcl_path)\n                        State.theme_sourced = True\n                    # this needs to be called every time the GUI is constructed\n                    gui_window.TKroot.tk.call('set_theme', 'dark')\n                except TclError as e:\n                    # _tkinter.TclError: Theme sun-valley-light already exists\n                    if IS_FROZEN:\n                        handle_exception(e)\n                    else:\n                        raise e\n            if not settings['mini_mode']:\n                gui_window['queue'].update(set_to_index=len(done_queue), scroll_to_index=len(done_queue))\n                pl_tracks_values, pl_length = format_pl_lb(pl_tracks)\n                gui_window['pl_length'].update(value=pl_length)\n                gui_window['pl_tracks'].update(values=pl_tracks_values)\n            set_callbacks()\n        elif settings['mini_mode']:\n            if selected_tab:\n                update_settings('mini_mode', not settings['mini_mode'])\n                gui_window.close()\n                return activate_gui(selected_tab)\n            else:\n                # flash border if already in mini mode\n                accent = settings['theme']['accent']\n                for _ in range(2):\n                    gui_window.TKroot.config(background=accent, bd=1)\n                    gui_window.read(50)\n                    gui_window.TKroot.config(background=accent, bd=0)\n                    gui_window.read(50)\n        if not settings['mini_mode'] and selected_tab is not None:\n            gui_window[selected_tab].select()\n            if selected_tab == 'tab_timer':\n                gui_window['timer_input'].set_focus()\n            elif selected_tab == 'tab_url':\n                gui_window[url_option].update(True)\n                gui_window['url_input'].set_focus()\n                with suppress(pyperclip.PyperclipException):\n                    default_text: str = pyperclip.paste()\n                    if default_text.startswith('http'):\n                        gui_window['url_input'].update(default_text)\n                        gui_window.metadata['url_input'] = default_text\n            elif selected_tab == 'tab_playlists':\n                with suppress(pyperclip.PyperclipException):\n                    default_text: str = pyperclip.paste()\n                    if default_text.startswith('http'):\n                        gui_window['pl_url_input'].update(default_text)\n                        gui_window.metadata['pl_url_input'] = default_text\n        with suppress(TclError):\n            focus_window(gui_window)\n\n    def uri_at_idx(idx=0, offset=None):\n        # converts listbox idx to uri\n        # raises IndexError\n        if idx < len(done_queue):\n            uri = done_queue[idx]\n        elif idx == len(done_queue):\n            uri = music_queue[0]\n        elif idx <= len(next_queue) + len(done_queue):\n            uri = next_queue[idx - 1 - len(done_queue)]\n        else:\n            uri = music_queue[idx - len(next_queue) - len(done_queue)]\n        return uri\n\n    def locate_uri(selected_track_index=None, uri=None):\n        with suppress(IndexError):\n            if uri is None:\n                if selected_track_index is None:\n                    raise IndexError\n                uri = uri_at_idx(idx=selected_track_index)\n            if uri.startswith('http'):\n                if uri in url_metadata:\n                    # if source is from playlist...\n                    uri = url_metadata[uri].get('pl_src', uri)\n                open_in_browser(uri)\n                return True\n            if os.path.exists(uri):\n                if platform.system() == 'Windows':\n                    Popen(f'explorer /select,\"{fix_path(uri)}\"')\n                elif platform.system() == 'Linux':\n                    try:\n                        Popen(['nautilus', uri])\n                    except FileNotFoundError:\n                        try:\n                            # fallback 1\n                            Popen(['dolphin', uri])\n                        except FileNotFoundError:\n                            # fallback 2\n                            Popen(['xdg-open', Path(uri).parent])\n                return True\n        # tray_notify(gt('ERROR') + ':' + gt('Could not locate URI'))\n        return False\n\n\n    def exit_program(quick_exit=False):\n        gui_window.close()\n        close_tray()\n        # stop any active scanning\n        with suppress(NameError, asyncio.TimeoutError, concurrent.futures.TimeoutError):\n            cast_browser.stop_discovery()\n        with suppress(PyChromecastError):\n            if cast is None:\n                stop('exit program')\n            elif cast is not None and cast.app_id == APP_MEDIA_RECEIVER and playing_status.busy():\n                try:\n                    cast.quit_app(30)\n                except PyChromecastError as e:\n                    app_log.error('could not cast.quit_app', exc_info=True)\n                    handle_exception(e)\n        DiscordPresence.close()\n        if settings['persistent_queue'] and not quick_exit:\n            save_queues()\n            with suppress(RuntimeError):\n                save_queue_thread.join()\n        try:\n            portalocker.unlock(lock_file)\n        except Exception as e:\n            # TODO: remove if errors are no longer raised\n            handle_exception(e)\n        sys.exit()\n\n\n    def playlist_action(playlist_name, action='play'):\n        if playlist_name in settings['playlists'] and settings['playlists'][playlist_name]:\n            if action == 'next':\n                next_queue.extend(get_audio_uris(playlist_name))\n            else:  # if action == 'play' or action == 'queue'\n                is_play = action == 'play'\n                if is_play:\n                    done_queue.clear()\n                    music_queue.clear()\n                shuffle_from = len(music_queue)\n                music_queue.extend(get_audio_uris(playlist_name))\n                if settings['shuffle']:\n                    better_shuffle(music_queue, shuffle_from)\n                if music_queue and (is_play or shuffle_from == 0):\n                    play()\n\n    def other_tray_actions(_tray_item):\n        if _tray_item.startswith('device:'):\n            device_uuid = _tray_item[7:]\n            with suppress(ValueError):\n                change_device(device_uuid)\n        elif _tray_item.startswith('PL:'):  # playlist\n            playlist_action(_tray_item[3:])\n        elif _tray_item == t('Select Folder'):\n            folder_action()\n        elif _tray_item.startswith('PF:'):  # play folder\n            folder_index = int(re.search(r'\\d+', _tray_item).group())\n            Thread(target=play_uris, name='PlayFolder', daemon=True, args=[[music_folders[folder_index]]]).start()\n\n\n    def event_is_close(main_event, main_values):\n        ignore_events = {'file_action', 'folder_action', 'pl_add_tracks', 'add_music_folder'}\n        return (main_values == Sg.WIN_CLOSED or\n                main_event in {'Escape:27', '\u001b'} and gui_window.metadata['last_event'] not in ignore_events)\n\n    def update_playlist_ui(set_to_index=None):\n        pl_tracks = gui_window.metadata['pl_tracks']\n        pl_values, pl_length = format_pl_lb(pl_tracks)\n        gui_window['pl_length'].update(value=pl_length)\n        gui_window['pl_tracks'].update(values=pl_values, set_to_index=set_to_index)\n\n    def read_main_window():\n        global track_position, track_start, track_end, timer, music_queue, done_queue, SYNC_WITH_CHROMECAST\n        global OLD_CAST_POS, OLD_CAST_VOLUME\n        window_read_tuple: (str, dict) = gui_window.read(timeout=100) # type: ignore\n        main_event, main_values = window_read_tuple\n        if main_event == 'KeyPress':\n            e = gui_window.user_bind_event\n            main_event = e.char if e.char else str(e.keysym) + ':' + str(e.keycode)\n        if event_is_close(main_event, main_values):\n            gui_window.close()\n            if settings['gui_exits_app']:\n                exit_program()\n            return False\n        if settings['mini_mode']:\n            gui_window.TKroot.update_idletasks()\n        main_value = main_values.get(main_event)\n        if 'mouse_leave' not in main_event and 'mouse_enter' not in main_event and main_event != Sg.TIMEOUT_KEY:\n            gui_window.metadata['last_event'] = main_event\n        # update timer text if timer is old\n        if not settings['mini_mode'] and timer == 0 and gui_window['timer_text'].metadata:\n            gui_window['timer_text'].update(t('No Timer Set'))\n            gui_window['timer_text'].metadata = False\n            gui_window['cancel_timer'].update(visible=False)\n        # these events modify main_event (chain events)\n        if main_event.startswith('MouseWheel'):\n            main_event = main_event.split(':', 1)[1]\n            if gui_window.metadata['mouse_hover'] == 'progress_bar':\n                delta = {'Up': settings['scrubbing_delta'], 'Down': -settings['scrubbing_delta']}.get(main_event, 0)\n                if playing_status.busy() and track_length is not None:\n                    OLD_CAST_POS = get_track_position()\n                    new_position = min(max(track_position + delta, 0), track_length)\n                    gui_window['progress_bar'].update(value=new_position)\n                    main_values['progress_bar'] = new_position\n                    main_event = 'progress_bar'\n            elif gui_window.metadata['mouse_hover'] in {'', 'volume_slider'}:  # not in another scroll view\n                with CAST_LOCK:\n                    delta = {'Up': settings['volume_delta'], 'Down': -settings['volume_delta']}.get(main_event, 0)\n                    new_volume = min(max(0, main_values['volume_slider'] + delta), 100)\n                    update_settings('volume', new_volume)\n                    update_settings('muted', False)\n                    update_volume(new_volume, 'mouse_wheel')\n                    OLD_CAST_VOLUME = new_volume\n                    SYNC_WITH_CHROMECAST = time.time() + 0.5\n        elif main_event in {'j', 'l'} and (main_values.get('tab_group', 'tab_queue') == 'tab_queue'):\n            if playing_status.busy() and track_length is not None:\n                delta = {'j': -settings['scrubbing_delta'], 'l': settings['scrubbing_delta']}[main_event]\n                get_track_position()\n                new_position = min(max(track_position + delta, 0), track_length)\n                gui_window['progress_bar'].update(value=new_position)\n                main_values['progress_bar'] = new_position\n                main_event = 'progress_bar'\n                gui_window.refresh()\n        # override keypress events\n        QUEUE_TAB_SELECTED = main_values.get('tab_group') in {'tab_queue', None}\n        if main_event != '__TIMEOUT__':\n            with suppress(KeyError):\n                el = gui_window.find_element_with_focus()\n                if el is not None and el.Key in {'track_format', 'sys_audio_delay'}:\n                    main_event, main_value = el.Key, main_values.get(el.Key)\n        if main_event == '__TIMEOUT__':\n            pass  # avoids checking multiple if statements\n        # change/select tabs\n        elif main_event == '1:49' and not settings['mini_mode']:  # Queue tab [Ctrl + 1]\n            gui_window['tab_queue'].select()\n        elif (main_event == '2:50' and not settings['mini_mode'] or  # URL tab [Ctrl + 2]\n              main_event == 'tab_group' and main_values.get('tab_group') == 'tab_url'):\n            gui_window['tab_url'].select()\n            gui_window['url_input'].set_focus()\n            with suppress(pyperclip.PyperclipException):\n                default_text: str = pyperclip.paste()\n                if default_text.startswith('http'):\n                    gui_window['url_input'].update(value=default_text)\n        elif (main_event == '3:51' and not settings['mini_mode'] or  # Library tab [Ctrl + 3]:\n              main_event == 'tab_group' and main_values['tab_group'] == 'tab_library'):\n            gui_window['tab_library'].select()\n        elif (main_event == '4:52' and not settings['mini_mode'] or  # Playlists tab [Ctrl + 4]:\n              main_event == 'tab_group' and main_values['tab_group'] == 'tab_playlists'):\n            with suppress(pyperclip.PyperclipException):\n                default_text: str = pyperclip.paste()\n                if default_text.startswith('http'):\n                    gui_window['pl_url_input'].update(value=default_text)\n            gui_window['tab_playlists'].select()\n            gui_window['playlist_combo'].set_focus()\n        elif (main_event == '5:53' and not settings['mini_mode'] or  # Timer Tab [Ctrl + 5]\n              main_event == 'tab_group' and main_values['tab_group'] == 'tab_timer'):\n            gui_window['tab_timer'].select()\n            gui_window['timer_input'].set_focus()\n        elif main_event == '6:54' and not settings['mini_mode']:  # Metadata tab [Ctrl + 6]\n            gui_window['tab_metadata'].select()\n            gui_window['metadata_file'].set_focus()\n        elif main_event == '7:55' and not settings['mini_mode']:  # Settings tab [Ctrl + 7]\n            gui_window['tab_settings'].select()\n        elif main_event in {'progress_bar_mouse_enter', 'queue_mouse_enter', 'pl_tracks_mouse_enter',\n                            'volume_slider_mouse_enter', 'library_mouse_enter'}:\n            if main_event in {'progress_bar_mouse_enter', 'volume_slider_mouse_enter'} and settings['mini_mode']:\n                gui_window.grab_any_where_off()\n            gui_window.metadata['mouse_hover'] = '_'.join(main_event.split('_')[:-2])\n        elif main_event in {'progress_bar_mouse_leave', 'queue_mouse_leave', 'pl_tracks_mouse_leave',\n                            'volume_slider_mouse_leave', 'library_mouse_leave'}:\n            if main_event in {'progress_bar_mouse_leave', 'volume_slider_mouse_leave'} and settings['mini_mode']:\n                gui_window.grab_any_where_on()\n            if main_event != 'volume_slider_mouse_leave':\n                gui_window.metadata['mouse_hover'] = ''\n        elif main_event == 'pause/resume' or main_event == 'k' and QUEUE_TAB_SELECTED:\n            if playing_status.paused():\n                resume('gui')\n            elif playing_status.playing():\n                pause()\n            elif music_queue:\n                play()\n            elif next_queue:\n                music_queue.appendleft(next_queue.popleft())\n                play()\n            elif done_queue:  # start from top again\n                music_queue.extend(done_queue)\n                done_queue.clear()\n                play()\n            else:\n                play_all()\n        # Shift + N\n        elif (main_event == 'next' or main_event == 'N' and QUEUE_TAB_SELECTED) and playing_status.busy():\n            next_track()\n        # Shift + P || Shift + B\n        elif (main_event == 'prev' or main_event == 'P' or main_event == 'B' and QUEUE_TAB_SELECTED) and playing_status.busy():\n            prev_track()\n        elif main_event == 'devices':\n            change_device(main_value.id)\n        elif main_event == 'sys_audio_delay':\n            with suppress(ValueError):\n                update_settings('sys_audio_delay', int(main_value))\n        elif main_event == 'track_format':\n            update_settings('track_format', main_value)\n        elif main_event == 'on_battery_res':\n            with suppress(KeyError):\n                res = get_all_resolutions()[main_value]\n                update_settings('on_battery_res', (res['w'], res['h']))\n        elif main_event == 'plugged_in_res':\n            with suppress(KeyError):\n                res = get_all_resolutions()[main_value]\n                update_settings('plugged_in_res', (res['w'], res['h']))\n        elif main_event == 'shuffle':\n            update_settings('shuffle', not settings['shuffle'])\n        elif main_event == 'repeat':\n            cycle_repeat()\n        elif (main_event == 'volume_slider' or ((main_event in {'a', 'd'} or main_event.isdecimal())\n                                                and QUEUE_TAB_SELECTED)):\n            # User scrubbed volume bar or pressed a, d, #\n            try:\n                new_volume = int(main_event) * 10\n            except ValueError:\n                delta = {'a': -settings['volume_delta'], 'd': settings['volume_delta']}.get(main_event, 0)\n                new_volume = main_values['volume_slider'] + delta\n            update_settings('volume', new_volume)\n            # un-mute if volume slider was moved\n            update_settings('muted', False)\n            update_volume(new_volume, 'volume_slider')\n        elif main_event in {'Up:38', 'Down:40'}:\n            focused_element = gui_window.FindElementWithFocus()\n            if settings['mini_mode'] or focused_element not in {gui_window['queue'], gui_window['pl_tracks'],\n                                                                gui_window['music_folders']}:\n                delta = settings['volume_delta'] if main_event == 'Up:38' else -settings['volume_delta']\n                new_volume = main_values['volume_slider'] + delta\n                update_settings('volume', new_volume)\n                # un-mute if volume slider was moved\n                update_settings('muted', False)\n                update_volume(new_volume, 'Up:38')\n        elif main_event == 'mute':  # toggle mute\n            update_volume(0 if update_settings('muted', not settings['muted']) else settings['volume'], 'mute')\n        elif main_event in {'Prior:33', 'Next:34'} and not settings['mini_mode']:  # page up, page down\n            focused_element = gui_window.FindElementWithFocus()\n            move = {'Prior:33': -3, 'Next:34': 3}[main_event]\n            if focused_element == gui_window['queue'] and main_values['queue']:\n                new_i = gui_window['queue'].get_indexes()[0] + move\n                new_i = min(max(new_i, 0), len(gui_window['queue'].Values) - 1)\n                gui_window['queue'].update(set_to_index=new_i, scroll_to_index=max(new_i - 3, 0))\n            elif focused_element == gui_window['pl_tracks'] and main_values['pl_tracks']:\n                new_i = gui_window['pl_tracks'].get_indexes()[0] + move\n                new_i = min(max(new_i, 0), len(gui_window.metadata['pl_tracks']) - 1)\n                gui_window['pl_tracks'].update(set_to_index=new_i, scroll_to_index=max(new_i - 3, 0))\n        elif main_event == 'queue' and main_value:\n            with suppress(ValueError):\n                selected_uri_index = gui_window['queue'].get_indexes()[0]\n                if selected_uri_index <= len(done_queue):\n                    prev_track(times=len(done_queue) - selected_uri_index, forced=True, ignore_timestamps=True)\n                else:\n                    next_track(times=selected_uri_index - len(done_queue), forced=True, ignore_timestamps=True)\n                values = create_track_list()\n                dq_len = len(done_queue)\n                gui_window['queue'].update(values=values, set_to_index=dq_len, scroll_to_index=dq_len)\n        elif main_event == 'album' and playing_status.busy():\n            locate_uri(len(done_queue))\n        # queue related actions\n        elif main_event == 'mini_mode':\n            update_settings('mini_mode', not settings['mini_mode'])\n            gui_window.close()\n            activate_gui()\n        elif main_event == 'queue_all':\n            queue_all()\n        elif main_event == 'clear_queue':\n            gui_window['queue'].update(set_to_index=0)\n            gui_window['queue'].update(values=[])\n            if playing_status.busy():\n                stop('clear_queue')\n            music_queue.clear()\n            next_queue.clear()\n            done_queue.clear()\n            save_queues()\n            gui_window.refresh()\n        elif main_event == 'save_to_pl':\n            indices = gui_window['queue'].get_indexes()\n            if len(indices) <= 1:\n                tracks_to_add = chain(done_queue, [music_queue[0]] if music_queue else [], next_queue, islice(music_queue, 1, None))\n            else:\n                tracks_to_add = (uri_at_idx(idx) for idx in indices)\n            pl_tracks = gui_window.metadata['pl_tracks'] = []\n            pl_tracks.extend(tracks_to_add)\n            gui_window.metadata['pl_name'] = ''\n            gui_window['tab_playlists'].select()\n            gui_window['pl_name'].set_focus()\n            gui_window['pl_name'].update(value=gui_window.metadata['pl_name'])\n            pl_tracks_values, pl_tracks_length = format_pl_lb(pl_tracks)\n            gui_window['pl_length'].update(value=pl_tracks_length)\n            gui_window['pl_tracks'].update(values=pl_tracks_values, set_to_index=0)\n        elif main_event == 'locate_uri':\n            if not settings['mini_mode'] and main_values['queue']:\n                for index in gui_window['queue'].get_indexes():\n                    locate_uri(index)\n            else:\n                locate_uri(len(done_queue))\n        elif main_event == 'copy_uri' or (main_event == '<<Copy>>' and QUEUE_TAB_SELECTED):\n            with suppress(IndexError):\n                text_to_copy = ', '.join(( uri_at_idx(index) for index in gui_window['queue'].get_indexes()))\n                if text_to_copy:\n                    pyperclip.copy(text_to_copy)\n        elif main_event == 'edit_metadata':\n            indices = gui_window['queue'].get_indexes()\n            if len(indices) == 1 and metadata_process_file(uri_at_idx(indices[0]), 'read_main_window:edit_metadata'):\n                gui_window['tab_metadata'].select()\n        elif main_event == 'move_to_next_up':\n            for i, index_to_move in enumerate(gui_window['queue'].get_indexes(), 1):\n                dq_len = len(done_queue)\n                nq_len = len(next_queue)\n                if index_to_move < dq_len:\n                    track = done_queue[index_to_move]\n                    del done_queue[index_to_move]\n                    if settings['reversed_play_next']:\n                        next_queue.appendleft(track)\n                    else:\n                        next_queue.append(track)\n                    if i == len(main_values['queue']):  # update gui after the last swap\n                        values = create_track_list()\n                        gui_window['queue'].update(values=values, set_to_index=len(done_queue) + len(next_queue),\n                                                   scroll_to_index=max(len(done_queue) + len(next_queue) - 16, 0))\n                        save_queues()\n                elif index_to_move > dq_len + nq_len:\n                    track = music_queue[index_to_move - dq_len - nq_len]\n                    del music_queue[index_to_move - dq_len - nq_len]\n                    if settings['reversed_play_next']:\n                        next_queue.appendleft(track)\n                    else:\n                        next_queue.append(track)\n                    if i == len(main_values['queue']):  # update gui after the last swap\n                        values = create_track_list()\n                        gui_window['queue'].update(values=values, set_to_index=dq_len + len(next_queue),\n                                                   scroll_to_index=max(len(done_queue) + len(next_queue) - 3, 0))\n                        save_queues()\n            gui_window.metadata['update_listboxes'] = False\n        elif main_event == 'move_up':\n            for i, index_to_move in enumerate(gui_window['queue'].get_indexes(), 1):\n                new_i = index_to_move - 1\n                dq_len = len(done_queue)\n                nq_len = len(next_queue)\n                if index_to_move < dq_len and new_i >= 0:  # move within dq\n                    # swap places\n                    done_queue[index_to_move], done_queue[new_i] = done_queue[new_i], done_queue[index_to_move]\n                elif index_to_move == dq_len and done_queue:  # move index -1 to 1 or top of next_queue\n                    if next_queue:\n                        next_queue.insert(0, done_queue.pop())\n                    else:\n                        music_queue.insert(1, done_queue.pop())\n                elif index_to_move == dq_len + 1:  # move 1 to -1\n                    if next_queue:\n                        done_queue.append(next_queue.popleft())\n                    else:\n                        track = music_queue[1]\n                        del music_queue[1]\n                        done_queue.append(track)\n                elif next_queue and dq_len < index_to_move <= nq_len + dq_len:  # within next_queue\n                    nq_i = new_i - dq_len - 1\n                    # swap places, could be more efficient using a custom deque with O(n) swaps instead of O(2n)\n                    next_queue[nq_i], next_queue[nq_i + 1] = next_queue[nq_i + 1], next_queue[nq_i]\n                elif next_queue and index_to_move == dq_len + nq_len + 1:  # moving into next queue\n                    track = music_queue[1]\n                    del music_queue[1]\n                    next_queue.insert(nq_len - 1, track)\n                elif new_i >= 0:  # moving within mq\n                    mq_i = new_i - dq_len - nq_len\n                    music_queue[mq_i], music_queue[mq_i + 1] = music_queue[mq_i + 1], music_queue[mq_i]\n                else:\n                    new_i = max(new_i, 0)\n                if i == len(main_values['queue']):  # update gui after moving the last selected track\n                    values = create_track_list()\n                    gui_window['queue'].update(values=values, set_to_index=new_i, scroll_to_index=max(new_i - 7, 0))\n                    save_queues()\n        elif main_event == 'move_down':\n            for i, index_to_move in enumerate(reversed(gui_window['queue'].get_indexes()), 1):\n                dq_len, nq_len, mq_len = len(done_queue), len(next_queue), len(music_queue)\n                if index_to_move < dq_len + nq_len + mq_len - 1:\n                    new_i = index_to_move + 1\n                    if index_to_move == dq_len - 1:  # move index -1 to 1\n                        if next_queue:\n                            next_queue.appendleft(done_queue.pop())\n                        else:\n                            music_queue.insert(1, done_queue.pop())\n                    elif index_to_move < dq_len:  # move within dq\n                        done_queue[index_to_move], done_queue[new_i] = done_queue[new_i], done_queue[index_to_move]\n                    elif index_to_move == dq_len:  # move 1 to -1\n                        if next_queue:\n                            done_queue.append(next_queue.popleft())\n                        else:\n                            track = music_queue[1]\n                            del music_queue[1]\n                            done_queue.append(track)\n                    elif next_queue and index_to_move == dq_len + nq_len:  # moving into music_queue\n                        music_queue.insert(2, next_queue.pop())\n                    elif index_to_move < dq_len + nq_len + 1:  # within next_queue\n                        nq_i = index_to_move - dq_len - 1\n                        next_queue[nq_i], next_queue[nq_i - 1] = next_queue[nq_i - 1], next_queue[nq_i]\n                    else:  # within music_queue\n                        mq_i = new_i - dq_len - nq_len\n                        # swap places\n                        music_queue[mq_i], music_queue[mq_i - 1] = music_queue[mq_i - 1], music_queue[mq_i]\n                    if i == len(main_values['queue']):  # update gui after moving the last selected track\n                        values, scroll_to = create_track_list(), max(new_i - 3, 0)\n                        gui_window['queue'].update(values=values, set_to_index=new_i, scroll_to_index=scroll_to)\n                        save_queues()\n        elif main_event == 'remove_track' and main_values['queue']:\n            for i, index_to_remove in enumerate(reversed(gui_window['queue'].get_indexes()), 1):\n                dq_len, nq_len, mq_len = len(done_queue), len(next_queue), len(music_queue)\n                if index_to_remove < dq_len:\n                    del done_queue[index_to_remove]\n                elif index_to_remove == dq_len:\n                    with suppress(IndexError):\n                        # remove the \"0. XXX\" track that could be playing right now\n                        music_queue.popleft()\n                        if next_queue:\n                            music_queue.appendleft(next_queue.popleft())\n                        # if queue is empty but repeat is all AND there are tracks in the done_queue\n                        if not music_queue and settings['repeat'] is False and done_queue:\n                            music_queue.extend(done_queue)\n                            done_queue.clear()\n                        # start playing new track if a track was being played\n                        if not sar.alive:\n                            if music_queue and playing_status.busy():\n                                play()\n                            else:\n                                stop('remove_track')\n                elif index_to_remove <= nq_len + dq_len:\n                    del next_queue[index_to_remove - dq_len - 1]\n                elif index_to_remove < nq_len + mq_len + dq_len:\n                    del music_queue[index_to_remove - dq_len - nq_len]\n                if i == len(main_values['queue']):  # update gui after the last removal\n                    values = create_track_list()\n                    new_i = min(len(values), index_to_remove)\n                    gui_window['queue'].update(values=values, set_to_index=new_i, scroll_to_index=max(new_i - 3, 0))\n        elif main_event == 'select_files':\n            Thread(target=file_action, name='FileAction', daemon=True,\n                   args=[main_values['fs_action']]).start()\n        elif main_event == 'select_folders':\n            Thread(target=folder_action, name='FolderAction', daemon=True,\n                   args=[main_values['fs_action']]).start()\n        elif main_event == 'install_update':\n            if not State.installing_update:\n                gui_window['install_update'].update(visible=False)\n                Thread(target=update_checker.auto_update, kwargs={'from_gui': True}, daemon=True, name='Updater').start()\n        elif main_event == 'play_all':\n            if not any(filter(lambda thread: thread.name == 'PlayAll', threading.enumerate())):\n                Thread(target=play_all, name='PlayAll', daemon=True).start()\n        elif main_event in {'library', 'Play::library', 'Play Next::library', 'Queue::library', 'Locate::library'}:\n            library_metadata = gui_window.metadata['library']\n            if library_metadata['region'] == 'heading':\n                col_index = library_metadata['column']\n                if col_index == library_metadata['sort_by']:\n                    reverse = library_metadata['ascending'] = not library_metadata['ascending']\n                else:\n                    library_metadata['sort_by'] = col_index\n                    reverse = library_metadata['ascending'] = True\n                library_items = gui_window['library'].Values\n                library_items.sort(key=lambda row: row[col_index - 1].casefold(), reverse=not reverse)\n                gui_window['library'].update(library_items)\n            elif main_event == 'Locate::library':\n                for index in main_values['library']:\n                    locate_uri(uri=gui_window['library'].Values[index][-1])\n            elif main_values['library']:\n                paths_to_play = (gui_window['library'].Values[index][-1] for index in main_values['library'])\n                if main_event in {'library', 'Play::library'}:\n                    if settings['queue_library']:\n                        play_all(paths_to_play)\n                    else:\n                        play_uris(paths_to_play)\n                else:\n                    # play_next has priority over queue_uris\n                    play_uris(paths_to_play, queue_uris=True, play_next=main_event == 'Play Next::library')\n        elif main_event == 'progress_bar' and track_length is not None:\n            if playing_status.stopped():\n                gui_window['progress_bar'].update(disabled=True, value=0)\n                return\n            else:\n                # do not debounce when playing locally\n                track_position = int(main_values['progress_bar'])\n                if cast is None:\n                    set_pos(track_position)\n                else:\n                    # debounce setting the track position\n                    # background_thread will call set_pos\n                    seek_queue.append(track_position)\n                    SYNC_WITH_CHROMECAST = time.time() + 1\n        # main window settings tab\n        elif main_event == 'open_email':\n            open_in_browser(create_support_email_url())\n        elif main_event == 'open_github':\n            open_in_browser('https://github.com/elibroftw/music-caster')\n        elif main_event == 'web_gui':\n            api_key = settings['api_key']\n            open_in_browser(f'http://{get_lan_ip()}:{State.PORT}?api_key={api_key}')\n        # toggle settings\n        elif main_event in TOGGLEABLE_SETTINGS:\n            update_settings(main_event, main_value)\n            if main_event == 'run_on_startup':\n                start_on_login_modifications()\n            elif main_event == 'persistent_queue':\n                if main_value:\n                    save_queues()\n                else:\n                    update_settings('queues', {'done': [], 'music': [], 'next': []})\n                update_settings('populate_queue_startup', False)\n                gui_window['populate_queue_startup'].update(value=False)\n            elif main_event in 'populate_queue_startup':\n                gui_window['persistent_queue'].update(value=False)\n                update_settings('persistent_queue', False)\n            elif main_event == 'discord_rpc':\n                with suppress(Exception):\n                    if main_value:\n                        if playing_status.busy():\n                            metadata = url_metadata['SYSTEM_AUDIO'] if sar.alive else get_uri_metadata(music_queue[0])\n                            title, artist = metadata['title'], get_first_artist(metadata['artist'])\n                            DiscordPresence.connect()\n                            if track_start is not None and track_length is not None:\n                                DiscordPresence.update(t('By') + f': {artist}', title, 'Listening', end=track_start + track_length)\n                    elif not main_value:\n                        DiscordPresence.clear()\n            elif main_event in {'show_album_art', 'vertical_gui', 'flip_main_window'}:\n                # re-render main GUI\n                gui_window.close()\n                activate_gui('tab_settings')\n            elif main_event in {'show_track_number', 'show_queue_index'}:\n                gui_window.metadata['update_listboxes'] = True\n            elif main_event == 'scan_folders' and main_value:\n                index_all_tracks()\n            elif main_event == 'folder_cover_override':\n                size = COVER_MINI if settings['mini_mode'] else COVER_NORMAL\n                bg = settings['theme']['background']\n                try:\n                    album_art_data = resize_img(get_current_art(), bg, size, default_art=DEFAULT_ART)\n                except OSError as e:\n                    handle_exception(e)\n                    album_art_data = resize_img(DEFAULT_ART, bg, size)\n                gui_window['artwork'].update(data=album_art_data)\n            elif main_event == 'lang':\n                State.lang = main_value\n                gui_window.close()\n                activate_gui('tab_settings')\n                refresh_tray(True)\n        elif main_event == 'remove_music_folder' and main_values['music_folders']:\n            with suppress(ValueError):\n                for selected_item in main_values['music_folders']:\n                    music_folders.remove(selected_item)\n                gui_window['music_folders'].update(music_folders)\n                refresh_tray()\n                save_settings()\n                if settings['scan_folders']:\n                    index_all_tracks()\n        elif main_event == 'add_music_folder':\n            initial_folder = settings['last_folder'] if settings['use_last_folder'] else get_default_music_folder()\n            folder_path = Sg.popup_get_folder(t('Select Folder'), initial_folder=initial_folder, no_window=True,\n                                              icon=WINDOW_ICON)\n            if folder_path:\n                add_music_folder([folder_path])\n        elif main_event == 'settings_file':\n            startfile(SETTINGS_FILE)\n        elif main_event == 'changelog_file':\n            try:\n                changelog_path = f'{sys._MEIPASS}/CHANGELOG.txt'\n            except AttributeError:\n                changelog_path = 'CHANGELOG.txt'\n            if not os.path.exists(changelog_path):\n                changelog_url = 'https://github.com/elibroftw/music-caster/blob/master/CHANGELOG.txt'\n                open_in_browser(changelog_url)\n            else:\n                startfile(changelog_path)\n        elif main_event == 'music_folders':\n            with suppress(IndexError):\n                Popen(f'explorer \"{fix_path(main_values[\"music_folders\"][0])}\"')\n        # url tab\n        elif main_event == 'url_input':\n            gui_window.metadata['url_input'] = main_value\n        elif main_event == 'url_input_cut':\n            cut_text = get_cut_text(gui_window, 'url_input')\n            if cut_text:\n                pyperclip.copy(cut_text)\n                gui_window.metadata['url_input'] = gui_window['url_input'].get()\n        elif main_event == 'url_input_copy':\n            with suppress(TclError):\n                pyperclip.copy(gui_window['url_input'].Widget.selection_get())\n        elif (main_event in {'\\r', 'special 16777220', 'special 16777221', 'url_submit'}\n              and main_values.get('tab_group') == 'tab_url' and main_values['url_input']):\n            urls_to_insert = main_values['url_input'].strip()\n            if '\\n' in urls_to_insert:\n                urls_to_insert = urls_to_insert.split('\\n')\n            else:\n                urls_to_insert = urls_to_insert.split(';')\n            gui_window['url_input'].update(value='')\n            if main_values['url_play'] or not music_queue:\n                music_queue.extendleft(reversed(urls_to_insert))\n                gui_window['url_msg'].update(t('Loading URL(s)'), text_color='yellow')\n                gui_window.read(1)\n                play()\n                gui_window['url_msg'].update('')\n                urls_to_insert.pop(0)\n            elif main_values['url_queue']:\n                music_queue.extend(urls_to_insert)\n                gui_window['url_msg'].update(t('Added URL(s)'), text_color='green')\n                gui_window.TKroot.after(2000, lambda: gui_window['url_msg'].update(value=''))\n            else:  # add to next queue\n                if settings['reversed_play_next']:\n                    next_queue.extendleft(reversed(urls_to_insert))\n                else:\n                    next_queue.extend(urls_to_insert)\n                gui_window['url_msg'].update(t('Added URL(s)'), text_color='green')\n                gui_window.TKroot.after(2000, lambda: gui_window['url_msg'].update(value=''))\n            for inserted_url in urls_to_insert:\n                uris_to_scan.put(inserted_url)\n            gui_window['url_input'].set_focus()\n            gui_window.metadata['update_listboxes'] = True\n        # video tab\n        elif main_event == 'video_select_file':\n            Thread(target=video_file_action(), name='VideoFileAction', daemon=True).start()\n        # timer tab\n        elif main_event == 'cancel_timer':\n            gui_window['timer_text'].update(t('No Timer Set'))\n            gui_window['timer_text'].metadata = False\n            gui_window['timer_error'].update(visible=False)\n            gui_window['cancel_timer'].update(visible=False)\n            cancel_timer()\n        # handle enter/submit event\n        elif main_event in SUBMIT_EVENTS and main_values.get('tab_group') == 'tab_timer':\n            try:\n                timer_value: str = main_values['timer_input']\n                timer_set_to = set_timer(timer_value)\n                gui_window['timer_text'].update(t('Timer set for $TIME').replace('$TIME', timer_set_to))\n                gui_window['timer_text'].metadata = True\n                gui_window['cancel_timer'].update(visible=True)\n                gui_window['timer_error'].update(visible=False)\n                gui_window['timer_input'].update(value='')\n                gui_window['timer_input'].set_focus()\n            except ValueError:\n                # flash timer error\n                for _ in range(3):\n                    gui_window['timer_error'].update(visible=True, text_color='#ffcccb')\n                    gui_window.read(10)\n                    gui_window['timer_error'].update(text_color='red')\n                    gui_window.read(10)\n                gui_window['timer_input'].set_focus()\n        elif main_event in {'shut_down', 'hibernate', 'sleep', 'timer_stop'}:\n            update_settings('timer_hibernate', main_values['hibernate'])\n            update_settings('timer_sleep', main_values['sleep'])\n            update_settings('timer_shut_down', main_values['shut_down'])\n        # playlists tab\n        elif main_event == 'playlist_combo':\n            # user selected a playlist from the drop-down\n            pl_name = gui_window.metadata['pl_name'] = main_value if main_value in settings['playlists'] else ''\n            pl_tracks = gui_window.metadata['pl_tracks'] = settings['playlists'].get(pl_name, []).copy()\n            gui_window['pl_name'].update(value=pl_name)\n            pl_tracks_values, pl_length = format_pl_lb(pl_tracks)\n            gui_window['pl_length'].update(value=pl_length)\n            gui_window['pl_tracks'].update(values=pl_tracks_values, set_to_index=0)\n        elif main_event in {'new_pl', 'n:78'}:\n            gui_window.metadata['pl_name'] = ''\n            gui_window.metadata['pl_tracks'] = []\n            gui_window['pl_name'].update(value='')\n            gui_window['pl_name'].set_focus()\n            gui_window['pl_length'].update(value='')\n            gui_window['pl_tracks'].update(values=[])\n            gui_window['playlist_combo'].update(value='')\n        elif main_event == 'export_pl':\n            if main_values['playlist_combo'] and settings['playlists'].get(main_values['playlist_combo']):\n                playlist_uris = settings['playlists'][main_values['playlist_combo']]\n                playlist_path = export_playlist(main_values['playlist_combo'], playlist_uris)\n                locate_uri(uri=playlist_path)\n        elif main_event == 'delete_pl':\n            pl_name = gui_window.metadata['pl_name'] = main_values.get('playlist_combo', '')\n            settings['playlists'].pop(pl_name, None)\n            pl_name = gui_window.metadata['pl_name'] = next(iter(settings['playlists']), '')\n            gui_window['playlist_combo'].update(value=pl_name, values=tuple(settings['playlists']))\n            pl_tracks = gui_window.metadata['pl_tracks'] = settings['playlists'].get(pl_name, []).copy()\n            # update playlist editor\n            gui_window['pl_name'].update(value=pl_name)\n            pl_tracks_values, pl_tracks_length = format_pl_lb(pl_tracks)\n            gui_window['pl_length'].update(value=pl_tracks_length)\n            gui_window['pl_tracks'].update(values=pl_tracks_values, set_to_index=0)\n            save_settings()\n            refresh_tray()\n        elif main_event == 'play_pl':\n            playlist_action(main_values['playlist_combo'])\n        elif main_event == 'queue_pl':\n            playlist_action(main_values['playlist_combo'], 'queue')\n            gui_window.metadata['update_listboxes'] = True\n        elif main_event == 'add_next_pl':\n            playlist_action(main_values['playlist_combo'], 'next')\n            gui_window.metadata['update_listboxes'] = True\n        elif main_event in {'pl_save', 's:83'} and main_values.get('tab_group') == 'tab_playlists':\n            # save playlist\n            if main_values['pl_name']:\n                pl_name = gui_window.metadata['pl_name']\n                save_name = main_values['pl_name']\n                if pl_name != save_name:\n                    # if user is renaming a playlist, remove old data\n                    settings['playlists'].pop(pl_name, '')\n                    pl_name = gui_window.metadata['pl_name'] = save_name\n                settings['playlists'][pl_name] = gui_window.metadata['pl_tracks']\n                # sort playlists alphabetically\n                playlist_names = sorted(settings['playlists'])\n                settings['playlists'] = {k: settings['playlists'][k] for k in playlist_names}\n                gui_window['playlist_combo'].update(value=pl_name, values=playlist_names)\n            save_settings()\n            gui_window['pl_saved'].update(visible=True)\n            gui_window.read(1)\n            gui_window.TKroot.after(2000, lambda: gui_window['pl_saved'].update(visible=False))\n            refresh_tray()\n        elif (main_event == 'pl_rm_items' and main_values['pl_tracks']\n              and main_values.get('tab_group') == 'tab_playlists'):\n            # remove items from playlist\n            # remove bottom to top to avoid dynamic indices\n            pl_tracks = gui_window.metadata['pl_tracks']\n            for i, to_remove in enumerate(reversed(gui_window['pl_tracks'].get_indexes()), 1):\n                pl_tracks.pop(to_remove)\n                if i == len(main_values['pl_tracks']):  # update gui after the last removal\n                    scroll_to_index = max(to_remove - 3, 0)\n                    new_values, new_length = format_pl_lb(pl_tracks)\n                    gui_window['pl_length'].update(value=new_length)\n                    gui_window['pl_tracks'].update(new_values, set_to_index=to_remove, scroll_to_index=scroll_to_index)\n        elif main_event == 'pl_add_tracks':\n            initial_folder = settings['last_folder'] if settings['use_last_folder'] else get_default_music_folder()\n            file_paths = Sg.popup_get_file('Select Audio Files', no_window=True, initial_folder=initial_folder,\n                                           multiple_files=True, file_types=AUDIO_FILE_TYPES, icon=WINDOW_ICON)\n            if file_paths:\n                pl_tracks = gui_window.metadata['pl_tracks']\n                pl_tracks.extend(get_audio_uris(file_paths))\n                update_settings('last_folder', os.path.dirname(file_paths[-1]))\n                with suppress(TclError):\n                    gui_window.TKroot.focus_force()\n                    gui_window.normal()\n                    new_values, pl_length = format_pl_lb(pl_tracks)\n                    gui_window['pl_length'].update(value=pl_length)\n                    new_i = len(new_values) - 1\n                    gui_window['pl_tracks'].update(new_values, set_to_index=new_i, scroll_to_index=max(new_i - 3, 0))\n        elif main_event == 'pl_url_input':\n            gui_window.metadata['pl_url_input'] = main_value\n        elif main_event == 'pl_url_input_cut':\n            cut_text = get_cut_text(gui_window, 'pl_url_input')\n            if cut_text:\n                pyperclip.copy(cut_text)\n                gui_window.metadata['pl_url_input'] = gui_window['pl_url_input'].get()\n        elif main_event == 'pl_url_input_copy':\n            with suppress(TclError):\n                pyperclip.copy(gui_window['pl_url_input'].Widget.selection_get())\n        elif main_event == 'pl_add_url' or (main_event in SUBMIT_EVENTS and main_values.get('tab_group') == 'tab_playlists'):\n            links = main_values['pl_url_input']\n            if '\\n' in links:\n                links = links.split('\\n')\n            else:\n                links = links.split(';')\n            for link in links:\n                if link.startswith('http://') or link.startswith('https://'):\n                    uris_to_scan.put(link)\n                    pl_tracks = gui_window.metadata['pl_tracks']\n                    pl_tracks.append(link)\n                    new_values, pl_length = format_pl_lb(pl_tracks)\n                    gui_window['pl_length'].update(value=pl_length)\n                    new_i = len(new_values) - 1\n                    gui_window['pl_tracks'].update(new_values, set_to_index=new_i, scroll_to_index=max(new_i - 3, 0))\n                    # empty the input field\n                    gui_window['pl_url_input'].update(value='')\n                    gui_window['pl_url_input'].set_focus()\n                else:\n                    tray_notify(t('ERROR') + ': ' + t(\"Invalid URL. URL's need to start with http:// or https://\"))\n        elif main_event == 'pl_move_up':\n            # only allow moving up if 1 item is selected and pl_files is not empty\n            for i, to_move in enumerate(gui_window['pl_tracks'].get_indexes(), 1):\n                if to_move:  # can't move the first index up\n                    new_i = to_move - 1\n                    pl_tracks = gui_window.metadata['pl_tracks']\n                    pl_tracks.insert(new_i, pl_tracks.pop(to_move))\n                    if i == len(main_values['pl_tracks']):  # update gui after the last swap\n                        new_values, pl_length = format_pl_lb(pl_tracks)\n                        gui_window['pl_length'].update(value=pl_length)\n                        gui_window['pl_tracks'].update(new_values, set_to_index=new_i,\n                                                       scroll_to_index=max(new_i - 3, 0))\n        elif main_event == 'pl_move_down':\n            # only allow moving down if 1 item is selected and pl_files is not empty\n            for i, to_move in enumerate(gui_window['pl_tracks'].get_indexes(), 1):\n                pl_tracks = gui_window.metadata['pl_tracks']\n                if to_move < len(pl_tracks) - 1:\n                    new_i = to_move + 1\n                    pl_tracks.insert(new_i, pl_tracks.pop(to_move))\n                    if i == len(main_values['pl_tracks']):  # update gui after the last swap\n                        pl_new_values, pl_length = format_pl_lb(pl_tracks)\n                        gui_window['pl_length'].update(value=pl_length)\n                        gui_window['pl_tracks'].update(values=pl_new_values, set_to_index=new_i,\n                                                       scroll_to_index=max(new_i - 3, 0))\n        elif main_event in {'pl_locate_selected', 'pl_tracks'}:\n            for i in gui_window['pl_tracks'].get_indexes():\n                locate_uri(uri=gui_window.metadata['pl_tracks'][i])\n        elif main_event == 'pl_copy_selected':\n            with suppress(IndexError):\n                text_to_copy = ', '.join(( gui_window.metadata['pl_tracks'][i] for i in gui_window['pl_tracks'].get_indexes()))\n                if text_to_copy:\n                    pyperclip.copy(text_to_copy)\n        elif main_event in {'play_pl_selected', 'queue_pl_selected', 'add_next_pl_selected'}:\n            uris = (gui_window.metadata['pl_tracks'][i] for i in gui_window['pl_tracks'].get_indexes())\n            play_uris(uris, queue_uris=main_event == 'queue_pl_selected',\n                      play_next=main_event == 'add_next_pl_selected', natural_sort=settings['shuffle'])\n        # metadata editor tab\n        elif main_event in {'metadata_browse', 'metadata_file'}:\n            initial_folder = settings['last_folder'] if settings['use_last_folder'] else get_default_music_folder()\n            selected_file = Sg.popup_get_file('Select audio file', initial_folder=initial_folder, no_window=True,\n                                              file_types=AUDIO_FILE_TYPES, icon=WINDOW_ICON)\n            metadata_process_file(selected_file, f'read_main_window:{main_event}')\n        elif main_event == 'metadata_select_art' and gui_window['metadata_file'].get():\n            selected_file = Sg.popup_get_file('Select image/audio file', no_window=True,\n                                              file_types=IMG_FILE_TYPES, icon=WINDOW_ICON)\n            if selected_file:\n                if Path(selected_file).suffix.casefold() in AUDIO_EXTS:\n                    mime, artwork = get_album_art(selected_file, settings['folder_cover_override'])\n                else:\n                    img = Image.open(selected_file).convert('RGB')\n                    data = io.BytesIO()\n                    img.save(data, format='jpeg', quality=95)\n                    mime, artwork = 'image/jpeg', b64encode(data.getvalue())\n                artwork = None if artwork == DEFAULT_ART else artwork\n                if artwork is not None:\n                    try:\n                        display_art = resize_img(artwork, settings['theme']['background'], COVER_MINI)\n                        gui_window['metadata_art'].metadata = (mime, artwork)\n                        gui_window['metadata_art'].update(data=display_art)\n                    except OSError as e:\n                        handle_exception(e)\n        elif main_event == 'metadata_search_art' and gui_window['metadata_file'].get():\n            # search for artwork using spotify API\n            gui_window['metadata_msg'].update(value=t('Searching for artwork...'), text_color='yellow')\n            found_artwork = False\n            for mkt in {'MX', 'CA', 'US', 'UK', 'HK'}:\n                title = main_values['metadata_title']\n                artist = main_values['metadata_artist']\n                url = f'https://api.spotify.com/v1/search?q={title}'\n                if artist:\n                    url += f'+artist:{artist}'\n                url += f'&type=track&market={mkt}'\n                r = requests.get(url, headers=get_spotify_headers()).json()\n                if 'tracks' in r:\n                    for art_link in (item['album']['images'][0]['url'] for item in r['tracks']['items']):\n                        original_art = b64encode(requests.get(art_link).content).decode()\n                        found_artwork = True\n                        try:\n                            display_art = resize_img(original_art, settings['theme']['background'], COVER_MINI)\n                            gui_window['metadata_art'].metadata = ('image/jpeg', original_art)\n                            gui_window['metadata_art'].update(data=display_art)\n                        except OSError as e:\n                            handle_exception(e)\n                            found_artwork = False\n                        break\n            if found_artwork:\n                gui_window['metadata_msg'].update(value=t('Artwork found'), text_color='green')\n                gui_window.TKroot.after(2000, lambda: gui_window['metadata_msg'].update(value=''))\n            else:\n                gui_window['metadata_msg'].update(value=t('No artwork found'), text_color='red')\n                gui_window.TKroot.after(2000, lambda: gui_window['metadata_msg'].update(value=''))\n        elif main_event == 'metadata_remove_art':\n            gui_window['metadata_art'].metadata = (None, None)\n            gui_window['metadata_art'].update(data=None)\n        elif main_event in {'metadata_save', 's:83'} and main_values.get('tab_group') == 'tab_metadata':\n            if gui_window['metadata_file'].get():\n                new_metadata = {'title': main_values['metadata_title'], 'artist': main_values['metadata_artist'],\n                            'album': main_values['metadata_album'], 'explicit': main_values['metadata_explicit'],\n                            'track_number': main_values['metadata_track_num']}\n                # album art optional\n                if gui_window['metadata_art'].metadata is not None:\n                    mime, art = gui_window['metadata_art'].metadata\n                    new_metadata['mime'] = mime\n                    new_metadata['art'] = art\n                gui_window['metadata_msg'].update(value=t('Saving metadata'), text_color='yellow')\n                try:\n                    set_metadata(gui_window['metadata_file'].get(), new_metadata)\n                    gui_window['metadata_msg'].update(value=t('Metadata saved'), text_color='green')\n                except Exception as e:  # e.g. ValueError track number incorrectly entered\n                    print('error', repr(e))\n                    error = t('ERROR') + ': ' + repr(e)\n                    gui_window['metadata_msg'].update(value=error, text_color='red')\n                gui_window.TKroot.after(2000, lambda: gui_window['metadata_msg'].update(value=''))\n                gui_window['title'].update(' ' + gui_window['title'].DisplayText + ' ')  # try updating now playing\n        elif main_event == 'exit_program':\n            exit_program()\n        # other GUI updates\n        if gui_window.metadata['update_listboxes'] and not settings['mini_mode']:\n            gui_window.metadata['update_listboxes'] = False\n            dq_len = len(done_queue)\n            lb_tracks = create_track_list()\n            gui_window['queue'].update(values=lb_tracks, set_to_index=dq_len, scroll_to_index=dq_len)\n            pl_tracks = gui_window.metadata['pl_tracks']\n            pl_values, pl_length = format_pl_lb(pl_tracks)\n            gui_window['pl_length'].update(value=pl_length)\n            gui_window['pl_tracks'].update(values=pl_values)\n            if len(all_tracks) != len(gui_window['library'].Values):\n                lib_data = sorted(\n                    (\n                        [\n                            track['title'],\n                            get_first_artist(track['artist']),\n                            track['album'],\n                            uri,\n                        ]\n                        for uri, track in index_all_tracks(False).items()\n                    ),\n                    key=lambda cols: cols[1],\n                )\n                gui_window['library'].update(values=lib_data)\n        if gui_window.metadata['update_volume_slider']:\n            gui_window['mute'].update(image_data=VOLUME_MUTED_IMG if settings['muted'] else VOLUME_IMG)\n            gui_window['mute'].set_tooltip(t('unmute') if settings['muted'] else t('mute'))\n            gui_window['volume_slider'].update(0 if settings['muted'] else settings['volume'])\n            gui_window.metadata['update_volume_slider'] = False\n        # update progress bar\n        if time.time() > SYNC_WITH_CHROMECAST:\n            progress_bar: Sg.Slider = gui_window['progress_bar']\n            time_elapsed_text, time_left_text = create_progress_bar_texts(\n                get_track_position(), track_length\n            )\n            if time_elapsed_text != gui_window['time_elapsed'].get():\n                gui_window['time_elapsed'].update(time_elapsed_text)\n            if time_left_text != gui_window['time_left'].get():\n                gui_window['time_left'].update(time_left_text)\n            if music_queue and playing_status.busy() and not sar.alive:\n                progress_bar.update(floor(track_position))\n        return True\n\n\n    def start_on_login_modifications():\n        \"\"\" Run platform specific implementation of startup modification \"\"\"\n        if platform.system() == 'Windows':\n            app_log.info('removing old startup shortcuts')\n            rm_old_startup_shortcuts()\n            app_log.info('removed old startup shortcuts')\n            app_log.info('creating/removing startup registry entry')\n            start_on_login_win32(working_dir, settings['run_on_startup'])\n            app_log.info('created/removed startup registry entry')\n        else:\n            print('TODO: start_on_login_modifications not implemented for', platform.system())\n\n\n    def cast_monitor(sent: bool = True, msg: dict | None = None, is_callback=True):\n        global track_position, track_start, track_end, OLD_CAST_VOLUME, OLD_CAST_POS\n        if cast is None:\n            return\n        # assume this code can raise exceptions\n        #   since I did remove it from that try-catch block\n        try:\n            if msg is None and playing_status.busy():\n                # block/monitor in background thread\n                if is_callback:\n                    # avoid recursion error\n                    if playing_status.playing():\n                        raise NotConnected\n                    return\n                return cast.media_controller.update_status(callback_function=cast_monitor)\n        except AttributeError:\n            # don't need to monitor if device switched randomly\n            return\n        except (NotConnected, UnsupportedNamespace):\n            app_log.info(f'cast.media_controller player state: {cast.media_controller.status.player_state}')\n            # we might care if not connected\n            with suppress(RequestTimeout):\n                cast.wait(3)\n            return\n        except Exception as e:\n            handle_exception(e)\n            return\n        try:\n            CAST_LOCK.acquire()\n            if cast.app_id == APP_MEDIA_RECEIVER and time.time() > SYNC_WITH_CHROMECAST:\n                media_controller = cast.media_controller\n                is_stopped = media_controller.status.player_is_idle\n                is_live = track_length is None\n                if not is_stopped and playing_status.busy():\n                    # sync track position with chromecast, also allows scrubbing from external apps\n                    with suppress(IndexError):  # music_queue may be mutated\n                        buffer = 2 if music_queue[0].startswith('http') else 0.6\n                        current_time = media_controller.status.adjusted_current_time\n                        if current_time is not None and abs(current_time - OLD_CAST_POS) > buffer:\n                            if current_time < OLD_CAST_POS:\n                                app_log.info(f'cast.media_controller player state: {media_controller.status.player_state}')\n                                app_log.info(f'updating OLD_CAST_POS from {OLD_CAST_POS} to {current_time}')\n                            OLD_CAST_POS = current_time\n                            # update track position only if out of buffer position\n                            if abs(current_time - get_track_position()) > buffer:\n                                if current_time < track_position and track_position - current_time > 2:\n                                    app_log.info(f'updating track position from {track_position:.2f} to {current_time:.2f}')\n                                track_start = time.monotonic() - track_position\n                                if track_length is not None:\n                                    track_end = track_start + track_length\n                if media_controller.status.player_is_paused and playing_status.playing():\n                    pause('cast_monitor')\n                elif media_controller.status.player_is_playing and playing_status.paused():\n                    resume('cast_monitor')\n                elif (is_stopped and playing_status.busy() and not is_live and time.monotonic() - track_end > 1):\n                    # if cast says nothing is playing, only stop if we are not at the end of the track\n                    #  this will prevent false positives\n                    stop('cast_monitor', False)\n                if cast.status is not None:\n                    cast_volume = round(cast.status.volume_level * 100, 1)\n                    # volume sync\n                    if settings['volume'] != cast_volume:\n                        if not settings['muted'] and (not isinstance(settings['volume'], (float, int)) or\n                                                        abs(settings['volume'] - cast_volume) > 0.05):\n                            # if volume was changed via Google Home App\n                            OLD_CAST_VOLUME = cast_volume\n                            if update_settings('volume', cast_volume) and settings['muted']:\n                                update_settings('muted', False)\n                            gui_window.metadata['update_volume_slider'] = True\n            elif playing_status.playing() and cast.media_controller.status.player_is_idle and time.time() - LAST_PLAYED > 300:\n                # paused for more than 5 minutes\n                stop('cast_monitor. app was not running')\n        except (NotConnected, AttributeError):  # don't care\n            pass\n        except UnsupportedNamespace:  # known error\n            # File \"pychromecast/controllers/media.py\", line 359, in update_status\n            # File \"pychromecast/controllers/init.py\", line 91, in send_message\n            # pychromecast.error.UnsupportedNamespace:\n            #  Namespace urn:x-cast:com.google.cast.media is not supported by running application.\n            pass\n        except Exception as e:\n            handle_exception(e)\n        finally:\n            with suppress(RuntimeError):\n                CAST_LOCK.release()\n\n\n\n    def handle_action(action):\n        actions = {\n            '__ACTIVATED__': activate_gui,\n            '__UPDATE_GUI__': update_gui,\n            '__EXIT__': exit_program,\n            # from tray menu\n            t('Exit'): exit_program,\n            t('Rescan Library'): index_all_tracks,\n            t('Refresh Devices'): lambda: refresh_tray(True),\n            # isdigit should be an if statement\n            t('Settings'): lambda: activate_gui('tab_settings'),\n            t('Playlists Tab'): lambda: activate_gui('tab_playlists'),\n            # PL should be an if statement\n            t('Set Timer'): lambda: activate_gui('tab_timer'),\n            t('Cancel Timer'): cancel_timer,\n            t('System Audio'): play_system_audio,\n            t('Play URL'): lambda: activate_gui('tab_url', 'url_play'),\n            t('Queue URL'): lambda: activate_gui('tab_url', 'url_queue'),\n            t('Play URL Next'): lambda: activate_gui('tab_url', 'url_play_next'),\n            t('Play Files'): file_action,\n            t('Queue Files'): lambda: file_action('qf'),\n            t('Play Files Next'): lambda: file_action('pfn'),\n            t('Play All'): play_all,\n            t('Pause'): pause,\n            t('Resume'): resume,\n            t('next track', 1): next_track,\n            t('previous track', 1): prev_track,\n            t('Stop'): lambda: stop('tray'),\n            t('Repeat One'): lambda: update_settings('repeat', True),\n            t('Repeat All'): lambda: update_settings('repeat', False),\n            t('Repeat Off'): lambda: update_settings('repeat', None),\n            t('locate track', 1): locate_uri\n        }\n        actions.get(action, lambda: other_tray_actions(action))()\n    update_checker = UpdateChecker()\n    try:\n        start_time = time.monotonic()\n        load_settings(True)  # starts indexing all tracks\n        if settings['important_message'] != IMPORTANT_INFORMATION and IMPORTANT_INFORMATION:\n            two_lined_info = []\n            for line in IMPORTANT_INFORMATION.splitlines(keepends=True):\n                two_lined_info.append(line)\n                if len(two_lined_info) == 2:\n                    tray_notify(''.join(two_lined_info), title='Music Caster - Important Information')\n                    two_lined_info.clear()\n            tray_notify(''.join(two_lined_info), title='Music Caster - Important Information')\n            update_settings('important_message', IMPORTANT_INFORMATION)\n        if settings['update_message'] == '':\n            tray_notify(WELCOME_MSG)\n        elif settings['update_message'] != UPDATE_MESSAGE and settings['notifications']:\n            tray_notify(UPDATE_MESSAGE)\n        # show important information regardless of notification settings\n        update_settings('update_message', UPDATE_MESSAGE)\n\n        # set file handlers only if installed from the setup (Not a portable installation)\n        if os.path.exists(UNINSTALLER):\n            with suppress(PermissionError):\n                add_reg_handlers(working_dir / 'Music Caster.exe', add_folder_context=settings['folder_context_menu'])\n\n        # remove any existing installer file we might've already run\n        with suppress(FileNotFoundError, OSError):\n            os.remove(get_installer_path())\n\n        rmtree('Update', ignore_errors=True)\n        Thread(target=background_thread, daemon=True, name='BackgroundTasks').start()\n        zconf = zeroconf.Zeroconf()\n        cast_browser = pychromecast.discovery.CastBrowser(MyCastListener(), zconf)\n        cast_browser.start_discovery()\n        try:\n            audio_player = AudioPlayer()\n        except Exception as exception:\n            tray_notify(t('WARNING: Failed to start audio player. Do not play on local device.'))\n            handle_exception(exception)\n        # system_media_controls = SystemMediaControls(on_smtc_btn_press)\n        # find a port to bind to\n        socket_timeout = 0.5 if args.shell else 0.1\n        while True:\n            with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s1, \\\n                    socket.socket(socket.AF_INET6, socket.SOCK_STREAM) as s2:\n                s1.settimeout(socket_timeout)\n                s2.settimeout(socket_timeout)\n                # check if ports are not occupied\n                if s1.connect_ex(('127.0.0.1', State.PORT)) != 0 and s2.connect_ex(('::1', State.PORT)) != 0:\n                    with suppress(OSError, PermissionError):\n                        # try to start server and bind it to PORT\n                        # Linux auto-maps ipv4 to ipv6 however Windows keep them seperate\n                        if platform.system() == 'Windows':\n                            server_kwargs = {'host': '0.0.0.0', 'port': State.PORT}\n                            Thread(target=waitress.serve, name='WaitressServe', daemon=True, args=(app,), kwargs=server_kwargs).start()\n                        server_kwargs = {'host': '::', 'port': State.PORT}\n                        Thread(target=waitress.serve, name='WaitressServe', daemon=True, args=(app,), kwargs=server_kwargs).start()\n                        break\n                State.PORT += 1  # port in use or failed to bind to port\n        with suppress(PermissionError):\n            if is_debug:\n                # only want to store PID of original instance\n                lock_file.read()\n            create_pid_file(port=State.PORT)\n        if not USING_TAURI_FRONTEND:\n            tray_process = mp.Process(target=system_tray, name='Music Caster Tray',\n                                      args=(daemon_commands, tray_process_queue), daemon=True)\n            tray_process.start()\n        api_key = settings['api_key']\n        print(f'Running on http://127.0.0.1:{State.PORT}/?api_key={api_key}')\n        print(f'Running on http://[::1]:{State.PORT}/?api_key={api_key}')\n        app_log.info(f'LAN IPV4: {get_ipv4()}:{State.PORT}/')\n        try:\n            app_log.info(f'LAN IPV6: {get_ipv6()}:{State.PORT}/')\n        except StopIteration:\n            app_log.info('Could not get LAN IPV6 address')\n        DiscordPresence.connect(settings['discord_rpc'])\n        if PHANTOMJS_DIR.is_dir() and not cmd_exists('phantomjs'):\n            add_to_path(PHANTOMJS_DIR / 'bin')\n        if args.device is not None:\n            end_time = time.monotonic() + WAIT_TIMEOUT\n            while not change_device(args.device) and time.monotonic() < end_time:\n                time.sleep(0.3)\n        if args.uris or args.start_playing:\n            # wait until previous device has been found or cannot be found\n            end_time = time.monotonic() + WAIT_TIMEOUT\n            while not change_device(settings['device']) and time.monotonic() < end_time:\n                time.sleep(0.3)\n        if args.uris:\n            if args.uris[0].lower().replace(' ', '').replace('_', '') == 'systemaudio':\n                play_system_audio()\n            else:\n                play_uris(args.uris, queue_uris=args.queue, play_next=args.playnext)\n        elif settings['persistent_queue']:\n            # load saved queues from settings.json\n            for queue_name in {'done', 'music', 'next'}:\n                queue = {'done': done_queue, 'music': music_queue, 'next': next_queue}[queue_name]\n                for file_or_url in settings['queues'].get(queue_name, []):\n                    if valid_audio_file(file_or_url) or file_or_url.startswith('http'):\n                        queue.append(file_or_url)\n                        uris_to_scan.put(file_or_url)\n            # position = args.position || previous session's position\n            track_position = args.position\n            if track_position == 0 and settings['position'] > 0:\n                track_position = settings['position']\n            if args.start_playing:\n                if not music_queue:\n                    if next_queue:\n                        music_queue.append(next_queue.popleft())\n                    elif done_queue:\n                        music_queue.extend(done_queue)\n                        done_queue.clear()\n                if music_queue:\n                    play(position=track_position, autoplay=not args.queue)\n            elif track_position and music_queue:\n                # restore position\n                play(position=track_position, autoplay=False)\n        elif settings['populate_queue_startup'] or args.start_playing:\n            try:\n                indexing_tracks_thread.join()\n                play_all(queue_only=not args.start_playing or args.queue)\n            except RuntimeError:\n                tray_notify(t('ERROR') + ':' + t('Could not populate queue because library scan is disabled'))\n        # open window if minimized argument not given\n        if not USING_TAURI_FRONTEND and not args.minimized and not settings.get('DEBUG', False):\n            daemon_commands.put('__ACTIVATED__')\n        TIME_TO_START = time.monotonic() - start_time\n        app_log.info('--------------------------------')\n        app_log.info(f'Music Caster Version: {VERSION}')\n        app_log.debug(f'Time to start (excluding imports) is {TIME_TO_START:.2f} seconds')\n        app_log.debug(f'Time to start (including imports) is {TIME_TO_START + TIME_TO_IMPORT:.2f} seconds')\n        last_position_save = time.monotonic()\n\n        # health check\n        if is_debug():\n            api_key = settings['api_key']\n            r = requests.get(f'http://127.0.0.1:{State.PORT}/?api_key={api_key}')\n            assert r.ok\n\n        while True:\n            while not daemon_commands.empty():\n                handle_action(daemon_commands.get())\n            if playing_status.playing() and track_length is not None and time.monotonic() > track_end:\n                app_log.info('calling next track because monotonic time is greater than track_end')\n                next_track(from_timeout=time.monotonic() > track_end)\n            elif timer and time.time() > timer:\n                stop('timer')\n                timer = 0\n                # use lock to prevent corrupting settings\n                with settings_file_lock:\n                    if settings['timer_shut_down']:  # shutdown computer\n                        os.system('shutdown /p /f') if platform.system() == 'Windows' else os.system('shutdown -h now')\n                    elif settings['timer_hibernate']:  # hibernate computer\n                        if platform.system() == 'Windows':\n                            os.system(\n                                r'rundll32.exe powrprof.dll,SetSuspendState Hibernate'\n                            )\n                    elif settings['timer_sleep']:  # sleep computer\n                        if platform.system() == 'Windows':\n                            os.system('rundll32.exe powrprof.dll,SetSuspendState 0,1,0')\n            # if settings.json was updated outside of Music Caster, reload settings\n            try:\n                if os.path.getmtime(SETTINGS_FILE) != settings_last_modified:\n                    load_settings()\n            except FileNotFoundError:\n                load_settings(first_load=True)\n            if settings['persistent_queue'] and time.monotonic() - last_position_save > 2.5:\n                update_settings('position', get_track_position())\n                last_position_save = time.monotonic()\n            if platform.system() == 'Windows' and None not in (settings['on_battery_res'], settings['plugged_in_res']):\n                if settings['on_battery_res'] != settings['plugged_in_res']:\n                    try:\n                        user32 = ctypes.windll.user32\n                        res_map = get_all_resolutions()\n                        refresh_rate = None\n                        if is_plugged_in(throw_error=False):\n                            res_info = res_map[fmt_res(*settings['plugged_in_res'])]\n                            # check if res differs from desireed res\n                            if user32.GetSystemMetrics(0) * res_info['dpi_scale'] != settings['plugged_in_res'][0]:\n                                refresh_rate = max(get_all_refresh_rates())\n                        else:  # on battery\n                            res_info = res_map[fmt_res(*settings['on_battery_res'])]\n                            # check if res differs from desireed res\n                            if user32.GetSystemMetrics(0) * res_info['dpi_scale'] != settings['on_battery_res'][0]:\n                                refresh_rate = 60 if 60 in get_all_refresh_rates() else min(get_all_refresh_rates())\n                        # res differs from desired res\n                        if refresh_rate is not None:\n                            set_resolution(res_info['w'], res_info['h'], res_info['dpi_scale'], refresh_rate=refresh_rate)\n                            refresh_tray_icon()\n                    except KeyError:\n                        update_settings('plugged_in_res', get_initial_res())\n                        update_settings('on_battery_res', get_initial_res())\n                        tray_notify(t('ERROR') + ': ' + t('Could not set resolution'))\n            if cast is not None:\n                cast_monitor(is_callback=False)\n            if not gui_window.is_closed():\n                read_main_window()\n            else:\n                time.sleep(0.3)\n    except KeyboardInterrupt:\n        exit_program()\n    except Exception as exception:\n        app_log.exception('FATAL exception detected')\n        # try to auto-update before exiting\n        if not settings.get('DEBUG', False):\n            update_checker.auto_update()\n        handle_exception(exception, True)\n"
  },
  {
    "path": "src/pyoxidizer.bzl",
    "content": "# This file defines how PyOxidizer application building and packaging is\n# performed. See PyOxidizer's documentation at\n# https://gregoryszorc.com/docs/pyoxidizer/stable/pyoxidizer.html for details\n# of this configuration file format.\n\n# Configuration files consist of functions which define build \"targets.\"\n# This function creates a Python executable and installs it in a destination\n# directory.\ndef make_exe():\n    # Obtain the default PythonDistribution for our build target. We link\n    # this distribution into our produced executable and extract the Python\n    # standard library from it.\n    dist = default_python_distribution()\n\n    # This function creates a `PythonPackagingPolicy` instance, which\n    # influences how executables are built and how resources are added to\n    # the executable. You can customize the default behavior by assigning\n    # to attributes and calling functions.\n    policy = dist.make_python_packaging_policy()\n\n    # Enable support for non-classified \"file\" resources to be added to\n    # resource collections.\n    # policy.allow_files = True\n\n    # Control support for loading Python extensions and other shared libraries\n    # from memory. This is only supported on Windows and is ignored on other\n    # platforms.\n    # policy.allow_in_memory_shared_library_loading = True\n\n    # Control whether to generate Python bytecode at various optimization\n    # levels. The default optimization level used by Python is 0.\n    # policy.bytecode_optimize_level_zero = True\n    # policy.bytecode_optimize_level_one = True\n    # policy.bytecode_optimize_level_two = True\n\n    # Package all available Python extensions in the distribution.\n    # policy.extension_module_filter = \"all\"\n\n    # Package the minimum set of Python extensions in the distribution needed\n    # to run a Python interpreter. Various functionality from the Python\n    # standard library won't work with this setting! But it can be used to\n    # reduce the size of generated executables by omitting unused extensions.\n    # policy.extension_module_filter = \"minimal\"\n\n    # Package Python extensions in the distribution not having additional\n    # library dependencies. This will exclude working support for SSL,\n    # compression formats, and other functionality.\n    # policy.extension_module_filter = \"no-libraries\"\n\n    # Package Python extensions in the distribution not having a dependency on\n    # copyleft licensed software like GPL.\n    # policy.extension_module_filter = \"no-copyleft\"\n\n    # Controls whether the file scanner attempts to classify files and emit\n    # resource-specific values.\n    # policy.file_scanner_classify_files = True\n\n    # Controls whether `File` instances are emitted by the file scanner.\n    # policy.file_scanner_emit_files = False\n\n    # Controls the `add_include` attribute of \"classified\" resources\n    # (`PythonModuleSource`, `PythonPackageResource`, etc).\n    # policy.include_classified_resources = True\n\n    # Toggle whether Python module source code for modules in the Python\n    # distribution's standard library are included.\n    # policy.include_distribution_sources = False\n\n    # Toggle whether Python package resource files for the Python standard\n    # library are included.\n    # policy.include_distribution_resources = False\n\n    # Controls the `add_include` attribute of `File` resources.\n    # policy.include_file_resources = False\n\n    # Controls the `add_include` attribute of `PythonModuleSource` not in\n    # the standard library.\n    # policy.include_non_distribution_sources = True\n\n    # Toggle whether files associated with tests are included.\n    # policy.include_test = False\n\n    # Resources are loaded from \"in-memory\" or \"filesystem-relative\" paths.\n    # The locations to attempt to add resources to are defined by the\n    # `resources_location` and `resources_location_fallback` attributes.\n    # The former is the first/primary location to try and the latter is\n    # an optional fallback.\n\n    # Use in-memory location for adding resources by default.\n    # policy.resources_location = \"in-memory\"\n\n    # Use filesystem-relative location for adding resources by default.\n    # policy.resources_location = \"filesystem-relative:prefix\"\n\n    # Attempt to add resources relative to the built binary when\n    # `resources_location` fails.\n    # policy.resources_location_fallback = \"filesystem-relative:prefix\"\n\n    # Clear out a fallback resource location.\n    # policy.resources_location_fallback = None\n\n    # Define a preferred Python extension module variant in the Python distribution\n    # to use.\n    # policy.set_preferred_extension_module_variant(\"foo\", \"bar\")\n\n    # Configure policy values to classify files as typed resources.\n    # (This is the default.)\n    # policy.set_resource_handling_mode(\"classify\")\n\n    # Configure policy values to handle files as files and not attempt\n    # to classify files as specific types.\n    # policy.set_resource_handling_mode(\"files\")\n\n    # This variable defines the configuration of the embedded Python\n    # interpreter. By default, the interpreter will run a Python REPL\n    # using settings that are appropriate for an \"isolated\" run-time\n    # environment.\n    #\n    # The configuration of the embedded Python interpreter can be modified\n    # by setting attributes on the instance. Some of these are\n    # documented below.\n    python_config = dist.make_python_interpreter_config()\n\n    # Make the embedded interpreter behave like a `python` process.\n    # python_config.config_profile = \"python\"\n\n    # Set initial value for `sys.path`. If the string `$ORIGIN` exists in\n    # a value, it will be expanded to the directory of the built executable.\n    # python_config.module_search_paths = [\"$ORIGIN/lib\"]\n\n    # Use jemalloc as Python's memory allocator.\n    # python_config.allocator_backend = \"jemalloc\"\n\n    # Use mimalloc as Python's memory allocator.\n    # python_config.allocator_backend = \"mimalloc\"\n\n    # Use snmalloc as Python's memory allocator.\n    # python_config.allocator_backend = \"snmalloc\"\n\n    # Let Python choose which memory allocator to use. (This will likely\n    # use the malloc()/free() linked into the program.\n    # python_config.allocator_backend = \"default\"\n\n    # Enable the use of a custom allocator backend with the \"raw\" memory domain.\n    # python_config.allocator_raw = True\n\n    # Enable the use of a custom allocator backend with the \"mem\" memory domain.\n    # python_config.allocator_mem = True\n\n    # Enable the use of a custom allocator backend with the \"obj\" memory domain.\n    # python_config.allocator_obj = True\n\n    # Enable the use of a custom allocator backend with pymalloc's arena\n    # allocator.\n    # python_config.allocator_pymalloc_arena = True\n\n    # Enable Python memory allocator debug hooks.\n    # python_config.allocator_debug = True\n\n    # Automatically calls `multiprocessing.set_start_method()` with an\n    # appropriate value when OxidizedFinder imports the `multiprocessing`\n    # module.\n    # python_config.multiprocessing_start_method = 'auto'\n\n    # Do not call `multiprocessing.set_start_method()` automatically. (This\n    # is the default behavior of Python applications.)\n    # python_config.multiprocessing_start_method = 'none'\n\n    # Call `multiprocessing.set_start_method()` with explicit values.\n    # python_config.multiprocessing_start_method = 'fork'\n    # python_config.multiprocessing_start_method = 'forkserver'\n    # python_config.multiprocessing_start_method = 'spawn'\n\n    # Control whether `oxidized_importer` is the first importer on\n    # `sys.meta_path`.\n    # python_config.oxidized_importer = False\n\n    # Enable the standard path-based importer which attempts to load\n    # modules from the filesystem.\n    # python_config.filesystem_importer = True\n\n    # Set `sys.frozen = False`\n    # python_config.sys_frozen = False\n\n    # Set `sys.meipass`\n    # python_config.sys_meipass = True\n\n    # Write files containing loaded modules to the directory specified\n    # by the given environment variable.\n    # python_config.write_modules_directory_env = \"/tmp/oxidized/loaded_modules\"\n\n    # Evaluate a string as Python code when the interpreter starts.\n    # python_config.run_command = \"<code>\"\n\n    # Run a Python module as __main__ when the interpreter starts.\n    # python_config.run_module = \"<module>\"\n\n    # Run a Python file when the interpreter starts.\n    # python_config.run_filename = \"/path/to/file\"\n\n    # Produce a PythonExecutable from a Python distribution, embedded\n    # resources, and other options. The returned object represents the\n    # standalone executable that will be built.\n    exe = dist.to_python_executable(\n        name=\".\",\n\n        # If no argument passed, the default `PythonPackagingPolicy` for the\n        # distribution is used.\n        packaging_policy=policy,\n\n        # If no argument passed, the default `PythonInterpreterConfig` is used.\n        config=python_config,\n    )\n\n    # Install tcl/tk support files to a specified directory so the `tkinter` Python\n    # module works.\n    # exe.tcl_files_path = \"lib\"\n\n    # Never attempt to copy Windows runtime DLLs next to the built executable.\n    # exe.windows_runtime_dlls_mode = \"never\"\n\n    # Copy Windows runtime DLLs next to the built executable when they can be\n    # located.\n    # exe.windows_runtime_dlls_mode = \"when-present\"\n\n    # Copy Windows runtime DLLs next to the build executable and error if this\n    # cannot be done.\n    # exe.windows_runtime_dlls_mode = \"always\"\n\n    # Make the executable a console application on Windows.\n    # exe.windows_subsystem = \"console\"\n\n    # Make the executable a non-console application on Windows.\n    # exe.windows_subsystem = \"windows\"\n\n    # Invoke `pip download` to install a single package using wheel archives\n    # obtained via `pip download`. `pip_download()` returns objects representing\n    # collected files inside Python wheels. `add_python_resources()` adds these\n    # objects to the binary, with a load location as defined by the packaging\n    # policy's resource location attributes.\n    #exe.add_python_resources(exe.pip_download([\"pyflakes==2.2.0\"]))\n\n    # Invoke `pip install` with our Python distribution to install a single package.\n    # `pip_install()` returns objects representing installed files.\n    # `add_python_resources()` adds these objects to the binary, with a load\n    # location as defined by the packaging policy's resource location\n    # attributes.\n    #exe.add_python_resources(exe.pip_install([\"appdirs\"]))\n\n    # Invoke `pip install` using a requirements file and add the collected resources\n    # to our binary.\n    #exe.add_python_resources(exe.pip_install([\"-r\", \"requirements.txt\"]))\n\n\n    # Read Python files from a local directory and add them to our embedded\n    # context, taking just the resources belonging to the `foo` and `bar`\n    # Python packages.\n    #exe.add_python_resources(exe.read_package_root(\n    #    path=\"/src/mypackage\",\n    #    packages=[\"foo\", \"bar\"],\n    #))\n\n    # Discover Python files from a virtualenv and add them to our embedded\n    # context.\n    #exe.add_python_resources(exe.read_virtualenv(path=\"/path/to/venv\"))\n\n    # Filter all resources collected so far through a filter of names\n    # in a file.\n    #exe.filter_resources_from_files(files=[\"/path/to/filter-file\"])\n\n    # Return our `PythonExecutable` instance so it can be built and\n    # referenced by other consumers of this target.\n    return exe\n\ndef make_embedded_resources(exe):\n    return exe.to_embedded_resources()\n\ndef make_install(exe):\n    # Create an object that represents our installed application file layout.\n    files = FileManifest()\n\n    # Add the generated executable to our install layout in the root directory.\n    files.add_python_resource(\".\", exe)\n\n    return files\n\ndef make_msi(exe):\n    # See the full docs for more. But this will convert your Python executable\n    # into a `WiXMSIBuilder` Starlark type, which will be converted to a Windows\n    # .msi installer when it is built.\n    return exe.to_wix_msi_builder(\n        # Simple identifier of your app.\n        \"myapp\",\n        # The name of your application.\n        \"My Application\",\n        # The version of your application.\n        \"1.0\",\n        # The author/manufacturer of your application.\n        \"Alice Jones\"\n    )\n\n\n# Dynamically enable automatic code signing.\ndef register_code_signers():\n    # You will need to run with `pyoxidizer build --var ENABLE_CODE_SIGNING 1` for\n    # this if block to be evaluated.\n    if not VARS.get(\"ENABLE_CODE_SIGNING\"):\n        return\n\n    # Use a code signing certificate in a .pfx/.p12 file, prompting the\n    # user for its path and password to open.\n    # pfx_path = prompt_input(\"path to code signing certificate file\")\n    # pfx_password = prompt_password(\n    #     \"password for code signing certificate file\",\n    #     confirm = True\n    # )\n    # signer = code_signer_from_pfx_file(pfx_path, pfx_password)\n\n    # Use a code signing certificate in the Windows certificate store, specified\n    # by its SHA-1 thumbprint. (This allows you to use YubiKeys and other\n    # hardware tokens if they speak to the Windows certificate APIs.)\n    # sha1_thumbprint = prompt_input(\n    #     \"SHA-1 thumbprint of code signing certificate in Windows store\"\n    # )\n    # signer = code_signer_from_windows_store_sha1_thumbprint(sha1_thumbprint)\n\n    # Choose a code signing certificate automatically from the Windows\n    # certificate store.\n    # signer = code_signer_from_windows_store_auto()\n\n    # Activate your signer so it gets called automatically.\n    # signer.activate()\n\n\n# Call our function to set up automatic code signers.\nregister_code_signers()\n\n# Tell PyOxidizer about the build targets defined above.\nregister_target(\"exe\", make_exe)\nregister_target(\"resources\", make_embedded_resources, depends=[\"exe\"], default_build_script=True)\nregister_target(\"install\", make_install, depends=[\"exe\"], default=True)\nregister_target(\"msi_installer\", make_msi, depends=[\"exe\"])\n\n# Resolve whatever targets the invoker of this configuration file is requesting\n# be resolved.\nresolve_targets()\n"
  },
  {
    "path": "src/shared.py",
    "content": "\"\"\"\nShared functions between app and build\n\"\"\"\nimport platform\nimport re\nfrom subprocess import DEVNULL, PIPE, Popen\n\n\ndef get_running_processes(look_for='', pid=None, add_exe=True):\n    if platform.system() == 'Windows':\n        cmd = f'tasklist /NH'\n        if look_for:\n            if not look_for.endswith('.exe') and add_exe:\n                look_for += '.exe'\n            cmd += f' /FI \"IMAGENAME eq {look_for}\"'\n        if pid is not None:\n            cmd += f' /FI \"PID eq {pid}\"'\n        p = Popen(\n            cmd,\n            shell=True,\n            stdout=PIPE,\n            stdin=DEVNULL,\n            stderr=DEVNULL,\n            text=True,\n            encoding='iso8859-2',\n        )\n        p.stdout.readline()\n        for task in iter(lambda: p.stdout.readline().strip(), ''):\n            m = re.match(r'(.+?) +(\\d+) (.+?) +(\\d+) +(\\d+.* K).*', task)\n            if m is not None:\n                yield {\n                    'name': m.group(1),\n                    'pid': int(m.group(2)),\n                    'session_name': m.group(3),\n                    'session_num': m.group(4),\n                    'mem_usage': m.group(5),\n                }\n    elif platform.system() == 'Linux':\n        cmd = ['ps', 'h']\n        if look_for:\n            cmd.extend(('-C', look_for))\n        p = Popen(cmd, stdout=PIPE, stdin=PIPE, stderr=DEVNULL, text=True)\n        for task in iter(lambda: p.stdout.readline().strip(), ''):\n            m = task.split(maxsplit=4)\n            yield {'name': m[-1], 'pid': int(m[0])}\n\n\ndef is_already_running(look_for='Music Caster', threshold=1, pid=None) -> bool:\n    \"\"\"\n    Returns True if more processes than `threshold` were found\n    # TODO: threshold feature for Linux\n    \"\"\"\n    if platform.system() == 'Windows':\n        for _ in get_running_processes(look_for=look_for, pid=pid):\n            threshold -= 1\n            if threshold < 0:\n                return True\n    else:  # Linux\n        p = Popen(\n            ['ps', 'h', '-C', look_for, '-o', 'comm'],\n            stdout=PIPE,\n            stdin=PIPE,\n            stderr=DEVNULL,\n            text=True,\n        )\n        return p.stdout.readline().strip() != ''\n    return False\n"
  },
  {
    "path": "src/static/style.css",
    "content": ":root {\n    --accent: #00bfff;\n}\n\nhtml {\n    font-size: large;\n}\n\nbody {\n    font-family: 'Roboto', Arial, Verdana, sans-serif;\n    background-color: #121212;\n}\n\na {\n    text-decoration: none;\n    color: inherit;\n}\n\n.playLink {\n    display: inline-block;\n    width: 85%;\n}\n\n.playPlaylist {\n    display: inline-block;\n    width: 95%;\n}\n\n.playNext {\n    margin: 1px 5px 0 5px;\n}\n\nbutton {\n    background-color: #2196f3;\n    border: none;\n    border-radius: 5px;\n    color: white;\n    padding: 5px 10px;\n    text-align: center;\n    text-decoration: none;\n    display: inline-block;\n    font-size: 16px;\n    cursor: pointer;\n}\n\ninput:focus {\n    outline: none !important;\n}\n\n#timerMinutes {\n    width: 5em;\n}\n\n.center {\n    margin: 0;\n    position: absolute;\n    top: 50%;\n    left: 50%;\n    transform: translate(-50%, -50%);\n}\n\n#player-container {\n    text-align: center;\n    display: flex;\n    width: 90%;\n    max-height: 90%;\n    background-color: #121212;\n    border-radius: 0.25rem;\n}\n\n#player-container > div {\n    width: 50%;\n}\n\n#body-container {\n    flex-grow: 2;\n    display: flex;\n    flex-direction: column;\n    justify-content: space-between;\n    gap: 1em;\n}\n\n.cover-art-container {\n    background-color: #121212;\n}\n\n.cover-art-container img {\n    max-width: 100%;\n    max-height: 100%;\n    border-radius: 0.25rem 0.25rem 0 0;\n    margin: auto;\n}\n\n.list {\n    display: flex;\n    margin: 0;\n    padding: 0;\n    list-style-type: none;\n}\n\n.body__buttons, .body-info, .player__footer {\n    padding-right: 2rem;\n    padding-left: 2rem;\n}\n\n.list--cover, .list--footer {\n    justify-content: space-between;\n}\n\n.list--header .list__link, .list--footer .list__link {\n    color: #fff;\n    fill: #fff;\n}\n\n.list--cover {\n    position: absolute;\n    top: 0.5rem;\n    width: 100%;\n}\n\n.list--cover li:first-of-type {\n    margin-left: 0.75rem;\n}\n\n.list--cover li:last-of-type {\n    margin-right: 0.75rem;\n}\n\n.list--cover a {\n    font-size: 1.15rem;\n    color: #fff;\n}\n\n.range {\n    position: relative;\n    top: -1.5rem;\n    right: 0;\n    left: 0;\n    margin: auto;\n    background: rgba(255, 255, 255, .95);\n    width: 80%;\n    height: 0.125rem;\n    border-radius: 0.25rem;\n    cursor: pointer;\n}\n\n.range:before, .range:after {\n    content: \"\";\n    position: absolute;\n    cursor: pointer;\n}\n\n.range:before {\n    width: 3rem;\n    height: 100%;\n    background: linear-gradient(to right, rgba(211, 3, 32, .5), rgba(211, 3, 32, .85));\n    border-radius: 0.25rem;\n    overflow: hidden;\n}\n\n.range:after {\n    top: -0.375rem;\n    left: 3rem;\n    z-index: 3;\n    width: 0.875rem;\n    height: 0.875rem;\n    background: #fff;\n    border-radius: 50%;\n    box-shadow: 0 0 3px rgba(0, 0, 0, .15), 0 2px 4px rgba(0, 0, 0, .15);\n    transition: all 0.2s cubic-bezier(0.4, 0, 1, 1);\n}\n\n.range:focus:after, .range:hover:after {\n    background: rgba(211, 3, 32, .95);\n}\n\n.body-info {\n    /* padding-top: 1.5rem;\n    padding-bottom: 1.25rem; */\n    display: flex;\n    flex-direction: column;\n    justify-content: space-evenly;\n    flex-grow: 2;\n    max-height: 40%;\n}\n\n#info__album, #info__track {\n    margin-bottom: 0.5rem;\n}\n\n#info__artist, #info__album {\n    font-size: 1rem;\n    font-weight: 300;\n    color: #666;\n}\n\n#info__track {\n    font-size: 1.5rem;\n    font-weight: 400;\n    color: var(--accent);\n}\n\n.body__buttons {\n    /* padding-bottom: 1rem; */\n}\n\n.list--buttons {\n    align-items: center;\n    justify-content: center;\n}\n\n.list--buttons li:nth-of-type(n+2) {\n    margin-left: 1.25rem;\n}\n\n.ctrl-btn {\n    border-radius: 50%;\n    box-shadow: 0 3px 12px rgba(214, 214, 214, 0.4);\n    display: inline-block;\n}\n\n.list--buttons a {\n    padding-top: 0.6rem;\n    padding-right: 0.75rem;\n    padding-bottom: 0.6rem;\n    padding-left: 0.75rem;\n    font-size: 1rem;\n    color: #fff;\n    opacity: 0.5;\n}\n\n.list--buttons a:focus, .list--buttons a:hover {\n    opacity: 0.75;\n}\n\n#play-pause-btn {\n    padding-top: 0.9rem;\n    padding-right: 1rem;\n    padding-bottom: 0.9rem;\n    padding-left: 1.19rem;\n    margin-left: 0.5rem;\n    font-size: 1.25rem;\n}\n\n#play-pause-btn:hover {\n    opacity: 1;\n}\n\n#repeat-one {\n    position: absolute;\n    margin-left: 1em;\n    color: var(--accent);\n    opacity: .9 !important;\n}\n\n.repeat-enabled, .shuffle-enabled {\n    color: var(--accent);\n    opacity: .9 !important;\n}\n\n#prev-btn, #next-btn {\n    font-size: 0.95rem;\n}\n\n.list__link {\n    transition: all 0.2s cubic-bezier(0.4, 0, 1, 1);\n}\n\n.list__link:focus, .list__link:hover, .list__link>img:hover {\n    color: var(--accent);\n    fill: var(--accent);\n}\n\n.player__footer {\n    padding-bottom: .5em;\n}\n\n.list--footer {\n    padding: 1em;\n    margin: 1em 2em;\n}\n\n.list--footer a {\n    opacity: 0.5;\n}\n\n.list--footer a:focus, .list--footer a:hover {\n    opacity: 0.9;\n}\n\n.fa.fa-pause {\n    margin-left: -3px;\n}\n\n.modal {\n    display: none;\n    position: fixed;\n    z-index: 5;\n    left: 0;\n    top: 0;\n    width: 100%;\n    height: 100%;\n    overflow: auto;\n    background-color: rgba(0, 0, 0, .8);\n}\n\n.modal-content {\n    width: 50%;\n    position: relative;\n    top: 0%;\n    margin: 0 auto;\n    text-align: center;\n}\n\n.modal-content > ul {\n    padding: 0;\n}\n\nh2 {\n    width: 80%;\n    font-size: x-large;\n    color: white;\n    text-align: center;\n    margin-left: auto;\n    margin-right: auto;\n}\n\n.modal-title {\n    position: sticky;\n    top: 10px;\n    background-color: #0006;\n    border-radius: 10px;\n    padding: .2em;\n    z-index: 10;\n}\n\n.track, .trackRow {\n    width: 100%;\n    width: -moz-available;\n    /* For Mozzila */\n    width: -webkit-fill-available;\n    /* For Chrome */\n    width: stretch;\n    margin-left: auto;\n    margin-right: auto;\n    display: inline-block;\n    background-color: #121212b2;\n    color: whitesmoke;\n    padding: 1em;\n    text-align: left;\n    border: 1px solid black;\n    font-size: large;\n}\n\n.track:hover, .trackRow a:first-child:hover, .modalRow:hover, .cyan, .queueTrack:hover, .downloadTrack:hover {\n    color: cyan;\n}\n\n.playNext:hover svg {\n    fill: cyan;\n}\n\n.modalRow {\n\tdisplay: flex;\n\tjustify-content: space-between;\n    width: 100%;\n    width: -moz-available;\n    width: -webkit-fill-available;\n    width: stretch;\n    margin-left: auto;\n    margin-right: auto;\n    background-color: #121212b2;\n\tcolor: whitesmoke;\n\tcursor: pointer;\n\tflex-direction: row;\n\theight: 34px;\n\tline-height: 34px;\n    padding: 1em;\n    text-align: left;\n    font-size: large;\n}\n\n.switch {\n    position: relative;\n    display: inline-block;\n    width: 60px;\n    height: 36px;\n}\n\n.switch input {\n    opacity: 0;\n    width: 0;\n    height: 0;\n}\n\n.slider {\n    position: absolute;\n    cursor: pointer;\n    top: 0;\n    left: 0;\n    right: 0;\n    bottom: 0;\n    background-color: #ccc;\n    -webkit-transition: .4s;\n    transition: .4s;\n}\n\n.slider:before {\n    position: absolute;\n    content: \"\";\n    height: 26px;\n    width: 26px;\n    left: 4px;\n    bottom: 4px;\n    background-color: white;\n    -webkit-transition: .4s;\n    transition: .4s;\n}\n\ninput:checked+.slider {\n    background-color: #2196F3;\n}\n\ninput:focus+.slider {\n    box-shadow: 0 0 1px #2196F3;\n}\n\ninput:checked+.slider:before {\n    -webkit-transform: translateX(26px);\n    -ms-transform: translateX(26px);\n    transform: translateX(26px);\n}\n\n/* Rounded sliders */\n.slider.round {\n    border-radius: 34px;\n}\n\n.slider.round:before {\n    border-radius: 50%;\n}\n\n#searchBar {\n    color: whitesmoke;\n    background-color: #121212b2;\n    /* background-image: url('https://www.w3schools.com/css/searchicon.png'); */\n    background-position: 10px 12px;\n    background-repeat: no-repeat;\n    font-size: large;\n    width: 100%;\n    width: -moz-available;\n    width: -webkit-fill-available;\n    width: stretch;\n    padding: 1em 20px 1em 40px;\n    margin-bottom: 12px;\n    border: 1px solid black;\n    border-radius: 2em;\n    position: sticky;\n    top: 20px;\n}\n\n#volControl svg {\n    margin-right: .5em;\n}\n\n#volControl path {\n    fill: #888;\n}\n\n#volRange {\n    width: 80%;\n    background: #121212;\n    -webkit-appearance: none;\n}\n\n#volRange:focus {\n    outline: none;\n}\n\n#volRange::-moz-range-progress  {\n  background-color: #00bfff;\n  height: 5px;\n}\n\n#volRange::-moz-range-track, #volRange::-webkit-slider-runnable-track {\n  background-color: #e7eaea;\n  height: 5px;\n}\n\n#volRange::-webkit-slider-runnable-track {\n    width: 80%;\n    cursor: pointer;\n    box-shadow: 1.7px 1.7px 1px rgba(0, 0, 0, 0.2), 0px 0px 1.7px rgba(13, 13, 13, 0.2);\n    border-radius: 25px;\n    border: 0px solid #010101;\n    margin-top: -18px;\n}\n\n/* #volRange::-webkit-progress-value {} e7eaea */\n\n#volRange::-webkit-slider-thumb {\n    box-shadow: 0px 0px 0.6px rgba(0, 0, 0, 0.1), 0px 0px 0px rgba(13, 13, 13, 0.1);\n    border: 0.5px solid rgba(0, 0, 0, 0.6);\n    height: 20px;\n    width: 20px;\n    border-radius: 50px;\n    background: #ffffff;\n    cursor: pointer;\n    -webkit-appearance: none;\n    margin-top: -5.8px;\n}\n\n#volRange:focus::-webkit-slider-runnable-track {\n  background: #f5f6f6;\n}\n\n#audioStream {\n    padding: 1em;\n}\n\n.row-filler {\n    padding: 1em;\n    height: 1em;\n}\n\n#devices {\n    padding: 0.5em;\n    margin: 0.5em 2em;\n}\n\n/* TOAST */\n\n#toast {\n    visibility: hidden;\n    min-width: 250px;\n    margin-left: -150px;\n    background-color: rgba(0, 0, 0, .9);\n    color: #fff;\n    text-align: center;\n    border-radius: 20px;\n    padding: 16px;\n    position: fixed;\n    z-index: 10000;\n    font-size: 20px;\n    left: 50%;\n    bottom: 60px\n}\n\n#toast.show {\n    visibility: visible;\n    -webkit-animation: fadein .5s, fadeout .5s 2s;\n    animation: fadein .5s, fadeout .5s 2s\n}\n\n/* END TOAST */\n\n@media screen and (max-width:1100px) {\n    .modal-content {\n        width: 60%;\n    }\n}\n\n\n/* MOBILE */\n@media screen and (max-width: 800px) {\n    #player-container {\n        flex-direction: column;\n    }\n\n    #player-container > div {\n        width: 100%;\n    }\n\n    .modal-content {\n        width: 85%;\n    }\n\n    #wrapper {\n        margin-top: 1em;\n    }\n\n    .playLink, .playPlaylist {\n        width: 80%;\n    }\n\n    .player-container {\n        width: 100%;\n        margin-top: auto;\n    }\n}\n"
  },
  {
    "path": "src/sys_tray.py",
    "content": "import multiprocessing as mp\nimport platform\nimport io\nimport sys\nfrom itertools import islice\nimport threading\nimport time\nimport os\nfrom base64 import b64decode\nimport ctypes\n\n\ndef system_tray(main_queue: mp.Queue, child_queue: mp.Queue):\n    from b64_images import FILLED_ICON, UNFILLED_ICON\n\n    if platform.system() == 'Linux':\n        os.environ['PYSTRAY_BACKEND'] = 'appindicator'\n    elif platform.system() == 'Windows' and getattr(sys, 'frozen', False):\n        my_app_id = 'elijahlopez.music_caster'\n        ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(my_app_id)\n    import pystray\n    from PIL import Image\n\n    filled_icon = Image.open(io.BytesIO(b64decode(FILLED_ICON)))\n    unfilled_icon = Image.open(io.BytesIO(b64decode(UNFILLED_ICON)))\n\n    def create_menu(lst, root=True):\n        # e.g. ['Item 1', ('Item 2 Display', 'item_2_key'), ['Sub Menu Title', ('Sub Menu Item 1 Display', 'KEY')]]\n        items = []\n        if root:\n            items.append(\n                pystray.MenuItem(\n                    '', get_tray_action('__ACTIVATED__'), default=True, visible=False\n                )\n            )\n        for element in lst:\n            if isinstance(element, list):\n                items.append(\n                    pystray.MenuItem(\n                        element[0], create_menu(islice(element, 1, None), root=False)\n                    )\n                )\n            elif isinstance(element, tuple) and len(element) == 2:\n                element, key = element\n                items.append(pystray.MenuItem(element, get_tray_action(element, key)))\n            else:\n                items.append(pystray.MenuItem(element, get_tray_action(element)))\n        return pystray.Menu(*items)\n\n    def get_tray_action(string, key=''):\n        def tray_action():\n            try:\n                main_queue.put(key) if key else main_queue.put(string)\n                if key == '__EXIT__':\n                    child_queue.put({'close': None})\n            except ValueError:\n                child_queue.put({'close': None})\n\n        return tray_action\n\n    def background():\n        while True:\n            while not child_queue.empty():\n                for parent_cmd, arguments in child_queue.get().items():\n                    if parent_cmd == 'tooltip':\n                        tray.title = arguments\n                    elif parent_cmd == 'menu':  # set icon to unfilled\n                        if tray.HAS_MENU:\n                            tray.menu = create_menu(arguments)\n                            tray.update_menu()\n                        else:\n                            print('pystray: menu not supported')\n                    elif parent_cmd == 'filled':  # set icon to filled\n                        tray.icon = filled_icon\n                    elif parent_cmd == 'unfilled':  # set icon to unfilled\n                        tray.icon = unfilled_icon\n                    elif parent_cmd == 'notify':\n                        if tray.HAS_NOTIFICATION:\n                            tray.notify(\n                                arguments['message'], title=arguments.get('title')\n                            )  # msg, title\n                        else:\n                            print('pystray: notify not supported')\n                    elif parent_cmd == 'hide':\n                        tray.visible = False\n                    elif parent_cmd in {'close', 'exit', '__EXIT__'}:\n                        tray.stop()\n                        sys.exit()\n            time.sleep(0.1)\n\n    tray = pystray.Icon(\n        'Music Caster SystemTray', unfilled_icon, title='Music Caster [LOADING]'\n    )\n    threading.Thread(target=background, daemon=True).start()\n    tray.run()\n"
  },
  {
    "path": "src/templates/index.html",
    "content": "<!DOCTYPE html>\r\n<head>\r\n<title>Music Caster - {{device_name}}</title>\r\n<link rel=\"shortcut icon\" href=\"https://raw.githubusercontent.com/elibroftw/music-caster/master/resources/favicons/favicon.ico\">\r\n<link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"https://raw.githubusercontent.com/elibroftw/music-caster/master/resources/favicons/apple-touch-icon.png\">\r\n<link rel=\"manifest\" href=\"https://raw.githubusercontent.com/elibroftw/music-caster/master/resources/favicons/site.webmanifest\">\r\n<link rel=\"mask-icon\" href=\"https://raw.githubusercontent.com/elibroftw/music-caster/master/resources/favicons/safari-pinned-tab.svg\" color=\"#00bfff\">\r\n<link rel=\"stylesheet\" id=\"stylesheet\" href=\"/static/style.css?v=1.4.12\">\r\n<link rel=\"stylesheet\" href=\"https://use.fontawesome.com/releases/v5.2.0/css/all.css\"\r\n    integrity=\"sha384-hWVjflwFxL6sNzntih27bfxkr27PmbbK/iSvJ+a4+0owXq79v+lsFkW54bOGbiDQ\" crossorigin=\"anonymous\">\r\n<meta name=\"msapplication-TileColor\" content=\"#ededed\">\r\n<meta name=\"msapplication-config\" content=\"https://raw.githubusercontent.com/elibroftw/music-caster/master/resources/favicons/browserconfig.xml\">\r\n<meta name=\"theme-color\" content=\"#ededed\">\r\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=0.86, minimum-scale=0.86\">\r\n</head>\r\n<body>\r\n    <div id=\"player-container\" class=\"center\">\r\n        <div class=\"cover-art-container\">\r\n            <img src=\"{{art|safe}}\" alt=\"Artwork\" style=\"text-align: center\" />\r\n        </div>\r\n        <!-- TODO: progress bar -->\r\n        <div id=\"body-container\">\r\n            <div class=\"body-info\">\r\n                <div id=\"info__album\">{{metadata['album'] if metadata['album'] else '<br>'|safe}}</div>\r\n                <div id=\"info__track\">{{metadata['title']}}</div>\r\n                <div id=\"info__artist\">{{metadata['artist'] if metadata['artist'] else '<br>'|safe}}</div>\r\n            </div>\r\n            <div class=\"body__buttons\">\r\n                <ul class=\"list list--buttons\">\r\n                    <!-- Repeat -->\r\n                    <li><a href=\"/action/repeat\" class=\"list__link {{repeat_enabled}}\">\r\n                    <span id=\"repeat-one\">{{1 if repeat_option else ''}}</span>\r\n                    <i class=\"fa fa-undo {{repeat_enabled}}\"></i></a></li>\r\n                    <!-- End repeat -->\r\n\r\n                    <li><a href=\"/action/prev\" id=\"prev-btn\" title=\"{{ gt('previous track') }}\" class=\"list__link ctrl-btn\"><i class=\"fa fa-step-backward\"></i></a></li>\r\n                    <li>\r\n                        <a href=\"/action/{{'pause' if playing_status.playing() else 'play'}}\" id=\"play-pause-btn\" class=\"list__link ctrl-btn\">\r\n                            <i class=\"fa fa-{{'pause' if playing_status.playing() else 'play'}}\"></i>\r\n                        </a>\r\n                    </li>\r\n                    <li><a href=\"/action/next\" id=\"next-btn\" title=\"{{ gt('next track') }}\" class=\"list__link ctrl-btn\"><i class=\"fa fa-step-forward\"></i></a></li>\r\n                    <li><a href=\"/action/shuffle\" title=\"{{ gt('shuffle') }}\" class=\"list__link {{shuffle}}\"><i class=\"fa fa-random {{shuffle}}\"></i></a></li>\r\n                </ul>\r\n            </div>\r\n\r\n            <!-- <div class=\"player__footer\"> -->\r\n                <div id=\"volControl\">\r\n                    <svg width=\"20\" height=\"20\" viewBox=\"0 0 480 512\">\r\n                        <path fill=\"black\" d=\"M215.03 71.05L126.06 160H24c-13.26 0-24 10.74-24 24v144c0 13.25 10.74 24 24 24h102.06l88.97 88.95c15.03 15.03 40.97 4.47 40.97-16.97V88.02c0-21.46-25.96-31.98-40.97-16.97zM480 256c0-63.53-32.06-121.94-85.77-156.24-11.19-7.14-26.03-3.82-33.12 7.46s-3.78 26.21 7.41 33.36C408.27 165.97 432 209.11 432 256s-23.73 90.03-63.48 115.42c-11.19 7.14-14.5 22.07-7.41 33.36 6.51 10.36 21.12 15.14 33.12 7.46C447.94 377.94 480 319.53 480 256zm-141.77-76.87c-11.58-6.33-26.19-2.16-32.61 9.45-6.39 11.61-2.16 26.2 9.45 32.61C327.98 228.28 336 241.63 336 256c0 14.38-8.02 27.72-20.92 34.81-11.61 6.41-15.84 21-9.45 32.61 6.43 11.66 21.05 15.8 32.61 9.45 28.23-15.55 45.77-45 45.77-76.88s-17.54-61.32-45.78-76.86z\" class=\"\"></path>\r\n                    </svg>\r\n                    <input id=\"volRange\" value=\"{{settings['volume']}}\" type=\"range\" min=\"0\" max=\"100\" step=\"1\"\r\n                            oninput=\"setVolume(this.value)\" onchange=\"setVolume(this.value)\"/>\r\n                </div>\r\n                {% if stream_url %}\r\n                <audio controls autoplay id=\"audioStream\" onpause=\"muteStream()\">\r\n                    <source src=\"{{stream_url}}\" type=\"audio/mpeg\">\r\n                </audio>\r\n                {% else %}\r\n                <div class=\"row-filler\" style=\"padding: 1em; height: 1em\"></div>\r\n                {% endif %}\r\n                <select name=\"devices\" id=\"devices\" onchange=\"changeDevice()\">\r\n                    {% for device_name, uuid in devices %}\r\n                        <option value=\"{{ uuid }}\" {{ 'selected' if loop.index0 == device_index else '' }}>{{ device_name }}</option>\r\n                    {% endfor %}\r\n                </select>\r\n                <ul class=\"list list--footer\">\r\n                    <li><a onclick=\"showModal('settings')\" href=\"/#settings\" class=\"list__link\"><i class=\"fas fa-cog\"></i></a></li>\r\n                    <li><a onclick=\"showModal('queue')\" href=\"/#queue\" class=\"list__link\">\r\n                        <svg width=\"20\" height=\"20\">\r\n                            <path d=\"M3.67 8.67h14V11h-14V8.67zm0-4.67h14v2.33h-14V4zm0 9.33H13v2.34H3.67v-2.34zm11.66 0v7l5.84-3.5-5.84-3.5z\"\r\n                                    class=\"style-scope yt-icon\"></path>\r\n                        </svg>\r\n                    </a></li>\r\n                    <li><a onclick=\"showModal('files')\" href=\"/#files\" class=\"list__link\"><i class=\"fa fa-file-audio\"></i></a></li>\r\n                    <li><a onclick=\"showModal('playlists')\" href=\"/#playlists\" class=\"list__link\">\r\n                        <svg width=\"17\" height=\"17\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 23.92 21.87\">\r\n                            <path d=\"M21.45,4.07V15.85A5.94,5.94,0,0,0,18.37,15a5.47,5.47,0,1,0,0,10.93A5.54,5.54,0,0,0,24,20.47h0V6.8h3.42V4.07Zm-2.91,19.3a2.91,2.91,0,1,1,2.91-2.9A3,3,0,0,1,18.54,23.37Z\" transform=\"translate(-3.51 -4.07)\"/>\r\n                            <rect width=\"16.23\" height=\"2.56\"/>\r\n                            <rect y=\"6.15\" width=\"16.23\" height=\"2.56\"/>\r\n                            <rect y=\"12.47\" width=\"8.37\" height=\"2.56\"/>\r\n                            <circle cx=\"15.04\" cy=\"16.4\" r=\"3.25\"/>\r\n                        </svg>\r\n                    </a></li>\r\n                    <li><a onclick=\"showModal('more')\" href=\"/#more\" class=\"list__link\"><i class=\"fas fa-ellipsis-h\"></i></a></li>\r\n                </ul>\r\n            <!-- </div> -->\r\n        </div>\r\n\r\n    </div>\r\n    <!-- MODALS -->\r\n    <div id=\"queue-modal\" class=\"modal\">\r\n        <div class=\"modal-content\">\r\n            <h2 class=\"modal-title\">{{ gt('Queue') }}</h2>\r\n\r\n            {% for track in queue %}\r\n                <a class=\"track {{ 'cyan' if loop.index0 == playing_index else '' }}\"\r\n                href=\"{{'/action/prev?ignore_timestamps&times=' ~ (playing_index - loop.index0) if loop.index0 < playing_index else\r\n                        '/action/next?ignore_timestamps&times=' ~ (loop.index0 - playing_index)}}\">{{track}}</a>\r\n            {% endfor %}\r\n        </div>\r\n    </div>\r\n    <div id=\"files-modal\" class=\"modal\">\r\n        <div id=\"tracks-list\" class=\"modal-content\">\r\n            <input type=\"text\" id=\"searchBar\" onkeyup=\"filterTracks()\" onfocus=\"this.value = this.value;\"\r\n                placeholder=\"{{ gt('Search for music...') }}\" title=\"Type in artist/tracks\">\r\n            {% for track in list_of_tracks %}\r\n                <div class=\"trackRow\">\r\n                    <a class=\"playLink\" title=\"play {{track.text}}\" href=\"/play?uri={{track.filename}}\">{{track.text}}</a>\r\n                    <a style=\"float: right;\" title=\"download file\" href=\"/file?path={{track.filename}}\" class=\"downloadTrack\">\r\n                        <i class=\"fas fa-download\"></i>\r\n                    </a>\r\n                    <a style=\"float: right;\" title=\"play file next\" href=\"/play?play_next=true&uri={{track.filename}}\" class=\"playNext\">\r\n                        <svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"#fff\" height=\"18\" viewBox=\"0 0 23.67 22.8\">\r\n                            <path d=\"M26.83,8.8,16.88,3.6l-.05,3.3c-9,2.45-14.9,13.15-13.45,19.5l4.1-1.1.6-.15,3.5-.95c-.95-4.25-.2-11.1,5.2-13.65L16.68,14Z\" transform=\"translate(-3.17 -3.6)\"/>\r\n                        </svg>\r\n                    </a>\r\n                    <a style=\"float: right;\" title=\"queue file\" href=\"/play?queue=true&uri={{track.filename}}\" class=\"queueTrack\">\r\n                        <i class=\"fas fa-plus\"></i>\r\n                    </a>\r\n                </div>\r\n            {% endfor %}\r\n        </div>\r\n    </div>\r\n    <div id=\"playlists-modal\" class=\"modal\">\r\n        <div class=\"modal-content\">\r\n            <h2 class=\"modal-title\">{{ gt('Playlists') }}</h2>\r\n            {% for playlist in settings['playlists'] %}\r\n                <div class=\"trackRow\">\r\n                    <a class=\"playLink\" title=\"play {{ playlist }}\" href=\"/play?uri={{ playlist|urlencode|replace('/', '%2F') }}\">{{playlist}}</a>\r\n                    <a style=\"float: right;\" title=\"play file next\" href=\"/play?play_next=true&uri={{playlist|urlencode|replace('/', '%2F')}}\" class=\"playNext\">\r\n                        <svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"#fff\" height=\"18\" viewBox=\"0 0 23.67 22.8\">\r\n                            <path d=\"M26.83,8.8,16.88,3.6l-.05,3.3c-9,2.45-14.9,13.15-13.45,19.5l4.1-1.1.6-.15,3.5-.95c-.95-4.25-.2-11.1,5.2-13.65L16.68,14Z\" transform=\"translate(-3.17 -3.6)\"/>\r\n                        </svg>\r\n                    </a>\r\n                    <a style=\"float: right;\" title=\"queue file\" href=\"/play?queue=true&uri={{playlist|urlencode|replace('/', '%2F')}}\" class=\"queueTrack\">\r\n                        <i class=\"fas fa-plus\"></i>\r\n                    </a>\r\n                </div>\r\n            {% endfor %}\r\n        </div>\r\n    </div>\r\n    <div id=\"settings-modal\" class=\"modal\">\r\n        <div class=\"modal-content\">\r\n            <h2 class=\"modal-title\">{{ gt('Settings') }} (v{{version}})</h2>\r\n            <ul style=\"list-style: none\">\r\n                <!-- data-key, title, [inner-text] -->\r\n                {% set setting_toggles=(\r\n                    ('auto_update', gt('Auto update')),\r\n                    ('notifications', gt('Notifications')),\r\n                    ('discord_rpc', gt('Discord presence')),\r\n                    ('run_on_startup', gt('Run on startup')),\r\n                    ('folder_context_menu', gt('Add Music Caster to folder context menu'), gt('Folder context menu')),\r\n                    ('scan_folders', gt('Scan folders'), gt('Scan folders')),\r\n                    ('use_last_folder', gt('Remember last folder')),\r\n                    ('gui_exits_app', gt('Exit app on GUI close')),\r\n\r\n                    ('reversed_play_next', gt('Reverse play next behaviour'), gt('Reversed play next')),\r\n                    ('queue_library', gt('Always queue library')),\r\n                    ('populate_queue_startup', gt('Populates queue from folders on startup'), gt('Populate queue on startup')),\r\n                    ('persistent_queue', gt('Save queue between sessions'), gt('Persistent queue')),\r\n                    ('smart_queue', gt('Smart queue')),\r\n\r\n                    ('save_window_positions', gt('Save window positions')),\r\n                    ('show_track_number', gt('Show track number in queue'), gt('Show track number')),\r\n                    ('flip_main_window', gt('Move track content to the left')),\r\n                    ('vertical_gui', gt('Vertical GUI')),\r\n                    ('show_album_art', gt('Show album art in GUI')),\r\n                    ('mini_on_top', gt('Keep mini mode on top')),\r\n                    ('folder_cover_override', gt(\"Use cover.* for art instead of file's album art\"), gt(\"cover.* image overrides file cover\")),\r\n                    ('show_queue_index', gt('Show index in queue'))\r\n                ) %}\r\n                {% for setting_toggle in setting_toggles %}\r\n                <li data-key='{{ setting_toggle[0] }}' title=\"{{ setting_toggle[1] }}\" class=\"modalRow setting\" onclick=\"toggleSetting(this)\">\r\n                    {{ setting_toggle[1] if setting_toggle|length == 2 else setting_toggle[2] }}\r\n                    <label class=\"switch\">\r\n                        <input type=\"checkbox\" {{'checked' if settings[setting_toggle[0]] else ''}}>\r\n                        <span class=\"slider round\"></span>\r\n                    </label>\r\n                </li>\r\n                {% endfor %}\r\n            </ul>\r\n        </div>\r\n    </div>\r\n    <div id=\"more-modal\" class=\"modal\">\r\n        <div class=\"modal-content\">\r\n            <h2 class=\"modal-title\">{{ gt('More') }}</h2>\r\n            <p class=\"modalRow\" title=\"rescan folders\" onclick=\"rescanLibrary()\">{{ gt('Rescan Library') }}</p>\r\n            <p class=\"modalRow\" title=\"search for chromecasts\" onclick=\"refreshDevices()\">{{ gt('Refresh Devices') }}</p>\r\n            <h2>{{ gt('Timer') }}</h2>\r\n            <ul style=\"list-style: none\">\r\n                {% for timer_toggle in (('timer_shut_down', gt('Shut Down Computer')),\r\n                                        ('timer_sleep', gt('Sleep Computer')),\r\n                                        ('timer_hibernate', gt('Hibernate Computer')),\r\n                                        ('timer_stop', gt('Only Stop Playback'))) %}\r\n                <li data-key='{{ timer_toggle[0] }}' title=\"{{ timer_toggle[1] }}\" class=\"modalRow timerSetting\" onclick=\"toggleSetting(this)\">\r\n                    {{ timer_toggle[1] }}\r\n                    <label class=\"switch\">\r\n                        <input type=\"radio\" id=\"{{ timer_toggle[0] }}\" name=\"timerOption\" disabled>\r\n                        <span class=\"slider round\"></span>\r\n                    </label>\r\n                </li>\r\n                {% endfor %}\r\n                <li class=\"modalRow\">\r\n                    <label title=\"Enter HH:MM or minutes\">{{ gt('Enter Time') }}</label>\r\n                    <input id=\"timerMinutes\" placeholder=\"HH:MM or minutes\" title=\"HH:MM or minutes\" type=\"text\">\r\n                    <button id=\"setTimer\" onclick=\"setTimer()\">{{ gt('Set') }}</button>\r\n                    <button id=\"cancelTimer\" onclick=\"cancelTimer()\">{{ gt('Cancel') }}</button>\r\n                </li>\r\n            </ul>\r\n            {% if settings['upload_pw'] %}\r\n            <form class=\"modalRow\" action=\"/upload/\" method=\"post\" enctype=\"multipart/form-data\">\r\n                <input type=\"file\" name=\"files\" required multiple>\r\n                <input type=\"password\" name=\"password\" required placeholder=\"server password\">\r\n                <button type=\"submit\">Upload</button>\r\n            </form>\r\n            {% endif %}\r\n        </div>\r\n    </div>\r\n    <div id=\"toast\"></div>\r\n<script>\r\n    // Flask sends the settings (dict)\r\n    const settingsFromServer = {{settings | tojson}};\r\n\r\n    const searchBar = document.getElementById('searchBar');\r\n\r\n    // modals\r\n    const modals = {};\r\n    modals['settings']  = document.getElementById('settings-modal');\r\n    modals['queue']     = document.getElementById('queue-modal');\r\n    modals['files']     = document.getElementById('files-modal');\r\n    modals['playlists'] = document.getElementById('playlists-modal');\r\n    modals['more']      = document.getElementById('more-modal');\r\n\r\n    function showModal(option) {\r\n        modals[option].style.display = 'block';\r\n        if (option === 'files') {\r\n            if (getComputedStyle(document.getElementById('player-container')).marginTop == '0px') {\r\n                searchBar.focus();\r\n            }\r\n            filterTracks();\r\n            let temp = searchBar.value;\r\n            searchBar.value = '';\r\n            searchBar.value = temp;\r\n        } else if (option === 'queue') {\r\n            const queueTracks = modals['queue'].getElementsByClassName('track');\r\n            // placeholder\r\n            let playingTrack = document.getElementsByClassName('track')[0];\r\n            for (const track of queueTracks) {\r\n                if (track.classList.contains('cyan')) {\r\n                    playingTrack = track;\r\n                    break;\r\n                }\r\n            }\r\n            playingTrack.scrollIntoView();\r\n            document.getElementById('queue-modal').scrollTop -= 20;\r\n        }\r\n        document.getElementById('player-container').style.filter = 'blur(6px)'\r\n    }\r\n\r\n    function closeModals() {\r\n        for (const option in modals) modals[option].style.display = 'none';\r\n        history.replaceState('', document.title, window.location.pathname + window.location.search);\r\n        document.getElementById('player-container').style.filter = '';\r\n    }\r\n\r\n    function getHash() {\r\n        try { return window.location.hash.slice(1); }\r\n        catch (err) { return ''; }\r\n    }\r\n\r\n    window.onclick = event => {  // close modal\r\n        if (Object.values(modals).includes(event.target)) {\r\n            closeModals();\r\n        }\r\n    }\r\n\r\n    window.addEventListener('hashchange', e => {\r\n        hashVal = getHash();\r\n        if (hashVal == '') closeModals();\r\n        else try { showModal(hashVal); } catch (TypeError) {};\r\n    });\r\n\r\n    window.onkeydown = event => {\r\n        modalDisplays = Object.values(modals).map(el => el.style.display);\r\n        if (event.key == 'Escape' && modalDisplays.includes('block')) closeModals();\r\n        //  else if (!modalDisplays.includes('block')) {\r\n        //     if (event.key == 'Space') {\r\n        //         window.location.replace('/')\r\n        //     } else if (event.key == '>') {\r\n\r\n        //     } else if (event.key == '<') {\r\n\r\n        //     }\r\n        // }\r\n        // add playback control\r\n    }\r\n\r\n    function filterTracks() {\r\n        const filter = searchBar.value.toLowerCase().normalize('NFD').replace(/[\\u0300-\\u036f]/g, '');\r\n        const trackRows = document.getElementById('tracks-list').getElementsByClassName('trackRow');\r\n        for (const trackRow of trackRows) {\r\n            a = trackRow.firstElementChild;\r\n            const txtValue = (a.textContent || a.innerText).toLowerCase().normalize('NFD').replace(/[\\u0300-\\u036f]/g, '');\r\n            if (txtValue.indexOf(filter) > -1) {\r\n                trackRow.style.display = '';\r\n            } else {\r\n                trackRow.style.display = 'none';\r\n            }\r\n        }\r\n    }\r\n\r\n    function toggleSetting(settingEl) {\r\n        const settingName = settingEl.dataset.key;\r\n        const checkBox = settingEl.getElementsByTagName('input')[0];\r\n        checkBox.checked = !checkBox.checked;\r\n        fetch('/change-setting/', {\r\n                method: 'POST',\r\n                headers: { 'Content-Type': 'application/json' },\r\n                body: JSON.stringify({setting_name: settingName, value: checkBox.checked})\r\n        });\r\n    }\r\n\r\n    function rescanLibrary() {\r\n        fetch('/rescan-library/');\r\n        const reScanLib = '{{ gt('Rescanning library') }}';\r\n        showToast(reScanLib);\r\n    }\r\n\r\n    function refreshDevices() {\r\n        fetch('/refresh-devices/');\r\n        const refreshDevices = '{{ gt('Refreshing Devices').capitalize() }}';\r\n        showToast(refreshDevices);\r\n    }\r\n\r\n    function showToast(message) {\r\n        const toast = document.getElementById('toast');\r\n        toast.innerHTML = message;\r\n        toast.className = 'show';\r\n        setTimeout(() => { toast.className = toast.className.replace('show', ''); }, 2400);\r\n    }\r\n\r\n    function countChar(str, char) {\r\n        for (let charIndex = 0; charIndex < len; ++charIndex) {\r\n            if (str[charIndex] === char) {\r\n                ++num;\r\n            }\r\n        }\r\n        return num;\r\n    }\r\n\r\n    function setTimer() {\r\n        const minutes = document.getElementById('timerMinutes').value;\r\n        if (minutes !== '') {\r\n            fetch('/timer/', {\r\n                method: 'POST',\r\n                headers: { 'Content-Type': 'text/plain' },\r\n                body: minutes\r\n            }).then(r => r.text()).then(text => {\r\n                const timerText = '{{ gt('Timer set for $TIME') }}';\r\n                showToast(timerText.replace(/\\$TIME/g, text));\r\n            });\r\n        } else {\r\n            const timerSetError = '{{ gt('Could not set timer') }}';\r\n            showToast(timerSetError);\r\n        }\r\n    }\r\n\r\n    function cancelTimer() {\r\n        const timerCancelled = '{{ gt('Timer cancelled') }}';\r\n        fetch('/timer/', {\r\n            method: 'POST',\r\n            headers: { 'Content-Type': 'text/plain' },\r\n            body: 'cancel'\r\n        }).then(() => showToast(timerCancelled));\r\n    }\r\n\r\n\r\n    function setVolume(newVol) {\r\n        newVol = parseInt(newVol);\r\n        fetch('/change-setting/', {\r\n            method: 'POST',\r\n            headers: { 'Content-Type': 'application/json' },\r\n            body: JSON.stringify({setting_name: 'volume', value: newVol})\r\n        });\r\n    }\r\n\r\n    function changeDevice() {\r\n        deviceUUID = document.getElementById('devices').value;\r\n        fetch(`/change-device/${deviceUUID}`, {\r\n            method: 'POST',\r\n            headers: { 'Content-Type': 'application/json' },\r\n        });\r\n    }\r\n\r\n    function reloadOnChange() {\r\n        fetch('/state/', {\r\n                method: 'GET',\r\n                headers: { 'Content-Type': 'application/json' }\r\n            }).then(response => response.json()).then(\r\n                response => {\r\n                    const currentTitle = document.getElementById('info__track').textContent;\r\n                    const currentArtist = document.getElementById('info__artist').textContent;\r\n                    const currentAlbum = document.getElementById('info__album').textContent;\r\n                    const currentVol = document.getElementById('volRange').value;\r\n                    const queueTracks = modals['queue'].getElementsByClassName('track');\r\n                    const audioStream = document.getElementById('audioStream')\r\n                    const trackPosition = response['track_position']\r\n                    if (response['title'] !== currentTitle ||\r\n                        response['artist'] !== currentArtist ||\r\n                        response['album'] !== currentAlbum ||\r\n                        response['lang'] !== settingsFromServer.lang ||\r\n                        response['queue_length'] !== queueTracks.length ||\r\n                        response['status'] !== '{{ playing_status.__str__() }}') {\r\n                        // reload window since now playing has changed\r\n                        window.location.reload();\r\n                    } else if (Math.round(response['volume']) != Math.round(currentVol)) {\r\n                        document.getElementById('volRange').value = response['volume']\r\n                    } else if (response['status'] === 'PLAYING' && trackPosition && audioStream) {\r\n                        // update position\r\n                        const currentTime = audioStream.currentTime;\r\n                        if (trackPosition < currentTime -1 || trackPosition > currentTime + 1) {\r\n                            document.getElementById('audioStream').currentTime = trackPosition;\r\n                        }\r\n                    }\r\n                    if (audioStream) {\r\n                        sessionStorage.setItem('streamMuted', audioStream.muted);\r\n                        sessionStorage.setItem('streamVolume', audioStream.volume);\r\n                    }\r\n                }\r\n            ).catch(error => {\r\n                // ignore network errors\r\n                if (error.name !== 'NetworkError') {\r\n                    console.log(error);\r\n                }\r\n            });\r\n        // check every 1.5 seconds\r\n        setTimeout(reloadOnChange, 1500);\r\n    }\r\n\r\n    function muteStream() {\r\n        // in order to improve UX, we mute the stream when the user clicks the pause button\r\n        const audioStream = document.getElementById('audioStream');\r\n        audioStream.muted = true;\r\n    }\r\n\r\n    window.onload = () => {\r\n        // show modal if there is a hash\r\n        try { showModal(getHash()); } catch (TypeError) {}\r\n\r\n        try {\r\n            const audioStream = document.getElementById('audioStream');\r\n            audioStream.currentTime = {{ stream_time }};\r\n            if (!sessionStorage.getItem('streamVolume')) {\r\n                audioStream.volume = 0.1;\r\n            } else {\r\n                audioStream.volume = sessionStorage.getItem('streamVolume');\r\n            }\r\n            if (sessionStorage.getItem('streamMuted') === 'false') {\r\n                audioStream.muted = false;\r\n            } else {\r\n                audioStream.muted = true;\r\n            }\r\n        } catch (TypeError) {}\r\n\r\n        // populate timer option\r\n        const timerToggles = document.getElementsByClassName('timerSetting');\r\n        for (const timerEl of timerToggles) {\r\n            const radio = timerEl.getElementsByTagName('input')[0];\r\n            if (timerEl.dataset.key in settingsFromServer) {\r\n                const val = settingsFromServer[timerEl.dataset.key];\r\n                radio.checked = val;\r\n                if (val) break;\r\n            } else {\r\n                // stop only option\r\n                radio.checked = true;\r\n            }\r\n        }\r\n\r\n        reloadOnChange();\r\n    };\r\n</script>\r\n</body>\r\n"
  },
  {
    "path": "src/test_cases/ipconfig.py",
    "content": "IPCONFIG_ELIBROFTW = '''Windows IP Configuration\n\n\nEthernet adapter Ethernet 3:\n\n   Media State . . . . . . . . . . . : Media disconnected\n   Connection-specific DNS Suffix  . :\n\nEthernet adapter Ethernet:\n\n   Connection-specific DNS Suffix  . :\n   Link-local IPv6 Address . . . . . : fe80::536e:5298:cc0b:3007%15\n   IPv4 Address. . . . . . . . . . . : 192.168.56.1\n   Subnet Mask . . . . . . . . . . . : 255.255.255.0\n   Default Gateway . . . . . . . . . :\n\nWireless LAN adapter Wi-Fi 2:\n\n   Media State . . . . . . . . . . . : Media disconnected\n   Connection-specific DNS Suffix  . :\n\nWireless LAN adapter Wi-Fi 3:\n\n   Media State . . . . . . . . . . . : Media disconnected\n   Connection-specific DNS Suffix  . :\n\nWireless LAN adapter Wi-Fi 4:\n\n   Media State . . . . . . . . . . . : Media disconnected\n   Connection-specific DNS Suffix  . : ht.home\n\nWireless LAN adapter Wi-Fi:\n\n   Connection-specific DNS Suffix  . : cgocable.net\n   IPv6 Address. . . . . . . . . . . : 2001:1970:46c7:6f00:b2a6:1d41:da88:1d67\n   Temporary IPv6 Address. . . . . . : 2001:1970:46c7:6f00:9dcc:d93a:540a:f506\n   Link-local IPv6 Address . . . . . : fe80::e427:6d89:2e48:2c23%8\n   IPv4 Address. . . . . . . . . . . : 192.168.0.89\n   Subnet Mask . . . . . . . . . . . : 255.255.255.0\n   Default Gateway . . . . . . . . . : fe80::d26d:c9ff:fe4d:774%8\n                                       192.168.0.1\n\nEthernet adapter Bluetooth Network Connection:\n\n   Media State . . . . . . . . . . . : Media disconnected\n   Connection-specific DNS Suffix  . :\n'''\nIPCONFIG_ERICCHAN1989_ALL = '''\n    Windows IP Configuration\n\nUnknown adapter NordLynx:\n\n\nConnection-specific DNS Suffix . :\nDescription . . . . . . . . . . . : NordLynx Tunnel\nPhysical Address. . . . . . . . . :\nDHCP Enabled. . . . . . . . . . . : No\nAutoconfiguration Enabled . . . . : Yes\nLink-local IPv6 Address . . . . . : fe80::e911:84b9:1c8:cded%55(Preferred)\nIPv4 Address. . . . . . . . . . . : 10.5.0.2(Preferred)\nSubnet Mask . . . . . . . . . . . : 255.255.0.0\nDefault Gateway . . . . . . . . . : 0.0.0.0\nDNS Servers . . . . . . . . . . . : 103.86.96.100\n103.86.99.100\nNetBIOS over Tcpip. . . . . . . . : Enabled\n\nEthernet adapter Ethernet:\n\nConnection-specific DNS Suffix . :\nDescription . . . . . . . . . . . : Realtek Gaming 2.5GbE Family Controller\nPhysical Address. . . . . . . . . : [removed for security purpose]\nDHCP Enabled. . . . . . . . . . . : No\nAutoconfiguration Enabled . . . . : Yes\nIPv4 Address. . . . . . . . . . . : 192.168.2.2(Preferred)\nSubnet Mask . . . . . . . . . . . : 255.255.255.0\nDefault Gateway . . . . . . . . . : 192.168.2.1\nDNS Servers . . . . . . . . . . . : 1.1.1.1\n8.8.8.8\nNetBIOS over Tcpip. . . . . . . . : Enabled\n\nUnknown adapter OpenVPN Data Channel Offload for NordVPN:\n\nMedia State . . . . . . . . . . . : Media disconnected\nConnection-specific DNS Suffix . :\nDescription . . . . . . . . . . . : OpenVPN Data Channel Offload\nPhysical Address. . . . . . . . . :\nDHCP Enabled. . . . . . . . . . . : Yes\nAutoconfiguration Enabled . . . . : Yes\n\nUnknown adapter Local Area Connection:\n\nMedia State . . . . . . . . . . . : Media disconnected\nConnection-specific DNS Suffix . :\nDescription . . . . . . . . . . . : TAP-NordVPN Windows Adapter V9\nPhysical Address. . . . . . . . . : [removed for security purpose]\nDHCP Enabled. . . . . . . . . . . : Yes\nAutoconfiguration Enabled . . . . : Yes\n\nWireless LAN adapter WiFi:\n\nMedia State . . . . . . . . . . . : Media disconnected\nConnection-specific DNS Suffix . :\nDescription . . . . . . . . . . . : Intel(R) Wi-Fi 6 AX201 160MHz\nPhysical Address. . . . . . . . . : [removed for security purpose]\nDHCP Enabled. . . . . . . . . . . : Yes\nAutoconfiguration Enabled . . . . : Yes\n\nWireless LAN adapter Local Area Connection* 1:\n\nMedia State . . . . . . . . . . . : Media disconnected\nConnection-specific DNS Suffix . :\nDescription . . . . . . . . . . . : Microsoft Wi-Fi Direct Virtual Adapter\nPhysical Address. . . . . . . . . : [removed for security purpose]\nDHCP Enabled. . . . . . . . . . . : Yes\nAutoconfiguration Enabled . . . . : Yes\n\nWireless LAN adapter Local Area Connection* 10:\n\nMedia State . . . . . . . . . . . : Media disconnected\nConnection-specific DNS Suffix . :\nDescription . . . . . . . . . . . : Microsoft Wi-Fi Direct Virtual Adapter #2\nPhysical Address. . . . . . . . . : [removed for security purpose]\nDHCP Enabled. . . . . . . . . . . : Yes\nAutoconfiguration Enabled . . . . : Yes\n\nEthernet adapter Bluetooth Network Connection:\n\nMedia State . . . . . . . . . . . : Media disconnected\nConnection-specific DNS Suffix . :\nDescription . . . . . . . . . . . : Bluetooth Device (Personal Area Network)\nPhysical Address. . . . . . . . . : [removed for security purpose]\nDHCP Enabled. . . . . . . . . . . : Yes\nAutoconfiguration Enabled . . . . : Yes\n\nEthernet adapter vEthernet (Default Switch):\n\nConnection-specific DNS Suffix . :\nDescription . . . . . . . . . . . : Hyper-V Virtual Ethernet Adapter\nPhysical Address. . . . . . . . . : [removed for security purpose]\nDHCP Enabled. . . . . . . . . . . : No\nAutoconfiguration Enabled . . . . : Yes\nLink-local IPv6 Address . . . . . : fe80::8593:9017:f01e:e94c%25(Preferred)\nIPv4 Address. . . . . . . . . . . : 172.29.0.1(Preferred)\nSubnet Mask . . . . . . . . . . . : 255.255.240.0\nDefault Gateway . . . . . . . . . :\nDHCPv6 IAID . . . . . . . . . . . : 419435869\nDHCPv6 Client DUID. . . . . . . . : 00-01-00-01-2E-2D-51-14-70-A6-CC-B2-9B-5C\nNetBIOS over Tcpip. . . . . . . . : Enabled\n\nEthernet adapter vEthernet (WSL (Hyper-V firewall)):\n\nConnection-specific DNS Suffix . :\nDescription . . . . . . . . . . . : Hyper-V Virtual Ethernet Adapter #2\nPhysical Address. . . . . . . . . : [removed for security purpose]\nDHCP Enabled. . . . . . . . . . . : No\nAutoconfiguration Enabled . . . . : Yes\nLink-local IPv6 Address . . . . . : fe80::d5b0:1ea6:d494:c06f%50(Preferred)\nIPv4 Address. . . . . . . . . . . : 172.21.160.1(Preferred)\nSubnet Mask . . . . . . . . . . . : 255.255.240.0\nDefault Gateway . . . . . . . . . :\nDHCPv6 IAID . . . . . . . . . . . : 838866269\nDHCPv6 Client DUID. . . . . . . . : 00-01-00-01-2E-2D-51-14-70-A6-CC-B2-9B-5C\nNetBIOS over Tcpip. . . . . . . . : Enabled\n'''\n\nIPCONFIG_ERICCHAN1989 = '''\nWindows IP Configuration\n\n\nUnknown adapter NordLynx:\n\n   Connection-specific DNS Suffix  . :\n   Link-local IPv6 Address . . . . . : fe80::e911:84b9:1c8:cded%55\n   IPv4 Address. . . . . . . . . . . : 10.5.0.2\n   Subnet Mask . . . . . . . . . . . : 255.255.0.0\n   Default Gateway . . . . . . . . . : 0.0.0.0\n\nEthernet adapter Ethernet:\n\n   Connection-specific DNS Suffix  . :\n   IPv4 Address. . . . . . . . . . . : 192.168.2.2\n   Subnet Mask . . . . . . . . . . . : 255.255.255.0\n   Default Gateway . . . . . . . . . : 192.168.2.1\n\nUnknown adapter OpenVPN Data Channel Offload for NordVPN:\n\n   Media State . . . . . . . . . . . : Media disconnected\n   Connection-specific DNS Suffix  . :\n\nUnknown adapter Local Area Connection:\n\n   Media State . . . . . . . . . . . : Media disconnected\n   Connection-specific DNS Suffix  . :\n\nWireless LAN adapter WiFi:\n\n   Media State . . . . . . . . . . . : Media disconnected\n   Connection-specific DNS Suffix  . :\n\nWireless LAN adapter Local Area Connection* 1:\n\n   Media State . . . . . . . . . . . : Media disconnected\n   Connection-specific DNS Suffix  . :\n\nWireless LAN adapter Local Area Connection* 10:\n\n   Media State . . . . . . . . . . . : Media disconnected\n   Connection-specific DNS Suffix  . :\n\nEthernet adapter Bluetooth Network Connection:\n\n   Media State . . . . . . . . . . . : Media disconnected\n   Connection-specific DNS Suffix  . :\n\nEthernet adapter vEthernet (Default Switch):\n\n   Connection-specific DNS Suffix  . :\n   Link-local IPv6 Address . . . . . : fe80::8593:9017:f01e:e94c%13\n   IPv4 Address. . . . . . . . . . . : 172.19.80.1\n   Subnet Mask . . . . . . . . . . . : 255.255.240.0\n   Default Gateway . . . . . . . . . :\n\nEthernet adapter vEthernet (WSL (Hyper-V firewall)):\n\n   Connection-specific DNS Suffix  . :\n   Link-local IPv6 Address . . . . . : fe80::b8db:cb1d:44eb:e56a%32\n   IPv4 Address. . . . . . . . . . . : 172.21.160.1\n   Subnet Mask . . . . . . . . . . . : 255.255.240.0\n   Default Gateway . . . . . . . . . :\n'''\n"
  },
  {
    "path": "src/test_harness.py",
    "content": "from base64 import b64decode\r\nfrom contextlib import suppress\r\nimport io\r\nfrom itertools import chain\r\nimport os\r\nimport platform\r\nfrom pathlib import Path\r\nimport time\r\n\r\nfrom mutagen._util import MutagenError\r\nfrom PIL import Image\r\nimport pytest\r\n\r\nfrom modules.db import DatabaseConnection\r\nfrom b64_images import DEFAULT_ART\r\nfrom meta import COVER_MINI, COVER_NORMAL, VERSION\r\nfrom shared import get_running_processes, is_already_running\r\nfrom test_cases.ipconfig import IPCONFIG_ELIBROFTW, IPCONFIG_ERICCHAN1989, IPCONFIG_ERICCHAN1989_ALL\r\nfrom utils import (\r\n    IPV4_GENERAL_PATTERN,\r\n    IPV4_WIFI_PATTERN,\r\n    REPEAT_ALL_IMG,\r\n    REPEAT_OFF_IMG,\r\n    REPEAT_ONE_IMG,\r\n    InvalidAudioFile,\r\n    State,\r\n    SystemAudioRecorder,\r\n    Unknown,\r\n    better_shuffle,\r\n    create_progress_bar_texts,\r\n    custom_art,\r\n    export_playlist,\r\n    clean_ipconfig,\r\n    fix_path,\r\n    get_album_art,\r\n    get_audio_length,\r\n    get_deezer_tracks,\r\n    get_default_output_device,\r\n    get_display_lang,\r\n    get_file_name,\r\n    get_first_artist,\r\n    get_ipv4,\r\n    get_ipv6,\r\n    get_lang_pack,\r\n    get_languages,\r\n    get_latest_release,\r\n    get_mac,\r\n    get_metadata,\r\n    get_proxy,\r\n    get_spotify_tracks,\r\n    get_translation,\r\n    get_youtube_comments,\r\n    get_yt_id,\r\n    natural_key_file,\r\n    parse_m3u,\r\n    repeat_img_tooltip,\r\n    resize_img,\r\n    t,\r\n    valid_audio_file,\r\n    valid_color_code,\r\n    ydl_extract_info,\r\n)\r\nfrom modules.url_metadata import ydl_get_metadata\r\n\r\nMUSIC_FILE_WITH_ALBUM_ART = (\r\n    r'C:\\Users\\maste\\OneDrive\\Music\\6ixbuzz, Pressa, Houdini - Up & Down.mp3'\r\n)\r\nTEST_MUSIC_FILES = [\r\n    r'C:\\Users\\maste\\OneDrive\\Music\\deadmau5 - My Pet Coelacanth.mp3',\r\n    r'C:\\Users\\maste\\OneDrive\\Music\\deadmau5 - Not Exactly.mp3',\r\n    r\"C:\\Users\\maste\\OneDrive\\Music\\deadmau5 - Phantoms Can't Hang.mp3\",\r\n    r'C:\\Users\\maste\\OneDrive\\Music\\deadmau5 - Rio.mp3',\r\n    r'C:\\Users\\maste\\OneDrive\\Music\\deadmau5 - SATRN.mp3',\r\n    r'C:\\Users\\maste\\OneDrive\\Music\\deadmau5 - Saved.mp3',\r\n    r'C:\\Users\\maste\\OneDrive\\Music\\deadmau5 - Slip.mp3',\r\n    r'C:\\Users\\maste\\OneDrive\\Music\\deadmau5 - So There I Was.mp3',  # DNE\r\n    r'C:\\Users\\maste\\OneDrive\\Music\\deadmau5 - Sofi Needs a Ladder.mp3',\r\n    r'C:\\Users\\maste\\OneDrive\\Music\\deadmau5 - Some Kind of Blue.mp3',\r\n    r'C:\\Users\\maste\\OneDrive\\Music\\deadmau5 - Sometimes Things Get, Whatever.mp3',\r\n    r'C:\\Users\\maste\\OneDrive\\Music\\deadmau5 - Three Pound Chicken Wing.mp3',\r\n    r'C:\\Users\\maste\\OneDrive\\Music\\deadmau5 & Kaskade - I Remember.mp3',\r\n    r'C:\\Users\\maste\\OneDrive\\Music\\deadmau5, Grabbitz - Let Go.mp3',\r\n    r'C:\\Users\\maste\\OneDrive\\Music\\Diplo, Trippie Redd - Wish.mp3',\r\n    r'C:\\Users\\maste\\OneDrive\\Music\\Dirty South, Alesso, Ruben Haze - City Of Dreams.mp3',\r\n    r\"C:\\Users\\maste\\OneDrive\\Music\\Dogzilla - Without You (John O'Callaghan Extended Remix).mp3\",\r\n    r'C:\\Users\\maste\\OneDrive\\Music\\Dogzilla - Without You (Ronald van Gelderen Extended Remix).mp3',\r\n    r'C:\\Users\\maste\\OneDrive\\Music\\Dogzilla - Without You (Will Atkinson Remix).mp3',\r\n    r\"C:\\Users\\maste\\OneDrive\\Music\\Drake - Hold On, We're Going Home.mp3\",\r\n    r'C:\\Users\\maste\\OneDrive\\Music\\Drake - Over (Ayobi Remix).mp3',\r\n    r'C:\\Users\\maste\\OneDrive\\Music\\Drake - Passionfruit.mp3',\r\n]\r\n\r\nLIST_TO_NAT_SORT_1 = [\r\n    '1. Hello World',\r\n    '3. Hello World',\r\n    '10. Hello World',\r\n    '2. Hello World',\r\n    '9. Hello World',\r\n    '11. Hello World',\r\n    '12. Hello World',\r\n]\r\nNAT_SORTED_LIST_1 = [\r\n    '1. Hello World',\r\n    '2. Hello World',\r\n    '3. Hello World',\r\n    '9. Hello World',\r\n    '10. Hello World',\r\n    '11. Hello World',\r\n    '12. Hello World',\r\n]\r\n\r\nLIST_TO_NAT_SORT_2 = [\r\n    'C:/Users/maste/Documents/MEGA/Music/1. Hello World',\r\n    'C:/Users/maste/Documents/MEGA/Music/3. Hello World',\r\n    'C:/Users/maste/Documents/MEGA/Music/10. Hello World',\r\n    'C:/Users/maste/Documents/MEGA/Music/2. Hello World',\r\n    'C:/Users/maste/Documents/MEGA/Music/9. Hello World',\r\n    'C:/Users/maste/Documents/MEGA/Music/11. Hello World',\r\n    'C:/Users/maste/Documents/MEGA/Music/12. Hello World',\r\n]\r\nNAT_SORTED_LIST_2 = [\r\n    'C:/Users/maste/Documents/MEGA/Music/1. Hello World',\r\n    'C:/Users/maste/Documents/MEGA/Music/2. Hello World',\r\n    'C:/Users/maste/Documents/MEGA/Music/3. Hello World',\r\n    'C:/Users/maste/Documents/MEGA/Music/9. Hello World',\r\n    'C:/Users/maste/Documents/MEGA/Music/10. Hello World',\r\n    'C:/Users/maste/Documents/MEGA/Music/11. Hello World',\r\n    'C:/Users/maste/Documents/MEGA/Music/12. Hello World',\r\n]\r\n\r\nGET_METADATA_FROM = [\r\n    r'C:\\Users\\maste\\Documents\\MEGA\\Music\\$teven Cannon - Inxanity.mp3',\r\n    r'C:\\Users\\maste\\Documents\\MEGA\\Music\\6ixbuzz, Pressa, Houdini - Up & Down.mp3',\r\n    r'C:\\Users\\maste\\Documents\\MEGA\\Music\\88GLAM, Lil Yachty - Lil Boat.mp3',\r\n    r'C:\\Users\\maste\\Documents\\MEGA\\Music\\Adam K & Soha - Twilight.mp3',\r\n]\r\nEXPECTED_METADATA = [\r\n    {\r\n        'album': 'Inxanity',\r\n        'artist': '$teven Cannon',\r\n        'explicit': True,\r\n        'sort_key': 'inxanity - $teven cannon',\r\n        'title': 'Inxanity',\r\n        'track_number': '1',\r\n    },\r\n    {\r\n        'album': '6ixupsidedown',\r\n        'artist': '6ixbuzz, Pressa, Houdini',\r\n        'explicit': True,\r\n        'title': 'Up & Down',\r\n        'sort_key': 'up & down - 6ixbuzz, pressa, houdini',\r\n        'track_number': '1',\r\n    },\r\n    {\r\n        'album': '88GLAM2.5',\r\n        'artist': '88GLAM, Lil Yachty',\r\n        'explicit': True,\r\n        'title': 'Lil Boat',\r\n        'sort_key': 'lil boat - 88glam, lil yachty',\r\n        'track_number': '6',\r\n    },\r\n    {\r\n        'album': 'Rebirth Classics - Ibiza',\r\n        'artist': 'Adam K & Soha',\r\n        'explicit': False,\r\n        'title': 'Twilight',\r\n        'sort_key': 'twilight - adam k & soha',\r\n        'track_number': '4',\r\n    },\r\n]\r\nEXPECTED_FIRST_ARTIST = ['$teven Cannon', '6ixbuzz', '88GLAM', 'Adam K & Soha']\r\nAUDIO_FILE_AND_NAMES = [\r\n    (\r\n        r'C:\\Users\\maste\\Documents\\MEGA\\Music\\Alesso, Matthew Koma - Years.mp3',\r\n        'Alesso, Matthew Koma - Years',\r\n    ),\r\n    (\r\n        'C:/Users/maste/Documents/MEGA/Music/Alesso, Matthew Koma - Years.mp3',\r\n        'Alesso, Matthew Koma - Years',\r\n    ),\r\n    (\r\n        r'Music\\Afrojack, Steve Aoki, Miss Palmer - No Beef.mp3',\r\n        'Afrojack, Steve Aoki, Miss Palmer - No Beef',\r\n    ),\r\n    (\r\n        'Music/Afrojack, Steve Aoki, Miss Palmer - No Beef.mp3',\r\n        'Afrojack, Steve Aoki, Miss Palmer - No Beef',\r\n    ),\r\n]\r\n\r\n\r\ndef test_get_running_processes():\r\n    assert len(list(get_running_processes())) > 0\r\n    for process in get_running_processes():\r\n        # 5 keys\r\n        assert len(process) == 5\r\n        assert isinstance(process['pid'], int)\r\n\r\n\r\n@pytest.mark.parametrize('file_path,expected', AUDIO_FILE_AND_NAMES)\r\ndef test_get_file_name(file_path, expected):\r\n    assert get_file_name(file_path) == expected\r\n\r\n\r\ndef test_display_lang():\r\n    lang = get_display_lang()\r\n    assert isinstance(lang, str)\r\n    assert len(lang) > 0\r\n\r\n\r\ndef test_internationalization():\r\n    assert isinstance(get_languages(), list)\r\n    # check if cache works\r\n    assert isinstance(get_languages(), list)\r\n    for code in get_languages():\r\n        assert isinstance(code, str)\r\n\r\n\r\n@pytest.mark.parametrize('code', ('en', 'es'))\r\ndef test_get_lang_pack(code):\r\n    pack = get_lang_pack(code)\r\n    assert len(pack) > 0\r\n    if code == 'en':\r\n        assert isinstance(pack, dict)\r\n    else:\r\n        assert isinstance(pack, list)\r\n\r\n\r\n@pytest.mark.parametrize('code', ('es', 'de', 'en'))\r\ndef test_get_translation(code):\r\n    State.lang = code\r\n    for line in get_lang_pack('en'):\r\n        get_translation(line, code)\r\n    unknown_title = Unknown('Title')\r\n    assert isinstance(unknown_title > 'unknown title', bool)\r\n    assert isinstance(unknown_title < 'unknown title', bool)\r\n    assert isinstance(unknown_title <= 'unknown title', bool)\r\n    assert isinstance(unknown_title >= 'unknown title', bool)\r\n\r\n\r\n@pytest.mark.parametrize(\r\n    'ext',\r\n    (\r\n        '.mp3',\r\n        '.flac',\r\n        '.m4a',\r\n        '.mp4',\r\n        '.aac',\r\n        '.mpeg',\r\n        '.ogg',\r\n        '.opus',\r\n        '.wma',\r\n        '.wav',\r\n    ),\r\n)\r\ndef test_valid_audio_file(ext):\r\n    assert valid_audio_file(f'x{ext}')\r\n\r\n\r\n@pytest.mark.parametrize(\r\n    'file',\r\n    chain(\r\n        TEST_MUSIC_FILES,\r\n        ['https://audio.tv', 'https://audio.com', 'audio.mp3', 'https://audio.mp4'],\r\n    ),\r\n)\r\ndef test_audio_length(file):\r\n    try:\r\n        assert get_audio_length(file) > 0\r\n        assert valid_audio_file(file)\r\n    except InvalidAudioFile:\r\n        assert not os.path.exists(file)\r\n\r\n\r\n@pytest.mark.parametrize('file', ('audio_player.py', 'file.mp4', 'README.txt'))\r\ndef test_audio_length_fail(file):\r\n    # the music players expects bad files to only raise InvalidAudioFile\r\n    with pytest.raises(InvalidAudioFile):\r\n        get_audio_length(file)\r\n\r\n\r\n@pytest.mark.skipif(\r\n    platform.system() != 'Windows',\r\n    reason='get_default_output_device only implemented on Windows',\r\n)\r\n@pytest.mark.no_ci\r\ndef test_default_output_device():\r\n    assert get_default_output_device()\r\n    print('Default Audio Device:', get_default_output_device())\r\n    sar = SystemAudioRecorder()\r\n    sar.start()  # start system audio recording\r\n    time.sleep(0.5)\r\n    sar.stop()  # stop system audio recording\r\n\r\n\r\n@pytest.mark.parametrize(\r\n    'unsorted,expected',\r\n    [(LIST_TO_NAT_SORT_1, NAT_SORTED_LIST_1), (LIST_TO_NAT_SORT_2, NAT_SORTED_LIST_2)],\r\n)\r\ndef test_natural_sort(unsorted, expected):\r\n    assert sorted(unsorted, key=natural_key_file) == expected\r\n\r\n\r\n@pytest.mark.parametrize(\r\n    'color_code',\r\n    (\r\n        '#fff',\r\n        '#ffffff',\r\n        '#aaa',\r\n        '#abc',\r\n        '#999',\r\n        '#000',\r\n        '#010',\r\n        '#000000',\r\n        '#999999',\r\n        '#aaaaaa',\r\n    ),\r\n)\r\ndef test_valid_color_code(color_code):\r\n    assert valid_color_code(color_code)\r\n\r\n\r\n@pytest.mark.parametrize(\r\n    'color_code',\r\n    (\r\n        'fff',\r\n        '000',\r\n        'abcdef',\r\n        '999999',\r\n        '.',\r\n        'czc/z',\r\n        '#...',\r\n        '#/.;ads',\r\n        '#fff.aa',\r\n        '#999999a',\r\n        '#ggg',\r\n    ),\r\n)\r\ndef test_invalid_color_codes(color_code):\r\n    assert not valid_color_code(color_code)\r\n\r\n\r\n@pytest.mark.parametrize(\r\n    'file,expected,expected_first_artist',\r\n    zip(GET_METADATA_FROM, EXPECTED_METADATA, EXPECTED_FIRST_ARTIST),\r\n)\r\n@pytest.mark.no_ci\r\ndef test_get_metadata(file, expected, expected_first_artist):\r\n    assert os.path.exists(file)\r\n    with suppress(MutagenError):\r\n        metadata = get_metadata(file)\r\n        assert metadata.pop('length') > 0\r\n        assert metadata.pop('time_modified') > 0\r\n        assert metadata == expected\r\n        assert get_first_artist(metadata['artist']) == expected_first_artist\r\n\r\n\r\ndef test_ipv4():\r\n    assert get_ipv4().count('.') == 3\r\n\r\n\r\ndef test_ipv6():\r\n    assert get_ipv6().count(':') > 0\r\n\r\n\r\ndef test_mac():\r\n    assert get_mac().count(':') == 5\r\n\r\n\r\ndef test_ipv4_wifi_match():\r\n    ipconfig_cleaned = clean_ipconfig(IPCONFIG_ELIBROFTW)\r\n    wifi_match = IPV4_WIFI_PATTERN.findall(ipconfig_cleaned)\r\n    assert len(wifi_match) > 0\r\n    assert wifi_match[-1][-1] == '192.168.0.89'\r\n\r\n\r\ndef test_ipv4_general_match():\r\n    ipconfig_cleaned = clean_ipconfig(IPCONFIG_ERICCHAN1989_ALL)\r\n    assert len(IPV4_WIFI_PATTERN.findall(ipconfig_cleaned)) == 0\r\n    matches = IPV4_GENERAL_PATTERN.findall(ipconfig_cleaned)\r\n    assert matches[-1] == '192.168.2.2'\r\n    ipconfig_cleaned = clean_ipconfig(IPCONFIG_ERICCHAN1989)\r\n    assert len(IPV4_WIFI_PATTERN.findall(ipconfig_cleaned)) == 0\r\n    matches = IPV4_GENERAL_PATTERN.findall(ipconfig_cleaned)\r\n    assert matches[-1] == '192.168.2.2'\r\n\r\n\r\ndef test_better_shuffle():\r\n    test_better_shuffle = list(range(10000))\r\n    better_shuffle(test_better_shuffle, 1, -2)\r\n    # shuffle everything except for the first and last element\r\n    assert test_better_shuffle[0] == 0\r\n    assert test_better_shuffle[-1] == 9999\r\n\r\n\r\ndef test_is_already_running():\r\n    assert isinstance(is_already_running(), bool)\r\n\r\n\r\n@pytest.mark.parametrize(\r\n    'url,expected_id',\r\n    (\r\n        ('https://youtu.be/Dlxu28sQfkE', 'Dlxu28sQfkE'),\r\n        ('https://www.youtube.com/watch?v=Dlxu28sQfkE&feature=youtu.be', 'Dlxu28sQfkE'),\r\n        ('https://www.youtube.com/watch/Dlxu28sQfkE', 'Dlxu28sQfkE'),\r\n        ('https://www.youtube.com/embed/Dlxu28sQfkE', 'Dlxu28sQfkE'),\r\n        ('https://www.youtube.com/v/Dlxu28sQfkE', 'Dlxu28sQfkE'),\r\n        (\r\n            'https://www.youtube.com/playlist?list=PLRbcUrcJVEmX_eaAsubNOWfE4SlhGqjW4',\r\n            'PLRbcUrcJVEmX_eaAsubNOWfE4SlhGqjW4',\r\n        ),\r\n    ),\r\n)\r\ndef test_yt_id(url, expected_id):\r\n    assert get_yt_id(url) == expected_id\r\n\r\n\r\ndef test_custom_art():\r\n    assert custom_art('sys')\r\n\r\n\r\n@pytest.mark.parametrize('file', TEST_MUSIC_FILES + ['DEFAULT_ART'])\r\ndef test_album_art(file):\r\n    _mime, img_data = get_album_art(file)\r\n    assert isinstance(img_data, bytes)\r\n\r\n\r\n@pytest.mark.parametrize(\r\n    'option,expected_img,expected_label',\r\n    (\r\n        (None, REPEAT_OFF_IMG, 'Repeat All'),\r\n        (True, REPEAT_ONE_IMG, 'Repeat Off'),\r\n        (False, REPEAT_ALL_IMG, 'Repeat One'),\r\n    ),\r\n)\r\ndef test_repeat_img_tooltip(option, expected_img, expected_label):\r\n    assert repeat_img_tooltip(option) == (expected_img, t(expected_label))\r\n\r\n\r\n@pytest.mark.parametrize('size', ((125, 425), COVER_MINI, COVER_NORMAL))\r\ndef test_resize_img(size):\r\n    base64data = resize_img(DEFAULT_ART, '#121212', new_size=size)\r\n    img_data = io.BytesIO(b64decode(base64data))\r\n    img: Image.Image = Image.open(img_data)\r\n    assert img.size == size\r\n\r\n\r\n@pytest.mark.parametrize(\r\n    'url',\r\n    (\r\n        'https://open.spotify.com/track/0Memc4WL8oO0xUnkXCsNnV?si=Mg58OQxeTj6lTkvNV919wg',  # spotify track\r\n        'https://open.spotify.com/album/2JSiQ1wnqVEdaf6Y39DsAJ?highlight=spotify:track:0Memc4WL8oO0xUnkXCsNnV',\r\n        'https://open.spotify.com/album/47MVgO7XNmxzoYSJIvqxAG',  # spotify album\r\n        'https://open.spotify.com/playlist/37i9dQZF1DXarRysLJmuju',  # spotify playlist\r\n    ),\r\n)\r\n@pytest.mark.skipif(True, reason='spotify web API access removal')\r\ndef test_spotify(url):\r\n    try:\r\n        metadata_list = get_spotify_tracks(url)\r\n        assert isinstance(metadata_list, list)\r\n        for metadata in metadata_list:\r\n            assert metadata['src']\r\n            assert 'explicit' in metadata\r\n    except AssertionError:\r\n        print('WARNING: Spotify down')\r\n        time.sleep(0.5)\r\n\r\n\r\n@pytest.mark.parametrize(\r\n    'url',\r\n    (\r\n        'https://www.deezer.com/track/65404135?utm_campaign=clipboard-generic',  # deezer track\r\n        'https://deezer.page.link/NTW1c5cRdkzy28P19',\r\n        'https://deezer.page.link/Prw6jnAYCNe8VrV17',\r\n        'https://www.deezer.com/album/217794942',  # deezer album\r\n        'https://deezer.page.link/XGPUgE6HN5LryeBE7',\r\n        'https://www.deezer.com/playlist/1963962142',  # deezer playlist\r\n        'https://deezer.page.link/URU2yh1GX1wyaoZy9',\r\n    ),\r\n)\r\n@pytest.mark.no_ci\r\ndef test_deezer(url):\r\n    with suppress(LookupError):\r\n        metadata_list = get_deezer_tracks(url)\r\n        assert isinstance(metadata_list, list)\r\n        for metadata in metadata_list:\r\n            assert metadata['src']\r\n            assert 'explicit' in metadata\r\n            assert isinstance(metadata['expiry'], (int, float))\r\n            assert metadata['url']\r\n\r\n\r\n@pytest.fixture\r\ndef running_in_ci(request):\r\n    return request.config.getoption('--ci')\r\n\r\n\r\n@pytest.mark.no_ci\r\n@pytest.mark.parametrize(\r\n    'url',\r\n    ('https://www.youtube.com/watch?v=PNP0hku7hSo', 'https://youtu.be/5XADIh_mJM4'),\r\n)\r\ndef test_ydl(running_in_ci, url):\r\n    try:\r\n        info = ydl_extract_info(url)\r\n        assert isinstance(info, dict)\r\n        metadata = ydl_get_metadata(info)\r\n\r\n        assert metadata['title']\r\n        assert metadata['artist']\r\n        assert metadata['url']\r\n        assert metadata['src']\r\n        assert metadata['url']\r\n        assert metadata['audio_url']\r\n        assert metadata['expiry']\r\n        assert metadata['id']\r\n        assert metadata['ext']\r\n        assert metadata['album']\r\n        assert metadata['ytid']\r\n        # assert isinstance(metadata['duration'], int)\r\n        assert metadata['timestamps']\r\n        assert isinstance(metadata['is_live'], bool)\r\n        assert metadata.type == 'youtube'\r\n\r\n        if 'thumbnail' in info:\r\n            assert metadata['album_cover_url'] is not None\r\n    except Exception:\r\n        if not running_in_ci:\r\n            raise\r\n\r\n\r\ndef test_get_proxies():\r\n    for _ in range(3):\r\n        get_proxy()\r\n\r\n\r\n@pytest.mark.parametrize(\r\n    'path,expected', ((r'C:\\Users\\maste\\OneDrive', 'C:/Users/maste/OneDrive'),)\r\n)\r\ndef test_fix_path(path, expected):\r\n    assert fix_path(path, False) == expected\r\n\r\n\r\n@pytest.mark.parametrize(\r\n    'path,expected', (('C:/Users/maste/OneDrive', r'C:\\Users\\maste\\OneDrive'),)\r\n)\r\n@pytest.mark.skipif(\r\n    platform.system() != 'Windows',\r\n    reason='this test checks if a posix path gets converted into a windows path',\r\n)\r\ndef test_fix_path_win32(path, expected):\r\n    assert fix_path(path) == expected\r\n\r\n\r\n# expected is (time elapse, time remaining)\r\n@pytest.mark.parametrize(\r\n    'position,length,expected',\r\n    (\r\n        (30, 300, ('0:30', '4:30')),\r\n        (60, 300, ('1:00', '4:00')),\r\n        (90, 300, ('1:30', '3:30')),\r\n        (90, 180, ('1:30', '1:30')),\r\n        (180, 180, ('3:00', '0:00')),\r\n        (45, 125, ('0:45', '1:20')),\r\n        (105, 125, ('1:45', '0:20')),\r\n        (105, 300, ('1:45', '3:15')),\r\n    ),\r\n)\r\ndef test_progress_bar_texts(position, length, expected):\r\n    assert create_progress_bar_texts(position, length) == expected\r\n\r\n\r\ndef test_export_playlist():\r\n    test_uris = TEST_MUSIC_FILES + ['https://www.youtube.com/watch?v=_jh9lMUjBLo']\r\n    path = export_playlist('test_playlist_support', test_uris)\r\n    for expected_uri, actual_uri in zip(test_uris, parse_m3u(path)):\r\n        print(expected_uri, actual_uri)\r\n        assert Path(expected_uri) == Path(actual_uri)\r\n    os.remove(path)\r\n\r\n\r\n@pytest.mark.parametrize('url', ('https://www.youtube.com/watch?v=MTk-Hwr15ao',))\r\ndef test_youtube_comments(url):\r\n    comments = list(get_youtube_comments(url, 10))\r\n    assert len(comments) > 0\r\n\r\n\r\n@pytest.fixture\r\ndef uploading_after(request):\r\n    return request.config.getoption('--upload')\r\n\r\n\r\n@pytest.fixture\r\ndef test_auto_update(request):\r\n    return request.config.getoption('--test-auto-update')\r\n\r\n\r\ndef test_get_latest_release(uploading_after, test_auto_update):\r\n    version = [int(x) for x in VERSION.split('.')]\r\n    latest_release = get_latest_release(VERSION, VERSION, True)\r\n    assert isinstance(latest_release, dict)\r\n    compare_ver = latest_release['version']\r\n    compare_ver = [int(x) for x in compare_ver.split('.')]\r\n    if test_auto_update:\r\n        assert version < compare_ver\r\n    elif uploading_after:\r\n        assert compare_ver < version\r\n    else:\r\n        assert compare_ver <= version\r\n\r\n@pytest.mark.ci_only\r\ndef test_database():\r\n    DatabaseConnection.DEFAULT_DATABASE_FILE.parent.mkdir(parents=True, exist_ok=True)\r\n    with DatabaseConnection(DatabaseConnection.DEFAULT_DATABASE_FILE) as _:\r\n        pass\r\n    if DatabaseConnection.DEFAULT_DATABASE_FILE.exists():\r\n        os.remove(DatabaseConnection.DEFAULT_DATABASE_FILE)\r\n"
  },
  {
    "path": "src/theme/LICENSE",
    "content": "MIT License\n\nCopyright (c) 2021 rdbende\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "src/theme/dark.tcl",
    "content": "# Copyright © 2021 rdbende <rdbende@gmail.com>\n\n# A stunning dark theme for ttk based on Microsoft's Sun Valley visual style \n\npackage require Tk 8.6\n\nnamespace eval ttk::theme::sun-valley-dark {\n    variable version 1.0\n    package provide ttk::theme::sun-valley-dark $version\n\n    ttk::style theme create sun-valley-dark -parent clam -settings {\n        proc load_images {imgdir} {\n            variable images\n            foreach file [glob -directory $imgdir *.png] {\n                set images([file tail [file rootname $file]]) \\\n                [image create photo -file $file -format png]\n            }\n        }\n\n        load_images [file join [file dirname [info script]] dark]\n\n        array set colors {\n            -fg             \"#ffffff\"\n            -bg             \"#1c1c1c\"\n            -disabledfg     \"#595959\"\n            -selectfg       \"#ffffff\"\n            -selectbg       \"#2f60d8\"\n        }\n\n        ttk::style layout TButton {\n            Button.button -children {\n                Button.padding -children {\n                    Button.label -side left -expand 1\n                } \n            }\n        }\n\n        ttk::style layout Toolbutton {\n            Toolbutton.button -children {\n                Toolbutton.padding -children {\n                    Toolbutton.label -side left -expand 1\n                } \n            }\n        }\n\n        ttk::style layout TMenubutton {\n            Menubutton.button -children {\n                Menubutton.padding -children {\n                    Menubutton.label -side left -expand 1\n                    Menubutton.indicator -side right -sticky nsew\n                }\n            }\n        }\n\n        ttk::style layout TOptionMenu {\n            OptionMenu.button -children {\n                OptionMenu.padding -children {\n                    OptionMenu.label -side left -expand 1\n                    OptionMenu.indicator -side right -sticky nsew\n                }\n            }\n        }\n\n        ttk::style layout Accent.TButton {\n            AccentButton.button -children {\n                AccentButton.padding -children {\n                    AccentButton.label -side left -expand 1\n                } \n            }\n        }\n\n        ttk::style layout TCheckbutton {\n            Checkbutton.button -children {\n                Checkbutton.padding -children {\n                    Checkbutton.indicator -side left\n                    Checkbutton.label -side right -expand 1\n                }\n            }\n        }\n\n        ttk::style layout Switch.TCheckbutton {\n            Switch.button -children {\n                Switch.padding -children {\n                    Switch.indicator -side left\n                    Switch.label -side right -expand 1\n                }\n            }\n        }\n\n        ttk::style layout Toggle.TButton {\n            ToggleButton.button -children {\n                ToggleButton.padding -children {\n                    ToggleButton.label -side left -expand 1\n                } \n            }\n        }\n\n        ttk::style layout TRadiobutton {\n            Radiobutton.button -children {\n                Radiobutton.padding -children {\n                    Radiobutton.indicator -side left\n                    Radiobutton.label -side right -expand 1\n                }\n            }\n        }\n\n        ttk::style layout Vertical.TScrollbar {\n            Vertical.Scrollbar.trough -sticky ns -children {\n                Vertical.Scrollbar.uparrow -side top\n                Vertical.Scrollbar.downarrow -side bottom\n                Vertical.Scrollbar.thumb -expand 1\n            }\n        }\n\n        ttk::style layout Horizontal.TScrollbar {\n            Horizontal.Scrollbar.trough -sticky ew -children {\n                Horizontal.Scrollbar.leftarrow -side left\n                Horizontal.Scrollbar.rightarrow -side right\n                Horizontal.Scrollbar.thumb -expand 1\n            }\n        }\n\n        ttk::style layout TSeparator {\n            TSeparator.separator -sticky nsew\n        }\n\n        ttk::style layout TCombobox {\n            Combobox.field -sticky nsew -children {\n                Combobox.padding -expand 1 -sticky nsew -children {\n                    Combobox.textarea -sticky nsew\n                }\n            }\n            null -side right -sticky ns -children {\n                Combobox.arrow -sticky nsew\n            }\n        }\n        \n        ttk::style layout TSpinbox {\n            Spinbox.field -sticky nsew -children {\n                Spinbox.padding -expand 1 -sticky nsew -children {\n                    Spinbox.textarea -sticky nsew\n                }\n                \n            }\n            null -side right -sticky nsew -children {\n                Spinbox.uparrow -side left -sticky nsew\n                Spinbox.downarrow -side right -sticky nsew\n            }\n        }  \n        \n        ttk::style layout Card.TFrame {\n            Card.field {\n                Card.padding -expand 1 \n            }\n        }\n\n        ttk::style layout TLabelframe {\n            Labelframe.border {\n                Labelframe.padding -expand 1 -children {\n                    Labelframe.label -side left\n                }\n            }\n        }\n\n        ttk::style layout TNotebook {\n            Notebook.border -children {\n                TNotebook.Tab -expand 1\n                Notebook.client -sticky nsew\n            }\n        }\n\n        ttk::style layout Treeview.Item {\n            Treeitem.padding -sticky nsew -children {\n                Treeitem.image -side left -sticky {}\n                Treeitem.indicator -side left -sticky {}\n                Treeitem.text -side left -sticky {}\n            }\n        }\n\n        # Button\n        ttk::style configure TButton -padding {8 4} -anchor center -foreground $colors(-fg)\n\n        ttk::style map TButton -foreground \\\n            [list disabled #7a7a7a \\\n                pressed #d0d0d0]\n\n        ttk::style element create Button.button image \\\n            [list $images(button-rest) \\\n                {selected disabled} $images(button-disabled) \\\n                disabled $images(button-disabled) \\\n                selected $images(button-rest) \\\n                pressed $images(button-pressed) \\\n                active $images(button-hover) \\\n            ] -border 4 -sticky nsew\n\n        # Toolbutton\n        ttk::style configure Toolbutton -padding {8 4} -anchor center\n\n        ttk::style element create Toolbutton.button image \\\n            [list $images(empty) \\\n                {selected disabled} $images(button-disabled) \\\n                selected $images(button-rest) \\\n                pressed $images(button-pressed) \\\n                active $images(button-hover) \\\n            ] -border 4 -sticky nsew\n\n        # Menubutton\n        ttk::style configure TMenubutton -padding {8 4 0 4}\n\n        ttk::style element create Menubutton.button \\\n            image [list $images(button-rest) \\\n                disabled $images(button-disabled) \\\n                pressed $images(button-pressed) \\\n                active $images(button-hover) \\\n            ] -border 4 -sticky nsew \n\n        ttk::style element create Menubutton.indicator image $images(arrow-down) -width 28 -sticky {}\n\n        # OptionMenu\n        ttk::style configure TOptionMenu -padding {8 4 0 4}\n\n        ttk::style element create OptionMenu.button \\\n            image [list $images(button-rest) \\\n                disabled $images(button-disabled) \\\n                pressed $images(button-pressed) \\\n                active $images(button-hover) \\\n            ] -border 4 -sticky nsew \n\n        ttk::style element create OptionMenu.indicator image $images(arrow-down) -width 28 -sticky {}\n\n        # Accent.TButton\n        ttk::style configure Accent.TButton -padding {8 4} -anchor center -foreground #000000\n\n        ttk::style map Accent.TButton -foreground \\\n            [list pressed #25536a \\\n                disabled #a5a5a5]\n\n        ttk::style element create AccentButton.button image \\\n            [list $images(button-accent-rest) \\\n                {selected disabled} $images(button-accent-disabled) \\\n                disabled $images(button-accent-disabled) \\\n                selected $images(button-accent-rest) \\\n                pressed $images(button-accent-pressed) \\\n                active $images(button-accent-hover) \\\n            ] -border 4 -sticky nsew\n\n        # Checkbutton\n        ttk::style configure TCheckbutton -padding 4\n\n        ttk::style element create Checkbutton.indicator image \\\n            [list $images(check-unsel-rest) \\\n                {alternate disabled} $images(check-tri-disabled) \\\n                {selected disabled} $images(check-disabled) \\\n                disabled $images(check-unsel-disabled) \\\n                {pressed alternate} $images(check-tri-hover) \\\n                {active alternate} $images(check-tri-hover) \\\n                alternate $images(check-tri-rest) \\\n                {pressed selected} $images(check-hover) \\\n                {active selected} $images(check-hover) \\\n                selected $images(check-rest) \\\n                {pressed !selected} $images(check-unsel-pressed) \\\n                active $images(check-unsel-hover) \\\n            ] -width 26 -sticky w\n\n        # Switch.TCheckbutton\n        ttk::style element create Switch.indicator image \\\n            [list $images(switch-off-rest) \\\n                {selected disabled} $images(switch-on-disabled) \\\n                disabled $images(switch-off-disabled) \\\n                {pressed selected} $images(switch-on-pressed) \\\n                {active selected} $images(switch-on-hover) \\\n                selected $images(switch-on-rest) \\\n                {pressed !selected} $images(switch-off-pressed) \\\n                active $images(switch-off-hover) \\\n            ] -width 46 -sticky w\n\n        # Toggle.TButton\n        ttk::style configure Toggle.TButton -padding {8 4 8 4} -anchor center -foreground $colors(-fg)\n\n        ttk::style map Toggle.TButton -foreground \\\n            [list {selected disabled} #a5a5a5 \\\n                {selected pressed} #d0d0d0 \\\n                selected #000000 \\\n                pressed #25536a \\\n                disabled #7a7a7a\n            ]\n\n        ttk::style element create ToggleButton.button image \\\n            [list $images(button-rest) \\\n                {selected disabled} $images(button-accent-disabled) \\\n                disabled $images(button-disabled) \\\n                {pressed selected} $images(button-rest) \\\n                {active selected} $images(button-accent-hover) \\\n                selected $images(button-accent-rest) \\\n                {pressed !selected} $images(button-accent-rest) \\\n                active $images(button-hover) \\\n            ] -border 4 -sticky nsew\n\n        # Radiobutton\n        ttk::style configure TRadiobutton -padding 4\n\n        ttk::style element create Radiobutton.indicator image \\\n            [list $images(radio-unsel-rest) \\\n                {selected disabled} $images(radio-disabled) \\\n                disabled $images(radio-unsel-disabled) \\\n                {pressed selected} $images(radio-pressed) \\\n                {active selected} $images(radio-hover) \\\n                selected $images(radio-rest) \\\n                {pressed !selected} $images(radio-unsel-pressed) \\\n                active $images(radio-unsel-hover) \\\n            ] -width 26 -sticky w\n\n        # Scrollbar\n        ttk::style element create Horizontal.Scrollbar.trough image $images(scroll-hor-trough) -sticky ew -border 6\n        ttk::style element create Horizontal.Scrollbar.thumb image $images(scroll-hor-thumb) -sticky ew -border 3\n\n        ttk::style element create Horizontal.Scrollbar.rightarrow image $images(scroll-right) -sticky {} -width 12\n        ttk::style element create Horizontal.Scrollbar.leftarrow image $images(scroll-left) -sticky {} -width 12\n\n        ttk::style element create Vertical.Scrollbar.trough image $images(scroll-vert-trough) -sticky ns -border 6\n        ttk::style element create Vertical.Scrollbar.thumb image $images(scroll-vert-thumb) -sticky ns -border 3\n\n        ttk::style element create Vertical.Scrollbar.uparrow image $images(scroll-up) -sticky {} -height 12\n        ttk::style element create Vertical.Scrollbar.downarrow image $images(scroll-down) -sticky {} -height 12\n\n        # Scale\n        ttk::style element create Horizontal.Scale.trough image $images(scale-trough-hor) \\\n            -border 5 -padding 0\n\n        ttk::style element create Vertical.Scale.trough image $images(scale-trough-vert) \\\n            -border 5 -padding 0\n\n        ttk::style element create Scale.slider \\\n            image [list $images(scale-thumb-rest) \\\n                disabled $images(scale-thumb-disabled) \\\n                pressed $images(scale-thumb-pressed) \\\n                active $images(scale-thumb-hover) \\\n            ] -sticky {}\n\n        # Progressbar\n        ttk::style element create Horizontal.Progressbar.trough image $images(progress-trough-hor) \\\n            -border 1 -sticky ew\n\n        ttk::style element create Horizontal.Progressbar.pbar image $images(progress-pbar-hor) \\\n            -border 2 -sticky ew\n\n        ttk::style element create Vertical.Progressbar.trough image $images(progress-trough-vert) \\\n            -border 1 -sticky ns\n\n        ttk::style element create Vertical.Progressbar.pbar image $images(progress-pbar-vert) \\\n            -border 2 -sticky ns\n\n        # Entry\n        ttk::style configure TEntry -foreground $colors(-fg)\n\n        ttk::style map TEntry -foreground \\\n            [list disabled #757575 \\\n                pressed #cfcfcf\n            ]\n\n        ttk::style element create Entry.field \\\n            image [list $images(entry-rest) \\\n                {focus hover !invalid} $images(entry-focus) \\\n                invalid $images(entry-invalid) \\\n                disabled $images(entry-disabled) \\\n                {focus !invalid} $images(entry-focus) \\\n                hover $images(entry-hover) \\\n            ] -border 5 -padding 8 -sticky nsew\n\n        # Combobox\n        ttk::style configure TCombobox -foreground $colors(-fg)\n\n        ttk::style map TCombobox -foreground \\\n            [list disabled #757575 \\\n                pressed #cfcfcf\n            ]\n\n        ttk::style configure ComboboxPopdownFrame -borderwidth 1 -relief solid\n\n        ttk::style map TCombobox -selectbackground [list \\\n            {readonly hover} $colors(-selectbg) \\\n            {readonly focus} $colors(-selectbg) \\\n        ] -selectforeground [list \\\n            {readonly hover} $colors(-selectfg) \\\n            {readonly focus} $colors(-selectfg) \\\n        ]\n\n        ttk::style element create Combobox.field \\\n            image [list $images(entry-rest) \\\n                {readonly disabled} $images(button-disabled) \\\n                {readonly pressed} $images(button-pressed) \\\n                {readonly hover} $images(button-hover) \\\n                readonly $images(button-rest) \\\n                invalid $images(entry-invalid) \\\n                disabled $images(entry-disabled) \\\n                focus $images(entry-focus) \\\n                hover $images(entry-hover) \\\n            ] -border 5 -padding {8 8 28 8}\n            \n        ttk::style element create Combobox.arrow image $images(arrow-down) -width 35 -sticky {}\n\n        # Spinbox\n        ttk::style configure TSpinbox -foreground $colors(-fg)\n\n        ttk::style map TSpinbox -foreground \\\n            [list disabled #757575 \\\n                pressed #cfcfcf\n            ]\n\n        ttk::style element create Spinbox.field \\\n            image [list $images(entry-rest) \\\n                invalid $images(entry-invalid) \\\n                disabled $images(entry-disabled) \\\n                focus $images(entry-focus) \\\n                hover $images(entry-hover) \\\n            ] -border 5 -padding {8 8 54 8} -sticky nsew\n\n        ttk::style element create Spinbox.uparrow image $images(arrow-up) -width 35 -sticky {}\n        ttk::style element create Spinbox.downarrow image $images(arrow-down) -width 35 -sticky {}\n\n        # Sizegrip\n        ttk::style element create Sizegrip.sizegrip image $images(sizegrip) \\\n            -sticky nsew\n\n        # Separator\n        ttk::style element create TSeparator.separator image $images(separator)\n\n        # Card\n        ttk::style element create Card.field image $images(card) \\\n            -border 10 -padding 4 -sticky nsew\n\n        # Labelframe\n        ttk::style element create Labelframe.border image $images(card) \\\n            -border 5 -padding 4 -sticky nsew\n        \n        # Notebook\n        ttk::style configure TNotebook -padding 1\n\n        ttk::style element create Notebook.border \\\n            image $images(notebook-border) -border 5 -padding 5\n\n        ttk::style element create Notebook.client image $images(notebook)\n\n        ttk::style element create Notebook.tab \\\n            image [list $images(tab-rest) \\\n                selected $images(tab-selected) \\\n                active $images(tab-hover) \\\n            ] -border 13 -padding {16 14 16 6} -height 32\n\n        # Treeview\n        ttk::style element create Treeview.field image $images(card) \\\n            -border 5\n\n        ttk::style element create Treeheading.cell \\\n            image [list $images(treeheading-rest) \\\n                pressed $images(treeheading-pressed) \\\n                active $images(treeheading-hover)\n            ] -border 5 -padding 15 -sticky nsew\n        \n        ttk::style element create Treeitem.indicator \\\n            image [list $images(arrow-right) \\\n                user2 $images(empty) \\\n                user1 $images(arrow-down) \\\n            ] -width 26 -sticky {}\n\n        ttk::style configure Treeview -background $colors(-bg) -rowheight [expr {[font metrics font -linespace] + 2}]\n        ttk::style map Treeview \\\n            -background [list selected #292929] \\\n            -foreground [list selected $colors(-selectfg)]\n\n        # Panedwindow\n        # Insane hack to remove clam's ugly sash\n        ttk::style configure Sash -gripcount 0\n    }\n}"
  },
  {
    "path": "src/theme/light.tcl",
    "content": "# Copyright © 2021 rdbende <rdbende@gmail.com>\n\n# A stunning light theme for ttk based on Microsoft's Sun Valley visual style \n\npackage require Tk 8.6\n\nnamespace eval ttk::theme::sun-valley-light {\n    variable version 1.0\n    package provide ttk::theme::sun-valley-light $version\n\n    ttk::style theme create sun-valley-light -parent clam -settings {\n        proc load_images {imgdir} {\n            variable images\n            foreach file [glob -directory $imgdir *.png] {\n                set images([file tail [file rootname $file]]) \\\n                [image create photo -file $file -format png]\n            }\n        }\n\n        load_images [file join [file dirname [info script]] light]\n\n        array set colors {\n            -fg             \"#202020\"\n            -bg             \"#fafafa\"\n            -disabledfg     \"#a0a0a0\"\n            -selectfg       \"#ffffff\"\n            -selectbg       \"#2f60d8\"\n        }\n\n        ttk::style layout TButton {\n            Button.button -children {\n                Button.padding -children {\n                    Button.label -side left -expand 1\n                } \n            }\n        }\n\n        ttk::style layout Toolbutton {\n            Toolbutton.button -children {\n                Toolbutton.padding -children {\n                    Toolbutton.label -side left -expand 1\n                } \n            }\n        }\n\n        ttk::style layout TMenubutton {\n            Menubutton.button -children {\n                Menubutton.padding -children {\n                    Menubutton.label -side left -expand 1\n                    Menubutton.indicator -side right -sticky nsew\n                }\n            }\n        }\n\n        ttk::style layout TOptionMenu {\n            OptionMenu.button -children {\n                OptionMenu.padding -children {\n                    OptionMenu.label -side left -expand 1\n                    OptionMenu.indicator -side right -sticky nsew\n                }\n            }\n        }\n\n        ttk::style layout Accent.TButton {\n            AccentButton.button -children {\n                AccentButton.padding -children {\n                    AccentButton.label -side left -expand 1\n                } \n            }\n        }\n\n        ttk::style layout TCheckbutton {\n            Checkbutton.button -children {\n                Checkbutton.padding -children {\n                    Checkbutton.indicator -side left\n                    Checkbutton.label -side right -expand 1\n                }\n            }\n        }\n\n        ttk::style layout Switch.TCheckbutton {\n            Switch.button -children {\n                Switch.padding -children {\n                    Switch.indicator -side left\n                    Switch.label -side right -expand 1\n                }\n            }\n        }\n\n        ttk::style layout Toggle.TButton {\n            ToggleButton.button -children {\n                ToggleButton.padding -children {\n                    ToggleButton.label -side left -expand 1\n                } \n            }\n        }\n\n        ttk::style layout TRadiobutton {\n            Radiobutton.button -children {\n                Radiobutton.padding -children {\n                    Radiobutton.indicator -side left\n                    Radiobutton.label -side right -expand 1\n                }\n            }\n        }\n\n        ttk::style layout Vertical.TScrollbar {\n            Vertical.Scrollbar.trough -sticky ns -children {\n                Vertical.Scrollbar.uparrow -side top\n                Vertical.Scrollbar.downarrow -side bottom\n                Vertical.Scrollbar.thumb -expand 1\n            }\n        }\n\n        ttk::style layout Horizontal.TScrollbar {\n            Horizontal.Scrollbar.trough -sticky ew -children {\n                Horizontal.Scrollbar.leftarrow -side left\n                Horizontal.Scrollbar.rightarrow -side right\n                Horizontal.Scrollbar.thumb -expand 1\n            }\n        }\n\n        ttk::style layout TSeparator {\n            TSeparator.separator -sticky nsew\n        }\n\n        ttk::style layout TCombobox {\n            Combobox.field -sticky nsew -children {\n                Combobox.padding -expand 1 -sticky nsew -children {\n                    Combobox.textarea -sticky nsew\n                }\n            }\n            null -side right -sticky ns -children {\n                Combobox.arrow -sticky nsew\n            }\n        }\n        \n        ttk::style layout TSpinbox {\n            Spinbox.field -sticky nsew -children {\n                Spinbox.padding -expand 1 -sticky nsew -children {\n                    Spinbox.textarea -sticky nsew\n                }\n                \n            }\n            null -side right -sticky nsew -children {\n                Spinbox.uparrow -side left -sticky nsew\n                Spinbox.downarrow -side right -sticky nsew\n            }\n        }  \n        \n        ttk::style layout Card.TFrame {\n            Card.field {\n                Card.padding -expand 1 \n            }\n        }\n\n        ttk::style layout TLabelframe {\n            Labelframe.border {\n                Labelframe.padding -expand 1 -children {\n                    Labelframe.label -side left\n                }\n            }\n        }\n\n        ttk::style layout TNotebook {\n            Notebook.border -children {\n                TNotebook.Tab -expand 1\n                Notebook.client -sticky nsew\n            }\n        }\n\n        ttk::style layout Treeview.Item {\n            Treeitem.padding -sticky nsew -children {\n                Treeitem.image -side left -sticky {}\n                Treeitem.indicator -side left -sticky {}\n                Treeitem.text -side left -sticky {}\n            }\n        }\n\n        # Button\n        ttk::style configure TButton -padding {8 4} -anchor center -foreground $colors(-fg)\n\n        ttk::style map TButton -foreground \\\n            [list disabled #a2a2a2 \\\n                pressed #636363 \\\n                active #1a1a1a]\n\n        ttk::style element create Button.button image \\\n            [list $images(button-rest) \\\n                {selected disabled} $images(button-disabled) \\\n                disabled $images(button-disabled) \\\n                selected $images(button-rest) \\\n                pressed $images(button-pressed) \\\n                active $images(button-hover) \\\n            ] -border 4 -sticky nsew\n\n        # Toolbutton\n        ttk::style configure Toolbutton -padding {8 4} -anchor center\n\n        ttk::style element create Toolbutton.button image \\\n            [list $images(empty) \\\n                {selected disabled} $images(button-disabled) \\\n                selected $images(button-rest) \\\n                pressed $images(button-pressed) \\\n                active $images(button-hover) \\\n            ] -border 4 -sticky nsew\n\n        # Menubutton\n        ttk::style configure TMenubutton -padding {8 4 0 4}\n\n        ttk::style element create Menubutton.button \\\n            image [list $images(button-rest) \\\n                disabled $images(button-disabled) \\\n                pressed $images(button-pressed) \\\n                active $images(button-hover) \\\n            ] -border 4 -sticky nsew \n\n        ttk::style element create Menubutton.indicator image $images(arrow-down) -width 28 -sticky {}\n\n        # OptionMenu\n        ttk::style configure TOptionMenu -padding {8 4 0 4}\n\n        ttk::style element create OptionMenu.button \\\n            image [list $images(button-rest) \\\n                disabled $images(button-disabled) \\\n                pressed $images(button-pressed) \\\n                active $images(button-hover) \\\n            ] -border 4 -sticky nsew \n\n        ttk::style element create OptionMenu.indicator image $images(arrow-down) -width 28 -sticky {}\n\n        # Accent.TButton\n        ttk::style configure Accent.TButton -padding {8 4} -anchor center -foreground #ffffff\n\n        ttk::style map Accent.TButton -foreground \\\n            [list disabled #ffffff \\\n                pressed #c1d8ee]\n\n        ttk::style element create AccentButton.button image \\\n            [list $images(button-accent-rest) \\\n                {selected disabled} $images(button-accent-disabled) \\\n                disabled $images(button-accent-disabled) \\\n                selected $images(button-accent-rest) \\\n                pressed $images(button-accent-pressed) \\\n                active $images(button-accent-hover) \\\n            ] -border 4 -sticky nsew\n\n        # Checkbutton\n        ttk::style configure TCheckbutton -padding 4\n\n        ttk::style element create Checkbutton.indicator image \\\n            [list $images(check-unsel-rest) \\\n                {alternate disabled} $images(check-tri-disabled) \\\n                {selected disabled} $images(check-disabled) \\\n                disabled $images(check-unsel-disabled) \\\n                {pressed alternate} $images(check-tri-hover) \\\n                {active alternate} $images(check-tri-hover) \\\n                alternate $images(check-tri-rest) \\\n                {pressed selected} $images(check-hover) \\\n                {active selected} $images(check-hover) \\\n                selected $images(check-rest) \\\n                {pressed !selected} $images(check-unsel-pressed) \\\n                active $images(check-unsel-hover) \\\n            ] -width 26 -sticky w\n\n        # Switch.TCheckbutton\n        ttk::style element create Switch.indicator image \\\n            [list $images(switch-off-rest) \\\n                {selected disabled} $images(switch-on-disabled) \\\n                disabled $images(switch-off-disabled) \\\n                {pressed selected} $images(switch-on-pressed) \\\n                {active selected} $images(switch-on-hover) \\\n                selected $images(switch-on-rest) \\\n                {pressed !selected} $images(switch-off-pressed) \\\n                active $images(switch-off-hover) \\\n            ] -width 46 -sticky w\n\n        # Toggle.TButton\n        ttk::style configure Toggle.TButton -padding {8 4 8 4} -anchor center -foreground $colors(-fg)\n\n        ttk::style map Toggle.TButton -foreground \\\n            [list {selected disabled} #ffffff \\\n                {selected pressed} #636363 \\\n                selected #ffffff \\\n                pressed #c1d8ee \\\n                disabled #a2a2a2 \\\n                active #1a1a1a\n            ]\n\n        ttk::style element create ToggleButton.button image \\\n            [list $images(button-rest) \\\n                {selected disabled} $images(button-accent-disabled) \\\n                disabled $images(button-disabled) \\\n                {pressed selected} $images(button-rest) \\\n                {active selected} $images(button-accent-hover) \\\n                selected $images(button-accent-rest) \\\n                {pressed !selected} $images(button-accent-rest) \\\n                active $images(button-hover) \\\n            ] -border 4 -sticky nsew\n\n        # Radiobutton\n        ttk::style configure TRadiobutton -padding 4\n\n        ttk::style element create Radiobutton.indicator image \\\n            [list $images(radio-unsel-rest) \\\n                {selected disabled} $images(radio-disabled) \\\n                disabled $images(radio-unsel-disabled) \\\n                {pressed selected} $images(radio-pressed) \\\n                {active selected} $images(radio-hover) \\\n                selected $images(radio-rest) \\\n                {pressed !selected} $images(radio-unsel-pressed) \\\n                active $images(radio-unsel-hover) \\\n            ] -width 26 -sticky w\n\n        # Scrollbar\n        ttk::style element create Horizontal.Scrollbar.trough image $images(scroll-hor-trough) -sticky ew -border 6\n        ttk::style element create Horizontal.Scrollbar.thumb image $images(scroll-hor-thumb) -sticky ew -border 3\n\n        ttk::style element create Horizontal.Scrollbar.rightarrow image $images(scroll-right) -sticky {} -width 12\n        ttk::style element create Horizontal.Scrollbar.leftarrow image $images(scroll-left) -sticky {} -width 12\n\n        ttk::style element create Vertical.Scrollbar.trough image $images(scroll-vert-trough) -sticky ns -border 6\n        ttk::style element create Vertical.Scrollbar.thumb image $images(scroll-vert-thumb) -sticky ns -border 3\n\n        ttk::style element create Vertical.Scrollbar.uparrow image $images(scroll-up) -sticky {} -height 12\n        ttk::style element create Vertical.Scrollbar.downarrow image $images(scroll-down) -sticky {} -height 12\n\n        # Scale\n        ttk::style element create Horizontal.Scale.trough image $images(scale-trough-hor) \\\n            -border 5 -padding 0\n\n        ttk::style element create Vertical.Scale.trough image $images(scale-trough-vert) \\\n            -border 5 -padding 0\n\n        ttk::style element create Scale.slider \\\n            image [list $images(scale-thumb-rest) \\\n                disabled $images(scale-thumb-disabled) \\\n                pressed $images(scale-thumb-pressed) \\\n                active $images(scale-thumb-hover) \\\n            ] -sticky {}\n\n        # Progressbar\n        ttk::style element create Horizontal.Progressbar.trough image $images(progress-trough-hor) \\\n            -border 1 -sticky ew\n\n        ttk::style element create Horizontal.Progressbar.pbar image $images(progress-pbar-hor) \\\n            -border 2 -sticky ew\n\n        ttk::style element create Vertical.Progressbar.trough image $images(progress-trough-vert) \\\n            -border 1 -sticky ns\n\n        ttk::style element create Vertical.Progressbar.pbar image $images(progress-pbar-vert) \\\n            -border 2 -sticky ns\n\n        # Entry\n        ttk::style configure TEntry -foreground $colors(-fg)\n\n        ttk::style map TEntry -foreground \\\n            [list disabled #0a0a0a \\\n                pressed #636363 \\\n                active #626262\n            ]\n\n        ttk::style element create Entry.field \\\n            image [list $images(entry-rest) \\\n                {focus hover !invalid} $images(entry-focus) \\\n                invalid $images(entry-invalid) \\\n                disabled $images(entry-disabled) \\\n                {focus !invalid} $images(entry-focus) \\\n                hover $images(entry-hover) \\\n            ] -border 5 -padding 8 -sticky nsew\n\n        # Combobox\n        ttk::style configure TCombobox -foreground $colors(-fg)\n\n        ttk::style configure ComboboxPopdownFrame -borderwidth 1 -relief solid\n\n        ttk::style map TCombobox -foreground \\\n            [list disabled #0a0a0a \\\n                pressed #636363 \\\n                active #626262\n            ]\n\n        ttk::style map TCombobox -selectbackground [list \\\n            {readonly hover} $colors(-selectbg) \\\n            {readonly focus} $colors(-selectbg) \\\n        ] -selectforeground [list \\\n            {readonly hover} $colors(-selectfg) \\\n            {readonly focus} $colors(-selectfg) \\\n        ]\n\n        ttk::style element create Combobox.field \\\n            image [list $images(entry-rest) \\\n                {readonly disabled} $images(button-disabled) \\\n                {readonly pressed} $images(button-pressed) \\\n                {readonly hover} $images(button-hover) \\\n                readonly $images(button-rest) \\\n                invalid $images(entry-invalid) \\\n                disabled $images(entry-disabled) \\\n                focus $images(entry-focus) \\\n                hover $images(entry-hover) \\\n            ] -border 5 -padding {8 8 28 8}\n            \n        ttk::style element create Combobox.arrow image $images(arrow-down) -width 35 -sticky {}\n\n        # Spinbox\n        ttk::style configure TSpinbox -foreground $colors(-fg)\n\n        ttk::style map TSpinbox -foreground \\\n            [list disabled #0a0a0a \\\n                pressed #636363 \\\n                active #626262\n            ]\n\n        ttk::style element create Spinbox.field \\\n            image [list $images(entry-rest) \\\n                invalid $images(entry-invalid) \\\n                disabled $images(entry-disabled) \\\n                focus $images(entry-focus) \\\n                hover $images(entry-hover) \\\n            ] -border 5 -padding {8 8 54 8} -sticky nsew\n\n        ttk::style element create Spinbox.uparrow image $images(arrow-up) -width 35 -sticky {}\n        ttk::style element create Spinbox.downarrow image $images(arrow-down) -width 35 -sticky {}\n\n        # Sizegrip\n        ttk::style element create Sizegrip.sizegrip image $images(sizegrip) \\\n            -sticky nsew\n\n        # Separator\n        ttk::style element create TSeparator.separator image $images(separator)\n\n        # Card\n        ttk::style element create Card.field image $images(card) \\\n            -border 10 -padding 4 -sticky nsew\n\n        # Labelframe\n        ttk::style element create Labelframe.border image $images(card) \\\n            -border 5 -padding 4 -sticky nsew\n        \n        # Notebook\n        ttk::style configure TNotebook -padding 1\n\n        ttk::style element create Notebook.border \\\n            image $images(notebook-border) -border 5 -padding 5\n\n        ttk::style element create Notebook.client image $images(notebook)\n\n        ttk::style element create Notebook.tab \\\n            image [list $images(tab-rest) \\\n                selected $images(tab-selected) \\\n                active $images(tab-hover) \\\n            ] -border 13 -padding {16 14 16 6} -height 32\n\n        # Treeview\n        ttk::style element create Treeview.field image $images(card) \\\n            -border 5\n\n        ttk::style element create Treeheading.cell \\\n            image [list $images(treeheading-rest) \\\n                pressed $images(treeheading-pressed) \\\n                active $images(treeheading-hover)\n            ] -border 5 -padding 15 -sticky nsew\n        \n        ttk::style element create Treeitem.indicator \\\n            image [list $images(arrow-right) \\\n                user2 $images(empty) \\\n                user1 $images(arrow-down) \\\n            ] -width 26 -sticky {}\n\n        ttk::style configure Treeview -foregound #1a1a1a -background $colors(-bg) -rowheight [expr {[font metrics font -linespace] + 2}]\n        ttk::style map Treeview \\\n            -background [list selected #f0f0f0] \\\n            -foreground [list selected #191919]\n\n        # Panedwindow\n        # Insane hack to remove clam's ugly sash\n        ttk::style configure Sash -gripcount 0\n    }\n}"
  },
  {
    "path": "src/theme/sun-valley.tcl",
    "content": "# Copyright © 2021 rdbende <rdbende@gmail.com>\n\nsource [file join [file dirname [info script]] light.tcl]\nsource [file join [file dirname [info script]] dark.tcl]\n\noption add *tearOff 0\n\nproc set_theme {mode} {\n\tif {$mode == \"dark\"} {\n\t\tttk::style theme use \"sun-valley-dark\"\n\n\t\tarray set colors {\n\t\t    -fg             \"#ffffff\"\n\t\t    -bg             \"#1c1c1c\"\n\t\t    -disabledfg     \"#595959\"\n\t\t    -selectfg       \"#ffffff\"\n\t\t    -selectbg       \"#2f60d8\"\n\t\t}\n\n        ttk::style configure . \\\n            -background $colors(-bg) \\\n            -foreground $colors(-fg) \\\n            -troughcolor $colors(-bg) \\\n            -focuscolor $colors(-selectbg) \\\n            -selectbackground $colors(-selectbg) \\\n            -selectforeground $colors(-selectfg) \\\n            -insertwidth 1 \\\n            -insertcolor $colors(-fg) \\\n            -fieldbackground $colors(-selectbg) \\\n            -font {\"Segoe Ui\" 10} \\\n            -borderwidth 1 \\\n            -relief flat\n\n        tk_setPalette \\\n        \tbackground [ttk::style lookup . -background] \\\n            foreground [ttk::style lookup . -foreground] \\\n            highlightColor [ttk::style lookup . -focuscolor] \\\n            selectBackground [ttk::style lookup . -selectbackground] \\\n            selectForeground [ttk::style lookup . -selectforeground] \\\n            activeBackground [ttk::style lookup . -selectbackground] \\\n            activeForeground [ttk::style lookup . -selectforeground]\n\n        ttk::style map . -foreground [list disabled $colors(-disabledfg)]\n\n        option add *font [ttk::style lookup . -font]\n        option add *Menu.selectcolor $colors(-fg)\n        option add *Menu.background #2f2f2f\n\n\t} elseif {$mode == \"light\"} {\n\t\tttk::style theme use \"sun-valley-light\"\n\n\t\tarray set colors {\n\t\t    -fg             \"#202020\"\n\t\t    -bg             \"#fafafa\"\n\t\t    -disabledfg     \"#a0a0a0\"\n\t\t    -selectfg       \"#ffffff\"\n\t\t    -selectbg       \"#2f60d8\"\n\t\t}\n\n        ttk::style configure . \\\n            -background $colors(-bg) \\\n            -foreground $colors(-fg) \\\n            -troughcolor $colors(-bg) \\\n            -focuscolor $colors(-selectbg) \\\n            -selectbackground $colors(-selectbg) \\\n            -selectforeground $colors(-selectfg) \\\n            -insertwidth 1 \\\n            -insertcolor $colors(-fg) \\\n            -fieldbackground $colors(-selectbg) \\\n            -font {\"Segoe Ui\" 10} \\\n            -borderwidth 0 \\\n            -relief flat\n\n        tk_setPalette background [ttk::style lookup . -background] \\\n            foreground [ttk::style lookup . -foreground] \\\n            highlightColor [ttk::style lookup . -focuscolor] \\\n            selectBackground [ttk::style lookup . -selectbackground] \\\n            selectForeground [ttk::style lookup . -selectforeground] \\\n            activeBackground [ttk::style lookup . -selectbackground] \\\n            activeForeground [ttk::style lookup . -selectforeground]\n\n        ttk::style map . -foreground [list disabled $colors(-disabledfg)]\n\n        option add *font [ttk::style lookup . -font]\n        option add *Menu.selectcolor $colors(-fg)\n        option add *Menu.background #e7e7e7\n\t}\n}\n"
  },
  {
    "path": "src/updater.go",
    "content": "package main\n\nimport (\n\t\"archive/zip\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"io/ioutil\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n)\n\nfunc loadSettings() map[string]interface{} {\n\tsettingsFile := \"settings.json\"\n\tloadedSettings := map[string]interface{}{\"DEBUG\": false}\n\tjsonFile, err := os.Open(settingsFile)\n\tif err == nil {\n\t\tbyteValue, err := ioutil.ReadAll(jsonFile)\n\t\tif err == nil {\n\t\t\tjson.Unmarshal(byteValue, &loadedSettings)\n\t\t}\n\t}\n\treturn loadedSettings\n}\n\nfunc main() {\n\treleasesURL := \"https://api.github.com/repos/elibroftw/music-caster/releases/latest\"\n\tex, _ := os.Executable()\n\tos.Chdir(filepath.Dir(ex))\n\t// load settings\n\n\tclient := http.Client{Timeout: time.Second * 5}\n\treq, _ := http.NewRequest(http.MethodGet, releasesURL, nil)\n\n\tres, err := client.Do(req)\n\tif err != nil {\n\t\tfmt.Println(\"Failed to make request\", err)\n\t\treturn\n\t}\n\n\tif res.Body != nil {\n\t\tdefer res.Body.Close()\n\t}\n\n\tbody, err := ioutil.ReadAll(res.Body)\n\n\tif err == nil {\n\t\tvar jsonResponse map[string]interface{}\n\t\tif json.Unmarshal(body, &jsonResponse) == nil {\n\t\t\t// if json parsing successful\n\t\t\tvar setupDownloadURL, portableDownloadURL string\n\t\t\tassets := jsonResponse[\"assets\"].([]interface{})\n\t\t\tfor _, v := range assets {\n\t\t\t\tasset := v.(map[string]interface{})\n\t\t\t\tif strings.HasSuffix(asset[\"name\"].(string), \".exe\") {\n\t\t\t\t\tsetupDownloadURL = asset[\"browser_download_url\"].(string)\n\t\t\t\t} else if strings.Contains(strings.ToLower(asset[\"name\"].(string)), \"portable\") {\n\t\t\t\t\tportableDownloadURL = asset[\"browser_download_url\"].(string)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tfmt.Println(\"Latest Version:\", jsonResponse[\"tag_name\"].(string))\n\t\t\tfmt.Println(\"Installer:      \", setupDownloadURL)\n\t\t\tfmt.Println(\"Portable:      \", portableDownloadURL)\n\n\t\t\tloadedSettings := loadSettings()\n\t\t\tif debugSetting, ok := loadedSettings[\"DEBUG\"].(bool); ok && debugSetting {\n\t\t\t\t// don't download anything\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tfile, err := os.Open(\"unins000.exe\")\n\t\t\tfile.Close()\n\t\t\tif errors.Is(err, os.ErrNotExist) { // portable or linux? installation\n\t\t\t\t// TODO: Linux support\n\t\t\t\tdownload(portableDownloadURL, \"Portable.zip\")\n\t\t\t\texec.Command(\"Music Caster\", \"--nupdate\").Start()\n\t\t\t} else {\n\t\t\t\t// installer exists\n\t\t\t\tdownload(setupDownloadURL, \"MC_Installer.exe\")\n\t\t\t\texec.Command(\"MC_Installer.exe\", `/VERYSILENT /CLOSEAPPLICATIONS /FORCECLOSEAPPLICATIONS /MERGETASKS=\"!desktopicon\"`)\n\t\t\t}\n\t\t}\n\t}\n\n}\n\nfunc extractZip(src string) error {\n\t// https://golangcode.com/unzip-files-in-go/\n\tvar filenames []string\n\n\tr, err := zip.OpenReader(src)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer r.Close()\n\n\tsrc, _ = filepath.Abs(src)\n\tdest := filepath.Dir(src)\n\tfmt.Println(\"Extracting\", src, \"to\", dest)\n\tfor _, f := range r.File {\n\n\t\t// Store filename/path for returning and using later on\n\t\tfpath := filepath.Join(dest, f.Name)\n\n\t\t// Check for ZipSlip. More Info: http://bit.ly/2MsjAWE\n\t\tif !strings.HasPrefix(fpath, filepath.Clean(dest)+string(os.PathSeparator)) {\n\t\t\treturn fmt.Errorf(\"%s: illegal file path\", fpath)\n\t\t}\n\n\t\tfilenames = append(filenames, fpath)\n\n\t\tif f.FileInfo().IsDir() {\n\t\t\t// Make Folder\n\t\t\tos.MkdirAll(fpath, os.ModePerm)\n\t\t\tcontinue\n\t\t}\n\n\t\t// Make File\n\t\tif err = os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\toutFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())\n\t\tif err == nil {\n\t\t\trc, err := f.Open()\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\t_, err = io.Copy(outFile, rc)\n\n\t\t\t// Close the file without defer to close before next iteration of loop\n\t\t\toutFile.Close()\n\t\t\trc.Close()\n\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\t// delete file\n    r.Close()\n\terr = os.Remove(src)\n\treturn err\n}\n\nfunc download(url string, filepath string) {\n\t// https://golangcode.com/download-a-file-from-a-url/\n\tfmt.Println(\"Downloading\", filepath)\n\n\tresp, err := http.Get(url)\n\tif err != nil {\n\t\treturn\n\t}\n\tdefer resp.Body.Close()\n\n\t// Create the file\n\tout, err := os.Create(filepath)\n\tif err != nil {\n\t\treturn\n\t}\n\n\t// Write the body to file\n\t_, err = io.Copy(out, resp.Body)\n    out.Close()\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\tif strings.HasSuffix(filepath, \".zip\") {\n\t\textractZip(filepath)\n\t}\n}\n\n// go build -ldflags \"-w -s\"\n"
  },
  {
    "path": "src/utils.py",
    "content": "# flake8: noqa: E402\nimport audioop\nimport base64\nimport ctypes\nimport glob\nimport io\nimport locale\nimport logging\nimport os\nimport platform\nimport re\nimport shutil\nimport socket\nimport subprocess\nimport sys\nimport tarfile\nimport tempfile\nimport time\nfrom typing import Tuple\nimport unicodedata\nimport webbrowser\nfrom base64 import b64decode, b64encode\nfrom contextlib import suppress\nfrom functools import lru_cache, wraps\nfrom itertools import chain, cycle, repeat\nfrom math import floor\nfrom pathlib import Path\nfrom queue import Empty, LifoQueue\nfrom random import getrandbits\nfrom subprocess import DEVNULL, PIPE, CalledProcessError, Popen, check_output\nfrom threading import Thread\nfrom urllib.parse import parse_qs, urlencode, urlparse\nfrom uuid import getnode\nfrom zipfile import ZipFile\n\n# 3rd party imports\nimport deemix.utils.localpaths as __lp\n\n# local imports\nfrom b64_images import DEFAULT_ART, REPEAT_ALL_IMG, REPEAT_OFF_IMG, REPEAT_ONE_IMG\nfrom deezer import TrackFormats\n\n__lp.musicdata = '/dz'\nimport mutagen\nimport mutagen._file\nimport mutagen.flac\nimport mutagen.id3\nimport pyaudio\nimport pypresence\nimport requests\nfrom meta import AUDIO_EXTS, AUDIO_HANDLER_EXTS, COVER_NORMAL, USER_AGENT, State\nfrom mutagen._util import MutagenError\nfrom mutagen.aac import AAC\nfrom mutagen.id3._util import ID3NoHeaderError\nfrom mutagen.mp3 import MP3, EasyMP3, HeaderNotFoundError\nfrom mutagen.mp4 import MP4, MP4Cover\nfrom mutagen.oggopus import OggOpus\nfrom mutagen.oggvorbis import OggVorbis\nfrom mutagen.wave import WAVE\nfrom PIL import Image, ImageDraw, ImageFile, ImageFont, UnidentifiedImageError\nfrom pychromecast import CastInfo\nfrom wavinfo import WavInfoEOFError, WavInfoReader  # until mutagen supports .wav\nfrom youtube_comment_downloader import SORT_BY_POPULAR, YoutubeCommentDownloader\n\n# CONSTANTS\nIS_FROZEN = getattr(sys, 'frozen', False)\nImageFile.LOAD_TRUNCATED_IMAGES = True\nyt_comment_downloader = YoutubeCommentDownloader()\nSPOTIFY_API = 'https://api.spotify.com/v1'\n# for stealing focus when bring window to front\n\nclass SystemAudioRecorder:\n\n    __slots__ = 'STREAM_CHUNK', 'BITS_PER_SAMPLE', 'pa', 'sample_rate', 'channels', 'alive', 'data_stream', 'lag'\n\n    def __init__(self):\n        self.STREAM_CHUNK = 1024\n        self.BITS_PER_SAMPLE = 16\n        self.pa = None\n        self.sample_rate = None\n        self.channels = None\n        self.alive = False\n        self.lag = 0.0\n        self.data_stream = LifoQueue()\n\n    def get_audio_data(self, delay=0):\n        if not self.alive:\n            return  # ensure that start() was called\n        silent_wav = b'\\x00' * self.STREAM_CHUNK\n        yield self.get_wav_header()\n        yield silent_wav * delay * 1000\n        last_sleep = time.time() + 1\n        while self.alive:\n            if self.lag and time.time() - last_sleep > 1:\n                sleep_for = min(0.2, self.lag)  # sleep for max 0.2 seconds at a time\n                self.lag -= sleep_for\n                time.sleep(sleep_for)\n                last_sleep = time.time()\n            try:\n                t1 = time.time()\n                yield self.data_stream.get(timeout=0.09)\n                t2 = time.time() - t1 - 0.05\n                if t2 > 0:\n                    # account for lag if chunk was recorded in late\n                    self.lag = t2\n                self.data_stream.task_done()\n                # discard old data\n                with suppress(Empty):\n                    while True:\n                        self.data_stream.get(False)\n                        self.data_stream.task_done()\n            except Empty:\n                yield silent_wav\n\n    def _start_recording(self):\n        if self.alive:\n            return\n        self.alive = True\n        selected_device = get_default_output_device()\n        stream = self.create_stream(selected_device)\n        for chunk in iter(lambda: audioop.mul(stream.read(self.STREAM_CHUNK), 2, 2) if self.alive else None, None):\n            self.data_stream.put(chunk)\n            default_output = get_default_output_device()  # check if output device has changed\n            if selected_device != default_output:\n                selected_device = default_output\n                stream.close()\n                stream = self.create_stream(selected_device)\n\n    def create_stream(self, output_device):\n        for i in range(self.pa.get_device_count()):\n            device_info = self.pa.get_device_info_by_index(i)\n            host_api_info = self.pa.get_host_api_info_by_index(device_info['hostApi'])\n            if (host_api_info['name'] == 'Windows WASAPI' and device_info['maxOutputChannels'] > 0\n                    and device_info['name'] == output_device):\n                self.channels = min(device_info['maxOutputChannels'], 2)\n                self.sample_rate = int(device_info['defaultSampleRate'])  # e.g. 48,000 bits\n                return self.pa.open(format=pyaudio.paInt16, input=True, as_loopback=True, channels=self.channels,\n                                    input_device_index=device_info['index'], rate=self.sample_rate,\n                                    frames_per_buffer=self.STREAM_CHUNK)\n        raise RuntimeError('Default Output Device Not Found')\n\n    def get_wav_header(self):\n        data_size = 2000 * 10 ** 6\n        o = bytes('RIFF', 'ascii')  # 4 bytes Marks file as RIFF\n        o += (data_size + 36).to_bytes(4, 'little')  # (4 bytes) File size in bytes excluding this and RIFF marker\n        o += bytes('WAVE', 'ascii')  # 4 bytes File type\n        o += bytes('fmt ', 'ascii')  # 4 bytes Format Chunk Marker\n        o += (16).to_bytes(4, 'little')  # 4 bytes Length of above format data\n        o += (1).to_bytes(2, 'little')  # 2 bytes Format type (1 - PCM)\n        o += self.channels.to_bytes(2, 'little')  # 2 bytes\n        o += self.sample_rate.to_bytes(4, 'little')  # 4 bytes\n        o += (self.sample_rate * self.channels * self.BITS_PER_SAMPLE // 8).to_bytes(4, 'little')  # 4 bytes\n        o += (self.channels * self.BITS_PER_SAMPLE // 8).to_bytes(2, 'little')  # 2 bytes\n        o += self.BITS_PER_SAMPLE.to_bytes(2, 'little')  # 2 bytes\n        o += bytes('data', 'ascii')  # 4 bytes Data Chunk Marker\n        o += data_size.to_bytes(4, 'little')  # 4 bytes Data size in bytes\n        return o\n\n    def stop(self):\n        self.alive = False\n\n    def start(self):\n        if platform.system() == 'Windows':\n            if not self.alive:\n                if self.pa is None:\n                    self.pa = pyaudio.PyAudio()\n                # initialization process takes ~0.2 seconds\n                Thread(target=self._start_recording, name='SystemAudioRecorder', daemon=True).start()\n        else:\n            print('TODO: SystemAudioRecorder')\n\n\nclass InvalidAudioFile(Exception):\n    pass\n\n\nclass Unknown(str):\n    __slots__ = 'property'\n\n    def __new__(cls, _property):\n        obj = super(Unknown, cls).__new__(cls)\n        obj.property = _property\n        return obj\n\n    def __repr__(self):\n        return t(f'Unknown {self.property}')\n\n    def __str__(self):\n        return self.__repr__()\n\n    def __lt__(self, other):\n        return str(self).__lt__(other)\n\n    def __le__(self, other):\n        return str(self).__le__(other)\n\n    def __gt__(self, other):\n        return str(self).__gt__(other)\n\n    def __ge__(self, other):\n        return str(self).__ge__(other)\n\n    def __eq__(self, other):\n        return str(other) == str(self)\n\n    def __ne__(self, other):\n        return not self.__eq__(str(other))\n\n    def split(self, *args, **kwargs):\n        return str(self).split(*args, **kwargs)\n\n    def __len__(self):\n        return len(str(self))\n\n\ndef exception_wrapper(f):\n    @wraps(f)\n    def wrapper(*args, **kwargs):\n        try:\n            f(*args, **kwargs)\n        except Exception as e:\n            print(f'Handled exception in {f.__name__}:', e)\n    return wrapper\n\n\nclass DiscordPresence:\n    \"\"\"\n    Exception safe wrapper for pypresence\n    \"\"\"\n    rich_presence: pypresence.Presence = None\n    MUSIC_CASTER_DISCORD_ID = '696092874902863932'\n\n    @classmethod\n    @exception_wrapper\n    def init_rpc(cls):\n        if cls.rich_presence is None:\n            cls.rich_presence = pypresence.Presence(cls.MUSIC_CASTER_DISCORD_ID, timeout=2)\n\n    @classmethod\n    @exception_wrapper\n    def connect(cls, confirm_connect=True):\n        if confirm_connect:\n            cls.init_rpc()\n            cls.rich_presence.connect()\n\n    @classmethod\n    @exception_wrapper\n    def update(cls, state: str, details: str, large_text: str, end: int = 0,\n               large_image='default', small_image='logo', small_text='Music Caster', confirm_connect=True):\n        if confirm_connect:\n            cls.init_rpc()\n            cls.rich_presence.update(state=state, details=details, large_image=large_image, large_text=large_text,\n                                     small_image=small_image, small_text=small_text)\n\n    @classmethod\n    @exception_wrapper\n    def clear(cls, confirm=True):\n        if confirm:\n            cls.rich_presence.clear()\n\n    @classmethod\n    @exception_wrapper\n    def close(cls):\n        if cls.rich_presence is not None:\n            cls.rich_presence.close()\n\n\n# friendly interface to create system tray menus out of local device or cast device\n#   or in the future, other wireless devices\n# otherwise would have to write lots of conditionals to make things work smoothly\n# TODO: should be interloped with playback functionalities as well to abstract that PITA\nclass Device:\n    CHECK_MARK = '✓'\n\n    def __init__(self, cast_info_or_none=None):\n        self.__device = cast_info_or_none\n        self.is_cast_info = isinstance(self.__device, CastInfo)\n        self.is_local_device = not self.is_cast_info\n\n    @property\n    def id(self):\n        return str(self.__device.uuid) if isinstance(self.__device, CastInfo) else None\n\n    @classmethod\n    def LOCAL_DEVICE(cls):\n        return t('Local device')\n\n    @property\n    def name(self):\n        if isinstance(self.__device, CastInfo):\n            return self.__device.friendly_name\n        return self.LOCAL_DEVICE()\n\n    def as_tray_name(self, active_id):\n        if active_id == self.id:\n            return f'{self.CHECK_MARK} {self.name}'\n        return f'    {self.name}'\n\n    @property\n    def tray_key(self):\n        return f'device:{self.id}' if self.is_cast_info else 'device:0'\n\n    @property\n    def gui_key(self):\n        return f'device::{self.id}' if self.is_cast_info else 'device::0'\n\n    def as_tray_item(self, active_id) -> tuple:\n        return self.as_tray_name(active_id), self.tray_key\n\n    def __eq__(self, other):\n        return self.id == other.id\n\n    def __str__(self):\n        return self.name\n\n    def __repr__(self):\n        return f'Device(id={self.id}, name={self.name})'\n\n\ndef get_file_name(file_path): return Path(file_path).stem\n\n\n# decorators\ndef timing(f):\n    @wraps(f)\n    def wrapper(*args, **kwargs):\n        _start = time.monotonic()\n        result = f(*args, **kwargs)\n        print(f'@timing {f.__name__} = {result} ELAPSED TIME:', time.monotonic() - _start)\n        return result\n    return wrapper\n\n\ndef time_cache(max_age, maxsize=None, typed=False):\n    \"\"\"Least-recently-used cache decorator with time-based cache invalidation.\n    max_age: Time to live for cached results (in seconds).\n    maxsize: Maximum cache size (see `functools.lru_cache`).\n    typed: Cache on distinct input types (see `functools.lru_cache`).\"\"\"\n    def _decorator(fn):\n        @lru_cache(maxsize=maxsize, typed=typed)\n        def _new(*args, __time_salt, **kwargs):\n            return fn(*args, **kwargs)\n\n        @wraps(fn)\n        def _wrapped(*args, **kwargs):\n            return _new(*args, **kwargs, __time_salt=int(time.time() / max_age))\n        return _wrapped\n    return _decorator\n\n\ntry:\n    LANGUAGES_FOLDER = f'{sys._MEIPASS}/languages'\nexcept AttributeError:\n    if os.path.exists('src/languages'):\n        print('WARNING 345:  application not running in src directory')\n        LANGUAGES_FOLDER = 'src/languages'\n    else:\n        LANGUAGES_FOLDER = 'languages'\n\n\n@lru_cache(maxsize=1)\ndef get_languages():\n    return list(chain([''], (get_file_name(lang) for lang in glob.iglob(f'{glob.escape(LANGUAGES_FOLDER)}/*.txt'))))\n\n\n@lru_cache(maxsize=3)\ndef get_lang_pack(lang):\n    # fails if not in src directory\n    en_lang_pack, other_lang_pack = {}, []\n    with open(f'{LANGUAGES_FOLDER}/{lang}.txt', encoding='utf-8') as f:\n        i = 0\n        line = f.readline().strip()\n        while line:\n            if not line.startswith('#'):\n                if lang == 'en':\n                    en_lang_pack[line] = i\n                else:\n                    other_lang_pack.append(line)\n                i += 1\n            line = f.readline().strip()\n    return en_lang_pack if lang == 'en' else other_lang_pack\n\n\ndef get_display_lang():\n    if platform.system() == 'Windows':\n        kernal32 = ctypes.windll.kernel32\n        return locale.windows_locale[kernal32.GetUserDefaultUILanguage()].split('_', 1)[0]\n    else:\n        return os.environ['LANG'].split('_', 1)[0]\n\n\n@lru_cache\ndef log_translation_error(string, lang):\n    log = logging.getLogger('music_caster')\n    log.error(f'failed to translate `{string}` to {lang}', exc_info=True)\n\n\ndef get_translation(string, lang='', as_title=False):\n    \"\"\" Translates string from English to lang or display language if valid\n    :param string: English string\n    :param lang: Optional code to translate to. Defaults to using display language\n    :param as_title: The phrase returned has each word capitalized\n    :return: string translated to display language \"\"\"\n    try:\n        string = get_lang_pack(lang or get_display_lang())[get_lang_pack('en')[string]]\n    except (IndexError, KeyError, FileNotFoundError):\n        if lang != 'en' and lang != '':\n            log_translation_error(string, lang)\n    if as_title:\n        string = ' '.join(word[0].upper() + word[1:] for word in string.split())\n    return string\n\n\ndef t(string, as_title=False):\n    return get_translation(string, lang=State.lang, as_title=as_title)\n\n\ndef natural_key_file(filename):\n    filename = unicodedata.normalize('NFKD', get_file_name(filename).casefold())\n    filename = u''.join([c for c in filename if not unicodedata.combining(c)])\n    return [int(s) if s.isdigit() else s for s in re.split(r'(\\d+)', filename)]\n\n\ndef valid_color_code(code):\n    match = re.search(r'^#(?:[\\da-fA-F]{3}){1,2}$', code)\n    return match\n\n\ndef get_audio_length(file_path) -> int:\n    \"\"\" throws InvalidAudioFile if file is invalid\n    :param file_path:\n    :return: length in seconds \"\"\"\n    try:\n        if file_path.casefold().endswith('.wav'):\n            a = WavInfoReader(file_path)\n            length = a.data.frame_count / a.fmt.sample_rate # type:ignore\n        elif file_path.casefold().endswith('.wma'):\n            try:\n                audio_info = mutagen.File(file_path).info  # type:ignore\n                length = audio_info.length\n            except AttributeError:\n                audio_info = AAC(file_path).info\n                length = audio_info.length\n        elif file_path.casefold().endswith('.opus'):\n            audio_info = mutagen.File(file_path).info  # type:ignore\n            length = audio_info.length\n        else:\n            audio_info = mutagen.File(file_path).info  # type:ignore\n            length = audio_info.length\n        return length\n    except (AttributeError, HeaderNotFoundError, MutagenError, WavInfoEOFError, StopIteration) as e:\n        raise InvalidAudioFile(f'{file_path} is an invalid audio file') from e\n\n\ndef valid_audio_file(uri) -> bool:\n    \"\"\"\n    check if uri has a valid audio extension\n    uri does not have to be a file that exists\n    \"\"\"\n    return Path(uri).suffix.casefold() in AUDIO_EXTS\n\n\ndef set_metadata(file_path: str, metadata: dict):\n    ext = os.path.splitext(file_path)[1].casefold()\n    audio: mutagen._file.FileType = mutagen.File(file_path) # type: ignore\n    title = metadata['title']\n    artists = metadata['artist'].split(', ') if ', ' in metadata['artist'] else metadata['artist'].split(',')\n    album = metadata['album']\n    track_place = metadata['track_number']      # X/Y\n    track_number = track_place.split('/')[0]    # X\n    rating = '1' if metadata['explicit'] else '0'\n    # b64 album art data should be b64 as a string not as bytes\n    if isinstance(metadata.get('art'), bytes):\n        metadata['art'] = metadata['art'].decode()\n    if '/' not in track_place:\n        tracks = max(1, int(track_place))\n        track_place = f'{track_place}/{tracks}'\n    if isinstance(audio, (MP3, WAVE)) or ext in {'.mp3', '.wav'}:\n        if title:\n            audio['TIT2'] = mutagen.id3._frames.TIT2(text=metadata['title'])\n        if artists:\n            audio['TPE1'] = mutagen.id3._frames.TPE1(text=artists)\n            audio['TPE2'] = mutagen.id3._frames.TPE1(text=artists[0])  # album artist\n        audio['TCMP'] = mutagen.id3._frames.TCMP(text=track_number)\n        audio['TRCK'] = mutagen.id3._frames.TRCK(text=track_place)\n        audio['TPOS'] = mutagen.id3._frames.TPOS(text=track_place)\n        if album:\n            audio['TALB'] = mutagen.id3._frames.TALB(text=album)\n        # audio['TDRC'] = mutagen.id3.TDRC(text=metadata['year'])\n        # audio['TCON'] = mutagen.id3.TCON(text=metadata['genre'])\n        # audio['TPUB'] = mutagen.id3.TPUB(text=metadata['publisher'])\n        audio['TXXX:RATING'] = mutagen.id3._frames.TXXX(text=rating, desc='RATING')\n        audio['TXXX:ITUNESADVISORY'] = mutagen.id3._frames.TXXX(text=rating, desc='ITUNESADVISORY')\n        if metadata.get('art') is not None:\n            img_data = b64decode(metadata['art'])\n            audio['APIC:'] = mutagen.id3._frames.APIC(encoding=0, mime=metadata['mime'], type=3, data=img_data)\n        else:  # remove all album art\n            for k in tuple(audio.keys()):\n                if 'APIC:' in k:\n                    audio.pop(k)\n    elif isinstance(audio, MP4):\n        if title:\n            audio['©nam'] = [title]\n        if artists:\n            audio['©ART'] = artists\n        if album:\n            audio['©alb'] = [album]\n        audio['trkn'] = [tuple((int(x) for x in track_place.split('/')))]\n        audio['rtng'] = [int(rating)]\n        if metadata.get('art') is not None:\n            image_format = 14 if metadata['mime'].endswith('png') else 13\n            img_data = b64decode(metadata['art'])\n            audio['covr'] = [MP4Cover(img_data, imageformat=image_format)]\n        elif 'covr' in audio:\n            del audio['covr']\n    elif isinstance(audio, (OggOpus, OggVorbis)):\n        if title:\n            audio['title'] = [title]\n        if artists:\n            audio['artist'] = artists\n        if album:\n            audio['album'] = [album]\n        audio['rtng'] = [rating]\n        audio['trkn'] = track_place\n        if metadata.get('art') is not None:\n            img_data = metadata['art']  # b64 data\n            audio['metadata_block_picture'] = img_data\n            audio['mime'] = metadata['mime']\n        else:\n            audio.pop('APIC:', None)\n            audio.pop('metadata_block_picture', None)\n    else:  # FLAC?\n        if title:\n            audio['TITLE'] = title # type: ignore\n        if artists:\n            audio['ARTIST'] = artists # type: ignore\n        if album:\n            audio['ALBUM'] = album # type: ignore\n        audio['TRACKNUMBER'] = track_number  # type: ignore\n        audio['TRACKTOTAL'] = track_place.split('/')[1]  # type: ignore\n        audio['ITUNESADVISORY'] = rating  # type: ignore\n        if metadata.get('art') is not None:\n            if ext == '.flac':\n                img_data = b64decode(metadata['art'])\n                pic = mutagen.flac.Picture()\n                pic.mime = metadata['mime']\n                pic.data = img_data\n                pic.type = 3\n                audio.clear_pictures() # type: ignore\n                audio.add_picture(pic) # type: ignore\n            else:\n                audio['APIC:'] = metadata['art'] # type: ignore\n                audio['mime'] = metadata['mime'] # type: ignore\n        else:\n            # remove existing album art\n            if ext == '.flac':\n                audio.clear_pictures() # type: ignore\n            else:\n                # remove all album art\n                for k in tuple(audio.keys()):\n                    if 'APIC:' in k:\n                        audio.pop(k)\n    audio.save()\n\n\ndef get_metadata(file_path: str):\n    title = unknown_title = Unknown('Title')\n    artist = unknown_artist = Unknown('Artist')\n    album = unknown_album = Unknown('Album')\n    length = None\n    try:\n        a = mutagen.File(file_path)\n        with suppress(AttributeError):\n            length = a.info.length\n        if isinstance(a, MP3):\n            audio = dict(EasyMP3(file_path))\n            audio['rating'] = a.get('TXXX:RATING', a.get('TXXX:ITUNESADVISORY', ['0']))\n        elif isinstance(a, MP4):\n            audio = dict(mutagen.File(file_path))\n            audio['rating'] = audio.get('rtng', [0])\n            for (tag, normalized) in (('©nam', 'title'), ('©alb', 'album'), ('©ART', 'artist')):\n                if tag in audio:\n                    audio[normalized] = audio.pop(tag)\n            audio['tracknumber'] = audio.get('trkn', [('1', '1')])[0]\n        elif isinstance(a, (OggOpus, OggVorbis)):\n            audio = dict(a)\n            if 'rtng' in audio:\n                audio['rating'] = audio.pop('rtng')\n            if 'trkn' in audio:\n                audio['tracknumber'] = audio.pop('trkn')\n        elif isinstance(a, WAVE) or file_path.endswith('.wav'):\n            audio = WavInfoReader(file_path).info.to_dict()\n            audio = {'title': [audio['title']], 'artist': [audio['artist']], 'album': [audio['product']]}\n        elif a is not None:\n            audio = dict(a)\n            audio = {k.casefold(): audio[k] for k in audio}\n            if file_path.endswith('.wma'):\n                audio = {k: [audio[k][0].value] for k in audio}\n        else:\n            audio = {}\n    except TypeError as e:\n        logging.getLogger('music_caster').error(repr(e))\n        logging.getLogger('music_caster').info(f'Could not open {file_path} as audio file')\n        raise InvalidAudioFile(f'Is {file_path} a valid audio file?') from e\n    except (ID3NoHeaderError, HeaderNotFoundError, AttributeError, WavInfoEOFError, StopIteration):\n        logging.getLogger('music_caster').info(f'Metadata not found for {file_path}')\n        audio = {}\n    title = str(audio.get('title', [title])[0])\n    album = str(audio.get('album', [album])[0])\n    try:\n        is_explicit = audio.get('rating', audio.get('itunesadvisory', ['0']))[0] not in {'C', 'T', '0', 0}\n    except IndexError:\n        is_explicit = False\n    track_number = str(audio['tracknumber'][0]).split('/', 1)[0] if 'tracknumber' in audio else None\n    with suppress(KeyError, TypeError):\n        if len(audio['artist']) == 1:\n            # in case the sep char is a slash\n            try:\n                audio['artist'] = audio['artist'][0].split('/')\n            except AttributeError:\n                audio['artist'] = [unknown_artist]\n        artist = ', '.join(audio['artist'])\n    if not title:\n        title = unknown_title\n    if not artist:\n        artist = unknown_artist\n    if not album:\n        album = unknown_album\n    if title == unknown_title or artist == unknown_artist:\n        # if title or artist are unknown, use the basename of the URI (excluding extension)\n        sort_key = get_file_name(file_path)\n    else:\n        sort_key = State.track_format.replace('&title', title).replace('&artist', artist)\n        sort_key.replace('&album', album if album != unknown_album else '')\n        sort_key = sort_key.replace('&trck', track_number or '')\n    metadata = {'title': title, 'artist': artist, 'album': album, 'explicit': is_explicit,\n                'sort_key': sort_key.casefold(), 'track_number': '1' if track_number is None else track_number,\n                # float works with sqlite REAL\n                'time_modified': os.path.getmtime(file_path)}\n    if length is not None:\n        metadata['length'] = length\n    return metadata\n\n\ndef open_in_browser(url):\n    t = Thread(target=webbrowser.open, daemon=True, args=(url,))\n    t.start()\n    return t\n\n\ndef get_album_art(file_path: str, folder_cover_override=False) -> Tuple[str, bytes]:  # mime: str, data: str\n    with suppress(MutagenError, AttributeError):\n        folder = os.path.dirname(file_path)\n        if folder_cover_override:\n            for ext in ('png', 'jpg', 'jpeg'):\n                folder_cover = os.path.join(folder, f'cover.{ext}')\n                if os.path.exists(folder_cover):\n                    with open(folder_cover, 'rb') as f:\n                        return ext, base64.b64encode(f.read())\n        audio = mutagen.File(file_path) # type: ignore\n        if isinstance(audio, mutagen.flac.FLAC):\n            pics = mutagen.flac.FLAC(file_path).pictures\n            with suppress(IndexError):\n                return pics[0].mime, base64.b64encode(pics[0].data)\n        elif isinstance(audio, MP4):\n            with suppress(KeyError, IndexError):\n                cover = audio['covr'][0]\n                image_format = cover.imageformat\n                mime = 'image/png' if image_format == 14 else 'image/jpeg'\n                return mime, base64.b64encode(cover)\n        elif isinstance(audio, (OggOpus, OggVorbis)):\n            with suppress(KeyError, IndexError):\n                mime = audio.get('mime')\n                if mime is None:\n                    mime = ['image/jpeg']\n                mime = mime[0]\n                return mime, audio['metadata_block_picture'][0]\n        else:\n            # ID3 or something else\n            if audio is not None:\n                for tag in audio.keys():\n                    if 'APIC' in tag:\n                        try:\n                            return audio[tag].mime, base64.b64encode(audio[tag].data)\n                        except AttributeError:\n                            mime = audio['mime'][0].value if 'mime' in audio else 'image/jpeg'\n                            return mime, base64.b64encode(audio[tag][0].value)\n    app_logger = logging.getLogger('music_caster')\n    app_logger.info(f'File {Path(file_path).name} does not have album art. Returning image/jpeg, DEFAULT_ART instead')\n    return 'image/jpeg', DEFAULT_ART\n\n\ndef fix_path(path, by_os=True): return str(Path(path)) if by_os else path.replace('\\\\', '/')\n\n\ndef get_first_artist(artists: str) -> str: return artists.split(', ', 1)[0]\n\n\ndef get_ipv6():\n    # return next((i[4][0] for i in socket.getaddrinfo(socket.gethostname(), None) if i[0] == socket.AF_INET6))\n    if platform.system() == 'Linux':\n        for logical_name in os.listdir('/sys/class/net'):\n            cmd = f\"ip addr show dev {logical_name} | awk '{{if ($1==\\\"inet6\\\") {{print $2}}}}'\"\n            p = Popen(cmd, shell=True,\n                      stdout=PIPE, stdin=DEVNULL, stderr=DEVNULL, text=True)\n            ip = p.stdout.readline().strip()\n            if ip != '':\n                return ip\n    with socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) as s:\n        try:\n            # doesn't even have to be reachable\n            s.connect(('fe80::116a:fd0a:4a0a:42a7', 1))\n            ip = f'[{s.getsockname()[0]}]'\n        except Exception:\n            ip = get_ipv4()\n    return ip\n\n\n# https://regex101.com/\nIPV4_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})')\nIPV4_GENERAL_PATTERN = re.compile(r'IPv4 Address.*:\\s*(\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3})')\n\n\ndef clean_ipconfig(ipconfig_raw):\n    # TODO: there is a way to optimise this and return a single IP, but that requires matching each description\n    ipconfig_output_split = ipconfig_raw.split('\\n\\n')[1:]\n    filtered_output = ''\n    for i in range(len(ipconfig_output_split) // 2):\n        if (\n            'WSL' not in ipconfig_output_split[i * 2]\n            and 'vEthernet' not in ipconfig_output_split[i * 2]\n            and 'Hyper-V' not in ipconfig_output_split[i * 2 + 1]\n        ):\n            filtered_output += ipconfig_output_split[i * 2] + ipconfig_output_split[i * 2 + 1]\n    return filtered_output\n\n\ndef get_ipv4():\n    try:\n        if platform.system() != 'Windows':\n            raise FileNotFoundError\n        ipconfig_output = clean_ipconfig(check_output(['ipconfig'], shell=True, text=True, encoding='iso8859-2'))\n        wifi_match = IPV4_WIFI_PATTERN.findall(ipconfig_output)\n        if wifi_match:\n            return wifi_match[-1][-1]\n        return IPV4_GENERAL_PATTERN.findall(ipconfig_output)[-1]\n    except (IndexError, CalledProcessError, FileNotFoundError):\n        # fallback in case the ipv4 cannot be found in ipconfig\n        # return next((i[4][0] for i in socket.getaddrinfo(socket.gethostname(), None) if i[0] == socket.AF_INET))\n        with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:\n            try:\n                # doesn't even have to be reachable\n                s.connect(('192.168.1.2', 1))\n                ip = s.getsockname()[0]\n            except Exception:\n                ip = '127.0.0.1'\n        return ip\n\n\ndef get_lan_ip() -> str:\n    return get_ipv6()\n\n\ndef get_mac(): return ':'.join(['{:02x}'.format((getnode() >> ele) & 0xff) for ele in range(0, 8 * 6, 8)][::-1])\n\n\ndef better_shuffle(seq, first=0, last=-1):\n    \"\"\"\n    Shuffles based on indices\n    \"\"\"\n    n = len(seq)\n    with suppress(IndexError, ZeroDivisionError):\n        first = first % n\n        last = last % n\n        # use Fisher-Yates shuffle (Durstenfeld method)\n        for i in range(first, last + 1):\n            size = last - i + 1\n            j = getrandbits(size.bit_length()) % size + i\n            seq[i], seq[j] = seq[j], seq[i]\n    return seq\n\n\n@lru_cache(maxsize=1)\ndef dz():\n    from deemix.__main__ import Deezer  # 1.4 seconds. 0.4 due to Downloader\n    return Deezer()\n\n\n@lru_cache(maxsize=2)\ndef ydl(proxy=None, quiet=False):\n    from yt_dlp import YoutubeDL\n    opts = {\n        'quiet': quiet,\n        'verbose': not quiet,\n        'socket_timeout': 10\n    }\n    if proxy is not None:\n        opts['proxy'] = proxy\n    return YoutubeDL(opts)\n\n\ndef ydl_extract_info(url, quiet=False):\n    \"\"\"\n    Raises IOError instead of YoutubeDL's DownloadError, saving us time on imports\n    \"\"\"\n    from yt_dlp.utils import DownloadError\n    with suppress(DownloadError):\n        return ydl(quiet=quiet).extract_info(url, download=False)\n    try:\n        return ydl(get_proxy(False)['https'], quiet=quiet).extract_info(url, download=False)\n    except DownloadError as e:\n        raise IOError from e\n\n\n@lru_cache(maxsize=1)\ndef get_yt_id(url, ignore_playlist=False):\n    query = urlparse(url)\n    if query.hostname == 'youtu.be':\n        return query.path[1:]\n    if query.hostname in {'www.youtube.com', 'youtube.com', 'music.youtube.com'}:\n        if not ignore_playlist:\n            with suppress(KeyError):\n                return parse_qs(query.query)['list'][0]\n        if query.path == '/watch':\n            return parse_qs(query.query)['v'][0]\n        if query.path[:7] == '/watch/':\n            return query.path.split('/')[2]\n        if query.path[:7] == '/embed/':\n            return query.path.split('/')[2]\n        if query.path[:3] == '/v/':\n            return query.path.split('/')[2]\n\n\ndef get_yt_urls(video_id):\n    \"\"\"\n    Returns possible youtube URL's for a single video id\n    \"\"\"\n    yield f'https://youtu.be/{video_id}'\n    for prefix in ('https://', 'https://www.'):\n        yield f'{prefix}youtube.com/watch?v={video_id}'\n        yield f'{prefix}youtube.com/watch/{video_id}'\n        yield f'{prefix}youtube.com/embed/{video_id}'\n        yield f'{prefix}youtube.com/v/{video_id}'\n\n\n@lru_cache(maxsize=1)\ndef is_os_64bit(): return platform.machine().endswith('64')\n\n\ndef delete_sub_key(root, current_key):\n    import winreg as wr\n    access = wr.KEY_ALL_ACCESS | wr.KEY_WOW64_64KEY\n    with suppress(FileNotFoundError):\n        with wr.OpenKeyEx(root, current_key, 0, access) as parent_key:\n            info_key = wr.QueryInfoKey(parent_key)\n            for x in range(info_key[0]):\n                sub_key = wr.EnumKey(parent_key, x)\n                try:\n                    wr.DeleteKeyEx(parent_key, sub_key, access)\n                except OSError:\n                    delete_sub_key(root, '\\\\'.join([current_key, sub_key]))\n            wr.DeleteKeyEx(parent_key, '', access)\n\n\ndef add_reg_handlers(path_to_exe, add_folder_context=True):\n    \"\"\" Register Music Caster as a program to open audio files and folders \"\"\"\n    # https://docs.microsoft.com/en-us/visualstudio/extensibility/registering-verbs-for-file-name-extensions?view=vs-2019\n    import winreg as wr\n    classes_path = r'SOFTWARE\\Classes'\n    mc_file = 'MusicCaster_file'\n    write_access = wr.KEY_WRITE | wr.KEY_WOW64_64KEY\n    read_access = wr.KEY_READ | wr.KEY_WOW64_64KEY\n    path_to_exe = str(path_to_exe)\n    # create URL protocol handler\n    url_protocol = fr'{classes_path}\\music-caster'\n    with wr.CreateKeyEx(wr.HKEY_CURRENT_USER, url_protocol, 0, write_access) as key:\n        wr.SetValueEx(key, None, 0, wr.REG_SZ, 'URL:music-caster Protocol')\n    with wr.CreateKeyEx(wr.HKEY_CURRENT_USER, url_protocol, 0, write_access) as key:\n        wr.SetValueEx(key, 'URL Protocol', 0, wr.REG_SZ, '')\n    with wr.CreateKeyEx(wr.HKEY_CURRENT_USER, fr'{url_protocol}\\DefaultIcon', 0, write_access) as key:\n        wr.SetValueEx(key, None, 0, wr.REG_SZ, f'\"{path_to_exe}\"')\n    with wr.CreateKeyEx(wr.HKEY_CURRENT_USER, fr'{url_protocol}\\shell\\open\\command', 0, write_access) as key:\n        wr.SetValueEx(key, None, 0, wr.REG_SZ, f'\"{path_to_exe}\" --urlprotocol \"%1\"')\n\n    # create Audio File type\n    with wr.CreateKeyEx(wr.HKEY_CURRENT_USER, fr'{classes_path}\\{mc_file}', 0, write_access) as key:\n        wr.SetValueEx(key, None, 0, wr.REG_SZ, 'Audio File')\n    with wr.CreateKeyEx(wr.HKEY_CURRENT_USER, fr'{classes_path}\\{mc_file}\\DefaultIcon', 0, write_access) as key:\n        wr.SetValueEx(key, None, 0, wr.REG_SZ, path_to_exe)  # define icon location\n\n    # create play context | open handler\n    with wr.CreateKeyEx(wr.HKEY_CURRENT_USER, fr'{classes_path}\\{mc_file}\\shell\\open', 0, write_access) as key:\n        wr.SetValueEx(key, None, 0, wr.REG_SZ, t('Play with Music Caster'))\n        wr.SetValueEx(key, 'MultiSelectModel', 0, wr.REG_SZ, 'Player')\n        wr.SetValueEx(key, 'Icon', 0, wr.REG_SZ, path_to_exe)\n    command_path = fr'{classes_path}\\{mc_file}\\shell\\open\\command'\n    with wr.CreateKeyEx(wr.HKEY_CURRENT_USER, command_path, 0, write_access) as key:\n        wr.SetValueEx(key, None, 0, wr.REG_SZ, f'\"{path_to_exe}\" --shell \"%1\"')\n\n    # create queue context\n    with wr.CreateKeyEx(wr.HKEY_CURRENT_USER, fr'{classes_path}\\{mc_file}\\shell\\queue', 0, write_access) as key:\n        wr.SetValueEx(key, None, 0, wr.REG_SZ, t('Queue in Music Caster'))\n        wr.SetValueEx(key, 'MultiSelectModel', 0, wr.REG_SZ, 'Player')\n        wr.SetValueEx(key, 'Icon', 0, wr.REG_SZ, path_to_exe)\n    command_path = fr'{classes_path}\\{mc_file}\\shell\\queue\\command'\n    with wr.CreateKeyEx(wr.HKEY_CURRENT_USER, command_path, 0, write_access) as key:\n        wr.SetValueEx(key, None, 0, wr.REG_SZ, f'\"{path_to_exe}\" -q --shell \"%1\"')\n\n    # create play next context\n    with wr.CreateKeyEx(wr.HKEY_CURRENT_USER, fr'{classes_path}\\{mc_file}\\shell\\play_next', 0, write_access) as key:\n        wr.SetValueEx(key, None, 0, wr.REG_SZ, t('Play next in Music Caster'))\n        wr.SetValueEx(key, 'MultiSelectModel', 0, wr.REG_SZ, 'Player')\n        wr.SetValueEx(key, 'Icon', 0, wr.REG_SZ, path_to_exe)\n    command_path = fr'{classes_path}\\{mc_file}\\shell\\play_next\\command'\n    with wr.CreateKeyEx(wr.HKEY_CURRENT_USER, command_path, 0, write_access) as key:\n        wr.SetValueEx(key, None, 0, wr.REG_SZ, f'\"{path_to_exe}\" -n --shell \"%1\"')\n\n    # set file handlers\n    for ext in AUDIO_HANDLER_EXTS:\n        key_path = fr'{classes_path}\\.{ext}'\n        try:  # check if key exists\n            with wr.OpenKeyEx(wr.HKEY_CURRENT_USER, key_path, 0, read_access) as _:\n                pass\n        except (WindowsError, FileNotFoundError):\n            # create key for extension if it does not exist with MC as the default program\n            with wr.CreateKeyEx(wr.HKEY_CURRENT_USER, key_path, 0, write_access) as key:\n                # set as default program unless .mp4 because that's a video format\n                wr.SetValueEx(key, None, 0, wr.REG_SZ, mc_file)\n        # add to Open With (prompts user to set default program when they try playing a file)\n        with wr.CreateKeyEx(wr.HKEY_CURRENT_USER, fr'{key_path}\\\\OpenWithProgids', 0, write_access) as key:\n            wr.SetValueEx(key, mc_file, 0, wr.REG_NONE, b'')  # type needs to be bytes\n\n    play_folder_key_path = fr'{classes_path}\\Directory\\shell\\MusicCasterPlayFolder'\n    queue_folder_key_path = fr'{classes_path}\\Directory\\shell\\MusicCasterQueueFolder'\n    play_next_folder_key_path = fr'{classes_path}\\Directory\\shell\\MusicCasterPlayNextFolder'\n    if add_folder_context:\n        # set \"open folder in Music Caster\" command\n        with wr.CreateKeyEx(wr.HKEY_CURRENT_USER, play_folder_key_path, 0, write_access) as key:\n            wr.SetValueEx(key, None, 0, wr.REG_SZ, t('Play with Music Caster'))\n            wr.SetValueEx(key, 'Icon', 0, wr.REG_SZ, path_to_exe)\n        with wr.CreateKeyEx(wr.HKEY_CURRENT_USER, fr'{play_folder_key_path}\\\\command', 0, write_access) as key:\n            wr.SetValueEx(key, None, 0, wr.REG_SZ, f'\"{path_to_exe}\" --shell \"%1\"')\n        # set \"queue folder in Music Caster\" command\n        with wr.CreateKeyEx(wr.HKEY_CURRENT_USER, queue_folder_key_path, 0, write_access) as key:\n            wr.SetValueEx(key, None, 0, wr.REG_SZ, t('Queue in Music Caster'))\n            wr.SetValueEx(key, 'Icon', 0, wr.REG_SZ, path_to_exe)\n        with wr.CreateKeyEx(wr.HKEY_CURRENT_USER, fr'{queue_folder_key_path}\\\\command', 0, write_access) as key:\n            wr.SetValueEx(key, None, 0, wr.REG_SZ, f'\"{path_to_exe}\" -q --shell \"%1\"')\n        # set \"play folder next in Music Caster\" command\n        with wr.CreateKeyEx(wr.HKEY_CURRENT_USER, play_next_folder_key_path, 0, write_access) as key:\n            wr.SetValueEx(key, None, 0, wr.REG_SZ, t('Play next in Music Caster'))\n            wr.SetValueEx(key, 'Icon', 0, wr.REG_SZ, path_to_exe)\n        with wr.CreateKeyEx(wr.HKEY_CURRENT_USER, fr'{play_next_folder_key_path}\\\\command', 0, write_access) as key:\n            wr.SetValueEx(key, None, 0, wr.REG_SZ, f'\"{path_to_exe}\" -n --shell \"%1\"')\n    else:\n        # remove commands for folders\n        delete_sub_key(wr.HKEY_CURRENT_USER, play_folder_key_path)\n        delete_sub_key(wr.HKEY_CURRENT_USER, queue_folder_key_path)\n        delete_sub_key(wr.HKEY_CURRENT_USER, play_next_folder_key_path)\n\n\ndef get_default_output_device():\n    \"\"\" returns the PyAudio formatted name of the default output device \"\"\"\n    import winreg as wr\n    read_access = wr.KEY_READ | wr.KEY_WOW64_64KEY\n    audio_path = r'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\MMDevices\\Audio\\Render'\n    audio_key = wr.OpenKeyEx(wr.HKEY_LOCAL_MACHINE, audio_path, 0, read_access)\n    num_devices = wr.QueryInfoKey(audio_key)[0]\n    active_last_used, active_device_name = -1, None\n    for i in range(num_devices):\n        device_key_path = f'{audio_path}\\\\{wr.EnumKey(audio_key, i)}'\n        device_key = wr.OpenKeyEx(wr.HKEY_LOCAL_MACHINE, device_key_path, 0, read_access)\n        if wr.QueryValueEx(device_key, 'DeviceState')[0] == 1:  # if enabled\n            properties_path = f'{device_key_path}\\\\Properties'\n            properties = wr.OpenKeyEx(wr.HKEY_LOCAL_MACHINE, properties_path, 0, read_access)\n            device_name = wr.QueryValueEx(properties, '{b3f8fa53-0004-438e-9003-51a46e139bfc},6')[0]\n            device_type = wr.QueryValueEx(properties, '{a45c254e-df1c-4efd-8020-67d146a850e0},2')[0]\n            pa_name = f'{device_type} ({device_name})'  # name shown in PyAudio\n            with suppress(FileNotFoundError):\n                last_used = wr.QueryValueEx(device_key, 'Level:0')[0]\n                if last_used > active_last_used:  # the bigger the number, the more recent it was used\n                    active_last_used = last_used\n                    active_device_name = pa_name\n    return active_device_name\n\n\ndef resize_img(base64data: bytes, bg, new_size=COVER_NORMAL, default_art=None) -> bytes:\n    \"\"\" Resize and return b64 img data to new_size (w, h). (use .decode() on return statement for str) \"\"\"\n\n    try:\n        img_data = io.BytesIO(b64decode(base64data))\n        art_img: Image.Image = Image.open(img_data)\n    except UnidentifiedImageError as e:\n        if default_art is None:\n            raise OSError from e\n        img_data = io.BytesIO(b64decode(default_art))\n        art_img: Image.Image = Image.open(img_data)\n    w, h = art_img.size\n    if w == h:\n        # resize a square\n        img = art_img.resize(new_size, Image.Resampling.LANCZOS)\n    else:\n        # resize by shrinking the longest side to the new_size\n        ratios = (1, h / w) if w > h else (w / h, 1)\n        ratio_size = (round(new_size[0] * ratios[0]), round(new_size[1] * ratios[1]))\n        art_img = art_img.resize(ratio_size, Image.Resampling.LANCZOS)\n        paste_width = (new_size[0] - ratio_size[0]) // 2\n        paste_height = (new_size[1] - ratio_size[1]) // 2\n        img = Image.new('RGB', new_size, color=bg)\n        img.paste(art_img, (paste_width, paste_height))\n    data = io.BytesIO()\n    if img.mode == 'CMYK':\n        img = img.convert('RGB')\n    img.save(data, format='png')\n    return b64encode(data.getvalue())\n\n\ndef export_playlist(playlist_name, uris):\n    # exports uris to ~/Downloads/safe(playlist_name).m3u\n    playlist_name = re.sub(r'(?u)[^-\\w. ]', '', playlist_name)  # clean name\n    playlist_path = Path.home() / 'Downloads'\n    playlist_path.mkdir(parents=True, exist_ok=True)\n    playlist_path /= f'{playlist_name}.m3u'\n    with open(playlist_path, 'w', encoding='utf-8') as f:\n        f.write('#EXTM3U\\n')\n        for uri in uris:\n            if uri.replace('\\\\', '/') != playlist_path:\n                f.write(uri + '\\n')\n    return str(playlist_path)\n\n\ndef parse_m3u(playlist_file):\n    with open(playlist_file, errors='ignore', encoding='utf-8') as f:\n        for line in iter(lambda: f.readline(), ''):\n            if not line.startswith('#'):\n                line = line.lstrip('file:').lstrip('/').rstrip()\n                # an m3u file cannot contain itself\n                if line != playlist_file:\n                    yield line\n\n\ndef get_latest_release(ver, this_version, force=False):\n    \"\"\"\n    returns {'version': latest_ver, 'setup': 'setup_link'} if the latest release version is newer (>) than VERSION\n    if latest release version <= VERSION, returns false\n    if force: return latest release even if latest version <= VERSION \"\"\"\n    releases_url = 'https://api.github.com/repos/elibroftw/music-caster/releases/latest'\n    with suppress(requests.RequestException):\n        release = requests.get(releases_url)\n        if release.status_code >= 400:\n            release = requests.get(releases_url, proxies=get_proxy(False))\n        release = release.json()\n        latest_ver = release.get('tag_name', f'v{this_version}')[1:]\n        _version = [int(x) for x in ver.split('.')]\n        compare_ver = [int(x) for x in latest_ver.split('.')]\n        if compare_ver > _version or force:\n            for asset in release.get('assets', []):\n                # check if setup exists\n                if 'exe' in asset['name']:\n                    return {'version': latest_ver, 'setup': asset['browser_download_url']}\n    return False\n\n\n@time_cache(600, maxsize=1)\ndef get_proxies(add_local=True):\n    from bs4 import (\n        BeautifulSoup,  # 0.32 seconds if at top level, here it is 0.1 seconds\n    )\n    try:\n        response = requests.get('https://free-proxy-list.net/', headers={'user-agent': USER_AGENT})\n        scraped_proxies = set()\n        soup = BeautifulSoup(response.text, 'lxml')\n        table = soup.find('table')\n        for row in table.find_all('tr'): # type: ignore\n            count = 0\n            proxy = ''\n            try:\n                is_https = row.find('td', {'class': 'hx'}).text == 'yes'\n            except AttributeError:\n                is_https = False\n            if is_https:\n                for cell in row.find_all('td'):\n                    if count == 1:\n                        proxy += ':' + cell.text.replace('&nbsp;', '')\n                        scraped_proxies.add(proxy)\n                        break\n                    proxy += cell.text.replace('&nbsp;', '')\n                    count += 1\n        proxies: list = [None, None, None, None, None] if add_local else []\n        for proxy in sorted(scraped_proxies):\n            proxies.extend(repeat(proxy, 3))\n    except (requests.RequestException, AttributeError):\n        return cycle([None])\n    return cycle(proxies)\n\n\ndef get_proxy(add_local=True):\n    proxy = next(get_proxies(add_local))\n    return {'http': proxy, 'https': proxy}\n\n\n@time_cache(max_age=3500, maxsize=1)\ndef get_spotify_headers():\n    # access token key expires in ~1 hour\n    r = requests.get('https://open.spotify.com/', headers={'user-agent': USER_AGENT})\n    access_token = re.search('\"accessToken\":\"([^\"]*)', r.text).group(1)\n    return {'Authorization': f'Bearer {access_token}'}\n\n\n# TODO: main_event == 'metadata_search_art' and gui_window['metadata_file'].get():\ndef search_album_art_spotify(title, artist, mkt):\n    for mkt in {'MX', 'CA', 'US', 'UK', 'HK'}:\n        url = f'https://api.spotify.com/v1/search?q={title}'\n        if artist:\n            url += f'+artist:{artist}'\n        url += f'&type=track&market={mkt}'\n        r = requests.get(url, headers=get_spotify_headers()).json()\n        if 'tracks' in r:\n            for art_link in (item['album']['images'][0]['url'] for item in r['tracks']['items']):\n                original_art = base64.b64encode(requests.get(art_link).content).decode()\n                return original_art\n\n\ndef parse_spotify_track(track_obj, parent_url='') -> dict:\n    \"\"\"\n    Returns a metadata dict for a given Spotify track\n    \"\"\"\n    try:\n        artist = ', '.join((artist['name'] for artist in track_obj['artists'] if artist['type'] == 'artist'))\n    except KeyError:\n        artist = Unknown('Artist')\n    title = track_obj['name']\n    is_explicit = track_obj['explicit']\n    album = track_obj['album']['name']\n    try:\n        src_url = track_obj['external_urls']['spotify']\n    except KeyError:\n        src_url = parent_url\n    track_number = str(track_obj['track_number'])\n    sort_key = State.track_format.replace('&title', title).replace('&artist', artist).replace('&album', str(album))\n    sort_key = sort_key.replace('&trck', track_number).casefold()\n    metadata = {'src': src_url, 'title': title, 'artist': artist, 'album': album,\n                'explicit': is_explicit, 'sort_key': sort_key, 'track_number': track_number}\n    with suppress(IndexError):\n        metadata['art'] = track_obj['album']['images'][0]['url']\n    return metadata\n\n\n@lru_cache\ndef get_spotify_track(url):\n    try:\n        track_id = urlparse(url).path.split('/track/', 1)[1]\n    except IndexError:\n        # e.g. */album/*?highlight=spotify:track:587w9pOR9UNvFJOwkW7NgD\n        track_id = re.search(r'track:.*', url).group()[6:]\n    track = requests.get(f'{SPOTIFY_API}/tracks/{track_id}', headers=get_spotify_headers()).json()\n    return {**parse_spotify_track(track), 'src': url}\n\n\n@lru_cache\ndef get_spotify_album(url):\n    album_id = urlparse(url).path.split('/album/', 1)[1]\n    api_url = f'{SPOTIFY_API}/albums/{album_id}'\n    r = requests.get(api_url, headers=get_spotify_headers()).json()\n    return [parse_spotify_track({**track, 'album': r}, parent_url=url) for track in r['tracks']['items']]\n\n\ndef get_spotify_playlist(url):\n    playlist_id = urlparse(url).path.split('/playlist/', 1)[1]\n    api_url = f'{SPOTIFY_API}/playlists/{playlist_id}/tracks'\n    response = requests.get(api_url, headers=get_spotify_headers()).json()\n    results = response['items']\n    while response['next'] is not None:\n        response = requests.get(response['next'], headers=get_spotify_headers()).json()\n        results.extend(response['items'])\n    return [parse_spotify_track(result['track'], url) for result in results if isinstance(result['track'], dict)]\n\n\ndef get_spotify_tracks(url):\n    \"\"\"\n    Returns a list of spotify track objects stemming from a Spotify url\n    Could raise: AttributeError, RequestException, KeyError, more?\n    \"\"\"\n    if 'track' in url:\n        return [get_spotify_track(url)]\n    if 'album' in url:\n        return get_spotify_album(url)\n    if 'playlist' in url:\n        return get_spotify_playlist(url)\n    return []\n\n\ndef get_cookies(domain_contains, cookie_name='', return_first=True, return_value=True):\n    \"\"\"\n    get_cookies('.youtube.com', '', False, False)\n    \"\"\"\n    import sqlite3\n\n    import browser_cookie3 as bc3  # 0.388 seconds if on top level, 0.06 here\n    for cookie_storage in (bc3.chrome, bc3.firefox, bc3.opera, bc3.edge, bc3.chromium):\n        cookies = []\n        with suppress(bc3.BrowserCookieError, sqlite3.OperationalError):\n            cookie_storage = cookie_storage()\n            for cookie in cookie_storage:\n                if cookie.domain.count(domain_contains):\n                    formatted_cookie = f'{cookie.name}={cookie.value}'\n                    if (not cookie_name or cookie.name == cookie_name) and not cookie.is_expired():\n                        cookie_to_use = cookie.value if return_value else formatted_cookie\n                        if return_first:\n                            return cookie_to_use\n                        cookies.append(cookie_to_use)\n        if cookies:\n            return 'Cookie: ' + '; '.join(cookies)\n    return ''\n\n\n@lru_cache\ndef parse_deezer_page(url):\n    if 'page.link' in url:\n        r = requests.get(url)\n        url = r.url\n    if '/track/' in url:\n        _type = 'track'\n    elif '/album/' in url:\n        _type = 'album'\n    elif '/playlist/' in url:\n        _type = 'playlist'\n    elif '/user/' in url:\n        _type = 'user'\n    else:\n        raise ValueError('Unknown URL')\n    _id = re.search(r'\\d+', urlparse(url).path).group()\n    return {'type': _type, 'sng_id': _id}\n\n\ndef parse_deezer_track(track_obj) -> dict:\n    from deemix.decryption import generateBlowfishKey, generateCryptedStreamURL\n    artists = []\n    sng_contributors = track_obj['SNG_CONTRIBUTORS']\n    if isinstance(sng_contributors, list):\n        sng_contributors = {'main_artist': sng_contributors}\n    try:\n        main_artists = sng_contributors['main_artist']\n    except KeyError:\n        main_artists = sng_contributors['mainartist']\n    for artist in main_artists + sng_contributors.get('featuring', []):\n        include = True\n        for added_artist in artists:\n            if added_artist in artist:\n                include = False\n                break\n        if include:\n            artists.append(artist)\n    artist_str = ', '.join(artists)\n    art = f\"https://cdns-images.dzcdn.net/images/cover/{track_obj['ALB_PICTURE']}/1000x1000-000000-80-0-0.jpg\"\n    title, album = track_obj['SNG_TITLE'], track_obj['ALB_TITLE']\n    length = int(track_obj['DURATION'])\n    is_explicit = track_obj['EXPLICIT_TRACK_CONTENT']['EXPLICIT_LYRICS_STATUS'] == '1'\n    sng_id = track_obj['SNG_ID']\n    metadata = {\n        'art': art, 'title': title, 'ext': 'mp3', 'artist': artist_str or Unknown('Artist'), 'album': album,\n        'length': length, 'sng_id': sng_id, 'explicit': is_explicit\n    }\n    with suppress(KeyError):\n        md5 = track_obj.get('FALLBACK', track_obj)['MD5_ORIGIN']\n        file_url = generateCryptedStreamURL(sng_id, md5, track_obj['MEDIA_VERSION'], TrackFormats.MP3_128)\n        bf_key = generateBlowfishKey(sng_id)\n        metadata['file_url'] = file_url\n        metadata['bf_key'] = bf_key\n        expiry_time = time.time() + 1800  # 30 minute expiry\n        metadata['expiry'] = expiry_time\n    return metadata\n\n\ndef set_dz_url(metadata):\n    src_url = metadata['src']\n    metadata['url'] = f'http://{get_ipv4()}:{State.PORT}/dz?{urlencode({\"url\": src_url})}'\n    # metadata['url'] = metadata['file_url']\n\n\ndef get_deezer_track(url):\n    sng_id = parse_deezer_page(url)['sng_id']\n    metadata = parse_deezer_track(dz().gw.get_track(sng_id))\n    metadata['src'] = url\n    set_dz_url(metadata)\n    return metadata\n\n\ndef get_deezer_album(url):\n    alb_id = parse_deezer_page(url)['sng_id']\n    tracks = []\n    for track in dz().gw.get_album_tracks(alb_id):\n        metadata = parse_deezer_track(track)\n        sng_id = metadata['sng_id']\n        metadata['src'] = f'https://www.deezer.com/track/{sng_id}'\n        set_dz_url(metadata)\n        tracks.append(metadata)\n    return tracks\n\n\ndef get_deezer_playlist(url):\n    pl_id = parse_deezer_page(url)['sng_id']\n    tracks = []\n    for track in dz().gw.get_playlist_tracks(pl_id):\n        metadata = parse_deezer_track(track)\n        sng_id = metadata['sng_id']\n        metadata['src'] = f'https://www.deezer.com/track/{sng_id}'\n        set_dz_url(metadata)\n        tracks.append(metadata)\n    return tracks\n\n\n@lru_cache\ndef get_deezer_tracks(url, login=True):\n    if login:\n        if not dz().logged_in:\n            if not dz().login_via_arl(get_cookies('.deezer.com', cookie_name='arl')):\n                raise LookupError('Not logged into deezer.com')\n    dz_type = parse_deezer_page(url)['type']\n    if dz_type == 'track':\n        return [get_deezer_track(url)]\n    elif dz_type == 'album':\n        return get_deezer_album(url)\n    elif dz_type == 'playlist':\n        return get_deezer_playlist(url)\n    return []\n\n\n@lru_cache\ndef custom_art(text):\n    img_data = io.BytesIO(b64decode(DEFAULT_ART))\n    art_img: Image.Image = Image.open(img_data)\n    size = art_img.size\n    x1 = y1 = size[0] * 0.95\n    x0 = x1 - len(text) * 0.0625 * size[0]\n    y0 = y1 - 0.11 * size[0]\n    d = ImageDraw.Draw(art_img)\n    try:\n        username = os.getenv('USERNAME')\n        fnt = ImageFont.truetype(f\"C:/Users/{username}/AppData/Local/Microsoft/Windows/Fonts/MYRIADPRO-BOLD.OTF\", 80)\n        shift = 5\n    except OSError:\n        try:\n            fnt = ImageFont.truetype('gadugib.ttf', 80)\n            shift = -5\n        except OSError:\n            try:\n                fnt = ImageFont.truetype('arial.ttf', 80)\n                shift = 0\n            except OSError:\n                # Linux\n                fnt = ImageFont.truetype('/usr/share/fonts/truetype/freefont/FreeMono.ttf', 80, encoding='unic')\n                shift = 0\n    d.rounded_rectangle((x0, y0, x1, y1), fill='#cc1a21', radius=7)\n    d.text(((x0 + x1) / 2, (y0 + y1) / 2 + shift), text, fill='#fff', font=fnt, align='center', anchor='mm')\n    data = io.BytesIO()\n    art_img.save(data, format='png', quality=95)\n    return b64encode(data.getvalue())\n\n\ndef get_youtube_comments(url, limit=-1):  # -> generator\n    # TODO: use proxies = get_proxy()\n    return yt_comment_downloader.get_comments_from_url(url, sort_by=SORT_BY_POPULAR, limit=limit)\n\n\ndef timestamp_to_time(text):\n    times = re.findall(r'\\d+:(?:\\d+:)*\\d+', text)\n    times = sorted({sum(int(x) * 60 ** i for i, x in enumerate(reversed(_time.split(':')))) for _time in times})\n    return times\n\n\ndef get_video_timestamps(video_info):\n    # try parsing chapters\n    with suppress(KeyError):\n        chapters = video_info['chapters']\n        times = set()\n        for chapter in chapters:\n            times.add(chapter['start_time'])\n            times.add(chapter['end_time'])\n        return sorted(times)\n    # try parsing description\n    description_timestamps = timestamp_to_time(video_info['description'])\n    if len(description_timestamps) > 1:\n        return description_timestamps\n    # try parsing comments\n    url = video_info['webpage_url']\n    with suppress(ValueError, RuntimeError):\n        for count, comment in enumerate(get_youtube_comments(url, limit=10)):\n            times = timestamp_to_time(comment['text'])\n            if len(times) > 2:\n                return times\n    return []\n\n# GUI utilitiies\n\ndef repeat_img_tooltip(repeat_setting):\n    if repeat_setting is None:\n        return REPEAT_OFF_IMG, t('Repeat All')\n    elif repeat_setting:\n        return REPEAT_ONE_IMG, t('Repeat Off')\n    return REPEAT_ALL_IMG, t('Repeat One')\n\n\ndef create_progress_bar_texts(position, length):\n    \"\"\"\":return: time_elapsed_text, time_left_text\"\"\"\n    position = floor(position)\n    mins_elapsed, secs_elapsed = floor(position / 60), floor(position % 60)\n    if secs_elapsed < 10:\n        secs_elapsed = f'0{secs_elapsed}'\n    elapsed_text = f'{mins_elapsed}:{secs_elapsed}'\n    try:\n        time_left = round(length) - position\n        mins_left, secs_left = time_left // 60, time_left % 60\n        if secs_left < 10:\n            secs_left = f'0{secs_left}'\n        time_left_text = f'{mins_left}:{secs_left}'\n    except TypeError:\n        time_left_text = '∞'\n    return elapsed_text, time_left_text\n\n\ndef truncate_title(title):\n    \"\"\" truncate title for mini mode \"\"\"\n    if len(title) > 29:\n        return title[:26] + '...'\n    return title\n\n\n# TKDnD\ndef drop_target_register(widget, *dndtypes):\n    widget.tk.call('tkdnd::drop_target', 'register', widget._w, dndtypes)\n\n\ndef dnd_bind(widget, sequence=None, func=None, add=None, need_cleanup=True):\n    \"\"\"Internal function.\"\"\"\n    what = ('bind', widget._w)\n    if isinstance(func, str):\n        widget.tk.call(what + (sequence, func))\n    elif func:\n        func_id = widget._register(func, widget._substitute_dnd, need_cleanup)\n        cmd = '%s%s %s' % (add and '+' or '', func_id, widget._subst_format_str_dnd)\n        widget.tk.call(what + (sequence, cmd))\n        return func_id\n    elif sequence:\n        return widget.tk.call(what + (sequence,))\n    else:\n        return widget.tk.splitlist(widget.tk.call(what))\n\n\ndef get_cut_text(window, key):\n    # fix for weird GUI cut/copy behaviour\n    cut_text = ''\n    new_text = window[key].get()\n    if not new_text:\n        return window.metadata[key]\n    i = 0\n    for v in window.metadata[key]:\n        if i >= len(new_text) or v != new_text[i]:\n            cut_text += v\n        else:\n            i += 1\n    return cut_text\n\n\ndef start_on_login_win32(working_dir, create_key=True, is_debug=True):\n    import winreg as wr\n    classes_path = r'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run'\n    access = wr.KEY_ALL_ACCESS | wr.KEY_WOW64_64KEY\n    path_to_exe = working_dir / 'Music Caster.exe' if IS_FROZEN else working_dir / 'music_caster.bat'\n    if not IS_FROZEN and not os.path.exists(path_to_exe):\n        with open('music_caster.bat', 'w') as f:\n            f.write(f'pythonw \"{os.path.basename(sys.argv[0])}\" -m')\n    with wr.OpenKeyEx(wr.HKEY_CURRENT_USER, classes_path, 0, access) as key:\n        if create_key and (IS_FROZEN or is_debug):\n            wr.SetValueEx(key, 'Music Caster', 0, wr.REG_SZ, f'\"{path_to_exe}\" -m')\n        if not create_key or (not IS_FROZEN and is_debug):\n            with suppress(FileNotFoundError):\n                wr.DeleteValue(key, 'Music Caster')\n\n\ndef rm_old_startup_shortcuts():\n    if platform.system() == 'Windows':\n        from knownpaths import FOLDERID, sh_get_known_folder_path\n        startup_dir = sh_get_known_folder_path(FOLDERID.Startup)\n        shortcut_paths = (f\"{startup_dir}\\\\{item}.lnk\" for item in ('Music Caster', 'Music Caster (Python)', 'Music Caster  [DEBUG]'))\n        for shortcut_path in shortcut_paths:\n            with suppress(FileNotFoundError):\n                os.remove(shortcut_path)\n\n\ndef startfile(file):\n    if platform.system() == 'Windows':\n        try:\n            return os.startfile(file)\n        except OSError:\n            return Popen(f'explorer \"{fix_path(file)}\"')\n    elif platform.system() == 'Darwin':\n        return Popen(['open', file])\n    # Linux\n    return Popen(['xdg-open', file])\n\n\ndef add_to_path(path):\n    if platform.system() == 'Windows':\n        os.environ['PATH'] += f'{path};'\n    else:\n        os.environ['PATH'] += f':{path}'\n\n\ndef cmd_exists(cmd):\n    if platform.system() == 'Windows':\n        return subprocess.call(f'where {cmd}', shell=True,\n                               stdout=subprocess.PIPE, stderr=subprocess.PIPE) == 0\n    return subprocess.call(f'type {cmd}', shell=True,\n                           stdout=subprocess.PIPE, stderr=subprocess.PIPE) == 0\n\n\ndef install_phantomjs(install_directory):\n    \"\"\"Downloads PhantomJS zip, extracts to install_dir. Does not bin dir to path\n    Raises multiple exceptions!\n\n    Args:\n        install_directory (Pathlike): path to extract phantomjs to\n    \"\"\"\n    # download phantomJS\n    tags = requests.get('https://api.github.com/repos/ariya/phantomjs/tags').json()\n    latest_tag = tags[0]['name']\n\n    if platform.system() == 'Windows':\n        dir_name = f'phantomjs-{latest_tag}-windows'\n        dl_link = f'https://bitbucket.org/ariya/phantomjs/downloads/{dir_name}.zip'\n    elif platform.system() == 'Linux':\n        dir_name = f'phantomjs-{latest_tag}-linux'\n        dl_link = f'https://bitbucket.org/ariya/phantomjs/downloads/phantomjs-{latest_tag}-linux-x86_64.tar.bz2'\n    elif platform.system() == 'Darwin':  # Mac OSX\n        dir_name = f'phantomjs-{latest_tag}-windows'\n        dl_link = f'https://bitbucket.org/ariya/phantomjs/downloads/phantomjs-{latest_tag}-macosx.zip'\n    r = requests.get(dl_link, stream=True)\n    temp_dir = tempfile.mkdtemp()\n    if dl_link.endswith('zip'):\n        with ZipFile(io.BytesIO(r.content)) as zf:\n            zf.extractall(temp_dir)\n    else:\n        with tarfile.open(fileobj=r.raw, mode='r|bz2') as tf:\n            tf.extractall(temp_dir)\n    shutil.move(Path(temp_dir) / dir_name, install_directory)\n"
  },
  {
    "path": "src/webview_demo.py",
    "content": "import webview\nimport platform\nimport sys\nimport os\nimport socket\n\nframeless = platform.system() == 'Windows'\n\ntry:\n    frontend_entry = f'{sys._MEIPASS}/frontend/index.html'\nexcept AttributeError:\n    frontend_entry = 'frontend/index.html'\n    if not os.path.exists(frontend_entry):\n        # assume running in DEBUG\n        frontend_entry = 'http://localhost:5173/'\n\nwebview.create_window('Music Caster', frontend_entry, frameless=frameless)\nwebview.start()\n# python -Om PyInstaller webview_demo.py\n"
  }
]