Repository: PrajwalVandana/maestro-cli Branch: master Commit: b0f790aabe2b Files: 27 Total size: 406.9 KB Directory structure: gitextract_c46jkt4m/ ├── .github/ │ └── workflows/ │ └── cross-build.yml ├── .gitignore ├── LICENSE ├── data/ │ └── icons/ │ └── maestro_icon.icns ├── dist/ │ └── maestro_music-2.0.6-py3-none-any.whl ├── install-scripts/ │ ├── mac │ ├── ubuntu │ └── windows.nsi ├── maestro/ │ ├── __init__.py │ ├── __main__.py │ ├── __version__.py │ ├── config.py │ ├── helpers.py │ ├── icon.py │ ├── jit_funcs.py │ ├── mac_presence.py │ └── main.py ├── readme.md ├── requirements.txt ├── scripts/ │ ├── add_album_art.py │ ├── custom_album_art.py │ └── rename_tracktitles.py ├── setup.py ├── specs/ │ ├── maestro-mac.spec │ ├── maestro-ubuntu.spec │ └── maestro-windows.spec └── uninstall-scripts/ └── unix ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/cross-build.yml ================================================ name: Cross-Platform Build on: push: branches: - dev jobs: pyinstaller-build-windows: runs-on: windows-latest steps: - name: Build executable uses: PrajwalVandana/pyinstaller@c7c491de8409921df7045f681695d4d5ab71a4e0 id: pyinstaller with: python_ver: '3.12' spec: specs/maestro-windows.spec requirements: requirements.txt - name: Install NSIS uses: repolevedavaj/install-nsis@v1.0.2 with: nsis-version: '3.10' - name: Run NSIS and zip dist run: | makensis install-scripts/windows.nsi Compress-Archive -Path "${{ steps.pyinstaller.outputs.executable_path }}/maestro/*" -Destination "${{ steps.pyinstaller.outputs.executable_path }}/maestro-windows.zip" - name: Upload to release uses: softprops/action-gh-release@v2 with: files: | install-scripts/maestro-installer.exe ${{ steps.pyinstaller.outputs.executable_path }}/maestro-windows.zip token: ${{ secrets.GITHUB_TOKEN }} draft: true prerelease: true pyinstaller-build-mac: runs-on: ${{ matrix.os }} strategy: matrix: os: [macos-12, macos-latest] steps: - name: Build executable uses: PrajwalVandana/pyinstaller@c7c491de8409921df7045f681695d4d5ab71a4e0 id: pyinstaller with: python_ver: '3.12' spec: specs/maestro-mac.spec requirements: requirements.txt - name: Read version from __version__.py id: version # read VERSION = "a.b.c" from __version__.py run: | VERSION=$(sed -n 's/VERSION = "\([^"]*\)"/\1/p' maestro/__version__.py) echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Add install script run: | mkdir -p "${{ steps.pyinstaller.outputs.executable_path }}/Scripts" mv install-scripts/mac "${{ steps.pyinstaller.outputs.executable_path }}/Scripts/postinstall" - name: Create .pkg id: pkgbuild # main branch # uses: PrajwalVandana/generate-mac-installer-github-action@fd5c2a03cfc2be65e32095573392ed03423a4208 # dev branch uses: PrajwalVandana/generate-mac-installer-github-action@9d0c29930827283cce48688cb2154b47c92a4042 with: root-directory: "${{ steps.pyinstaller.outputs.executable_path }}/maestro" scripts-directory: "${{ steps.pyinstaller.outputs.executable_path }}/Scripts" identifier: com.maestro.maestro-cli version: ${{ steps.version.outputs.version }} install-location: /usr/local/bin/maestro-bundle/ - name: Rename .pkg, compress dist run: | mv "${{ steps.pkgbuild.outputs.output-path }}" "${{ steps.pyinstaller.outputs.executable_path }}/${{ matrix.os == 'macos-latest' && 'maestro-apple-silicon' || 'maestro-mac-intel' }}.pkg" tar -czf "${{ steps.pyinstaller.outputs.executable_path }}/${{ matrix.os == 'macos-latest' && 'maestro-apple-silicon' || 'maestro-mac-intel' }}.tar.gz" "${{ steps.pyinstaller.outputs.executable_path }}/maestro" - name: Upload to release uses: softprops/action-gh-release@v2 with: files: | ${{ steps.pyinstaller.outputs.executable_path }}/${{ matrix.os == 'macos-latest' && 'maestro-apple-silicon' || 'maestro-mac-intel' }}.pkg ${{ steps.pyinstaller.outputs.executable_path }}/${{ matrix.os == 'macos-latest' && 'maestro-apple-silicon' || 'maestro-mac-intel' }}.tar.gz token: ${{ secrets.GITHUB_TOKEN }} draft: true prerelease: true pyinstaller-build-linux: runs-on: ubuntu-20.04 steps: - name: Build executable uses: PrajwalVandana/pyinstaller@c7c491de8409921df7045f681695d4d5ab71a4e0 id: pyinstaller with: python_ver: '3.12' spec: specs/maestro-ubuntu.spec requirements: requirements.txt - name: Add install script and compress run: | mv install-scripts/ubuntu "${{ steps.pyinstaller.outputs.executable_path }}/maestro/install-maestro" tar -czf "${{ steps.pyinstaller.outputs.executable_path }}/maestro-ubuntu.tar.gz" "${{ steps.pyinstaller.outputs.executable_path }}/maestro" - name: Upload to release uses: softprops/action-gh-release@v2 with: files: | ${{ steps.pyinstaller.outputs.executable_path }}/maestro-ubuntu.tar.gz token: ${{ secrets.GITHUB_TOKEN }} draft: true prerelease: true ================================================ FILE: .gitignore ================================================ .vscode build *.egg-info **/*.log *.egg __pycache__ *.code-workspace **/*.env .pylintrc todo.md test_songs/ song_info.txt *_test.py *_ref.* sound_vis_cache/ gui_helper.py **/*.spotdl profile.* ================================================ FILE: LICENSE ================================================ Copyright © 2022 Prajwal Vandana Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: install-scripts/mac ================================================ #!/bin/sh MAESTRO_BUNDLE_LOC=/usr/local/bin/maestro-bundle MAESTRO_SYMLINK_LOC=/usr/local/bin/maestro # Make maestro executable chmod +x $MAESTRO_BUNDLE_LOC/maestro # Recursively find and disable quarantine xattr -d -r com.apple.quarantine $MAESTRO_BUNDLE_LOC # Remove existing symlink rm -rf $MAESTRO_SYMLINK_LOC # Create a symlink to the maestro executable ln -s $MAESTRO_BUNDLE_LOC/maestro $MAESTRO_SYMLINK_LOC ================================================ FILE: install-scripts/ubuntu ================================================ #!/bin/sh PREINSTALL_LOC=. MAESTRO_BUNDLE_LOC=/usr/local/bin/maestro-bundle MAESTRO_SYMLINK_LOC=/usr/local/bin/maestro # Make maestro executable echo Changing permissions for maestro executable chmod +x $PREINSTALL_LOC/maestro || echo Failed to change permissions for maestro executable, try running with sudo # Remove existing bundle files echo Removing any existing maestro bundle files at $MAESTRO_BUNDLE_LOC rm -rf $MAESTRO_BUNDLE_LOC || echo Failed to remove any existing maestro bundle files at $MAESTRO_BUNDLE_LOC, try running with sudo # Move files to $MAESTRO_BUNDLE_LOC echo Creating maestro bundle at $MAESTRO_BUNDLE_LOC mkdir -p $MAESTRO_BUNDLE_LOC || echo Failed to create maestro bundle directory at $MAESTRO_BUNDLE_LOC, try running with sudo echo Moving files to $MAESTRO_BUNDLE_LOC mv $PREINSTALL_LOC/maestro $MAESTRO_BUNDLE_LOC/maestro || echo Failed to move maestro executable to $MAESTRO_BUNDLE_LOC, try running with sudo mv $PREINSTALL_LOC/_internal $MAESTRO_BUNDLE_LOC/_internal || echo Failed to move dependency files to $MAESTRO_BUNDLE_LOC, try running with sudo # Remove existing symlink echo Removing any existing symlink at $MAESTRO_SYMLINK_LOC rm -rf $MAESTRO_SYMLINK_LOC || echo Failed to remove any existing maestro symlink at $MAESTRO_SYMLINK_LOC, try running with sudo # Create a symlink to the maestro executable echo Creating symlink to maestro executable ln -s $MAESTRO_BUNDLE_LOC/maestro $MAESTRO_SYMLINK_LOC || echo Failed to create symlink to maestro executable, try running with sudo ================================================ FILE: install-scripts/windows.nsi ================================================ !define PRODUCT_NAME "maestro-cli" OutFile "maestro-installer.exe" Section ; Set installation directory SetOutPath $PROGRAMFILES64\maestro-bundle ; Add files to installation directory File ..\dist\maestro\maestro.exe File /r ..\dist\maestro\_internal ; Add $PROGRAMFILES64\maestro-bundle to PATH EnVar::SetHKCU EnVar::AddValue "Path" "$PROGRAMFILES64\maestro-bundle" ; Add uninstaller registry key WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "DisplayName" "${PRODUCT_NAME}" WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" "UninstallString" "$PROGRAMFILES64\maestro-uninstall.exe" SectionEnd Section -Post WriteUninstaller "$PROGRAMFILES64\maestro-uninstall.exe" SectionEnd Section "Uninstall" ; Remove $PROGRAMFILES64\maestro-bundle RMDir /r "$PROGRAMFILES64\maestro-bundle" ; Remove $PROGRAMFILES64\maestro-bundle from PATH EnVar::SetHKCU EnVar::DeleteValue "Path" "$PROGRAMFILES64\maestro-bundle" ; Remove uninstaller registry key DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" ; Remove uninstaller Delete "$PROGRAMFILES64\maestro-uninstall.exe" SectionEnd ================================================ FILE: maestro/__init__.py ================================================ ================================================ FILE: maestro/__main__.py ================================================ import sys from multiprocessing import freeze_support from maestro.main import cli if __name__ == "__main__": # check if frozen if getattr(sys, "frozen", False): freeze_support() # click passes ctx, no param needed cli() # pylint: disable=no-value-for-parameter ================================================ FILE: maestro/__version__.py ================================================ VERSION = "2.0.6" ================================================ FILE: maestro/config.py ================================================ import os from datetime import date, datetime from urllib.parse import urljoin # region constants DISCORD_ID = 1039038199881810040 PROMPT_MODES_LIST = ["insert", "append", "tag", "find"] PROMPT_MODES = {mode: i for i, mode in enumerate(PROMPT_MODES_LIST)} LOOP_MODES = { "none": 0, "one": 1, "inf": 2, } EXTS = (".mp3", ".wav", ".flac", ".ogg") METADATA_KEYS = ( "album", "albumartist", "artist", "artwork", "comment", "compilation", "composer", "discnumber", "genre", "lyrics", "totaldiscs", "totaltracks", "tracknumber", "tracktitle", "year", "isrc", "#bitrate", "#codec", "#length", "#channels", "#bitspersample", "#samplerate", ) INDIC_SCRIPTS = ( "bengali", "assamese", "modi", "malayalam", "devanagari", "sinhala", "tibetan", "gurmukhi", "tamil", "balinese", "thai", "burmese", "telugu", "kannada", "gujarati", "urdu", "lao", "javanese", "manipuri", "oriya", "khmer", ) CUR_YEAR = date.today().year # region paths MAESTRO_DIR = os.path.join(os.path.expanduser("~"), ".maestro-files/") SETTINGS_FILE = os.path.join(MAESTRO_DIR, "settings.json") LOGFILE = os.path.join(MAESTRO_DIR, "maestro.log") OLD_LOG_DIR = os.path.join(MAESTRO_DIR, "old-logs/") DEFAULT_SETTINGS = { "song_directory": os.path.join(MAESTRO_DIR, "songs/"), "last_version_sync": 0, } OLD_SONGS_INFO_PATH = os.path.join(MAESTRO_DIR, "songs.txt") SONGS_INFO_PATH = os.path.join(MAESTRO_DIR, "songs.json") OLD_STATS_DIR = os.path.join(MAESTRO_DIR, "stats/") OVERRIDE_LYRICS_DIR = os.path.join(MAESTRO_DIR, "override-lyrics/") TRANSLATED_LYRICS_DIR = os.path.join(MAESTRO_DIR, "translated-lyrics/") # endregion # region player HORIZONTAL_BLOCKS = { 1: "▏", 2: "▎", 3: "▍", 4: "▌", 5: "▋", 6: "▊", 7: "▉", 8: "█", } SCRUB_TIME = 5 # in seconds VOLUME_STEP = 1 MIN_PROGRESS_BAR_WIDTH = 20 MIN_VOLUME_BAR_WIDTH, MAX_VOLUME_BAR_WIDTH = 10, 40 LYRIC_PADDING = 3 _FARTHEST_RIGHT_CONTROL_DESC = 5 INDENT_CONTROL_DESC = 0 PLAY_CONTROLS = [ ("SPACE", "pause/play"), ("b", "go [b]ack to previous song"), ("r", "[r]eplay song"), ("n", "skip to [n]ext song"), ( "l", "[l]oop the current song once ('l' in status bar). press again to loop infinitely ('L' in status bar). press once again to turn off looping", ), ("c", "toggle [c]lip mode"), ("v", "toggle [v]isualization"), ("LEFT", "rewind 5s"), ("RIGHT", "fast forward 5s"), ("[/-", "decrease volume"), ("]/=", "increase volume (] has issues on Windows)"), ("m", "[m]ute/unmute"), ( "e", "[e]nd the song player after the current song finishes (indicator in status bar, 'e' to cancel)", ), ("q", "[q]uit the song player immediately"), ( "UP/DOWN", "to scroll through the queue/lyrics (mouse scrolling should also work)", ), ( "SHIFT+UP/DOWN", "move the selected song up/down in the queue", ), ("ENTER", "play the selected song/seek to selected lyric"), ("p", "sna[p] back to the currently playing song/lyric"), ( "g", "go to the next pa[g]e/loop of the queue (ignored if not repeating queue)", ), ( "BACKSPACE/DELETE", "delete the selected (not necessarily currently playing!) song from the queue", ), ("d", "toggle [D]iscord rich presence"), ( "a", "[a]dd a song to the end of the queue (opens a prompt to enter the song name or ID: ENTER to confirm, ESC to cancel)", ), ( "i", "[i]nsert a song in the queue after the selected song (opens a prompt like 'a')", ), ( ",", "add (comma-separated) tag(s) to all songs in the queue (opens a prompt like 'a')", ), ( "s", "toggle [s]tream (streams to maestro-music.vercel.app/listen-along/[USERNAME]), requires login", ), ("y", "toggle l[y]rics"), ( "t", "toggle [t]ranslated lyrics (if available, ignored if lyrics mode is off)", ), ("{/_", "focus queue"), ("}/+", "focus lyrics (} has issues on Windows)"), ( "o", "rel[o]ad song data (useful if you've changed e.g lyrics, tags, metadata, etc. while playing)", ), ("h", "toggle this [h]elp message"), ("f", "[f]ind a song in the queue (opens a prompt like 'a')"), ] for key, desc in PLAY_CONTROLS: if INDENT_CONTROL_DESC < len(key) <= _FARTHEST_RIGHT_CONTROL_DESC: INDENT_CONTROL_DESC = len(key) # endregion # region visualizer FPS = 60 STEP_SIZE = 512 # librosa default VIS_SAMPLE_RATE = STEP_SIZE * FPS VERTICAL_BLOCKS = { 0: " ", 1: "▁", 2: "▂", 3: "▃", 4: "▄", 5: "▅", 6: "▆", 7: "▇", 8: "█", } VISUALIZER_HEIGHT = 8 # should divide 80 WAVEFORM_HEIGHT = 6 # should also divide 80 VIS_FLATTEN_FACTOR = 3 # higher = more flattening; 1 = no flattening WAVEFORM_FLATTEN_FACTOR = 20 # endregion # region stream STREAM_SAMPLE_RATE = 44100 STREAM_CHUNK_SIZE = 256 ICECAST_SERVER = ( "maestro-icecast.eastus2.cloudapp.azure.com" # Azure-hosted Icecast server ) MAESTRO_SITE = "https://maestro-music.vercel.app" # MAESTRO_SITE = "http://localhost:3000" # DEBUG IMAGE_URL = f"{MAESTRO_SITE}/api/get_artwork/" # endregion # region auth AUTH_SERVER = f"{MAESTRO_SITE}/api/" # AUTH_SERVER = "http://localhost:5001/api/" # DEBUG USER_EXISTS_URL = urljoin(AUTH_SERVER, "user_exists") SIGNUP_URL = urljoin(AUTH_SERVER, "signup") LOGIN_URL = urljoin(AUTH_SERVER, "login") UPDATE_METADATA_URL = urljoin(AUTH_SERVER, "update_metadata") UPDATE_ARTWORK_URL = urljoin(AUTH_SERVER, "update_artwork") UPDATE_TIMESTAMP_URL = urljoin(AUTH_SERVER, "update_timestamp") # endregion # endregion settings = {} def print_to_logfile(*args, **kwargs): if "file" in kwargs: raise ValueError("file kwargs not allowed for 'print_to_logfile'") print( datetime.now().strftime("[%Y-%m-%d %H:%M:%S]"), *args, **kwargs, file=open(LOGFILE, "a", encoding="utf-8"), ) ================================================ FILE: maestro/helpers.py ================================================ # region imports import atexit import curses import logging import os import subprocess import threading logging.disable(logging.CRITICAL) import click import msgspec from getpass import getpass from random import randint from time import sleep, time from typing import Iterable from urllib.parse import quote, quote_plus from maestro import config from maestro.config import print_to_logfile # endregion class Song: def __init__(self, song_id: int): if song_id < 1: raise ValueError("Song ID must be greater than 0.") self._song_id = song_id self._metadata = None self._metadata_changed = False self._parsed_lyrics = False self._parsed_override_lyrics = False self._parsed_translated_lyrics = False atexit.register(self._save) def reset(self): self._metadata = None self._metadata_changed = False self._parsed_lyrics = False self._parsed_override_lyrics = False self._parsed_translated_lyrics = False def __eq__(self, value): if not isinstance(value, type(self)): return False return value.song_id == self.song_id def __hash__(self): return hash(self.song_id) def __repr__(self): return f"Song(ID={self.song_id})" @property def override_lyrics_path(self): return os.path.join( config.OVERRIDE_LYRICS_DIR, f"{self.song_title}.lrc" ) @property def translated_lyrics_path(self): return os.path.join( config.TRANSLATED_LYRICS_DIR, f"{self.song_title}.lrc" ) @property def song_id(self): return self._song_id @property def song_file(self): """e.g. song.mp3""" return SONG_DATA[self._song_id]["filename"] @property def song_path(self): """e.g. /path/to/song.mp3""" return os.path.join(config.settings["song_directory"], self.song_file) @property def song_title(self): """e.g. 'song'""" return os.path.splitext(self.song_file)[0] @song_title.setter def song_title(self, v): old_path = self.song_path old_override_lyrics_path = self.override_lyrics_path old_translated_lyrics_path = self.translated_lyrics_path SONG_DATA[self._song_id]["filename"] = ( v + os.path.splitext(self.song_file)[1] ) if os.path.exists(old_path): os.rename(old_path, self.song_path) if os.path.exists(old_override_lyrics_path): os.rename(old_override_lyrics_path, self.override_lyrics_path) if os.path.exists(old_translated_lyrics_path): os.rename(old_translated_lyrics_path, self.translated_lyrics_path) self._load_metadata() self._metadata["tracktitle"] = v self._metadata_changed = True @property def tags(self) -> set[str]: return SONG_DATA[self._song_id]["tags"] @tags.setter def tags(self, v: set[str]): SONG_DATA[self._song_id]["tags"] = v @property def clips(self) -> dict[str, list[int, int]]: return SONG_DATA[self._song_id]["clips"] @property def set_clip(self) -> str: return SONG_DATA[self._song_id]["set-clip"] @set_clip.setter def set_clip(self, v: str): SONG_DATA[self._song_id]["set-clip"] = v @property def listen_times(self) -> dict[int | str, float]: return SONG_DATA[self._song_id]["stats"] def _load_metadata(self): import music_tag self._metadata = music_tag.load_file(self.song_path) @property def artist(self): return self.get_metadata("artist") or "No Artist" @artist.setter def artist(self, v): self.set_metadata("artist", v) @property def album(self): return self.get_metadata("album") or "No Album" @album.setter def album(self, v): self.set_metadata("album", v) @property def album_artist(self): return self.get_metadata("albumartist") or "No Album Artist" @album_artist.setter def album_artist(self, v): self.set_metadata("albumartist", v) @property def duration(self): return self.get_metadata("#length") @property def artwork(self): if self._metadata is None: self._load_metadata() return ( self._metadata["artwork"].first if "artwork" in self._metadata else None ) @property def raw_lyrics(self) -> str | None: return self.get_metadata("lyrics") @raw_lyrics.setter def raw_lyrics(self, v): if self._metadata is None: self._load_metadata() if v is None and "lyrics" in self._metadata: del self._metadata["lyrics"] else: self._metadata["lyrics"] = v self._parsed_lyrics = False self._metadata_changed = True @property def raw_override_lyrics(self) -> str | None: if not os.path.exists(self.override_lyrics_path): return None with open(self.override_lyrics_path, "r", encoding="utf-8") as f: return f.read() @raw_override_lyrics.setter def raw_override_lyrics(self, v): import safer if v is None: if os.path.exists(self.override_lyrics_path): os.remove(self.override_lyrics_path) else: os.makedirs(config.OVERRIDE_LYRICS_DIR, exist_ok=True) with safer.open( self.override_lyrics_path, "w", encoding="utf-8" ) as f: f.write(v) self._parsed_override_lyrics = False @property def raw_translated_lyrics(self) -> str | None: if not os.path.exists(self.translated_lyrics_path): return None with open(self.translated_lyrics_path, "r", encoding="utf-8") as f: return f.read() @raw_translated_lyrics.setter def raw_translated_lyrics(self, v): import safer if v is None: if os.path.exists(self.translated_lyrics_path): os.remove(self.translated_lyrics_path) else: os.makedirs(config.TRANSLATED_LYRICS_DIR, exist_ok=True) with safer.open( self.translated_lyrics_path, "w", encoding="utf-8" ) as f: f.write(v) def _parse_lyrics(self, raw_lyrics): if raw_lyrics is None: return None raw_lyrics_list = raw_lyrics.splitlines() for line in raw_lyrics_list: if line and not line.strip().startswith("["): # not LRC format return raw_lyrics_list import pylrc return pylrc.parse(raw_lyrics) if raw_lyrics else None @property def parsed_lyrics(self): if self._parsed_lyrics is False: self._parsed_lyrics = self._parse_lyrics(self.raw_lyrics) return self._parsed_lyrics @property def parsed_override_lyrics(self): if self._parsed_override_lyrics is False: self._parsed_override_lyrics = self._parse_lyrics( self.raw_override_lyrics ) return self._parsed_override_lyrics @property def parsed_translated_lyrics(self): if self._parsed_translated_lyrics is False: self._parsed_translated_lyrics = self._parse_lyrics( self.raw_translated_lyrics ) return self._parsed_translated_lyrics def get_metadata(self, key, resolve=True): """ Get metadata value for `key`. If 'resolve' is False, then a MetadataItem is returned instead of the resolved value. """ if self._metadata is None: self._load_metadata() if key not in self._metadata: return None if resolve: return self._metadata[key].first return self._metadata[key] def set_metadata(self, key, value): if self._metadata is None: self._load_metadata() if key not in config.METADATA_KEYS: raise ValueError(f"{key} is not a valid metadata key.") if key.startswith("#"): raise ValueError(f"{key} is not editable.") if key == "tracktitle": self.song_title = value # also change file names elif key == "lyrics": self.raw_lyrics = value # unset self._parsed_lyrics elif value is None: if key in self._metadata: del self._metadata[key] else: self._metadata[key] = value self._metadata_changed = True def remove_from_data(self): del SONG_DATA[self.song_id] def _save(self): if self._metadata_changed: self._metadata.save() class SongData: def __init__(self): self.songs = None atexit.register(self._save) def load(self): self.songs = {} with open(config.SONGS_INFO_PATH, "r", encoding="utf-8") as f: s = f.read() if not s: return d = msgspec.json.decode(s) for k, v in d.items(): self.songs[int(k)] = v if "tags" not in v: v["tags"] = set() else: v["tags"] = set(v["tags"]) v["stats_"] = {} for year in v["stats"]: if year.isdigit(): v["stats_"][int(year)] = v["stats"][year] else: v["stats_"][year] = v["stats"][year] v["stats"] = v.pop("stats_") if "set-clip" not in v: v["set-clip"] = "default" def __getitem__(self, key): if self.songs is None: self.load() return self.songs[key] def __setitem__(self, key, value): if self.songs is None: self.load() self.songs[key] = value def __delitem__(self, key): if self.songs is None: self.load() del self.songs[key] def __iter__(self): if self.songs is None: self.load() return iter(self.songs) def items(self): if self.songs is None: self.load() return self.songs.items() def values(self): if self.songs is None: self.load() return self.songs.values() def _save(self): import safer if self.songs is not None: with safer.open(config.SONGS_INFO_PATH, "wb") as f: f.write(msgspec.json.encode(self.songs)) def add_song(self, filename, tags=None): if tags is None: tags = set() if self.songs is None: self.load() if not self.songs: song_id = 1 else: song_id = max(self) + 1 self.songs[song_id] = { "filename": os.path.split(filename)[1], "tags": set(tags), "clips": {}, "stats": { config.CUR_YEAR: 0.0, "total": 0.0, }, "set-clip": "default", } song = Song(song_id) song.set_metadata("tracktitle", song.song_title) return song SONG_DATA = SongData() class Songs: """ Wrapper around dict of all `Song` objects. """ def __init__(self): self._songs = None self._song_data = SONG_DATA def load(self): self._songs = {Song(k) for k in self._song_data} def __contains__(self, value: Song): if self._songs is None: self.load() return value in self._songs def __iter__(self) -> Iterable[Song]: if self._songs is None: self.load() return iter(self._songs) def __len__(self): if self._songs is None: self.load() return len(self._songs) SONGS = Songs() def is_safe_username(url): return quote(url, safe="") == url if url else False def bounded_shuffle(lst, radius=-1): """ Randomly shuffle `lst`, but with the constraint that each element can only move at most `radius` positions away from its original position. To shuffle with no bounds, set `radius = -1`. """ n = len(lst) if radius == -1: radius = n elif radius == 0: return index_at = list(range(n)) for i in range(n - 1, 0, -1): j = randint(max(0, index_at[i] - radius), i) index_at[j], index_at[i] = index_at[i], index_at[j] lst[j], lst[i] = lst[i], lst[j] def set_timeout(func, timeout, *args, **kwargs): def wrapper(): sleep(timeout) func() threading.Thread( target=wrapper, daemon=True, args=args, kwargs=kwargs ).start() class Scroller: def __init__(self, num_lines, win_size): self.num_lines = num_lines self.win_size = win_size self.pos = 0 self.top = 0 def scroll_forward(self): if self.pos < self.num_lines - 1: if ( self.pos == self.halfway and self.top < self.num_lines - self.win_size ): self.top += 1 self.pos += 1 def scroll_backward(self): if self.pos > 0: if self.pos == self.halfway and self.top > 0: self.top -= 1 self.pos -= 1 @property def halfway(self): return self.top + (self.win_size - 1) // 2 def resize(self, win_size=None): if win_size is not None: self.win_size = win_size self.top = max(0, self.pos - (self.win_size - 1) // 2) self.top = max(0, min(self.num_lines - self.win_size, self.top)) def refresh(self): self.resize() def fit_string_to_width(s, width, length_so_far): if length_so_far + len(s) > width: remaining_width = width - length_so_far if remaining_width >= 3: s = s[: (remaining_width - 3)] + "..." else: s = "." * remaining_width length_so_far += len(s) return s, length_so_far def addstr_fit_to_width(stdscr, s, width, length_so_far, *args, **kwargs): s, length_so_far = fit_string_to_width(s, width, length_so_far) if s: if length_so_far <= width: stdscr.addstr(s, *args, **kwargs) else: stdscr.addstr(s[:-1], *args, **kwargs) stdscr.insstr(s[-1], *args, **kwargs) return length_so_far class FFmpegProcessHandler: def __init__(self, username, password): self.process = None self.username = username self.password = password def start(self): threading.Thread(target=self._start, daemon=True).start() def _start(self): from spotdl.utils.ffmpeg import get_ffmpeg_path self.process = subprocess.Popen( # fmt: off [ str(get_ffmpeg_path()), "-re", # Read input at native frame rate "-f", "s16le", # Raw PCM 16-bit little-endian audio "-ar", str(config.STREAM_SAMPLE_RATE), # Set the audio sample rate "-ac", "2", # Set the number of audio channels to 2 (stereo) '-i', 'pipe:', # Input from stdin "-f", "mp3", # Output format # "-report", # DEBUG f"icecast://{self.username}:{self.password}@{config.ICECAST_SERVER}:8000/{self.username}", # Azure-hosted maestro Icecast URL ], # fmt: on stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) def terminate(self): if self.process is not None: self.process.terminate() self.process = None def restart(self): self.terminate() self.start() def write(self, chunk): if self.process is not None: try: self.process.stdin.write(chunk) except BrokenPipeError as e: # pylint: disable=unused-variable print_to_logfile("FFmpeg processs error:", e) class PlaybackHandler: def __init__( self, stdscr: "curses._CursesWindow", playlist: list[Song], clip_mode, visualize, stream, creds, want_lyrics, want_translated_lyrics, ): from just_playback import Playback self.stdscr = stdscr self.scroller = Scroller( len(playlist), stdscr.getmaxyx()[0] - 2 # -2 for status bar ) self.playlist = playlist self.i = 0 self._volume = 0 self.clip_mode = clip_mode self.want_discord = False self.want_vis = visualize # want to visualize self._want_stream = stream # want to stream self.username, self.password = creds self.want_lyrics = want_lyrics self.want_translated_lyrics = want_translated_lyrics and want_lyrics self.playback = Playback() self._paused = False self.last_timestamp = 0 self.looping_current_song = config.LOOP_MODES["none"] self.duration = 0 self.restarting = False self.ending = False self.prompting: None | tuple = None self.clip = (0, 0) self.italic = True self.can_mac_now_playing = False self.mac_now_playing = None self.update_now_playing = False self.discord_connected = 2 self.discord_rpc = None self.discord_last_update = 0 self.can_update_discord = True # pypresence installed self.discord_updating = False # lock self._librosa = None self.can_visualize = True self.can_show_visualization = ( self.want_vis # space to show visualization and self.screen_height > config.VISUALIZER_HEIGHT + 5 ) self.audio_data = None self.audio_data = {} # dict(song_id: (vis data, stream data)) self.audio_processing_thread = threading.Thread( target=self._audio_processing_loop, daemon=True, ) self.audio_processing_thread.start() self.compiled = None self.ffmpeg_process = FFmpegProcessHandler(self.username, self.password) if self.want_stream: self.ffmpeg_process.start() self.break_stream_loop = False self.streaming_thread = threading.Thread( target=self._streaming_loop, daemon=True, ) self.streaming_thread.start() self.lyrics: list | None = None self.translated_lyrics: list | None = None self.lyrics_scroller = Scroller(0, 0) self.lyrics_width = 50 self.lyric_pos = None self.show_help = False self.help_pos = 0 self._focus = 0 # 0: queue, 1: lyrics def _load_audio(self, path, sr): import numpy as np # shape = (# channels, # frames) audio_data = self._librosa.load(path, mono=False, sr=sr)[0] if len(audio_data.shape) == 1: # mono -> stereo audio_data = np.repeat([audio_data], 2, axis=0) elif audio_data.shape[0] == 1: # mono -> stereo audio_data = np.repeat(audio_data, 2, axis=0) elif audio_data.shape[0] == 6: # 5.1 -> stereo audio_data = np.delete(audio_data, (1, 3, 4, 5), axis=0) return audio_data def _audio_processing_loop(self): import numpy as np try: from librosa import load, stft, amplitude_to_db self._librosa = type( "librosa", (), { "load": staticmethod(load), "stft": staticmethod(stft), "amplitude_to_db": staticmethod(amplitude_to_db), }, ) except ImportError: self.can_visualize = False self.can_show_visualization = False print_to_logfile( "Librosa not installed. Visualization will be disabled." ) while True: try: keys_to_delete = [] for k in self.audio_data: if k not in self.playlist[self.i : self.i + 5]: keys_to_delete.append(k) for k in keys_to_delete: del self.audio_data[k] for i in range(self.i, min(self.i + 5, len(self.playlist))): processing_song = self.playlist[i] if self.song != processing_song and ( self.song not in self.audio_data or ( self.want_vis and self.audio_data[self.song][0] is None ) or ( self.want_stream and self.audio_data[self.song][1] is None ) ): break if processing_song in self.audio_data and ( ( self.audio_data[processing_song][0] is not None or not self.want_vis ) and ( self.audio_data[processing_song][1] is not None or not self.want_stream ) or self._librosa is None ): continue processing_song_path = ( os.path.join( # NOTE: NOT SAME AS self.song_path config.settings["song_directory"], self.playlist[i].song_file, ) ) if processing_song not in self.audio_data: self.audio_data[processing_song] = [ ( self._librosa.amplitude_to_db( np.abs( self._librosa.stft( self._load_audio( processing_song_path, sr=config.VIS_SAMPLE_RATE, ) ) ), ref=np.max, ) + 80 if self.want_vis and self.can_visualize else None ), ( np.int16( self._load_audio( processing_song_path, sr=config.STREAM_SAMPLE_RATE, ) * (2**15 - 1) * 0.5 # reduce volume (avoid clipping) ) # convert to 16-bit PCM if self.want_stream else None ), ] else: if ( self.audio_data[processing_song][0] is None and self.want_vis and self.can_visualize ): self.audio_data[processing_song][0] = ( self._librosa.amplitude_to_db( np.abs( self._librosa.stft( self._load_audio( processing_song_path, sr=config.VIS_SAMPLE_RATE, ) ) ), ref=np.max, ) + 80 ) if ( self.audio_data[processing_song][1] is None and self.want_stream ): self.audio_data[processing_song][1] = np.int16( self._load_audio( processing_song_path, sr=config.STREAM_SAMPLE_RATE, ) * (2**15 - 1) * 0.5 # reduce volume (avoid clipping) ) # convert to 16-bit PCM except: # hacky fix # pylint: disable=bare-except pass sleep(1) def _streaming_loop(self): while True: if ( self.want_stream and self.username is not None and self.audio_data is not None and self.song in self.audio_data and self.audio_data[self.song][1] is not None and self.playback is not None # is 0 for a while after resuming, and is -1 if playback is # inactive or file is not loaded and self.playback.curr_pos > 0 ): for fpos in range( int(self.playback.curr_pos * config.STREAM_SAMPLE_RATE), self.audio_data[self.song][1].shape[1], config.STREAM_CHUNK_SIZE, ): try: # print_to_logfile( # self.song_id, # fpos / config.STREAM_SAMPLE_RATE, # self.playback.curr_pos, # ) # DEBUG self.ffmpeg_process.write( self.audio_data[self.song][1][ :, fpos : fpos + config.STREAM_CHUNK_SIZE, ] .reshape((-1,), order="F") .tobytes() if not self.paused else b"\x00" * 4 * config.STREAM_CHUNK_SIZE ) except KeyError as e: print_to_logfile("KeyError in streaming loop:", e) self.update_stream_metadata() if self.break_stream_loop: self.break_stream_loop = False break sleep(0.01) # region properties @property def paused(self): return self._paused @paused.setter def paused(self, value): self._paused = value if self.want_stream: self.threaded_update_icecast_metadata() @property def volume(self): return self._volume @volume.setter def volume(self, v): self._volume = v if self.playback is not None: self.playback.set_volume(v / 100) @property def want_stream(self): return self._want_stream @want_stream.setter def want_stream(self, value): if self._librosa is None: value = False self._want_stream = value @property def song(self): return self.playlist[self.i] @property def song_id(self): return self.song.song_id @property def song_file(self): return self.song.song_file @property def song_path(self): return self.song.song_path @property def song_title(self): return self.song.song_title @property def song_artist(self): return self.song.artist @property def song_album(self): return self.song.album @property def album_artist(self): return self.song.album_artist @property def artwork(self): return ( self.song.artwork.raw_thumbnail([1024, 1024]) if self.song.artwork else None ) @property def screen_height(self): return self.stdscr.getmaxyx()[0] @property def screen_width(self): return self.stdscr.getmaxyx()[1] @property def can_show_lyrics(self): return self.want_lyrics and self.lyrics is not None @property def can_show_translated_lyrics(self): return ( self.want_translated_lyrics and self.translated_lyrics is not None ) @property def focus(self): return self._focus if self.can_show_lyrics else 0 @focus.setter def focus(self, value): self._focus = value if self.can_show_lyrics else 0 # endregion def seek(self, pos): if self.playback is not None: pos = max(self.clip[0] if self.clip_mode else 0, pos) self.playback.seek(pos) self.break_stream_loop = True if self.can_mac_now_playing and self.mac_now_playing is not None: self.mac_now_playing.pos = round(pos) self.update_now_playing = True self.last_timestamp = pos def scroll_forward(self): if self.show_help: self.help_pos += 1 elif self.focus == 1: if self.lyric_pos is None: self.lyric_pos = self.lyrics_scroller.pos self.lyric_pos = min(self.lyric_pos + 1, len(self.lyrics) - 1) elif self.focus == 0: self.scroller.scroll_forward() def scroll_backward(self): if self.show_help: self.help_pos -= 1 elif self.focus == 1: if self.lyric_pos is None: self.lyric_pos = self.lyrics_scroller.pos self.lyric_pos = max(self.lyric_pos - 1, 0) elif self.focus == 0: self.scroller.scroll_backward() def snap_back(self): if self.focus == 1: self.lyric_pos = None elif self.focus == 0: self.scroller.pos = self.i self.scroller.refresh() def set_volume(self, v): """Set volume w/o changing self.volume.""" self.playback.set_volume(v / 100) def quit(self): self.playback.stop() if self.ffmpeg_process is not None: self.ffmpeg_process.terminate() if self.discord_rpc is not None: self.discord_rpc.close() def prompting_delete_char(self): if self.prompting[1] > 0: self.prompting = ( self.prompting[0][: self.prompting[1] - 1] + self.prompting[0][self.prompting[1] :], self.prompting[1] - 1, self.prompting[2], ) def update_screen(self): self.output(self.playback.curr_pos) def connect_to_discord(self): try: from pypresence import Client as DiscordRPCClient except ImportError: print_to_logfile( "pypresence not installed. Discord presence will be disabled." ) self.can_update_discord = False discord_rpc = DiscordRPCClient(client_id=config.DISCORD_ID) discord_rpc.start() return discord_rpc def update_discord_metadata(self): if self.discord_updating: return if self.can_update_discord and self.want_discord: self.discord_updating = True t = time() if self.discord_last_update + 15 > t: sleep(15 - (t - self.discord_last_update)) song_name, artist_name, album_name = "", "", "" # minimum 2 characters (Discord requirement) new_song_name = self.song_title.ljust(2) new_artist_name = "by " + self.song_artist new_album_name = self.song_album.ljust(2) if ( new_song_name != song_name or new_artist_name != artist_name or new_album_name != album_name ): song_name = new_song_name artist_name = new_artist_name album_name = new_album_name d = dict( details=song_name, state=artist_name, large_image=( f"{config.IMAGE_URL}/{self.username}?_={time()}" if self.username else "maestro-icon" ), small_image="maestro-icon-small", large_text=album_name, buttons=( [ { "label": "Listen Along", "url": f"{config.MAESTRO_SITE}/listen-along/{self.username}", } ] if self.username and self.want_stream else None ), ) d = {k: v for k, v in d.items() if v is not None} try: self.discord_rpc.set_activity(**d) self.discord_last_update = time() except Exception as e: # pylint: disable=bare-except print_to_logfile("Discord update error:", e) song_name, artist_name, album_name = "", "", "" self.discord_connected = 2 try: self.discord_rpc = self.connect_to_discord() self.discord_connected = 1 self.discord_updating = False self.update_discord_metadata() except Exception as err: print_to_logfile("Discord connection error:", err) self.discord_connected = 0 finally: self.discord_updating = False def update_mac_now_playing_metadata(self): from maestro.icon import img as default_artwork if self.can_mac_now_playing: self.mac_now_playing.paused = False self.mac_now_playing.pos = 0 self.mac_now_playing.length = self.duration self.mac_now_playing.cover = ( self.artwork if self.artwork else default_artwork ) multiprocessing_put_word( self.mac_now_playing.title_queue, self.song_title, ) multiprocessing_put_word( self.mac_now_playing.artist_queue, self.song_artist, ) self.update_now_playing = True def update_icecast_metadata(self): import requests return requests.post( config.UPDATE_METADATA_URL, data={ "mount": self.username, "song": quote_plus(self.song_title), "artist": quote_plus(self.song_artist), "album": quote_plus(self.song_album), "albumartist": quote_plus(self.album_artist), "paused": int(self.paused), }, auth=(self.username, self.password), timeout=5, ) def icecast_metadata_update_loop(self): success = False last_metadata_update_attempt = 0 while not success: t = time() if t - last_metadata_update_attempt > 5: try: response = self.update_icecast_metadata() if response.ok: last_metadata_update_attempt = 0 success = True else: raise Exception( f"Icecast Server error {response.status_code}: {response.text}" ) except Exception as e: # retry in 5 seconds print_to_logfile("Update metadata failed:", e) last_metadata_update_attempt = t sleep(0.01) def threaded_update_icecast_metadata(self): threading.Thread( target=self.icecast_metadata_update_loop, daemon=True ).start() def update_stream_metadata(self): # artwork + icecast metadata import requests self.break_stream_loop = True if ( self.discord_connected or self.want_stream and self.username is not None ): if not requests.post( config.UPDATE_ARTWORK_URL, params={"mount": self.username}, files={"artwork": self.artwork}, auth=(self.username, self.password), timeout=5, ).ok: print_to_logfile("Failed to update artwork.") if self.want_stream: self.threaded_update_icecast_metadata() def update_metadata(self): def f(): self.update_mac_now_playing_metadata() self.update_stream_metadata() self.update_discord_metadata() threading.Thread(target=f, daemon=True).start() def initialize_discord(self): self.want_discord = True try: self.discord_rpc = self.connect_to_discord() self.discord_connected = 1 except Exception as e: # pylint: disable=broad-except,unused-variable self.discord_connected = 0 print_to_logfile("Discord connection error:", e) def threaded_initialize_discord(self): threading.Thread(target=self.initialize_discord, daemon=True).start() def output(self, pos): from maestro.jit_funcs import render screen_height = self.screen_height if self.want_lyrics: screen_width = self.screen_width - self.lyrics_width else: screen_width = self.screen_width self.can_show_visualization = ( self.want_vis and self.can_visualize and screen_height > config.VISUALIZER_HEIGHT + 5 ) self.scroller.resize( screen_height - 3 # -3 for status bar - 1 # -1 for header - (self.prompting != None) # - add mode # - visualizer - (config.VISUALIZER_HEIGHT if self.can_show_visualization else 0) ) if self.clip_mode: pos -= self.clip[0] self.stdscr.erase() length_so_far = 0 if self.want_discord: if self.discord_connected == 2: length_so_far = addstr_fit_to_width( self.stdscr, "Connecting to Discord ... ", screen_width, length_so_far, curses.color_pair(12), ) elif self.discord_connected == 1: length_so_far = addstr_fit_to_width( self.stdscr, "Discord connected! ", screen_width, length_so_far, curses.color_pair(17), ) else: length_so_far = addstr_fit_to_width( self.stdscr, "Failed to connect to Discord. ", screen_width, length_so_far, curses.color_pair(14), ) visualize_message = "" visualize_color = 12 if self.want_vis: if self.audio_data is None and self.can_visualize: self.audio_processing_thread = threading.Thread( target=self._audio_processing_loop, daemon=True, ) self.audio_data = {} self.audio_processing_thread.start() if not self.can_visualize: visualize_message = "Librosa is required for visualization." visualize_color = 14 elif not self.can_show_visualization: visualize_message = "Window too small for visualization." visualize_color = 14 elif self.song not in self.audio_data: visualize_message = "Loading visualization..." visualize_color = 12 elif not self.compiled: visualize_message = "Compiling renderer..." visualize_color = 12 if self.want_stream: prefix = " " if self.want_discord else "" if self.username: long_stream_message = ( prefix + f"Streaming at {config.MAESTRO_SITE}/listen-along/{self.username}" ) short_stream_message = prefix + f"Streaming as {self.username}!" if ( length_so_far + len(long_stream_message) + 2 + (len(visualize_message) if visualize_message else -2) < screen_width ): length_so_far = addstr_fit_to_width( self.stdscr, long_stream_message, screen_width, length_so_far, curses.color_pair(16), ) else: length_so_far = addstr_fit_to_width( self.stdscr, short_stream_message, screen_width, length_so_far, curses.color_pair(16), ) else: length_so_far = addstr_fit_to_width( self.stdscr, prefix + "Please log in to stream.", screen_width, length_so_far, curses.color_pair(14), ) length_so_far = addstr_fit_to_width( self.stdscr, " " * (screen_width - length_so_far - len(visualize_message)) + visualize_message, screen_width, length_so_far, curses.color_pair(visualize_color), ) self.stdscr.move(1, 0) song_display_color = 5 if self.looping_current_song else 3 progress_bar_display_color = ( 17 if (self.clip_mode and self.clip != (0, self.duration)) else 15 ) # for aligning song names longest_song_id_length = max( len(str(song.song_id)) for song in self.playlist ) for j in range( self.scroller.top, self.scroller.top + self.scroller.win_size ): if j <= len(self.playlist) - 1: length_so_far = 0 length_so_far = addstr_fit_to_width( self.stdscr, " " * ( longest_song_id_length - len(str(self.playlist[j].song_id)) ) + f"{self.playlist[j].song_id} ", screen_width, length_so_far, curses.color_pair(2), ) if j == self.i: length_so_far = addstr_fit_to_width( self.stdscr, f"{self.playlist[j].song_title} ", screen_width, length_so_far, curses.color_pair(song_display_color) | curses.A_BOLD, ) else: length_so_far = addstr_fit_to_width( self.stdscr, f"{self.playlist[j].song_title} ", screen_width, length_so_far, ( curses.color_pair(4) if (j == self.scroller.pos) else curses.A_NORMAL ), ) length_so_far = addstr_fit_to_width( self.stdscr, f"{', '.join(self.playlist[j].tags)}", screen_width, length_so_far, curses.color_pair(2), ) self.stdscr.move((j - self.scroller.top) + 2, 0) if self.prompting is not None: # pylint: disable=unsubscriptable-object if self.prompting[2] == config.PROMPT_MODES["tag"]: adding_song_length = addstr_fit_to_width( self.stdscr, "Add tag(s) to songs: " + self.prompting[0], screen_width, 0, ) else: adding_song_length = addstr_fit_to_width( self.stdscr, config.PROMPT_MODES_LIST[self.prompting[2]].capitalize() + " song: " + self.prompting[0], screen_width, 0, ) self.stdscr.move(self.stdscr.getyx()[0] + 1, 0) length_so_far = 0 length_so_far = addstr_fit_to_width( self.stdscr, ("| " if self.paused else "> ") + f"({self.song_id}) ", screen_width, length_so_far, curses.color_pair(song_display_color + 10), ) length_so_far = addstr_fit_to_width( self.stdscr, f"{self.song_title} ", screen_width, length_so_far, curses.color_pair(song_display_color + 10) | curses.A_BOLD, ) length_so_far = addstr_fit_to_width( self.stdscr, "%d/%d " % (self.i + 1, len(self.playlist)), screen_width, length_so_far, curses.color_pair(12), ) length_so_far = addstr_fit_to_width( self.stdscr, f"{'c' if self.clip_mode else ' '}", screen_width, length_so_far, curses.color_pair(17) | curses.A_BOLD, ) loop_char = " " if self.looping_current_song == config.LOOP_MODES["one"]: loop_char = "l" elif self.looping_current_song == config.LOOP_MODES["inf"]: loop_char = "L" length_so_far = addstr_fit_to_width( self.stdscr, loop_char, screen_width, length_so_far, curses.color_pair(15) | curses.A_BOLD, ) volume_line_length_so_far = addstr_fit_to_width( self.stdscr, f"{'e' if self.ending else ' '} ", screen_width, length_so_far, curses.color_pair(14) | curses.A_BOLD, ) addstr_fit_to_width( self.stdscr, " " * (screen_width - volume_line_length_so_far - 1), screen_width, volume_line_length_so_far, curses.color_pair(16), ) self.stdscr.insstr( # hacky fix for curses bug " ", curses.color_pair(16), ) self.stdscr.move( screen_height - 2 - (config.VISUALIZER_HEIGHT if self.can_show_visualization else 0), 0, ) addstr_fit_to_width( self.stdscr, " " * (screen_width - 1), screen_width, 0, curses.color_pair(16), ) self.stdscr.insstr( # hacky fix for curses bug " ", curses.color_pair(16), ) self.stdscr.move( self.stdscr.getyx()[0], 0, ) song_data_length_so_far = addstr_fit_to_width( self.stdscr, self.song_artist + " - ", screen_width, 0, curses.color_pair(12), ) if self.italic: try: song_data_length_so_far = addstr_fit_to_width( self.stdscr, self.song_album, screen_width, song_data_length_so_far, curses.color_pair(12) | curses.A_ITALIC, ) except: # pylint: disable=bare-except self.italic = False print_to_logfile("Failed to italicize text in curses.") if not self.italic: song_data_length_so_far = addstr_fit_to_width( self.stdscr, self.song_album, screen_width, song_data_length_so_far, curses.color_pair(12), ) addstr_fit_to_width( self.stdscr, f" ({self.album_artist})", screen_width, song_data_length_so_far, curses.color_pair(12), ) self.stdscr.move( screen_height - (config.VISUALIZER_HEIGHT if self.can_show_visualization else 0) - 1, 0, ) length_so_far = 0 secs = int(pos) length_so_far = addstr_fit_to_width( self.stdscr, f"{format_seconds(secs)} / {format_seconds(self.duration)} ", screen_width, length_so_far, curses.color_pair(progress_bar_display_color), ) if not length_so_far >= screen_width: if ( screen_width - length_so_far >= config.MIN_PROGRESS_BAR_WIDTH + 2 ): progress_bar_width = screen_width - length_so_far - 2 bar = "|" progress_block_width = ( progress_bar_width * 8 * pos ) // self.duration for _ in range(progress_bar_width): if progress_block_width > 8: bar += config.HORIZONTAL_BLOCKS[8] progress_block_width -= 8 elif progress_block_width > 0: bar += config.HORIZONTAL_BLOCKS[progress_block_width] progress_block_width = 0 else: bar += " " self.stdscr.addstr( bar, curses.color_pair(progress_bar_display_color) ) self.stdscr.insstr( # hacky fix for curses bug "|", curses.color_pair(progress_bar_display_color) ) else: self.stdscr.addstr( " " * (screen_width - length_so_far - 1), curses.color_pair(16), ) self.stdscr.insstr( # hacky fix for curses bug " ", curses.color_pair(16) ) # right align volume bar if not volume_line_length_so_far >= screen_width: self.stdscr.move( screen_height - 3 - ( config.VISUALIZER_HEIGHT if self.can_show_visualization else 0 ), volume_line_length_so_far, ) if ( screen_width - volume_line_length_so_far >= config.MIN_VOLUME_BAR_WIDTH + 10 ): bar = f"{str(int(self.volume)).rjust(3)}/100 |" volume_bar_width = min( screen_width - volume_line_length_so_far - (len(bar) + 1), config.MAX_VOLUME_BAR_WIDTH, ) block_width = int(volume_bar_width * 8 * self.volume / 100) for _ in range(volume_bar_width): if block_width > 8: bar += config.HORIZONTAL_BLOCKS[8] block_width -= 8 elif block_width > 0: bar += config.HORIZONTAL_BLOCKS[block_width] block_width = 0 else: bar += " " bar += "|" bar = bar.rjust(screen_width - volume_line_length_so_far) self.stdscr.addstr(bar, curses.color_pair(16)) elif screen_width - volume_line_length_so_far >= 7: self.stdscr.addstr( f"{str(int(self.volume)).rjust(3)}/100".rjust( screen_width - volume_line_length_so_far ), curses.color_pair(16), ) if self.can_show_visualization: if self.clip_mode: pos += self.clip[0] self.stdscr.move( screen_height - ( config.VISUALIZER_HEIGHT if self.can_show_visualization else 0 ), 0, ) if ( self.song not in self.audio_data or self.audio_data[self.song][0] is None ): self.stdscr.addstr( ( (" " * (screen_width - 1) + "\n") * config.VISUALIZER_HEIGHT ).rstrip() ) elif not self.compiled: if self.compiled is None: self.compiled = False def thread_func(): vdata = self.audio_data[self.song][0] render( screen_width, vdata, min(round(pos * config.FPS), vdata.shape[2] - 1), config.VISUALIZER_HEIGHT, ) self.compiled = True threading.Thread(target=thread_func, daemon=True).start() self.stdscr.addstr( ( (" " * (screen_width - 1) + "\n") * config.VISUALIZER_HEIGHT ).rstrip() ) elif self.compiled: vdata = self.audio_data[self.song][0] rendered_lines = render( screen_width, vdata, min( round(pos * config.FPS), # fmt: off vdata.shape[2] - 1, ), config.VISUALIZER_HEIGHT, ) for i in range(len(rendered_lines)): self.stdscr.addstr(rendered_lines[i][:-1]) self.stdscr.insstr(rendered_lines[i][-1]) if i < len(rendered_lines) - 1: self.stdscr.move(self.stdscr.getyx()[0] + 1, 0) if self.can_show_lyrics: from grapheme import graphemes # self.stdscr.redrawwin() # workaround for foreign characters num_lines = min( len(self.lyrics), ( screen_height // 2 if self.can_show_translated_lyrics else screen_height - 1 ), ) cur_lyric_i = None is_timed = is_timed_lyrics(self.lyrics) if is_timed: for i, lyric in enumerate(self.lyrics): if lyric.time > pos: cur_lyric_i = i - 1 break if cur_lyric_i is None: cur_lyric_i = len(self.lyrics) - 1 self.lyrics_scroller.pos = ( self.lyric_pos or cur_lyric_i or self.lyrics_scroller.pos ) self.lyrics_scroller.resize(num_lines) if not is_timed: self.lyrics_scroller.pos = min( max( self.lyrics_scroller.win_size // 2, self.lyrics_scroller.pos, ), self.lyrics_scroller.num_lines - self.lyrics_scroller.win_size // 2, ) self.stdscr.move(0, screen_width) lyric_focus_msg = ( f"Focus: {'lyrics' if self.focus == 1 else 'queue'}" ) addstr_fit_to_width( self.stdscr, " " * (self.lyrics_width - len(lyric_focus_msg)) + lyric_focus_msg, self.lyrics_width, 0, curses.color_pair(19), ) for i in range( self.lyrics_scroller.top, self.lyrics_scroller.top + num_lines ): vertical_pos = (i - self.lyrics_scroller.top) * ( 2 if self.can_show_translated_lyrics else 1 ) + 1 style = curses.color_pair(9) # pylint: disable=unsubscriptable-object lyric_text = get_lyric(self.lyrics[i]).strip() if cur_lyric_i is not None: if i == cur_lyric_i: style = curses.color_pair(9) | curses.A_BOLD self.stdscr.move( vertical_pos, screen_width + config.LYRIC_PADDING - 2, ) self.stdscr.addstr("> ", style) elif i == self.lyric_pos: style = curses.color_pair(4) self.stdscr.move( vertical_pos, screen_width + config.LYRIC_PADDING - 2, ) self.stdscr.addstr("> ", style) elif i < cur_lyric_i: style = curses.color_pair(9) | curses.A_DIM try: width = 0 for g in graphemes(lyric_text): width += 1 # NOTE: why -1? No one knows. if width < self.lyrics_width - config.LYRIC_PADDING: self.stdscr.move( vertical_pos, screen_width + config.LYRIC_PADDING + width - 1, ) self.stdscr.addstr(g, style) else: break except curses.error: # bottom right corner errors break if ( self.can_show_translated_lyrics and i < len(self.translated_lyrics) and self.stdscr.getyx()[0] < screen_height ): style = curses.A_DIM if i == cur_lyric_i: style |= curses.A_BOLD elif i == self.lyric_pos and is_timed: style |= curses.color_pair(4) try: width = 0 for g in graphemes( get_lyric(self.translated_lyrics[i]).strip() ): width += 1 if ( width < self.lyrics_width - config.LYRIC_PADDING - 1 ): self.stdscr.move( vertical_pos + 1, screen_width + config.LYRIC_PADDING + 1 + width - 1, ) self.stdscr.addstr(g, style) else: break except curses.error: # bottom right corner errors break elif self.want_lyrics: self.stdscr.move(0, screen_width + config.LYRIC_PADDING) addstr_fit_to_width( self.stdscr, "No lyrics found.", self.lyrics_width - config.LYRIC_PADDING, 0, curses.color_pair(4), ) if self.show_help: l = 15 r = self.screen_width - l t = 5 b = self.screen_height - t if l < r and t < b: # draw border self.stdscr.addch(t, l, curses.ACS_ULCORNER) self.stdscr.addch(t, r, curses.ACS_URCORNER) self.stdscr.addch(b, l, curses.ACS_LLCORNER) self.stdscr.addch(b, r, curses.ACS_LRCORNER) for x in range(l + 1, r): self.stdscr.addch(t, x, curses.ACS_HLINE) self.stdscr.addch(b, x, curses.ACS_HLINE) for y in range(t + 1, b): self.stdscr.addch(y, l, curses.ACS_VLINE) self.stdscr.addch(y, r, curses.ACS_VLINE) # draw text self.stdscr.move(t + 1, l + 1) i = max( 0, min(self.help_pos, len(config.PLAY_CONTROLS) - (b - t - 1)), ) while self.stdscr.getyx()[0] < b: if i < len(config.PLAY_CONTROLS): key, desc = config.PLAY_CONTROLS[i] length_so_far = addstr_fit_to_width( self.stdscr, key + " " * (config.INDENT_CONTROL_DESC - len(key)) + " ", r - l - 1, 0, curses.color_pair(18) | curses.A_BOLD, ) length_so_far = addstr_fit_to_width( self.stdscr, desc + " " * (r - l - 1 - length_so_far - len(desc)), r - l - 1, length_so_far, curses.color_pair(18), ) i += 1 self.stdscr.move(self.stdscr.getyx()[0] + 1, l + 1) else: self.stdscr.addstr(" " * (r - l - 1)) if self.prompting is not None: # pylint: disable=unsubscriptable-object self.stdscr.move( screen_height - ( config.VISUALIZER_HEIGHT if self.can_show_visualization else 0 ) - 4, # 4 lines for status bar + adding entry adding_song_length + (self.prompting[1] - len(self.prompting[0])), ) self.stdscr.refresh() def init_curses(stdscr): curses.start_color() curses.use_default_colors() # region colors curses.init_pair(1, curses.COLOR_WHITE, -1) curses.init_pair(2, curses.COLOR_BLACK + 8, -1) # bright black curses.init_pair(3, curses.COLOR_BLUE, -1) curses.init_pair(4, curses.COLOR_RED, -1) curses.init_pair(5, curses.COLOR_YELLOW, -1) curses.init_pair(6, curses.COLOR_GREEN, -1) curses.init_pair(7, curses.COLOR_MAGENTA, -1) curses.init_pair(9, curses.COLOR_CYAN, -1) curses.init_pair(11, curses.COLOR_WHITE, curses.COLOR_BLACK) curses.init_pair(12, curses.COLOR_BLACK + 8, curses.COLOR_BLACK) curses.init_pair(13, curses.COLOR_BLUE, curses.COLOR_BLACK) curses.init_pair(14, curses.COLOR_RED, curses.COLOR_BLACK) curses.init_pair(15, curses.COLOR_YELLOW, curses.COLOR_BLACK) curses.init_pair(16, curses.COLOR_GREEN, curses.COLOR_BLACK) curses.init_pair(17, curses.COLOR_MAGENTA, curses.COLOR_BLACK) curses.init_pair(18, -1, curses.COLOR_BLACK) curses.init_pair(19, curses.COLOR_CYAN, curses.COLOR_BLACK) # endregion curses.curs_set(False) stdscr.nodelay(True) try: curses.set_escdelay(25) # 25 ms except: # pylint: disable=bare-except pass class SongParamType(click.ParamType): name = "song" def convert(self, value, param, ctx) -> Song: if value.isdecimal(): value = int(value) if type(value) == int: song = Song(value) if song in SONGS: return song self.fail(f"No song found with ID {value}.", param, ctx) if not value.isdecimal(): results = search_song(value) if not any(results): self.fail(f"No song found matching '{value}'.", param, ctx) for result in results: if len(result) == 1: return result[0] if len(result) > 1: break if param is not None: # called by click for song in sum(results, []): print_entry(song, value) self.fail("Multiple songs found", param, ctx) self.fail("Invalid song argument", param, ctx) CLICK_SONG = SongParamType() def yt_embed_artwork(yt_dlp_info, crop): import music_tag import requests yt_dlp_info["thumbnails"].sort(key=lambda d: d["preference"]) best_thumbnail = yt_dlp_info["thumbnails"][-1] # default thumbnail if "width" not in best_thumbnail: # different so that any square thumbnail is chosen best_thumbnail["width"] = 0 best_thumbnail["height"] = -1 for thumbnail in yt_dlp_info["thumbnails"][:-1]: # print(thumbnail) if "height" in thumbnail and ( ( thumbnail["height"] == thumbnail["width"] and ( (best_thumbnail["width"] != best_thumbnail["height"]) or ( thumbnail["height"] >= best_thumbnail["height"] and thumbnail["width"] >= best_thumbnail["width"] ) ) ) or ( thumbnail["height"] >= best_thumbnail["height"] and (thumbnail["width"] >= best_thumbnail["width"]) and (best_thumbnail["width"] != best_thumbnail["height"]) ) ): best_thumbnail = thumbnail response = requests.get(best_thumbnail["url"], timeout=5) image_data = response.content if best_thumbnail["width"] != best_thumbnail["height"] and crop: from io import BytesIO from PIL import Image image = Image.open(BytesIO(image_data)) width, height = image.size if width > height: image = image.crop( ( (width - height) // 2, 0, (width + height) // 2, height, ) ) elif height > width: image = image.crop( ( 0, (height - width) // 2, width, (height + width) // 2, ) ) image_data = BytesIO() image.save(image_data, format="JPEG") image_data = image_data.getvalue() m = music_tag.load_file(yt_dlp_info["requested_downloads"][0]["filepath"]) m["artwork"] = image_data m.save() def clip_editor(stdscr, song: Song, name, start=None, end=None): from just_playback import Playback playback = Playback() playback.load_file(song.song_path) init_curses(stdscr) if name in song.clips: clip_start, clip_end = song.clips[name] else: clip_start, clip_end = 0, playback.duration if start is not None: clip_start = start if end is not None: clip_end = end editing_start = True change_output = True playback.play() playback.pause() playback.seek(clip_start) last_timestamp = playback.curr_pos while True: if playback.curr_pos >= clip_end: playback.pause() change_output = change_output or ( (playback.curr_pos - last_timestamp) >= (playback.duration / (8 * (stdscr.getmaxyx()[1] - 2))) ) if change_output: clip_editor_output( stdscr, song, playback.curr_pos, playback.paused, playback.duration, clip_start, clip_end, editing_start, ) c = stdscr.getch() next_c = stdscr.getch() while next_c != -1: c, next_c = next_c, stdscr.getch() if c == -1: continue change_output = False if editing_start: if c == curses.KEY_LEFT: change_output = True playback.pause() clip_start = max(0, clip_start - 0.1) playback.seek(clip_start) elif c == curses.KEY_SLEFT: change_output = True playback.pause() clip_start = max(0, clip_start - 1) playback.seek(clip_start) elif c == curses.KEY_RIGHT: change_output = True playback.pause() clip_start = min(clip_start + 0.1, clip_end) playback.seek(clip_start) elif c == curses.KEY_SRIGHT: change_output = True playback.pause() clip_start = min(clip_start + 1, clip_end) playback.seek(clip_start) elif c == curses.KEY_ENTER: break else: c = chr(c) if c == " ": # space change_output = True if playback.playing: playback.pause() else: playback.resume() elif c in "tT": change_output = True playback.pause() playback.seek(clip_end - 1) editing_start = False elif c in "qQ": return (None, None) elif c in "\r\n": break else: if c == curses.KEY_LEFT: change_output = True playback.pause() clip_end = max(clip_end - 0.1, clip_start) playback.seek(clip_end - 1) elif c == curses.KEY_SLEFT: change_output = True playback.pause() clip_end = max(clip_end - 1, clip_start) playback.seek(clip_end - 1) elif c == curses.KEY_RIGHT: change_output = True playback.pause() clip_end = min(clip_end + 0.1, playback.duration) playback.seek(clip_end - 1) elif c == curses.KEY_SRIGHT: change_output = True playback.pause() clip_end = min(clip_end + 1, playback.duration) playback.seek(clip_end - 1) elif c == curses.KEY_ENTER: break else: c = chr(c) if c == " ": change_output = True if playback.playing: playback.pause() else: playback.resume() elif c in "tT": change_output = True playback.pause() playback.seek(clip_start) editing_start = True elif c in "qQ": return (None, None) elif c in "\r\n": break return clip_start, clip_end def clip_editor_output( stdscr, song: Song, pos, paused, duration, clip_start, clip_end, editing_start, ): stdscr.erase() if stdscr.getmaxyx()[0] < 3: stdscr.addstr("Window too small.", curses.color_pair(4)) stdscr.refresh() return screen_width = stdscr.getmaxyx()[1] stdscr.insstr( f"{format_seconds(clip_start, show_decimal=True)}" + (" <" if editing_start else ""), curses.color_pair(7), ) end_str = ( "> " if not editing_start else "" ) + f"{format_seconds(clip_end, show_decimal=True)}" stdscr.move(0, screen_width - len(end_str)) stdscr.insstr(end_str, curses.color_pair(7)) stdscr.move(1, 0) clip_bar_width = screen_width - 2 if clip_bar_width > 0: bar = "|" before_clip_block_width = round( (clip_bar_width * 8 * clip_start) / duration ) clip_block_width = round( clip_bar_width * 8 * (clip_end - clip_start) / duration ) num_chars_added = 0 stdscr.addstr("|", curses.color_pair(7)) while before_clip_block_width: if before_clip_block_width >= 8: stdscr.addstr(" ", curses.color_pair(7)) before_clip_block_width -= 8 else: stdscr.addstr( config.HORIZONTAL_BLOCKS[before_clip_block_width], curses.color_pair(7) | curses.A_REVERSE, ) clip_block_width -= 8 - before_clip_block_width before_clip_block_width = 0 num_chars_added += 1 while num_chars_added < clip_bar_width: if clip_block_width >= 8: stdscr.addstr(config.HORIZONTAL_BLOCKS[8], curses.color_pair(7)) clip_block_width -= 8 elif clip_block_width > 0: stdscr.addstr( config.HORIZONTAL_BLOCKS[clip_block_width], curses.color_pair(7), ) clip_block_width = 0 else: stdscr.addstr(" ", curses.color_pair(7)) num_chars_added += 1 stdscr.insstr("|", curses.color_pair(7)) stdscr.move(stdscr.getyx()[0] + 1, 0) progress_bar_width = screen_width - 2 if progress_bar_width > 0: bar = "|" progress_block_width = (progress_bar_width * 8 * pos) // duration for _ in range(progress_bar_width): if progress_block_width > 8: bar += config.HORIZONTAL_BLOCKS[8] progress_block_width -= 8 elif progress_block_width > 0: bar += config.HORIZONTAL_BLOCKS[progress_block_width] progress_block_width = 0 else: bar += " " stdscr.addstr(bar, curses.color_pair(5)) stdscr.insstr("|", curses.color_pair(5)) # hacky fix for curses bug stdscr.move(stdscr.getyx()[0] + 1, 0) stdscr.move(stdscr.getyx()[0] + 1, 0) # 1-line spacing # region pause indicator, song ID+title, tags length_so_far = 0 length_so_far = addstr_fit_to_width( stdscr, ("| " if paused else "> ") + f"({song.song_id}) ", screen_width, length_so_far, curses.color_pair(3), ) length_so_far = addstr_fit_to_width( stdscr, f"{song.song_title} ", screen_width, length_so_far, curses.color_pair(3) | curses.A_BOLD, ) length_so_far = addstr_fit_to_width( stdscr, f"{', '.join(song.tags)} ", screen_width, length_so_far, curses.color_pair(2), ) stdscr.move(stdscr.getyx()[0] + 1, 0) # endregion # region credits length_so_far = 0 length_so_far = addstr_fit_to_width( stdscr, f"{song.artist} - ", screen_width, length_so_far, curses.color_pair(2), ) try: length_so_far = addstr_fit_to_width( stdscr, song.album, screen_width, length_so_far, curses.color_pair(2) | curses.A_ITALIC, ) except: # pylint: disable=bare-except print_to_logfile("Failed to italicize text in curses.") length_so_far = addstr_fit_to_width( stdscr, song.album, screen_width, length_so_far, curses.color_pair(2), ) addstr_fit_to_width( stdscr, f" ({song.album_artist})", screen_width, length_so_far, curses.color_pair(2), ) stdscr.move(stdscr.getyx()[0] + 1, 0) # endregion stdscr.move(stdscr.getyx()[0] + 1, 0) # 1-line spacing # region controls controls = [ ("t", "toggle between editing the start and end of the clip"), ( "LEFT/RIGHT", "move whichever clip end you are editing by 0.1 seconds", ), ( "SHIFT+LEFT/RIGHT", "move whichever clip end you are editing by 1 second", ), ("SPACE", "play/pause"), ("ENTER", "exit the editor and save the clip"), ("q", "exit the editor without saving the clip"), ] for control in controls: length_so_far = 0 length_so_far = addstr_fit_to_width( stdscr, f"{control[0]}: ", screen_width, length_so_far, curses.A_BOLD, ) length_so_far = addstr_fit_to_width( stdscr, f"{control[1]}", screen_width, length_so_far, ) stdscr.move(stdscr.getyx()[0] + 1, 0) # endregion stdscr.refresh() def get_username(): import keyring return keyring.get_password("maestro-music", "username") def get_password(): import keyring return keyring.get_password("maestro-music", "password") def signup(username=None, password=None, login_=True): if username is None: username = input("Username: ") if not username: click.secho("Username cannot be empty.", fg="red") return if not is_safe_username(username): click.secho( "Username must be URL-safe (no spaces or special characters).", fg="red", ) return import requests response = requests.get( config.USER_EXISTS_URL, params={"user": username}, timeout=5 ) if response.status_code == 200: click.secho(f"Username {username} already exists.", fg="red") return if password is None: password = getpass("Password (8-1024 characters):") if len(password) < 8 or len(password) > 1024: click.secho("Passwords should be 8-1024 characters long.", fg="red") return confirm_password = getpass("Confirm password:") if password != confirm_password: click.secho("Passwords do not match.", fg="red") return response = requests.post( config.SIGNUP_URL, auth=(username, password), timeout=5 ) if response.status_code == 201: click.secho(f"Successfully signed up user '{username}'!", fg="green") if login_: login(username, password) else: click.secho( f"Signup failed with status code {response.status_code}: {response.text}", fg="red", ) def login(username=None, password=None): if username is None: username = input("Username: ") if not username: click.secho("Username cannot be empty.", fg="red") return if not is_safe_username(username): click.secho( "Username must be URL-safe (no spaces or special characters).", fg="red", ) return import keyring current_username = keyring.get_password("maestro-music", "username") if current_username == username: click.secho(f"User '{username}' is already logged in.", fg="yellow") return if current_username is not None: click.secho( f"Logging in as user '{username}' will log out current user '{current_username}'.", fg="yellow", ) import requests if password is None: password = getpass("Password:") response = requests.post( config.LOGIN_URL, auth=(username, password), timeout=5 ) if response.status_code == 200: click.secho(f"Successfully logged in user '{username}'!", fg="green") keyring.set_password("maestro-music", "username", username) keyring.set_password("maestro-music", "password", password) else: click.secho( f"Login failed with status code {response.status_code}: {response.text}", fg="red", ) def format_seconds(secs, show_decimal=False, digital=True, include_hours=None): """Format seconds into a string. show_decimal: whether to show the decimal part of the seconds digital: whether to use digital format ([HH]:MM:SS) or words (e.g. 1h 2m 3s) include_hours: whether to include hours in the output (e.g. 71:05 vs 1:11:05) """ h = int(secs // 3600) if include_hours is None: include_hours = h > 0 m = int(secs // 60) if include_hours: m %= 60 s = int(secs % 60) if digital: return ( (f"{h}:" if include_hours else "") + f"{m}:{s:02}" + (f".{secs%1:0.2f}"[2:] if show_decimal else "") ) return ( (f"{h}h " if include_hours else "") + f"{m}m {s}" + (f".{secs%1:0.2f}"[2:] if show_decimal else "") + "s" ) def search_song(phrase): """ CASE INSENSITIVE. Returns a tuple of three lists: 0: songs that match the phrase exactly 1: songs that start with the phrase 1: songs that contain the phrase but do not start with it """ phrase = phrase.lower() results = [], [], [] # is, starts, contains but does not start for song in SONGS: song_title = song.song_title.lower() if song_title == phrase: results[0].append(song) elif song_title.startswith(phrase): results[1].append(song) elif phrase in song_title: results[2].append(song) return results def print_entry( song: Song, highlight: str | None = None, year: int | str | None = None ): """ Pretty prints ([] means optional) [ - ()] highlight: a string to highlight (the first occurrence of) in the song name """ click.secho(f"{song.song_id} ", fg="bright_black", nl=False) if highlight is None: click.secho(song.song_title + " ", fg="blue", nl=False, bold=True) else: highlight_loc = song.song_title.lower().find(highlight.lower()) click.secho( song.song_title[:highlight_loc], fg="blue", nl=False, bold=True, ) click.secho( song.song_title[highlight_loc : highlight_loc + len(highlight)], fg="yellow", nl=False, bold=True, ) click.secho( song.song_title[highlight_loc + len(highlight) :] + " ", fg="blue", nl=False, bold=True, ) click.secho( format_seconds( song.duration, show_decimal=True, digital=False, ) + " ", nl=False, ) if year is not None: click.secho( format_seconds( song.listen_times.get(year, 0), show_decimal=True, digital=False, ) + " ", fg="yellow", nl=False, ) click.secho( f"{song.listen_times.get(year, 0) / song.duration:0.2f} ", fg="green", nl=False, ) if song.set_clip in song.clips: start, end = map( lambda f: format_seconds(f, show_decimal=True), song.clips[song.set_clip], ) click.secho( f"[{start}, {end}] ", fg="magenta", nl=False, ) click.secho(", ".join(song.tags), fg="bright_black") click.secho( f"{(len(str(song.song_id))+1)*' '}{song.artist if song.artist else 'No Artist'} - ", fg="bright_black", nl=False, ) click.secho( (song.album if song.album else "No Album"), italic=True, fg="bright_black", nl=False, ) click.secho( f" ({song.album_artist if song.album_artist else 'No Album Artist'})", fg="bright_black", ) def multiprocessing_put_word(q, word): for c in word: q.put(c) q.put("\n") def versiontuple(v): return tuple(map(int, v.split("."))) def pluralize(count, word, include_count=True): return f"{count} " * include_count + word + ("s" if count != 1 else "") def is_timed_lyrics(lyrics): from pylrc.classes import Lyrics return isinstance(lyrics, Lyrics) def get_lyric(lyric_obj): if isinstance(lyric_obj, str): return lyric_obj return lyric_obj.text # pylrc.classes.LyricLine def set_lyric(lyrics, i, val): if isinstance(lyrics[i], str): lyrics[i] = val else: lyrics[i].text = val def display_lyrics(lyrics, song, prefix: str = ""): if prefix: prefix += " " if lyrics is None: click.secho( f'No {prefix}lyrics found for "{song.song_title}" (ID: {song.song_id}).', fg="red", ) return if prefix: prefix = prefix.capitalize() + "l" else: prefix = "L" click.echo(f"{prefix.capitalize()}yrics for ", nl=False) click.secho(song.song_title, fg="blue", bold=True, nl=False) click.echo(f" (ID {song.song_id}):") if is_timed_lyrics(lyrics): for lyric in lyrics: click.echo( f"\t[{format_seconds(lyric.time, show_decimal=True)}] {lyric.text}" ) else: click.echo("\n".join([f"\t{lyric}" for lyric in lyrics])) def filter_songs( tags: set[str], exclude_tags, artists, albums, album_artists, match_all, combine_artists, ): songs = [] for song in SONGS: search_criteria = ( ( ( any( artist.lower() in song.artist.lower() + ( f", {song.album_artist.lower()}" if combine_artists else "" ) for artist in artists ) ), artists, ), ( (any(album.lower() in song.album.lower() for album in albums)), albums, ), ( ( any( album_artist.lower() in song.album_artist.lower() + ( f", {song.artist.lower()}" if combine_artists else "" ) for album_artist in album_artists ) ), album_artists, ), ) search_criteria = tuple( c[0] for c in filter(lambda t: t[1], search_criteria) ) if match_all: if not search_criteria: search_criteria = (True,) search_criteria = all(search_criteria) and ( not tags or (tags <= song.tags) # subset ) elif any(search_criteria): search_criteria = True elif tags: search_criteria = tags & song.tags else: search_criteria = not search_criteria if search_criteria and not exclude_tags & song.tags: songs.append(song) return songs ================================================ FILE: maestro/icon.py ================================================ img = b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x04\x00\x00\x00\x04\x00\x08\x06\x00\x00\x00\x7f\x1d+\x83\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00DeXIfMM\x00*\x00\x00\x00\x08\x00\x01\x87i\x00\x04\x00\x00\x00\x01\x00\x00\x00\x1a\x00\x00\x00\x00\x00\x03\xa0\x01\x00\x03\x00\x00\x00\x01\x00\x01\x00\x00\xa0\x02\x00\x04\x00\x00\x00\x01\x00\x00\x04\x00\xa0\x03\x00\x04\x00\x00\x00\x01\x00\x00\x04\x00\x00\x00\x00\x00\xd3\xdd\xea\x1d\x00\x00\x00\x1ciDOT\x00\x00\x00\x02\x00\x00\x00\x00\x00\x00\x02\x00\x00\x00\x00(\x00\x00\x02\x00\x00\x00\x02\x00\x00\x00m.{a\xb5\x85\x00\x00@\x00IDATx\x01\xec\xdd\t\x94]U\x99\xb7q\x036\xd8*\x1f*4\xcb\x01m\xdan\xc4V\xcc\x00$L"S\x18\xc2\x1cl\x8d@\x83\x11\x88\x04ADTh%B\xa2\x08\x01T\x94\xb4H0i\x08H0D\x8cH\x18\x12\xc2\x10P\xa2L\x8d\x88\xb4\xda6\xa2\x80\xa8 4C\x90\x0cd\x7fgWSB\x92\xaaJ\x9d[w8g\xef_\xadU+P\xb9u\xef9\xef~\xf6\xde\xef\xff\xc9\xb9\xe7\xbe\xe2\x15\xbeT\xa0\xa8\xc0\xad\xb7\xde\xfa\xfa\x85\x0b\x17\x0e+\xbe\x0f\xbc\xe5\x96[\x8e-\xfe\xff\xd4\xe2\xcf)\xc5\xf7\xcc\xe2\xbf\xe7\x17\x7f\xdeU\xfc\xf9\xdb\xe2\xcfg\x8a\xefe\xc5\x7f\x07\xdfj\x80\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x81\xce0\x10{\xf2\xd8\x9b\x17\xf5\x8f=z\xec\xd5c\xcf>\xb3\xf8\x9eR\xfcw\xec\xe5\x8f\x8d\xbd}\xec\xf1\x8b\xff\x7f\xbd\xd0\xa3\x02*\x90Y\x05\xae\xbb\xee\xba7\xdct\xd3M;\xc5\xc5\xa0\xf8>\xa7\xf8\x9eS,\x06\xf7\x14\xdfO\x15\xdf\x02\xbd\x1a`\x00\x03\x18\xc0\x00\x060\x80\x01\x0c` Q\x06\x8a\xde\xff\x7fc\xef\x1f3\xc0\x8bY\xe0\xd8\x98\rbF\xc8,\x169]\x15H\xab\x02\x97_~\xf9\xda\x85\xe9{O\xf1}H1\xb9\xcf,\xbe\xaf-&\xfb\xc3B>\xc9\x81\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x01\x0c\xf4\xc0\xc0\xc313\xc4\xec\x103D\xcc\x121S\xa4\x95\x92\x9c\x8d\n$R\x81+\xaf\xbcr\xbdb\x12\x8f,\xbeO-\xbe\xe7\x17\xdfO\x17\xdf\xec\xad\x1a`\x00\x03\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x8d2\x103E\xcc\x16\xa7\x16\xdf#c\xe6H$>9\r\x15\xa8W\x05\xe2\xe4+\xec\xdc\x01\xc5w|\x8f\xfe\xdd\xc5\xf7r\x81\x9f\xf0\xc0\x00\x060\x80\x01\x0c`\x00\x03\x18\xc0\x00\x06Z\xc5@\xcc\x1c/f\x8f\x98A\x0e \x04\xea\x95!\x1dm\xcd*\x10/\xc3)&\xda\x89\xc5\x84\xbe\xa9\xf8^\xda\xaa\x89\xedym\x1a\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x18\xc0@?\x18\x88\x99\xe4\xa6\x98QbV\xa9Y\xbcr\xb8*P\xad\n\x14\xef\xb9Y\xa7\x98L{\x17\x93jZ\xf1\xed\xfd\xfb.\xddj\xf4\xd2-\xbf\x87\x1d\x0c`\x00\x03\x18\xc0\x00\x060\x80\x81v0\x103\xcb\xb4\x98ab\x96\xa9V\xbar4*P\xc1\n\x14w\xe1|e1i\xf6,&\xcd\x7f\x14\xdfO\x16\xff\xdd\x8e\x89\xea5\xd4\x19\x03\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x18h\x1a\x031\xcb\xc4L\x13\xb3M\xcc8\x15\x8c^\x0eI\x05:S\x81\x89\x13\'\xaeUL\x8c\x91\xc5\x04\xb9\xa0\xf8~\\\xe8\'=0\x80\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x81T\x18\x88\x19\xe7\xc5\xac\xb3k\xcc>\x9dI]^U\x05:\\\x81\xe2}2o-&\xf5\xa9\xc5\xf7oS\x99\xdc\xce\xc3F\x85\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x01\x0c\xf4\xc1@\xcc>\xa7\xc6,\xd4\xe18\xe6\xe5U\xa0\xf5\x15\x88\x97\xbf\x14\xf6+\xde\xbd\xff\xea\x02\xfc\x17\xfa\x98\x18M\xbb\xfc\xc6kX\x801\x80\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x81\x8a1\xf0B\xccD1\x1by\x8b@\xebs\xa8Whs\x05\xe6\xcf\x9f\xff\xe6\x02\xee/\x15\xdf\xbf\xaf\xd8\xc4#\x1a\xbc\xd7\x0b\x03\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x18\xe8\x18\x031#\xc5\xac\x143S\x9bc\x9a\x97S\x81\xe6V\xe0\xc5\x8f\xee\xbb\xa8\x08\xfdK\x04\x7f\xd6\x15\x03\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x18\xc0@\xaf\x0c,)D\xc0E>R\xb0\xb9\x99\xd4\xb3\xb5\xa1\x02\xc5\xa4\x8e7\xf5\xbb\xce\xe4\xeeurw\xcc0\x1a\x13c\x82\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x81j3\xf0b\x96\x1a\xd9\x86\xe8\xe6%T\xa0\xe1\n\x0c*l\xd5\x98b1\xb9\xc7\x82R\xed\x05\xc5\xf8\x18\x1f\x0c`\x00\x03\x18\xc0\x00\x060\x80\x01\x0c\xd4\x82\x81{b\xc6*\x12\xda\xa0\x86S\x9a_T\x81fW\xe0\xe6\x9bo\x1e]X\xaa{-"\xb5XD\\}\xe0=n\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x18\xa8\x11\x031k\xc5\xcc\xd5\xec\x1c\xe7\xf9T\xa0T\x05\n\x10\xf7.\xbe\xef\x12\xfc\x05\x7f\x0c`\x00\x03\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\xade f\xaf\x98\xc1J\x856\x0fV\x81\x81V\xa0\x80n\xd7br/2\xc1[;\xc1\xd5W}1\x80\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x81\x1e\x18X\x143\xd9@s\x9d\xdfW\x81>+P\xbc\xffd\xd3\x02\xbe\x1f\xf4\x00\xa0K\x88jt\t\x91\xf1\xb3\x89`\x00\x03\x18\xc0\x00\x060\x80\x01\x0c` \t\x06~\x103Z\x9f!\xce_\xaa@\xd9\n\\s\xcd5\xff\xaf0Lg\x17\x8b\x84\x8f\xf3\x13\xf4\xc9\x1e\x0c`\x00\x03\x18\xc0\x00\x060\x80\x01\x0c`\xa0:\x0c\xc4\x8f\x0f<;f\xb6\xb29\xcf\xe3U`\xa5\nL\x9c8q\xad\x02\xa6qE\xf0\xff#C\x98\x84!\xb4PWg\xa16\x16\xc6\x02\x03\x18\xc0\x00\x060\x80\x01\x0c`\xa0\x99\x0c\xfc1f\xb7\x98\xe1V\nu\xfeG\x05\xfaS\x81\xe2.\x93\xc3\x8b\xd0\xff\x9f\x82\xbf\xe0\x8f\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x01\x0c`\xa06\x0c\xfcg\xccr\xfd\xc9|\x1e\xa3\x02\xaf\x987o\xdek\ns\xf4\xd5\xe2{\xb9I^\x9bI\xdeLs\xe8\xb9\x98h\x0c`\x00\x03\x18\xc0\x00\x060\x80\x01\x0c\xd4\x98\x81\x98\xe5b\xa6\x8b\xd9N\xc4U\x81^+P\xdc@b\xf7\x02\x94\xdf\x08\xfe\x82?\x060\x80\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x81z3\x10\xb3]\xccx\xbd\x06@\x7f\x91g\x05\x16,X\xb0A1\xb9g\x98\xe0\xf5\x9e\xe0\xc6\xcf\xf8a\x00\x03\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03=00#f\xbe<\xd3\xae\xb3^\xa9\x02\xc5\xfbC\xf6-\x00q\x93\xbf\x1a_\xe2\xd3\xc3\x04w\xc9\x96\xf1\xc4\x00\x060\x80\x01\x0c`\x00\x03\x18\xc0\x00\x06^\xce\xc0\x1fc\xf6[)\x0c\xfa\x9f|*p\xf9\xe5\x97\xffmqI\xc8y\xc2#C\x88\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x01\x0c` \x0f\x06b\x06\x8cY0\x9f\xe4\xebL_q\xd3M7\r-\x06\xfe~\x93<\x8fIn\x9c\x8d3\x060\x80\x01\x0c`\x00\x03\x18\xc0\x00\x060\xd0\xcd@\xcc\x821\x13\x8a\xc6\xe9W`Pq\x13\x88O\x17\x03\xbf\xa4{\xf0\xfdi!\xc0\x00\x060\x80\x01\x0c`\x00\x03\x18\xc0\x00\x060\x90\x1d\x03Kb6,"\xf0\xa0\xf4cp\x86gX\x18\x9e\r\x8bI=\xdf\xc4\xcenb\xbf\xfc}?\xfe\xdb\xfb\xc00\x80\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x81\x9730?f\xc5\x0c#r\xba\xa7\\\x0c\xe8VE\xf0\xff\xad\xf0/\xfcc\x00\x03\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x18\xc0\xc0*\x0c\xfc6f\xc6t\x13qFgV\xbc\xbf\xe3\xc8\xe2\xfb\xf9U\x06\xf8\xe5\xc6\xc7\x7f3\x80\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x18\xc0\x00\x062f f\xc6\x98\x1d3\x8a\xcai\x9d\xea5\xd7\\\xb3n1\x80\x17\x08\xfe\xec\x1e\x060\x80\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x01\x0c\xf4\x87\x81\x98!c\x96L+\x1d\'~6\xc5\xe5\x1b\x1b\x17\x03w{\x7f\x06\xd8c,\x04\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x18\xc0\x00\x060\xd0\xcd@\xcc\x921S&\x1e\x9b\xd38\xbdb\xa0\x86\x16\x03\xf7H\xf7\xe0\xf9\xd3D\xc6\x00\x060\x80\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x81\x92\x0c<\x12\xb3e\x1a)9\xd1\xb3(>\xc6a\xaf\xc2\xd6s\xe6\xcc\xae\x9b\x95\xc4K\x8f\xd6Yg\x9d>\x17\x1f\x81U`\xc7\x00\x060\x80\x01\x0c`\x00\x03\x18\xc0@g\x19\x88={\xec\xdd\xe3\r\x07c//+\xb5.+\xc5,\x1a3i\xc1\xbc\xafVW\xe0\xfa\xeb\xaf_\xbf(\xf8m\x80n>\xd0\xe7\x9f\x7f~\x183fLx\xf3\x9b\xdf,\xf03\xce\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x18\xa81\x03\xb1\xa7\x8f\xbd}\xec\xf1e\xa7\xe6g\xa7\x98Ic6mu\xfe\xcd\xfa\xf9\x8bK-6,\n}7\x80\x9b\x07\xf0\xb4i\xd3\xba\xee:\xfa\xc67\xbe\xd1\x02_\xe3\x05\xbeX\x18\x8c\x9f\x1a`\x00\x03\x18\xc0\x00\x060\x80\x01\x0c\xf4\xc8@\xec\xf5\xe3\'\r\xc4\xde_\x96j^\x96\x8a\xd94f\xd4\xacCz\xabN>\x16\xb6\x80\xf5>\xc0\x0e\x1c\xd8Y\xb3fu}\x1e\xa9\x7f\xe9\x17\x9a\x89\x03\x0c`\x00\x03\x18\xc0\x00\x060\x80\x81\xbc\x18\x88\x19\xe0\xb0\xc3\x0e\x0b1\x13\xc8V\x03\xcfV1\xa3\x92\x00\xc5*\xd2\xcc\xaf\x17/\xfb\xf7/\xff\xb76\x0e\xe8\x8d7\xde\x18&N\x9c\x18\xb6\xdcr\xcb\x1e\xad`1^~\xae\x06\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x18\xc8\x88\x81\x98\rbF\x88Y\x81\x0ch\xf9k\xae\xb9f\xdd\xc2\x8a\xdc\x0e\x94\x95A\x99;wn\x18=ztXk\xad\xb5V\x9b\\B\x98\x10\x8a\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x01\x0cT\x8d\x81\x98]b\x86\x89YF\xbe[9\xdf\xc5\xcc\x1b\xb3o1fy\x7f\x15\x85\xb8\x00\x1c+\xc3q\xd2I\'\xb9\xb3?\xabJ\xfc`\x00\x03\x18\xc0\x00\x060\x80\x01\x0c`\xa0\x96\x0c\xc4O\x0c\x88\x99F\xce[9\xe7\xc5\xec\x9bu\xfa/n\xfaw\x04(^\x82\xe2\xb2\xcb.\x0b\xc3\x86\r\xab\xe5$/@v\xdcj\x80\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x01\x0c\xfc\x95\x81\x98mb\xc6\x91\xf9^\xca|1\x03g)\x01\x8a\xf7@lU\x18\x90\xe7\xc1pk(j\x11\xc6\x8f\x1f\x1f\xd6Yg\x9d\xbfN\x16\x81\x9aP\xc0\x00\x060\x80\x01\x0c`\x00\x03\x18\xc0\x00\x06\xea\xce@\xcc81\xeb\xc4\xcc#\xfbu}2\xc0\xf3E-\xb6*\xc65\x9f\xaf\xe2\x847,\x06\xff\xb7\x00\xb85L\x9b6-\xfc\xd3?\xfd\x93\xe0\xcf\x94b\x00\x03\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\xc92\xb0\xe9\xa6\x9bve\x1f\x19\xb0\xebj\x80\xdf\xc6L\x9c\x8b\x01\x18T\x0c\xfa\xfc\xdc\x07\xbe\xb8\xf4#|\xf4\xa3\x1f\rk\xaf\xbdv\xb2\x93\xbc\x00\xda\xb9\xa9\x01\x060\x80\x01\x0c`\x00\x03\x18\xc0\x00\x060\xd0\xc5@\xcc>1\x03\xc5,\x94{\x1e\x8c\x99\xb8\xc8K\x83\x92\x97\x00\xc5`\x7f:\xf7\xc1\xfe\xeew\xbf\x1b\x86\x0e\x1dj!\xb4\x10b\x00\x03\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\xd91\x10\xb3P\xccD\xb9\xe7\xc2"\x1b\x7f*i\x01P\\\xe60\xb4\x18\xe4%9\x0f\xf4\xc4\x89\x13\xc3k_\xfb\xda\xec&\xb9\xab\x01\\\r\x81\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x01\x0ct3\x103Q\xccF9g\xc3\x98\x8dcF.j\x92\xde\xd7\xe5\x97_\xfe\xb7\xc5M\xff\xee\xcfu\x80\xe7\xcd\x9b\x17\xf6\xd8c\x0f\xc1\x9f\xe1\xc5\x00\x060\x80\x01\x0c`\x00\x03\x18\xc0\x00\x060\xf0"\x031#\xc5\xac\x94kN\x8c\x199f\xe5\xe4\x0c@qb\xe7\xe5:\xa8\x97^zi\xd8d\x93MLr\x0b=\x060\x80\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x81U\x18\x88Y)f\xa6\\\xf3b\xf1V\x80o$%\x00\x8a\x81\xdc\'\xd7\xc1\xfc\xd2\x97\xbe\x14^\xfd\xeaW\x9b\xe4\xabL\xf2\x02p5Q\x03\x0c`\x00\x03\x18\xc0\x00\x060\x80\x01\x0c`\xa0\x8b\x81\x98\x99bv\xca57\xc6\xcc\x9c\x84\x04X\xb0`\xc1\x06\xc5\xc9\xfc1\xb7\x81\x8cw\xb6<\xf4\xd0C-h\x164\x0c`\x00\x03\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\xfdd f\xa8\x98\xa5r\xcb\x8f13\xc7\xec\\{\tP\x9c\xc8\x8c\xdc\x06\xef\xea\xab\xaf\x0e\xc3\x87\x0f7\xc9\xfb9\xc9]\r\xe0j\x08\x0c`\x00\x03\x18\xc0\x00\x060\x80\x01\x0c`\xa0\x9b\x81\x98\xa5b\xa6\xca-G\xc6\xec\\\xd4\xa0\xbe_\x85\xb9\xd9=\xb7A\xbb\xec\xb2\xcb\xc2\xc6\x1bo,\xfc\x0b\xff\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x18\xc0\x00\x06\x1ad f\xaa\x98\xadr\xcb\x931C\xd7\xd2\x00\\u\xd5U\xaf.n\xfc\xf7@N\x03v\xdey\xe7\x85\xf5\xd7_\xdf$op\x92\x17\xa0\xab\x9d\x1a`\x00\x03\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03]\x0c\xc4l\x153VN\x992f\xe8\x98\xa5k\'\x01\x8a\x03\xffJN\x035i\xd2\xa4\xb0\xce:\xebX\xac,V\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x18\xc0\x00\x06\x9a\xc4@\xccX1k\xe5\x94-c\x96\xae\x95\x00\xb8\xe9\xa6\x9b\xb6*\x0ezy.\x834~\xfcx\x13\xbcI\x13\xdcU\x00\xae\x82\xc0\x00\x060\x80\x01\x0c`\x00\x03\x18\xc0\x00\x06Ve f\xae\\\xf2e\xcc\xd21S\x175\xa8\xfe\xd7\xc4\x89\x13\xd7*\x0e\xf8\xee\x1c\x06\xa78\xcfp\xe0\x81\x07\n\xff\xc2?\x060\x80\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x81\x163\x10\xb3W\xcc`\x99d\xcd\xbbc\xb6\xae\xbc\x01(\x06d\\\x0e\x03r\xf3\xcd7\x87Q\xa3F\x99\xe4-\x9e\xe4\x05\xf0j\xac\x06\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x18\xc0@\x17\x031\x83\xc5,\x96C\xe6\x8c\xd9\xba\xd2\x02\xe0\x9ak\xae\xf9\x7f\xc5@\xfc1\xf5\xc1(.\xc7\x08;\xef\xbc\xb3E\xc8"\x84\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x01\x0c`\xa0\xcd\x0c\xc4,\x163Y\xea\xb93f\xeb\x98\xb1++\x01\nCqv\xea\x83\xb0`\xc1\x82\xb0\xddv\xdb\x99\xe4m\x9e\xe4\xae\x04p%\x04\x060\x80\x01\x0c`\x00\x03\x18\xc0\x00\x060\xd0\xcd@\xccd1\x9b\xa5\x9e?c\xc6.\xce\xb9z_\x85\x81\xf9\xa7\xa2\xf8KR\x1e\x80\xf9\xf3\xe7\x87-\xb7\xdcR\xf8\x17\xfe1\x80\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x01\x0ct\x98\x81\x98\xcdbFK9\x83\xc6\x8c\x1d\xb3v\xe5\x0c@a&\xaeL\xb9\xf0\xd1.\t\xff\x8cc1\xf1,\xf4j\x80\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x81\x8a0\x103Z\xeaW\x02\xc4\xac])\x01\xb0p\xe1\xc2]R\x0e\xff\xf1\xfd%.\xfb\x17|\x85\x7f\x0c`\x00\x03\x18\xc0\x00\x060\x80\x01\x0c`\xa0z\x0c\xc4\xac\x163[\xca\x994f\xee\x82\xbdj|\x15\x85^\x94j\xb1\xe3\x1d&\xdd\xf0\xafz\x93\xbc \x9fuU\x03\x0c`\x00\x03\x18\xc0\x00\x060\x80\x01\x0c`\xa0\x8b\x81\x98\xd9\x12\xfft\x80E\x95H\xff\x85\x89\xd8+\xd5\xf0_\\j\xe1\xa3\xfe,(6\x15\x0c`\x00\x03\x18\xc0\x00\x060\x80\x01\x0c`\xa0\x06\x0c\xc4\x8f\x08\x8c\x19.\xd5|\x1a\xb3w\xc7%@Q\xdc;S-\xf0\x81\x07\x1eh\xa2\xd7`\xa2\xbb\x1a\xc0\xd5\x10\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x18\xc0@d f\xb8T\xf3i\xcc\xde\xc59v\xee\xab\xb0+\x07\xa4Z\xdc\xf1\xe3\xc7\x0b\xff\xc2?\x060\x80\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x81\x9a1\x10\xb3\\\xaa95f\xf0N\x19\x80A\xc5\x8b\xdf\x9bba\'M\x9ad\x92\xd7l\x92\x17\x93\xc0\x98\xa9\x01\x060\x80\x01\x0c`\x00\x03\x18\xc0\x00\x060\xd0\xc5@\xcct)f\xd5\x98\xc1\x8b\xec3\xa8\xed\x12\xa0x\xff\xc1\x07S,\xe8y\xe7\x9d\x17\xd6Yg\x1d\x0b\x87\x85\x03\x03\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x18\xc0@M\x19\x88\x99.f\xbb\x143k\xcc\xe2m\x17\x00E!\xff3\xb5b^v\xd9ea\xfd\xf5\xd77\xc9k:\xc9]\x05\xe0*\x08\x0c`\x00\x03\x18\xc0\x00\x060\x80\x01\x0c`\xa0\x9b\x81\x98\xedb\xc6K-\xb7\xc6,^\x9cc\xfb\xbe\x8a\x17\x1c\x99Z\x11\xaf\xbe\xfa\xea\xb0\xf1\xc6\x1b\x0b\xff\x1d\x08\xff\x83\x06\r\n\xeb\xae\xbbnXo\xbd\xf5\xc2\x1b\xde\xf0\x86\xf0\xc67\xbe1\xbc\xe5-o\to{\xdb\xdb\xc2\xdb\xdf\xfe\xf6\xb0\xe9\xa6\x9b\x86\xcd6\xdb,\xbc\xf3\x9d\xef\x0c\xefz\xd7\xbb\xc2\xbb\xdf\xfd\xee\xb0\xf9\xe6\x9b\xfbV\x03\x0c`\x00\x03\x18\xc0\x00\x060\x80\x81\x8e1\x10{\xd2\xd8\x9b\xc6\x1e5\xf6\xaa\xb1g\x8d\xbdk\xecac/\x1b{\xda\xd8\xdb\xc6\x1e7\xf6\xba\xb1\xe7-\x12\x9b\xef6\xd7 f\xbc\x98\xf5R\xcb\xaf1\x93\xb7\xcd\x00\x14\xef;\xb86\xa5\x02\x16\x97P\x84\xe1\xc3\x87\x9b\x8cm\x98\x8cq\xf1{\xdd\xeb^\xd7\xb5 n\xb2\xc9&]\x0b\xe6{\xde\xf3\x9e\xe0[\r0\x80\x01\x0c`\x00\x03\x18\xc0\x00\x06Rg \xca\x82\xd8\x03G9\x10{\xe2\xd8\x1b\x93\x02\xad\x97"1\xeb\xc5\xcc\x97R\x86\x8d\x99\xbc-\x02\xe0\xa6\x9bn\xda<\xa5\xc2\xc5s9\xf4\xd0CM\xbc\x16\x84\xffh9_\xf3\x9a\xd7\x84\x8d6\xda\xa8k\xa1\x8b\x864\xf5E\xdd\xf9i\\0\x80\x01\x0c`\x00\x03\x18\xc0\x00\x06\xca0\x10{\xe4(\x05b\xcf\x1c{gW\n\xb4F\x08\xc4\xcc\x97Z\x8e\x8d\xd9\xbc\xe5\x12\xa00\r\x17\xa6T\xb8\xd3O?]\xf8ob\xf8\x8f7\xdb\xd8`\x83\r\xba\x16\xb1xYT\x99\xc5\xcfcm\x96\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\xb93\x10{\xe8(\x04bO\xed\xe6\xe4\xcd\x95\x011\xfb\xa5\x94ec6o\xa9\x00(^\xe0ME\xc1\x96\xa4R\xb4K/\xbd4\xbc\xfa\xd5\xaf&\x00\x06(\x00\xe2\xa5K\xd1X\xbe\xe3\x1d\xef\x10\xf8\xbd\x9d\x01\x03\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03Md \xf6\xd8\xb1\xd7\xf6v\x81\x81\xcb\x80\x98\xfdb\x06L%\xcf\xc6l\x1e3z\xcb$@\xf1\xbe\x89\xd3R)\xd6\xfc\xf9\xf3\xbb\xccZQ,\x02\xa0\x81\x1a\xbc\xf2\x95\xaf\x0c\x7f\xf7w\x7f\xd7u\xb3\x93\xdc-\xad\xf3\xf7/\x15\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\xed` \xdeh0\xf6\xe0\xb1\x17\x97c\x1a\xcbq\xf1\xea\x8a\x98\x05S\xc9\xb51\xa3\xb7D\x00\x14\xef/xea\x17~\x9fJ\xa1\xf6\xdcsO\x93\xa6\x81\xe0\x1f\xefb\xfa\xf7\x7f\xff\xf7]w[m\xc7"\xe75l\xa6\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x18X\x99\x81\xf8\x89X\xb1\'\x8f\xbd9\x11P^\x04\xc4,\x98J\xae\x8d\x19=f\xf5\xa6K\x80\xe2\x89\xf7O\xa5H\x13\'N4QJ\x84\xff\xb5\xd6Z+l\xb8\xe1\x86]\x1fob\xf1]y\xf1U\x0f\xf5\xc0\x00\x060\x80\x01\x0c`\x00\x03\x18\xe8$\x03\xf1#\x08c\xaf\x1e{v2\xa0\xff2`\xd2\xa4I)I\x80\xfd[!\x00\xe6\xa6 \x00\xbe\xfb\xdd\xef\x86\xd7\xbe\xf6\xb5&G?\x04@\xbc\xb4(~D\x89\xbb\xf7\xdb\xd4:\xb9\xa9ym\xfca\x00\x03\x18\xc0\x00\x060\x80\x8153\x10{\xf6\xd8\xbb{{@\xff$@\xcc\x841\x1b\xa6\x90q\x8b\x7f\xac\x9f\xdbT\x01P\\R\xb0q\xf1\xa4\xcb\xeb^\x9c\xf8\xd9\x8fC\x87\x0e\x15\xfe\xd7\x10\xfe\xe3\xa2\xf1\xa67\xbd)\xb8\x8b\xff\x9a\x17Z\x9b\x91\x1aa\x00\x03\x18\xc0\x00\x060\x80\x01\x0cT\x89\x81\xd8\xc3\xc7^\x9e\x08X\xb3\x08\x88\xd90f\xc4\xba\xe7\xdc\x98\xd5cfo\x9a\x04(\x9e\xf0\x94\xba\x17%\x1e\xffQG\x1d%\xfc\xf7\x11\xfe\xd7^{\xed.k(\xf8\xdb\xc4\xaa\xb4\x899\x16^\x11\x10{|o\r\xe8]\x06\xc4\x8c\x98B\xd6\x8d\x99\xbd)\x02\xa0x\xbf\xfcZEA\x1e\xac{Q\xa6M\x9b\x06\xfe>\xc2\x7f\xfc\x9cQ\x97\xfa\x97_XmFj\x86\x01\x0c`\x00\x03\x18\xc0\x00\x060Pe\x06b\x8f\x1f{}\x12\xa0g\t\x10\x05\xc9\xf4\xe9\xd3S\x90\x00\x0f\xc6\xec>`\tP\x98\x84]\xeb\x1e\xfe\x8b\xcb!\xba>\xae\x0e\xf4\xabC\x1f\xdf\xfb\x12?N\xa4\xca\x8b\x96c\xb3\xa9b\x00\x03\x18\xc0\x00\x060\x80\x01\x0c```\x0c\xc4\x9e\xdf\xbd\xd0V\xcfC1#\xc6\xda\xc4\xccX\xf7\xdc\x1b\xb3{3\x04\xc0\x05u/\xc4\xf8\xf1\xe3\x19\xafU\xfe\xf5?\xbe\'\xe8mo{\x9b\xe0\xff\x9e\x81-\xa46"\xf5\xc3\x00\x060\x80\x01\x0c`\x00\x03\x18\xa8\x13\x031\x03\xb8?\xc0\xea" f\xc6\xba\xe7\xdeB\x00\\0 \x01PX\x90W\x16O\xf2x\x9d\x0bq\xd9e\x97\x85u\xd6Y\x87\x00x\x99\x00x\xfd\xeb_\xefr\x7f\xc1\x9f\xfc\xc1\x00\x060\x80\x01\x0c`\x00\x03\x18\xc8\x94\x81\xf8\xb6\x80\x98\t\\!\xfd\x92\x08\x88\x991f\xc7:g\xdf\x98\xddc\x86oX\x02\x14O\xb0G\x9d\x0b\x10\x8f}\xd8\xb0a\xc0~1\xfcG\xd3\xf7\x0f\xff\xf0\x0f\x16\xfaL\x17\xfa:\x99i\xc7\xea_R0\x80\x01\x0c`\x00\x03\x18\xc0@\xeb\x19\x88\xd9\xc0\xd5\x00/I\x80\x98\x1d\xeb\x9e\x7fc\x86\x1f\x88\x00\xf8\x8f:\x17\xe0\xa4\x93N\x12\xfe_\x0c\xff\xeb\xaf\xbf\xbe\x7f\xf5\x17\xfc\xc9\x1f\x0c`\x00\x03\x18\xc0\x00\x060\x80\x01\x0c\xac\xc4@\xbc\x1a f\x05W\x03\xfc\x9f\x08\x88\x19\xb2\xce\x19\xb88\xf6\xe9\r\t\x80\xa9S\xa7\xfeM\xf1\xcbO\xd4\xf5\xe4\xe7\xce\x9d\x1b\xd6[o\xbd\xecA^k\xad\xb5\xc2\xc6\x1bo\xbc\xd2$gS[oS\xd5X\x8d1\x80\x01\x0c`\x00\x03\x18\xc0\x00\x06\xea\xc4@\xcc\x0c\x83\x06\r\xca>?\xc5\x0c\x19\xb3d]sp\xcc\xf01\xcb\x97\x96\x00\xc5\xa5\x03{\xd7\xf8\xa4\xc3\xe8\xd1\xa3\xb3\x877\xbe\x8f\xc5\x1d\xfemSG\x01p\xe3\x8d7v\xdd\xf0.GS\xb5\xf6\xdak\x87\xb7\xbf\xfd\xed\xc2?\xc3\x8b\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x01\x0c4\x8d\x81\x981b\xd6\xc81c\xc5\x1b#\xc6\x8cY\xc7l\x1c3}\xbf\x05@q\x827\xd6\xf1$\x8f?\xfe\xf8,\xc1\x8c\x9f\xdd\xe9f\x7f\xad7\xa0,\xb3\x1ac\x00\x03\x18\xc0\x00\x060\x80\x01\x0c\xe4\xc8@\xcc\x1a1s\xe4(\x01b\xc6\xacc6\x8e\x99\xbe_\x02\xe0\xa6\x9bnzm\xf1\xe0\xa5u;\xc9\x05\x0b\x16\x84\r6\xd8 ;(\xe3]:7\xdbl\xb3\xa6\x19\xbe\x1c\x174\xe7l#\xc7\x00\x060\x80\x01\x0c`\x00\x03\x18\xc0@\xdf\x0c\xc4\xcc\x91\xe3\'\x04\xc4\x8c\x19\xb3f\xdd\xf2q\xcc\xf41\xdb\xafQ\x02\x14\x97\n\xec_\xc3\x93\x0b\xc7\x1csLv\xe1\x7f\xddu\xd7\r\xef|\xe7;\x85\x7f\x97xa\x00\x03\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03-g f\x8f\x98Ar\xbb\x12 f\xcd:f\xe4\x98\xed\xfb#\x00\xa6\xd4\xed\xe4\xe6\xcf\x9f\x1f^\xf7\xba\xd7e\x05b\x9cx\xff\xfc\xcf\xff\xdc\xf2I\xce\x84\xf6mB\xd5G}0\x80\x01\x0c`\x00\x03\x18\xc0\x00\x06rb f\x90\xdc$@\xcc\x9a1s\xd6-\'\x17\x02\xe0\xdc\xfe\x08\x80\xbb\xebvbG\x1duTV\xe1?^z\xe3_\xfem49m4\xce\x15\xef\x18\xc0\x00\x060\x80\x01\x0c`\xa0:\x0c\xc4,\x92\xdb\xdb\x01b\xe6\xac[N.\x04\xc0\xdd}\n\x80\xf8\x1e\x81\xe2A\xcb\xebtb\xd7]w]Xo\xbd\xf5\xb2\x11\x00\xf1\xe6\x1b\xde\xf3_\x9d\xc5\xcfFd,0\x80\x01\x0c`\x00\x03\x18\xc0\x00\x06rd f\x92\x9cn\x0c\x183g\xcc\x9eu\xca\xca1\xdb\xf7y\x1f\x80\xe2dF\xd6\xe9\x84\xe2\xb1\x8e\x1f?>\x9b\xf0\x1f?~\xc3\xdd\xfem09n0\xce\x19\xf7\x18\xc0@]\x19\x18\xfa\xe80e\xca\x94\xaeK=\x1f\x7f\xfc\xf1\x1e\x83}\x7f\x7fH\x00`\xad7\xd6\xfc\x1c\x1b)1\x103K.7g\x8fY\xb4N\xd99f\xfd\x9e\x04\xc0!u:\x89\xe1\xc3\x87\'/\x00\xe2Gk\xbc\xeb]\xef\x12\xfe\x85\x7f\x0c`\x00\x03\x18\xc0@\x8b\x18\xd8~\xfb\xed\xc3\xb8q\xe3\xc2\xd7\xbe\xf6\xb5p\xfd\xf5\xd7\x87\xdf\xff\xfe\xf7\xfd\xcd\xf5\xfd~\x1c\x01 \xe4\xa5\x14\xf2\x9c\x0b\x9e\xfbb f\x97\x1c>\x1e0f\xd1:e\xe7B\x00\x1c\xb2\x9a\x00(>"\xe0\xcc\xba\x9c\xc4\xb7\xbf\xfd\xed\xe4\xc3\x7f\xbc\x91\x86;\xfe[`\xfbZ`\xfd\x1d>0\x80\x01\x0c\x94c`\x9bm\xb6\tc\xc7\x8e\r_\xfe\xf2\x97\xc3\xb5\xd7^\x1b~\xf7\xbb\xdf\xf5;\xc4\x0f\xe4\x81\x04@\xb9q\xc2\xb5za\xa0\xde\x0c\xc4\x0c\x93\xc3M\x01c&\xadK~.\x8es\xf2j\x02\xa0\xf8\xe15u9\x811c\xc6$/\x006\xdexc\xff\xda\xd3\xa2\x7f\xed\xb1\xa9\xd4{S1~\xc6\x0f\x03\x18\xe8\x0f\x03#F\x8c\x08\xff\xfa\xaf\xff\x1a&O\x9e\x1c\xae\xba\xea\xaa\xf0\xc0\x03\x0f\x84\x15+V\x0c$\xc77\xfc\xbb?\xf9\xc9O\xec\xe9\xf6t\x0c` +\x06b\x96)\x02g\xd2\xdf1\x93\xd6%?\xc7\xac\xdf\x93\x00x\xb8\x0e\'p\xe3\x8d7\x86\xf5\xd7_?i\x98\xe2\xf9\xf5\xa7\xb9\xf1\x18M0\x060\x80\x01\x0c`\xe0=a\xcb-\xb7\x0c\x07\x1dtP8\xed\xb4\xd3\xc2\x9c9s\xc2/\x7f\xf9\xcb\xb0|\xf9\xf2\x86\x03{\xb3\x7f\xf1\xee\xbb\xef\xb6\xaf\x0b\x7f\x18\xc0@v\x0c\xe4\x90\xd9b6\xadC\x86.\x8e\xf1\xe1\x95\x04\xc0u\xd7]\xf7\x86\x9a\x1cx\x988qb\xd2\xe1\xff\x95\xaf|\xa5\xf7\xfd\xdb \xb2\xdb \x04\x18!\x16\x03\x18\xe8/\x03\xc3\x86\r\x0b\x1f\xf8\xc0\x07\xba\xfa\x81\xd9\xb3g\x87\x9f\xff\xfc\xe7a\xd9\xb2e\xcd\xce\xecM}>\x02\x00\xdf\xfd\xe5\xdb\xe3\xb0\x92\x12\x03\xf1~\x001\xdb\xa4|%@\xcc\xa6u\xc9\xd11\xf3\xffU\x02\x14\xef\xff\xdf\xb1.\x07\x1e-\x7f\xca\x10\xfd\xc3?\xfc\x83\xf0G\x00`\x00\x03\x18\xc0\x00\x06\n\x06\x86\x0c\x19\x12F\x8f\x1e\x1d&L\x98\x10f\xce\x9c\x19~\xfa\xd3\x9f\x86%K\x9645\x9c\xb7\xe3\xc9\x08\x00\xa1.\xa5P\xe7\\\xf0\\\x86\x81\x98mR\xcen[m\xb5Um\x04@\xcc\xfc/\x17\x00\xc7\xd6A\x00\xcc\x9a5+i\x80^\xff\xfa\xd7k\xf84\xfd\x18\xc0\x00\x060\x90-\x03\xfb\xee\xbbo8\xe9\xa4\x93\xc2\xc5\x17_\x1c\xee\xba\xeb\xae\xf0\xdcs\xcf\xb5#\x9f\xb7\xfc5\x08\x00\x81\xa9L`\xf2X\xbc\xa4\xc6@\xcc8)K\x80\x98Q\xeb\x90\xa5\x0b\x01p\xec\xcb\x05\xc09u8\xe8\xc3\x0e;,Yx\\\xfao\xb1Om\xb1w>\x98\xc6\x00\x06\xfab`\xd4\xa8Q\xe1S\x9f\xfaT\xf8\x8f\xff\xf8\x8f\x10o\x92\xf7\xec\xb3\xcf\xb6<\x88w\xea\x05\x08\x00s\xa1\xaf\xb9\xe0\xef\xf0\x91:\x03\xa9\xbf\x15 f\xd4:d\xe9B\x00\x9c\xf3r\x010\xa7\x0e\x07\xfd\xe67\xbf9Y\x01\xf0\xb6\xb7\xbd-\xdb\x7f\xf1I}\xd1s~6v\x0c` w\x06F\x8e\x1c\x19>\xf1\x89O\x84\xf8qx?\xfa\xd1\x8f\xc2SO=\xd5\xa9,\xde\x91\xd7%\x00\xac\x01\xb9\xaf\x01\xce\xdf\x1c\x88Y\'\xd5\xab\x00bF\xadC\x96.\x04\xc0\x9c\xbf\n\x80\xe2\x80\xef\xa9\xfaAO\x9b6-Yh^\xfb\xda\xd7\n\xff.\xf9\xc5\x00\x060\x80\x81$\x18\xd8i\xa7\x9d\xc21\xc7\x1c\x13\xce;\xef\xbc\xb0p\xe1\xc2\xf0\xe7?\xff\xb9#\xa1\xbbJ/J\x00\x08?\x020\x060\xf0\x9e\x103O\xaa\x12 f\xd5\xaa\xe7\xe9\x98\xf9\xff*\x00\n\x1b\xf0\xbfU?\xe0\x83\x0f>8Y`6\xddt\xd3$\x9a>\x0b\x9b\xcd\r\x03\x18\xc0@^\x0c\xec\xb0\xc3\x0e\xe1\xa8\xa3\x8e\n\xe7\x9e{n\xb8\xe1\x86\x1b\xc2\x1f\xff\xf8\xc7*\xe5\xee\xca\x1c\x0b\x01\x90\xd7\xbc\xb0\x0e\x1ao\x0c\xf4\xcc@\xcc<\xa9\n\x80\x98U\xab\x9e\xa7c\xe6\xef\x12\x00\xc5\x81\xbe\xbe\xea\x07\x1b\x8f\xef\x8do|c\x92\xc0l\xb0\xc1\x06\xc2\xbf\x7f\xf5\xc3\x00\x060\x80\x81\xca3\xb0\xddv\xdb\x85#\x8e8"|\xf5\xab_\r\xf3\xe6\xcd\x0b\x0f?\xfcpe\x02v\xd5\x0f\x84\x00\xe89\x0c\x08I\xea\x82\x81\xfc\x18\x88\xd9\'E\t\x10\xb3j\x1d2u\xcc\xfe\xaf(.\xcf\x1bV\xf5\x83\x9d:uj\x92\xa0\xac\xbd\xf6\xda!\xde\x14\xc3\xe2\x97\xdf\xe2g\xcc\x8d9\x060Pe\x06F\x8c\x18\x11>\xfc\xe1\x0f\x87\xb3\xce:+\\}\xf5\xd5\xe1\xc1\x07\x1f\x0c+V\xac\xa8z\xce\xae\xec\xf1\x11\x00\xe6{\x95\xe7\xbbc\xc3g;\x19\x88\xd9\'f\xa0\x14%@\xcc\xacU\xcf\xd51\xfbG\x01p`\xd5\x0ft\xcc\x981IB\x12MQ;\'\x9c\xd7\xb2\xc0c\x00\x03\x18\xc0\xc0\xaa\x0c\xc4\xcf0>\xe4\x90C\xc2\xe9\xa7\x9f\x1e\xae\xbc\xf2\xca\xf0\xeb_\xff:\xbc\xf0\xc2\x0b\x95\r\xd3u<0\x02\xc0\xbc[u\xde\xf9\x7fL\xe4\xcc@\xaaWv\xc7\xccZ\xf5\\\x1d\xb3\xff+\x8a\xf7\x02\x1c[\xf5\x03M\xf1\xee\xff\xf1c\xff\xde\xfd\xeew\x13\x00.\xfb\xc5\x00\x060\x80\x81\xb610l\xd8\xb0\x10\x1b\x94/|\xe1\x0b\xe1\x8a+\xae\x08\xbf\xf8\xc5/\xc2\xb2e\xcb\xea\x98\xa9ku\xcc\x04\x80\xb0\x97s\xd8s\xee\xf8_\x95\x81\x98\x81b\x16J\xed*\x80:|\x1a@\xcc\xfe\xaf(\xc2\xff\xa9U\x16\x003g\xceL\x0e\x8e\x08\xfb\x9b\xde\xf4\xa6\xb65|\xabN:\xffo!\xc6\x00\x060\x90>\x03C\x87\x0e\r\x07\x1ex`8\xe5\x94S\xc2\xacY\xb3\xc2}\xf7\xdd\x17\x96.]Z\xab\xe0\x9c\xca\xc1\x12\x00\xe9\xcf7k\xaa1\xc6@9\x06b\x16JM\x00\xc4\xf3\x89\xd9\xb5\xca\xd9:f\xffx\x05\xc0\x94*\x1f\xe4q\xc7\x1d\x97\x1c\x1c\xfe\xf5\xbf\xdc\x02aAU/\x0c`\x00\x03}30x\xf0\xe0\xb0\xff\xfe\xfb\x87\xcf}\xees\xe1\xd2K/\r\xf7\xdcsOx\xfe\xf9\xe7S\xc9\xcf\xb5?\x0f\x02\xa0o~\xcdo\xf5\xc1@~\x0c\xa4z\x15@\xcc\xaeU\xce\xd61\xfbG\x010\xb3\xca\x07\xb9\xf5\xd6[\'\'\x00\xbc\xf7?\xbfE\xce\xc6f\xcc1\x80\x81f2\xb0\xf7\xde{\x87\x13O<1\xcc\x981#\xdcq\xc7\x1da\xf1\xe2\xc5\xb5\x0f\xc9)\x9f\x00\x01`\xfe7s\xfe{.<\xa5\xc2@\x8a\xf7\x02\x88\xd9\xb5\xca\xd9:f\xff\xf8\x16\x80\xf9U=\xc8\x05\x0b\x16\x84u\xd6Y\')\x01\xb0\xd6Zk\xb9\xf3\xbf\xf7\xfbz\xfb\x07\x060\x80\x81~3\xb0\xc7\x1e{\x84\x13N8!L\x9f>=,Z\xb4(<\xfd\xf4\xd3)g\xe5$\xcf\x8d\x00\x10\xd8R\tl\xce\x03\xcb\xcdd ~"@\xccF)\xbd\x15 f\xd7\x98a\xab\x9a\xafc\xf6\x8fW\x00\xdcU\xd5\x03<\xfb\xec\xb3\x93\x02"\xc2\x1d?\xfb\xb2\x99\x13\xc7sY\x881\x80\x01\x0c\xa4\xc3\xc0\xae\xbb\xee\x1a\xe2\xe5\x83\xe7\x9f\x7f~W\xf3\xf0\xe4\x93O&\x19\x88s;)\x02 \x9d9j\xbd5\x96\x18h.\x031\x1b\xa5$\x00\xe2\xb9\xc4\x0c[\xd5|\x1d\xb3\x7f\xbc\x02\xe0\xc1\xaa\x1e\xe0\x07>\xf0\x81\xe4\x80\xd8l\xb3\xcd\x08\x00\xff\xf2\x87\x01\x0c`\x00\x03a\xc7\x1dw\x0cG\x1f}t\xf8\xf7\x7f\xff\xf7p\xd3M7\x85\xc7\x1e{,\xb7\\\x9c\xcd\xf9\x12\x00\xcd\r\x0c\x02\x98zb \x1d\x06b6JM\x00\xc4\x0c[\xd5|\x1d\xb3\x7f\xbc\x02\xe0\x99\xaa\x1e\xe0;\xde\xf1\x8e\xa4\x80Xo\xbd\xf54\xfd\x9a~\x0c`\x00\x03\x192\xb0\xfd\xf6\xdb\x87q\xe3\xc6\x85\xaf}\xedk\xe1\xfa\xeb\xaf\x0f\x8f>\xfah6\xe1\xd7\x89\x86@\x00\xa4\x13V\x04Oc\x89\x81\xe63\x103RJ\x12 f\xd8\xaa\xe6\xeb\xe2\xb8\x9e\x8e\x02`Y\x15\x0fp\xde\xbcy\xc9\xbd\'\xe4\xef\xff\xfe\xef5\xfe\x196\xfe6\x8a\xe6o\x14j\xaa\xa6Uf`\x9bm\xb6\t\x1f\xf9\xc8G\xc2\x97\xbf\xfc\xe5p\xed\xb5\xd7\x86\x87\x1ezH\x06\xce\xbc\x02\x04\x805\xab\xcak\x96c\xc3g\xa7\x19\x88\x19)%\x01\x10\xefk\x10\xb3l\x153v\xcc\xfe\xf1-\x00\x95<\xb8s\xce9\')\x10\xe2G\xffm\xbe\xf9\xe6\x04\x00\x01\x80\x01\x0c` !\x06F\x8c\x18\x11\x0e=\xf4\xd00y\xf2\xe40w\xee\xdc\xf0\xc0\x03\x0f\x84\x15+Vd\x1ew\x9d\xfe\xaa\x15 \x00\x04\xacN\x07,\xaf\x8f\xc1*3\x103R\xccJ)I\x80\x98e\xab\x9a\xb3++\x00\x0e?\xfc\xf0\xa4 \xf8\xbb\xbf\xfb;M\x7fBM\x7f\x95\x17Q\xc7f\x93\xc7@k\x18\xd8r\xcb-\xc3A\x07\x1d\x14N;\xed\xb40g\xce\x9c\xf0\xab_\xfd*,_\xbe|\xd5\xac\xe7\xffU`\xb5\n\x10\x00\xad\x99\x93\xd6:u\xc5@:\x0c\xc4\xac\x94\x92\x00\x88Y\x96\x00(y\xa5\xc1\xf0\xe1\xc3\x93\x82`\xd3M7%\x00\x08\x00\x0c`\x00\x035a`\xd8\xb0a!\xde\xc4g\xe2\xc4\x89a\xf6\xec\xd9\xe1\xfe\xfb\xef\x0fK\x97.]-\xd8\xf9\x81\n\xf4\xa7\x02\x04@:!E\xe04\x96\x18h\r\x031+\xa5$\x00b\x96%\x00J\x08\x80\x85\x0b\x17\x86W\xbf\xfa\xd5\xc9@\xb0\xee\xba\xebj\xfak\xd2\xf4[\xd4[\xb3\xa8\xab\xab\xbaV\x99\x81!C\x86\x84\xd1\xa3G\x87\t\x13&\x84\xcb.\xbb,\xdc{\xef\xbda\xc9\x92%\xfd\xc9u\x1e\xa3\x02\xfd\xaa\x00\x01`\r\xac\xf2\x1a\xe8\xd8\xf0Y\x15\x06bfJE\x02\xc4,\x1b3m\x15%@%\xdf\x020c\xc6\x8cd\x06?B\xbc\xd1F\x1b\x11\x00\x04\x00\x060\x80\x81\x8a0\xb0\xef\xbe\xfb\x86\x7f\xfb\xb7\x7f\x0b\x97\\rI\xd7\xdd\xd9\x9f{\xee\xb9~\x858\x0fR\x81F+@\x00\x08XU\tX\x8e\x03\x8bUf f\xa6T\x04@<\x8f\x98i\t\x80~^\x05\xf0\xf9\xcf\x7f>\xa9\xc1\x8f\x1f\x05Q\xe5\xc9\xe6\xd8l\x06\x18\xc0@\xaa\x0c\x8c\x1a5*|\xfa\xd3\x9f\x0e\x17^xa\xb8\xfd\xf6\xdb\xc3\xb3\xcf>\xdbh\x86\xf3{*\xd0p\x05\x08\x00kl\xaak\xac\xf3\xc2v3\x19H\xedm\x001\xd3\x12\x00\xfd\x14\x00\x87\x1crH2\x02`\x9du\xd6\x11\xfe+\xf2\xaf~\xcd\\\xa0<\x97\r\x0f\x03\xd5c`\xb7\xddv\x0b\xc7\x1f\x7f|\xf8\xd6\xb7\xbe\x15n\xbb\xed\xb6\xf0\xd4SO5\x1c\xd8\xfc\xa2\n4\xb3\x02\x04@\xf5\xd6\x0bk\xb81\xc1@5\x19\x88\xd9)\x95\xab\x00b\xa6%\x00\xfa)\x00\xb6\xdez\xebd\x06~\x83\r6 \x00\x08\x00\x0c`\x00\x03Mf`\xe7\x9dw\x0e\xc7\x1e{l\xf8\xe67\xbf\x19\x8a\xcf\xb4\r\x8f?\xfex3\xf3\x9a\xe7R\x81\xa6V\x80\x00\xa8f\xd0\x10\x00\x8d\x0b\x06\xaa\xc7@\xccN\xa9\x08\x80\x98i\t\x80~\n\x80\r7\xdc0\x99\x81\xdfd\x93M4\xfeMn\xfc-\xd6\xd5[\xac\x8d\x891i%\x03;\xec\xb0C\x18?~|\x982eJ\xb8\xe1\x86\x1b\xc2\x1f\xfe\xf0\x87\xa6\x863O\xa6\x02\xad\xae\x00\x01`\x8dl\xe5\x1a\xe9\xb9\xf1\x95\x12\x031;\xa5"\x00b\xa6%\x00\xfa!\x00\xae\xbe\xfa\xead\x06}\xd0\xa0A\xe1\xdd\xef~7\x01@\x00`\x00\x03\x18\xe8\'\x03\xdbm\xb7]8\xf2\xc8#\xc39\xe7\x9c\x13\xe6\xcd\x9b\x17\x1e~\xf8\xe1Vg3\xcf\xaf\x02-\xaf\x00\x01 \xa0\xa5\x14\xd0\x9c\x0b\x9e[\xc9@\xccN1C\xa5"\x01b\xb6\xad\x9a\x04\xa8\xdc\xa7\x00\x9c{\xee\xb9\xc9\x0c\xf8k^\xf3\x1aM\x7f?\x9b\xfeV.$\x9e\xdbF\x85\x81j20b\xc4\x880v\xec\xd8p\xd6Yg\x85\xb8A\xfe\xf6\xb7\xbf\r+V\xachy\x18\xf3\x02*\xd0\xee\n\x10\x00\xd5\\\x83\xec\r\xc6\x05\x03\xd5d f\xa8T\x04@\xcc\xb6\x04\xc0\x1a\xae\x028\xe1\x84\x13\x92\x19p\x1f\xffW\xcdE\xc5bo\\0\xd0y\x06.\xbf\xfcra\xbf\xdd)\xd4\xebu\xac\x02\x04@\xe7\xd7\x1c\xeb\xbe1\xc0@}\x18H\xe9\xe3\x00c\xb6%\x00\xd6 \x00\x0e:\xe8\xa0d\x04\x80\xf7\xff\xd7g\xa1\xb1)\x18+\x0c\xb4\x97\x81\xf8/\xfe\xbeT \x97\n\x10\x00\xed]_\xac\xe7\xea\x8d\x81z3\x90\xd2}\x00b\xb6%\x00\xd6 \x00\xe2\x9d\x9dS\xb9\xe4\xe3]\xefz\x97\xb7\x00x\x0b\x00\x060\x80\x81\x1e\x18 \x00r\x89\xbe\xce3V\x80\x00\xa8w\x18\x11&\x8d\x1f\x06\xda\xcb@\xccP\xa9\xe4\xc1\x98m\t\x805\x08\x80\xcd6\xdb,\x89\x01_w\xddu5\xfd=4\xfd\x16\xd0\xf6.\xa0\xea\xad\xdeUe\x80\x00\x10\x8cs\xaa\x00\x01`-\xae\xeaZ\xec\xb8\xb0YU\x06b\x96JA\x02\xc4lK\x00\xacA\x00\xac\xbf\xfe\xfaI\x0c\xf6\xeb^\xf7:\x02\x80\x00\xc0\x00\x060\xd0\x0b\x03\x04@N\xf1\xd7\xb9\x12\x00BVUC\x96\xe3\xc2fU\x19\x88Y*\x05\x01\x10\xb3-\x01\xd0\x87\x00\x88\x1f\xf9\x94\xc2@\xc7sx\xe3\x1b\xdf\xa8\xf1\xef\xa5\xf1\xaf\xeaB\xe3\xb8l\x82\x18h\x1f\x03\x04\x80P\x9cS\x05\x08\x80\xf6\xad-\xd6q\xb5\xc6@\x1a\x0c\xc4,\x95J.\x8c\x19\xb7J\x12\xa0R\x1f\x038c\xc6\x8cd\x06\xda\r\x00\xd3X|l"\xc6\x11\x03\xada\x80\x00\xc8)\xfe:W\x02\xa05\xeb\x88\xf5Y]1\x90.\x03)\xdd\x080f\\\x02\xa0\x97\xab\x00\xce>\xfb\xecd\x04\xc0;\xdf\xf9NW\x00\xb8\x02\x00\x03\x18\xc0@/\x0c\x10\x00BqN\x15 \x00\xd2\r)\x02\xa8\xb1\xc5@k\x18\x88Y*\x95+\x00b\xc6%\x00z\x11\x00\x13&LHb\xa0\x07\r\x1a\xa4\xe9\xef\xa5\xe9\xb7H\xb6f\x91TWu\xad\x1b\x03\x04@N\xf1\xd7\xb9\x12\x00\xd6\xe8\xba\xad\xd1\x8e\x17\xb3U` f\xaa\x14$@\xcc\xb8\x04@/\x02\xe0\xb8\xe3\x8eKb\x90}\x02\x80E\xb3\n\x8b\xa6c\xc0a\x95\x19 \x00\x84\xe2\x9c*@\x00X\x8f\xab\xbc\x1e;6|V\x95\x81T>\t f\\\x02\xa0\x17\x01p\xd8a\x87%!\x00\xd6[o=W\x00\xb8\x02\x00\x03\x18\xc0@\x1f\x0c\x10\x009\xc5_\xe7J\x00\x08XU\rX\x8e\x0b\x9bUf f\xaa\x14\xae\x00\x88\x19\x97\x00\xe8E\x00\x1cp\xc0\x01I\x0c\xf2\x1b\xde\xf0\x06\x8d\x7f\x1f\x8d\x7f\x95\x17\x1a\xc7f#\xc4@{\x18 \x00\x84\xe2\x9c*@\x00\xb4g]\xb1~\xab3\x06\xd2b f\xaa\x14\x04@\xcc\xb8\x04@/\x02`\xe7\x9dwNb\x90}\x04`Z\x8b\x8f\xcd\xc4xb\xa0\xf9\x0c\x10\x009\xc5_\xe7J\x004\x7f\r\xb1.\xab)\x06\xd2g \x95\x8f\x02\x8c\x19\x97\x00\xe8E\x00l\xb1\xc5\x16I\x08\x80\xb7\xbc\xe5-\xae\x00p\x05\x00\x060\x80\x81>\x18 \x00\x84\xe2\x9c*@\x00\xa4\x1fT\x84Qc\x8c\x81\xe63\x103U\nW\x00\xc4\x8cK\x00\xf4"\x00\xde\xf1\x8ew$1\xc8o{\xdb\xdb4\xfe}4\xfe\x16\xc8\xe6/\x90j\xaa\xa6uc\x80\x00\xc8)\xfe:\xd7Y\xb3f\xe9\x0b\xf4\x05\x18\xc0\x00\x06J2\x103U\n\x02 f\\\x02\xa0\x17\x01\xb0\xc9&\x9b$1\xc8o\x7f\xfb\xdbM\xf0\x92\x13\xbcn\xe1\xc5\xf1\n\xdc\x18\x18\x18\x03\x04\x80P\x9cS\x05.\xb8\xe0\x02}\x81\xbe\x00\x03\x18\xc0@I\x06b\xa6JA\x00\xc4\x8cK\x00\xf4"\x00R\xb9\xccc\xd3M75\xc1KNpaj`aJ\xfd\xd4\xafn\x0c\x10\x009\xc5_\xe7J\x00X\xa3\xeb\xb6F;^\xccV\x81\x81\x98\xa9R\x10\x001\xe3\x12\x00\xbd\x08\x80\x8d6\xda(\x89A\xdel\xb3\xcd\x08\x00\x02\x00\x03\x18\xc0@\x1f\x0c\x10\x00BqN\x15 \x00\x84\xa9*\x84)\xc7\x80\xc3\xba1\x103U\n\x02 f\\\x02\xa0\x17\x01\xf0\xfa\xd7\xbf>\x89A~\xe7;\xdf\xa9\xf1\xef\xa3\xf1\xaf\xdb\xe2\xe3xm\x98\x18h>\x03\x04@N\xf1\xd7\xb9\x12\x00\xcd_C\xac\xcbj\x8a\x81\xf4\x19\x88\x99*\x05\x01\x10?\xce\x90\x00\xe8E\x00\xac\xb7\xdezI\x0c\xf2\xbb\xde\xf5.\x02\x80\x00\xc0\x00\x060\xd0\x07\x03\x04\x80P\x9cS\x05\x08\x80\xf4\x83\x8a0j\x8c1\xd0|\x06b\xa6JA\x00\xc4\x8cK\x00\xf4"\x00^\xf5\xaaW%1\xc8\xef~\xf7\xbb5\xfe}4\xfe\x16\xc8\xe6/\x90j\xaa\xa6uc\x80\x00\xc8)\xfev\xf6\\\x17/^\x1c\xe2\xc7\xf0]r\xc9%\xe1\xbf\xff\xfb\xbf;r0\x04\x805\xbank\xb4\xe3\xc5l\x15\x18\x88\x99*\x05\x01\x103.\x01\xd0\x8b\x00X{\xed\xb5\x93\x18\xe4\xcd7\xdf\x9c\x00 \x000\x80\x01\x0c\xf4\xc1\x00\x01\xd0\x91\x1c\x9a\xfc\x8b>\xff\xfc\xf3\xe1\xde{\xef\r\x97]vY8\xf9\xe4\x93\xc3\x81\x07\x1e\x18\x06\x0f\x1e\xfc\xd7\xb9\xb8h\xd1\xa2\x8e\xd4\x80\x00\x10\xa6\xaa\x10\xa6\x1c\x03\x0e\xeb\xc6@\xccT)\x08\x80\x98q\t\x00\x02\xe0\xaf\xcdH\xdd&\xa2\xe3\xb5y`\x00\x03\xcd`\x80\x00\xe8H\x0eM\xeaE\x97.]\x1a\xee\xbf\xff\xfe0{\xf6\xec0i\xd2\xa4\xf0\xc1\x0f~0\x0c\x1d:\xb4\xcf\xfd\x95\x00\xb0~5c\xfd\xf2\x1c8\xc2@{\x18 \x00nm\x898xE\x95l\x84+\x00\xda3\x99,Z\xea\x8c\x01\x0ct\x9a\x01\x02 \xa9,\xde\xf2\x93Y\xbe|y\xf8\xd5\xaf~\x15\xe6\xcc\x99\x13\xbe\xf4\xa5/\x85\x83\x0f>8l\xb1\xc5\x16}\x86\xfd\x9e\x18\'\x00\xac}=q\xe1g\xb8\xc0@5\x19 \x00\x08\x80\xda\\\x02\xe2-\x00\xd5\\D,\xee\xc6\x05\x03\xd5a\x80\x00hyf\xae\xed\x0b\xbc\xf0\xc2\x0b\xe1\x81\x07\x1e\x08s\xe7\xce\rg\x9eyf8\xec\xb0\xc3\xc2\xf0\xe1\xc3K\x87\xfd\x9e\xe6;\x01P\x9d5\xa0\xa7\xf1\xf13\xe3\x83\x01\x0c\xbc\x9c\x01\x02\x80\x00 \x00\xfax?\xed\xcb\'\x8b\xff\xb6xb\x00\x03Ug\x80\x00\xa8m>o\xfa\x81?\xf4\xd0C\xe1\xba\xeb\xae\x0b_\xf9\xcaW\xc2\xe1\x87\x1f\x1e\xb6\xdez\xeb\xa6\x84\xfd\x9e\xe6\x00\x01`m\xec\x89\x0b?\xc3\x05\x06\xaa\xc9\x00\x01@\x00\x10\x00\x04@\xcb\x9aB\x0b\x7f5\x17~\xe3\x92\xee\xb8\x10\x00M\xcf\xd1\xb5x\xc2G\x1f}4,X\xb0 |\xfd\xeb_\x0f\xe3\xc6\x8d\x0b\xdbo\xbf}[\xd7u\x02 \xdd5\xc5~al1\x90\x1e\x03\x04\x00\x01@\x00\x10\x00mm\x14m$\xe9m$\xc6\xb4:cJ\x00\xd4"\xaf\x0f\xe8 \x1f{\xec\xb1p\xf3\xcd7\x87o|\xe3\x1b\xe1\xe8\xa3\x8f\x0e\xef{\xdf\xfb:\xbe\x86\x13\x00\xd5Y\x03\xac\xc7\xc6\x02\x03\x18X\x13\x03\x04\x00\x01@\x00\x10\x00\x1do\x1e\xd7\xb4P\xf9{\x9b\x19\x06\xfa\xc7\x00\x010\xa0l]\xb9_~\xe2\x89\'\xc2\x0f\x7f\xf8\xc30u\xea\xd4p\xdcq\xc7\x85]v\xd9\xa5\x92\xeb5\x01\xd0\xbf\xf9i\x1dS\'\x0c`\xa0\n\x0c\x10\x00\x04\x00\x01@\x00T\xb2\xa1\xac\xc2\x02\xe9\x18l\xd4uc\x80\x00\xa8\\\x86\xef\xf7\x01=\xf5\xd4S\xe1\xc7?\xfeq\x98>}z8\xe1\x84\x13\xc2\x1e{\xecQ\x9b\xb5\x99\x00\xb0V\xd6m\xadt\xbc\x98\xcd\x99\x01\x02\x80\x00 \x00\x08\x80\xda4\x999/\xd6\xce]\xb3\xd2\x1f\x06\x08\x80~\xe7\xed\x8e>\xf0\xd9g\x9f\rw\xdeyg\x981cF8\xf1\xc4\x13\xc3\xde{\xef]\xebu\x98\x00\xb0>\xf5g}\xf2\x18\x9c`\xa0\x1a\x0c\x10\x00\x04\x00\x01@\x00\xd4\xba\xf1\xb4\x99Tc31\x0e\xd5\x18\x07\x02\xa0\xa3\xb9\xbe\xc7\x17\xff\xcb_\xfe\x12\xee\xb9\xe7\x9ep\xe9\xa5\x97\x86\xcf}\xeesa\xbf\xfd\xf6\x0b\x83\x07\x0fNj\xdd%\x00\xaa1\xff\xad\xc3\xc6\x01\x03\x18\xe8\x0f\x03\x04\x00\x01@\x00\x10\x00I5\xa2\xfdY\xf8<\xc6\x06\x99*\x03\x04@\x8f\x19\xbcm?\\\xbati\xb8\xef\xbe\xfb\xc2\xacY\xb3\xc2)\xa7\x9c\x12\xde\xff\xfe\xf7\x87!C\x86$\xbf\xc6\x12\x00\xd6\xd4T\xd7T\xe7\x85\xed\x14\x19 \x00\x08\x00\x02\x80\x00H\xbe9Mq\xf1vN\x9a\x92\x9e\x18 \x00\xda\x96\xf5\xc3\xb2e\xcb\xc2/~\xf1\x8bp\xc5\x15W\x84/|\xe1\x0ba\xcc\x981a\xd8\xb0aY\xae\xa7\x04\x80\xf5\xa8\xa7\xf5\xc8\xcfp\x81\x81j2@\x00\x10\x00\x04\x00\x01\x90e\xc3jS\xaa\xe6\xa6d\\\x066.\x04@k\x04\xc0\x0b/\xbc\x10~\xfd\xeb_\x87+\xaf\xbc2\x9c~\xfa\xe9\xe1\x90C\x0e\t[n\xb9\xa5\xb5\xf3\xc5\xfd\x93\x00\x18\xd8\xbc\xb5\xee\xa9\x1f\x060\xd0N\x06\x08\x00\x02\x80\x00 \x004\xb1\x18\xc0@"\x0c\x10\x00\x03\x17\x00+V\xac\x08\x0f>\xf8`\x88\xb5<\xeb\xac\xb3\xc2\xd8\xb1c\xc3\x88\x11#\xcc\x91>\xe6\x08\x01 \xbc\xb43\xbcx-\xbca``\x0c\x10\x00\x04\x00\x01\xd0GSc\x81\x19\xd8\x02\xa3~\xea\x87\x81\xf62@\x00\x94\x17\x00\x0f?\xfcp\x987o^8\xe7\x9cs\xc2\x91G\x1e\x19\xb6\xddv[a\xbf\xe4\xbeH\x00\xb4w\x9e[W\xd5\x1b\x03\x18\x18\x08\x03\x04\x00\x01@\x00\x94lt\x062\xe1\xfc\xae\x05\x1b\x03\x18h%\x03\x04@\xdf\x02\xe0\x0f\x7f\xf8C\xb8\xe1\x86\x1b\xc2\x94)S\xc2\xf8\xf1\xe3\xc3{\xdf\xfb^a\xbf\t{ \x01`]k\xe5\xba\xe6\xb9\xf1\x85\x81\xe62@\x00\x10\x00\x04@\x13\x9a\x1f\x0bSs\x17&\xf5TO\x0c4\xc6\x00\x01\xf0\x92\x00x\xfc\xf1\xc7\xc3-\xb7\xdc\x12\xbe\xf9\xcdo\x86c\x8e9&\xec\xb4\xd3N\xc2~\x8b\xf6;\x02\xa0\xb1\xf9j\x9dS7\x0c`\xa0\x13\x0c\x10\x00\x04\x00\x01\xd0\xa2\x86\xa8\x13\x13\xdak\xdaH0\x907\x03\xb9\n\x80\xff\xfd\xdf\xff\r\xb7\xddv[\xf8\xd6\xb7\xbe\x15>\xf1\x89O\x84\x91#G\n\xfbm\xdc\xdb\x08\x80\xbc\xd7\x1d\xfb\x8e\xf1\xc7@\xbd\x18 \x00\x08\x00\x02\xa0\x8dM\x92\x05\xb2^\x0b\xa4\xf12^uc \x07\x01\xf0\xcc3\xcf\x84\xdbo\xbf=\\x\xe1\x85\xe1S\x9f\xfaT\x185j\x94\xb0\xdf\xe1}\x8c\x00\xb0V\xd6m\xadt\xbc\x98\xcd\x99\x01\x02\x80\x00 \x00:\xdc8\xe5\xbc\x009w\x1b0\x06\x9a\xcb@j\x02`\xf1\xe2\xc5\xe1\xee\xbb\xef\x0e\x97\\rI8\xe9\xa4\x93\xc2\xbe\xfb\xee+\xecWp\xcf"\x00\x9a;\x8f\xad\x8b\xea\x89\x01\x0c\xb4\x92\x01\x02\x80\x00 \x00*\xd8L\xb5r\xd2{n\x9b\n\x06\xd2e\xa0\xce\x02\xe0\xf9\xe7\x9f\x0f\xf7\xde{o\x989sf\x980aB\x18=zt\x182d\x88\xc0_\x83=\x8a\x00HwM\xb1_\x18[\x0c\xa4\xc7\x00\x01@\x00\x10\x005h\xae,\xbe\xe9-\xbe\xc6\xd4\x98\xb6\x82\x81\xba\x08\x80\xa5K\x97\x86\xfb\xef\xbf?\xcc\x9e=;L\x9c81|\xe0\x03\x1f\x08\xc3\x86\r\x13\xf6k\xba\x1f\x11\x00\xd6\xb3V\xacg\x9e\x13W\x18h\r\x03\x04\x00\x01@\x00\xd4\xb4\xe1\xb2(\xb6fQTWu\xad3\x03U\x14\x00\xcb\x97/\x0f\xbf\xfa\xd5\xaf\xc2\x9c9s\xc2i\xa7\x9d\x16\x0e:\xe8\xa0\xb0\xc5\x16[\x08\xfb\t\xed=\x04\x80u\xb3\xce\xeb\xa6c\xc7on\x0c\x10\x00\x04\x00\x01\x90P\x13\x96\xdb\x02\xe6|m\xda\x18X\x99\x81N\x0b\x80\x15+V\x84\x07\x1ex \\u\xd5Ua\xf2\xe4\xc9\xe1\xd0C\x0f\r\xc3\x87\x0f\x17\xf6\x13\xdfg\x08\x80\x95\xe7\xa1uI=0\x80\x81*3@\x00\x10\x00\x04@\xe2\x8dY\x95\x17 \xc7f\x83\xc4@s\x19\xe8\x94\x00\xf8\xf3\x9f\xff\x1c>\xf2\x91\x8f\x84\xad\xb7\xdeZ\xd8\xcfpO!\x00\x9a;\x8f\xad\x8b\xea\x89\x01\x0c\xb4\x92\x01\x02\x80\x00 \x002l\xd6Z\xb9\xa8xn\x9b\x16\x06:\xc7@\xa7\x04@\xfcW\x7f\xe3\xde\xb9q\xeft\xed\t\x80|\xc7\xbe\xd3\xecy}\xeca\xa0<\x03\x04\x00\x01@\x00$&\x00\xf6\xdcs\xcf\xf0\xcdo~3\xdcy\xe7\x9d\xe1\xd1G\x1f\r\x7f\xf8\xc3\x1f\xc2=\xf7\xdc\x13\xa6O\x9f\x1e\xf6\xdf\x7f\x7fMzb\xe3m\xe3+\xbf\xf1\xa5\\3\x02\x00\x0f\x9d\xe0\x9b\x00\xc0]\'\xb8\xf3\x9a\xb8\xc3@c\x0c\x10\x00\x04\x00\x01\x90H \xdcj\xab\xad\xba>+{\xd9\xb2e\xa1\xaf\xaf\x1bn\xb8\xa1\xeb\x8e\xdb\x16\xcd\xc6\x16MuS\xb7*3@\x00\xe0\xb3\x13|\x12\x00\xb8\xeb\x04w^\x13w\x18h\x8c\x01\x02\x80\x00 \x00\x12\x10\x00\xdbm\xb7]\xf8\xe9O\x7f\xdaW\xee_\xed\xef\x88\x80\xc6\x16M\x9b\x8d\xbaU\x99\x01\x02\x00\x9f\x9d\xe0\x93\x00\xc0]\'\xb8\xf3\x9a\xb8\xc3@c\x0c\x10\x00\x04\x00\x01\x90\x80\x00\x18H\xf3E\x044\xb6x\xdat\xd4\xad\x8a\x0c\x10\x00\xb8\xec\x04\x97\x03\xd9\x83V\xb3\xd3%~p\xc1\x05\x17x[[\x02=L\'\x98\xf5\x9a\xd6\xca\x9c\x19 \x00\x08\x00\x02\xa0\xe6\x9b\xe7)\xa7\x9cR\xa2]\xea\xfd\xa1D\x80\xcd0\xe7\xcd0\x95s\x9f6mZ\xef\x93\xbc\x85\x7f\xe3&\x80y\xaf\x1f\x04@\xde\xe3\x9f\xca\xfa\xe9\x17\x01\x80\xbb\xfe\xb2\xe2qX\xc1@\xe7\x19 \x00\x08\x00\x02\x80\x00hj\xd0_\xd3\x93\x11\x01\x9d_\xf8m\xbe\xe9\x8e\x01\x01\x90\xee\xd8Vy\xde\x12\x00\xb8\xab2\x9f\x8e\r\x9f\x18X\x99\x01\x02\x80\x00 \x00\x08\x805e\xf6\x96\xfc=\x11\xb0\xf2blsR\x8ff0@\x00\xe0\xa8\x19\x1c\x95}\x0e\x02\x00we\x99\xf1x\xcc`\xa0s\x0c\x10\x00\x04\x00\x01@\x00\xb4$\xe0\xf7\xf7I\x89\x80\xcem\x006\xdf\xf4jO\x00\xa47\xa6u\x98\xa7\x04\x00\xee\xea\xc0\xa9c\xc4)\x06\xfe\x8f\x01\x02\x80\x00 \x00\x08\x80\xfef\xf5\x96>\x8e\x08\xb01\xdb\x98\x07\xce\x00\x010\xf0\x1a\xe2\xb0|\r\t\x80\xf25\xc3\x99\x9aa\x00\x03\x9db\x80\x00 \x00\x08\x00\x02\xa0\xa5\xc1\xbe\xec\x93\x13\x016\xc4Nm\x88)\xbc.\x01`\xfet\x82c\x02\x00w\x9d\xe0\xcek\xe2\x0e\x03\x8d1@\x00\x10\x00\x04\x00\x01P6\xa3\xb7\xe5\xf1D@c\x8b\xba\xcd0\xef\xba\x11\x00y\x8f\x7f\xa7\xe6?\x01\x80\xbbN\xb1\xe7u\xb1\x87\x81\xf2\x0c\x10\x00\x04\x00\x01@\x00\xb4%\xd07\xfa"D@\xf9\x85\xddf\x98o\xcd\x08\x80|\xc7\xbe\x93\xf3\x9e\x00\xc0]\'\xf9\xf3\xda\xf8\xc3@9\x06\x08\x00\x02\x80\x00 \x00\x1a\xcd\xe6m\xfd="\xa0\xdc\xe2n3\xcc\xb3^\x04@\x9e\xe3\xde\xe9\xf9N\x00\xe0\xae\xd3\x0cz}\x0cb\xa0\xff\x0c\x10\x00\x04\x00\x01@\x00\xb45\xc8\x0f\xf4\xc5\x88\x80\xfe/\xf06\xc3\xfcjE\x00\xe47\xe6U\x98\xe7\x04\x00\xee\xaa\xc0\xa1c\xc0!\x06\xfa\xc7\x00\x01@\x00\x10\x00\x04\xc0@3yG~\x9f\x08\xe8\xdf"o3\xcc\xabN\x04@^\xe3]\x95\xf9M\x00\xe0\xae*,:\x0e,b`\xcd\x0c\x10\x00\x04\x00\x01@\x00t$\xc07\xebE\x89\x805/\xf46\xc3|jD\x00\xe43\xd6U\x9a\xd7\x04\x00\xee\xaa\xc4\xa3c\xc1#\x06\xfaf\x80\x00 \x00\x08\x80\x1a\x0b\x80\xd3O?\xbdY9\xba\xf6\xcfC\x04\xf4\xbd\xd8\xdb\x0c\xf3\xa8\x0f\x01\x90\xc78Wm>\x13\x00\xb8\xab\x1a\x93\x8e\x07\x93\x18\xe8\x9d\x01\x02\x80\x00 \x00j,\x00>\xf3\x99\xcf\xd4>\xb87\xfb\x04\x88\x80\xde\x17|\x9ba\xfa\xb5!\x00\xd2\x1f\xe3*\xcec\x02\x00wU\xe4\xd21\xe1\x12\x03=3@\x00\x10\x00\x04@\x8d\x05\xc0^{\xed\xd5\xec\xfc\x9c\xcc\xf3\x11\x01=/\xfa6\xc3\xb4\xebB\x00\xa4=\xbeU\x9d\xbf\x04\x00\xee\xaa\xca\xa6\xe3\xc2&\x06Vg\x80\x00 \x00\x08\x80\x1a\x0b\x80\xb8\xa8\xdd\x7f\xff\xfd\xc9\x84\xf6V\x9c\x08\x11\xb0\xfa\xc2o3L\xb7&\x04@\xbac[\xe5yK\x00\xe0\xae\xca|:6|b`e\x06\x08\x00\x02\x80\x00\xa8\xb9\x008\xfe\xf8\xe3[\x91\x9b\x93{N"`\xe5\xc5\xdff\x98f=\x08\x804\xc7\xb5\xea\xf3\x95\x00\xc0]\xd5\x19u|\x18\xc5\xc0K\x0c\x10\x00\x04\x00\x01Ps\x01\x10\x17\xb4k\xae\xb9&\xb9\xc0\xde\xaa\x13"\x02^\xda\x00l\x86\xe9\xd5\x82\x00HoL\xeb0O\t\x00\xdc\xd5\x81S\xc7\x88S\x0c\xfc\x1f\x03\x04\x00\x01@\x00$ \x00\xb6\xd8b\x8bp\xcb-\xb7\xb4*3\'\xf9\xbcD\x80F \xc5F\x80\x00\xc0u\'\xb8&\x00p\xd7\t\xee\xbc&\xee0\xd0\x18\x03\x04\x00\x01@\x00$ \x00\xe2\x028x\xf0\xe0p\xce9\xe7\x84\xc5\x8b\x17\'\x19\xd8[uRD@c\x9b\x87M\xb7\x9au#\x00\xaa9.\xa9\xcf\x17\x02\x00w\xa93\xee\xfc0\x9e\x12\x03\x04\x00\x01@\x00$"\x00\xba\x17\xa6\x1dw\xdc1\\t\xd1E\xe1\xb9\xe7\x9ekUfN\xf2y\x89\x00\x9b{\xf7\x1c\xaa\xf3\x9f\x04\x00\x8e;\xc1/\x01\x80\xbbNp\xe75q\x87\x81\xc6\x18 \x00\x08\x00\x02 1\x01\xd0\xbd\x18F\x110c\xc6\x0c"\xa0\xa4\xae \x02\x1a\xdbL\xba\xb9\xf3gg\xebG\x00t\xb6\xfe\xb9\xf2O\x00\xe0.W\xf6\x9d7\xf6\xeb\xc8\x00\x01@\x00\x10\x00\x89\n\x80\xee\x05\xa9[\x04\xfc\xe5/\x7f)\x19\x85\xf3~8\x11`S\xef\x9eCu\xfa\x93\x00\xc0m\'x%\x00p\xd7\t\xee\xbc&\xee0\xd0\x18\x03\x04\x00\x01@\x00$.\x00\xba\x17\xc7(\x02.\xbe\xf8\xe2@\x04\x94\x13\x1bD@c\x9bK7w\xfelo\xfd\x08\x80\xf6\xd6\x1b\xdf\xffWo\x02\x00w\xe6\x02\x060P\x1f\x06\x08\x00\x02\x80\x00\xc8D\x00t/\xcc;\xed\xb4S\xb8\xe4\x92K\xc2\xf3\xcf?_.\tg\xfeh"\xa0>\x1b[7\xeb9\xfeI\x00\xe0\xb4\x13\xdc\x13\x00\xb8\xeb\x04w^\x13w\x18h\x8c\x01\x02\x80\x00 \x002\x13\x00\xdd\x8b\xe5\xce;\xef\x1c\xbe\xfd\xedo\x13\x01%\xc5\x06\x11\xd0\xd8f\xd3\xcd\x9d?[[?\x02\xa0\xb5\xf5\xc5o\xcf\xf5%\x00z\xae\x0b^\xd4\x05\x03\x18\xa8"\x03\x04\x00\x01@\x00d*\x00\xba\x17\xa4]v\xd9%\\z\xe9\xa5D\x00\x11\x10\xba\x99\xf0g}\x1b\x16\x02\xa0\xbecW\xe7yG\x00\xe0\xae\xce\xfc:v\xfc\xe6\xc6\x00\x01@\x00\x10\x00\x99\x0b\x80\xeeE/\x8a\x80\x993g\x86%K\x96\x94\x8c\xc2y?\xdc\x15\x01\x1a\x87\xee9T\x85?\t\x00\xdc\x15\x01\xed\xdb\x10Sh>\x08\x00\xbct\x82c\x02\x00w\x9d\xe0\xcek\xe2\x0e\x03\x8d1@\x00\x10\x00\x04\x00\x01\xd0\x16\x01\xd0\xbdHG\x11\xf0\xfd\xef\x7f?,_\xbe\xbc\xb1D\x9c\xe9o\x11\x01\x8dmr\xdd\xdc\xe5\xf2\'\x01\x80\x93N\xb0N\x00\xe0\xae\x13\xdcyM\xdca\xa01\x06\x08\x00\x02\x80\x00 \x00\xda*\x00\xba\x17\xeb\xbd\xf6\xda+\\y\xe5\x95D@I\xa1A\x044\xb6\xd9us\x97\xfa\x9f\x04\x00>:\xc18\x01\x80\xbbNp\xe75q\x87\x81\xc6\x18 \x00\x08\x00\x02\x80\x00\xe8\x88\x00\xe8^\xb4\xf7\xde{\xef\xf0\x83\x1f\xfc\x80\x08 \x02:\xcaa7\x8fu\xff\x93\x00h\xac\x19\xaa\xfb\xb8w\xfa\xf8\t\x00\xdcu\x9aA\xaf\x8fA\x0c\xf4\x9f\x01\x02\x80\x00 \x00\x08\x80J\x04\xaf}\xf6\xd9\'\\u\xd5UD\x00\x11P\t\x1e\xeb\xdaH\x10\x00\xfdo\x80\xea:\xc6U|\xc0\x84\t\x13\x92\x1a\xf3fm\x88)<\x0f\x01\xa0A\xec\x04\xc7\x04\x00\xee:\xc1\x9d\xd7\xc4\x1d\x06\x1ac\x80\x00 \x00\x08\x00\x02 \xc90\x18E\xc0\xf5\xd7_O\x04\xf4 \x02\xe6\xce\x9d\x9b\xe4\x98k\x04\xde\x13\x08\x80\xc6\x9a!\xec\x0c\xacn\x04\xc0\xc0\xea\x87?\xf5\xc3\x00\x06\xda\xc9\x00\x01@\x00\x10\x00\x04@\xd2a\xf0\xfd\xef\x7f\x7fX\xb0`\x01\x11\xf02\x11p\xef\xbd\xf7&=\xe6\xed\xdcD\xab\xf6Z\x04\x80&\xb2\x13L\x12\x00\xb8\xeb\x04w^\x13w\x18h\x8c\x01\x02\x80\x00 \x00\x08\x80\xe4\xc3\xe0\xae\xbb\xee\x1a:\xd5\xa0\xbe,wW\xe6?\x7f\xf1\x8b_$?\xe6\xb96\x05\x04@c\xcdP\xae\xbc4\xeb\xbc;\xb5\xbe^p\xc1\x05\xd62=\x0c\x060\x80\x81\x92\x0c\x10\x00\x04\x00\x01Pr\xd24\xaba\xf2<\xado\xd4G\x8e\x1c\x19.\xbb\xec\xb2\xb0d\xc9\x92\xca\x84\xef*\x1c\xc8\x8f~\xf4#\x9be\xa2\xf3\x9e\x00h\xfd\xbab\xed^\xbd\xc6\x04\xc0\xea5\xc1\x89\x9a`\x00\x03Ue\x80\x00 \x00\x08\x80D\x83@U\x17\x9dv\x1c\xd7n\xbb\xed\x16\xbe\xf3\x9d\xef\x84\xa5K\x97V!oW\xee\x18\xa6N\x9dJ\x00$:\xef\t\x00\rg;\xd6\xd8U_\x83\x00\xc0\xdd\xaaL\xf8\x7fL`\xa0\xba\x0c\x10\x00\x04\x00\x01\x90h\x10\xc8q\xe1\xdd}\xf7\xdd\xc3\xe5\x97_.\xf8\xafA9\xc4\x1b$\xe6\xc8G\x0e\xe7L\x00T\xb7\xe1J\x99?\x02\x00w)\xf3\xed\xdc\xf0\x9d\x1a\x03\x04\x00\x01@\x00\x10\x00\xb5\x0f\x83{\xec\xb1G\x98={\xb6\xe0\xbf\x86\xe0\x1f\xff:\xde\x101\xb5\x8d\xcc\xf9\xbc\xd4\x9c\x11\x00/\xd5\x02\x17\xed\xab\x05\x01\xd0\xbeZ\xe3Z\xad1\x80\x81\x812@\x00\x10\x00\x04\x00\x01P\xdb@\xb8\xe7\x9e{\x86+\xae\xb8B\xf0\xefG\xf0\x8f\x0f\xf9\xd3\x9f\xfe\x14v\xdey\xe7\xda\x8e\xf7@7\xbc\x1c~\x9f\x00\xd0\x18v\x82s\x02\x00w\x9d\xe0\xcek\xe2\x0e\x03\x8d1@\x00\x10\x00\x04\x00\x01P\xbb@8j\xd4\xa8\xf0\xbd\xef}/,[\xb6\xac\x9f\xd1\xd7\xc3\x1e}\xf4\xd1p\xc0\x01\x07\xd4n\xacm\xee\xe56w\x02\xa0\\\xbd\xf0\xd5\x9cz\x11\x00\xcd\xa9#\x1e\xd5\x11\x03\x18h\x07\x03\x04\x00\x01@\x00\x10\x00\xb5\t\x85{\xed\xb5W\x983g\x8e\xe0_\xc2g\xbc\xf0\xc2\x0b]\xb2d\x87\x1dv\xa8\xcd8\xb7c\xf3K\xf55\x08\x00\xcdc\'\xd8&\x00p\xd7\t\xee\xbc&\xee0\xd0\x18\x03\x04\x00\x01@\x00\x10\x00\x95\x0f\x86{\xef\xbdw\xf8\xfe\xf7\xbf\x1f\x96/_^"\xfa\xe6\xfd\xd0\x18\xfc\xaf\xbe\xfa\xea\xb0\xef\xbe\xfbV~|m\xe0\x8dm\xe0=\xd5\x8d\x00h^-{\xaa\xaf\x9f\xf5\\_\x02\xa0\xe7\xba\xe0E]0\x80\x81*2@\x00\x10\x00\x04\x00\x01P\xd9\x80\xb8\xcf>\xfb\x84\x1f\xfc\xe0\x07\x82\x7f\t\x97!\xf8\xe7\xddl\x10\x00y\x8f\x7f\xa7\x1aM\x02\x00w\x9db\xcf\xebb\x0f\x03\xe5\x19 \x00\x08\x00\x02\x80\x00\xa8\x9c\x00\x88\xffj}\xd5UW\t\xfe\x82\x7f\xe5\xd8\xacz\xa3A\x00\x94o\x84\xaa>\xa6u8>\x02\x00wu\xe0\xd41\xe2\x14\x03\xff\xc7\x00\x01@\x00\x10\x00\x04@eB\xd6~\xfb\xed\x17\xe6\xce\x9d\x1b\xe2\xbfb\xfb\xea_\x05\xfc\x8b\xbf\x86\xe6\xe5\r\r\x01\x80\x87\x97\xf3\xd0\xdb\x7fo\xb5\xd5V!\xdeL\xf5C\x1f\xfaP8\xea\xa8\xa3\xc2I\'\x9d\x14\xbe\xf4\xa5/\x85\xb3\xcf>;\x9cu\xd6Y\xe1\xcc3\xcf\x0c\x93\'O\x0e\xa7\x9f~z\xf8\xecg?\xdb\xf5\x98\x0f|\xe0\x03a\xe4\xc8\x91a\xd8\xb0a\xab\xed\x19\x04\x00\xeezc\xcd\xcf\xb1\x81\x81\xea1@\x00\x10\x00\x04\x00\x01\xb0Z3\xd7\xee\xc5z\xff\xfd\xf7\x0f\xd7\\s\x8d\xe0\xdf\xbf\xcc\xdf\xf5(\xc1\xbfz\x1bj\xbb\xe7MO\xafG\x00\xe0\xe2\xe5\\\x8c\x181"\x8c\x1d;\xb6+\xcc\xcf\x9e=;\xdc}\xf7\xdd\xe1\xb1\xc7\x1e+\xb1\xd2\xac\xfe\xd0x/\x96\x07\x1f|0\xdcp\xc3\ra\xea\xd4\xa9\xe1\xc4\x13O\xecz\xde\xd5\x1f\xd9\xfa\x9f\\p\xc1\x05\x1d\xdf\xbf^^o\xffm\xfea\x00\x03u`\x80\x00 \x00\x08\x00\x02\xa0c\r\xd4\xe8\xd1\xa3\xc3u\xd7]\'\xf8\x97\xe8\x93\x05\x7f\xcdE_\xcd\x05\x01\x907\x1f\xdbo\xbf}8\xe1\x84\x13\xc2\xcc\x993\xc3/\x7f\xf9\xcb\xe4\xd7V\x02 o\xde\xfbZ\x0b\xfd\x1d60\xd0;\x03\x04\x00\x01@\x00\x10\x00m\x17\x00\x07\x1ex`\x987o^X\xb1bE\x89\xe8\x9b\xf7C\x05\xff\xde72\x9b\xfcK\xb5!\x00^\xaaE.\\\xec\xb1\xc7\x1e\xe1\x8c3\xce\x08?\xfe\xf1\x8f\xb3\xfb\x88T\x02 ?\xdes\x99\xd7\xce\x13\xdb\xadd\x80\x00 \x00\x08\x00\x02\xa0m\x02\xe0\xfd\xef\x7f\x7f\x98?\x7f\xbe\xe0_\xc2e\x08\xfe\x9a\x802M\x00\x01\x90\x07/\xef{\xdf\xfb\xc2\x97\xbf\xfc\xe5\xf0\x8b_\xfc\xa2\xc4j\x92\xdeC\t\x80\x90\xf5\xcf\xef\xe2\x01\x03\xfdc\x80\x00 \x00\x08\x00\x02\xa0\xe9\x02`\xcc\x981\xe1\xc6\x1bo\xacyk\xd9\xde\xc3\x17\xfc\xfb\xb7i\xd9\xdc\xfb\xae\x13\x01\xd0w}\xea\xc4\xcf\x96[n\xd9\xf5\xd1|\x8f>\xfah{\x17\xa3\x1a\xbd\x1a\x01\x90\x0e\xefu\x9a\x9b\x8e\x15wug\x80\x00 \x00\x08\x00\x02\xa0i\x02 ~\xa6\xf4\xcd7\xdf\\\xa3\xf6\xb1\xf3\x87*\xf8k$\x9a\xd9H\x10\x00\xf5\xe7i\xf0\xe0\xc1a\xd2\xa4I\x03\xfe\xb8\xbe\xce\xafn\xad?\x02\x02\xa0\xfe\xbc7s\xfd\xf3\\x\xc0@\xff\x18 \x00\x08\x00\x02\x80\x00\x18\xb0\x008\xe8\xa0\x83\xc2-\xb7\xdc\xd2\xfan/\xa1W\x10\xfc\xfb\xb7I\xd9\xcc\xcb\xd5\x89\x00(W\xaf\xaa\xf1u\xd8a\x87\x85\xfb\xef\xbf?\xa1\x95\xae\xb5\xa7B\x00\xd4\x9b\xf7\xaa\xcd?\xc7\x83\xa7\\\x18 \x00\x08\x00\x02\x80\x00hX\x00\x1cr\xc8!\xe1\xd6[omm\x87\x97\xd8\xb3\x0b\xfe\x1a\x8cV6\x18\x04@=\xf9\xday\xe7\x9d\xc3\xdc\xb9s\x13[\xedZ\x7f:\x04@=yo\xe5\x1a\xe8\xb91\x81\x8153@\x00\x10\x00\x04\x00\x01PZ\x00\xfc\xeb\xbf\xfek\xf8\xe1\x0f\x7f\xd8\xfa\xee.\xa1W\x10\xfc\xd7\xbc!\xd9\xb4\x07^#\x02`\xe05l7\x87\x9f\xfc\xe4\'\xc3\x93O>\x99\xd0j\xd7\xbeS!\x00\xea\xc7{\xbb\xe7\x97\xd7\xc3\x08\x06Vg\x80\x00 \x00\x08\x00\x02\xa0\xdf\x02\xe0\xd0C\x0f\r\xb7\xddv[\xfb\xba\xbb\x04^I\xf0_}\xe3\xb1\x19\xb7\xae&\x04@\xebj\xdbln\xb7\xd9f\x9bp\xe5\x95W&\xb0\xcau\xee\x14\x08\x80\xfa\xf0\xde\xec\xf9\xe3\xf9\x8c=\x06\x1ag\x80\x00 \x00\x08\x00\x02`\x8d\x02\xe0\xc3\x1f\xfepX\xb4hQ\xe7\xba\xbc\x1a\xbe\xb2\xe0\xdf\xf8\xc6dSo\xbcv\x04@\xe3\xb5k\'w\xf1\x93R\x1ey\xe4\x91\x1a\xael\xd5:d\x02\xa0\x1e\xbc\xb7sny-L``\xcd\x0c\x10\x00\x04\x00\x01@\x00\xf4*\x00\xc6\x8e\x1d\x1b~\xf2\x93\x9fT\xab\xe3\xab\xf8\xd1\x08\xfek\xdexl\xce\xad\xab\x11\x01\xd0\xba\xda6\x8b\xdb\x13O<1<\xff\xfc\xf3\x15_\xc9\xeaqx\x04@\xf5yo\xd6\xbc\xf1<\xc6\x1a\x03\xcdc\x80\x00 \x00\x08\x00\x02`5\x01\xf0\x91\x8f|$\xdc~\xfb\xed\xf5\xe8\x00+r\x94\x82\x7f\xf36&\x9b|\xe3\xb5$\x00\x1a\xaf];\xb8\x8b\x81\xd5W\xf3*@\x00T\x9b\xf7v\xcc)\xaf\x81\x01\x0c\x94g\x80\x00 \x00\x08\x00\x02\xe0\xaf\x02\xe0\x88#\x8e\x08w\xdcqG\xf3\xba\xb3\x0c\x9eI\xf0/\xbf\xf1\xd8\xac[W3\x02\xa0u\xb5\x1d\x08\xb7\xc3\x86\r\x0b\xf3\xe7\xcf\xcf`El\xef)\x12\x00\xd5\xe4} s\xc5\xef\x1aS\x0c\xb4\x9e\x01\x02\x80\x00 \x00\x08\x800n\xdc\xb8p\xd7]w\xb5\xb7s\xab\xf9\xab\t\xfe\xad\xdf\xa04\x01\xe5kL\x00\x94\xafY\xab9\xdbb\x8b-\xc2\xc2\x85\x0bk\xbe\xe2U\xf3\xf0\t\x80\xea\xf1\xde\xea\xf9\xe4\xf9\x8d9\x06\x06\xce\x00\x01@\x00\x10\x00\x19\x0b\x80\x8f~\xf4\xa3\xe1?\xff\xf3?\xab\xd9\xd9U\xf4\xa8\x04\xff\x81o<6\xef\xd6\xd5\x90\x00h]m\x1b\xe1v\xab\xad\xb6\xf2\x91\xa9-\\\xcb\t\x80j\xf1\xde\xc8\x1c\xf1;\xc6\x10\x03\xedg\x80\x00 \x00\x08\x80\x0c\x05\xc0QG\x1d\x15\xee\xb9\xe7\x9e\x16\xb6e\xe9=\xb5\xe0\xdf\xfe\rJSP\xbe\xe6\x04@\xf9\x9a\xb5\x8a\xb3x\xd9\xbfOOi\xed^@\x00T\x87\xf7V\xcd#\xcfk\x8c1\xd0|\x06\x08\x00\x02\x80\x00\xc8H\x00\x1c}\xf4\xd1\xe1\xa7?\xfdik;\xb2\xc4\x9e]\xf0o\xfe\xc6c3o]M\t\x80\xd6\xd5\xb6,\xb7W^yeb\xaba\xf5N\x87\x00\xa8\x0e\xefe\xe7\x87\xc7\x1b;\x0ct\x8e\x01\x02\x80\x00 \x002\x10\x00\x1f\xfb\xd8\xc7\xc2\xcf~\xf6\xb3\xeauo\x15>"\xc1\xbfs\x1b\x93\xa6\xa0\xf1\xda\x13\x00\x8d\xd7\xae\x99\xdc\xfd\xfb\xbf\xff{\x85W\xb7t\x0e\x8d\x00\xa8\x06\xef\xcd\x9c;\x9e\xcb\x98b\xa0\xf5\x0c\x10\x00\x04\x00\x01\x90\xb0\x008\xf6\xd8c\xc3}\xf7\xdd\x97N\xb7\xd7\x863\x11\xfc[\xbf\xf1\xd8\xdc[Wc\x02\xa0u\xb5\xed/\xb7\'\x9exb\x1bV*/\x11+@\x00t\x9e\xf7\xfe\xce\x0b\x8f3V\x18\xa8\x0e\x03\x04\x00\x01@\x00$(\x00>\xfe\xf1\x8f\x87\x9f\xff\xfc\xe7:\xc4\x12\x15\x10\xfc\xab\xb31i\x12\x1a\x1f\x0b\x02\xa0\xf1\xda5\x83\xbb\xbd\xf6\xda+,^\xbc\xb8\xc4\xca\xe3\xa1\x03\xa9\x00\x01\xd0Y\xde\x9b1g<\x871\xc4@\xfb\x19 \x00\x08\x00\x02 !\x010v\xecX\xc1\xbfd7)\xf8\xb7\x7f\xe3\xb1\xd9\xb7\xae\xe6\x04@\xebj\xbb&n\x87\x0e\x1d\xea\xadV%\xd7\xdf\x81>\x9c\x00\xe8\x1c\xefk\x9a\x0f\xfe\xde\xd8`\xa0\xba\x0c\x10\x00\x04\x00\x01\x90\x88\x00\x982eJX\xb1b\xc5@\xfb\xa9l~_\xf0\xaf\xee\xc6\xa4ih|l\x08\x80\xc6k7P\xeeb\x18\xf5\xd5\xde\n\x10\x00\x9d\xe3}\xa0\xf3\xc5\xef\x1b;\x0ct\x8e\x01\x02\x80\x00 \x00\x12\x10\x00\xe7\x9csN{\xbb\xae\x1a\xbf\x9a\xe0\xdf\xb9\r\xc7f\xdf\xfa\xda\x13\x00\xad\xafqO\x1c\x7f\xe8C\x1f\nqm\xf1\xd5\xde\n\x10\x00\x9d\xe1\xbd\xa79\xe0g\xc6\x02\x03\xf5a\x80\x00 \x00\x08\x80\x9a\x0b\x801c\xc6\x84\xe5\xcb\x97\xb7\xb7\xeb\xaa\xe1\xab\t\xfe\xf5\xd9\x984\x11\x8d\x8f\x15\x01\xd0x\xed\x1a\xe5n\xf0\xe0\xc1n\xb6\xda\xa1=\x81\x00h?\xef\x8d\xce\x13\xbfg\xac0P\x1d\x06\x08\x00\x02\x80\x00\xa8\xb9\x00\xb8\xe5\x96[:\xd4z\xd5\xe3e\x05\xff\xeal86\xff\xd6\x8f\x05\x01\xd0\xfa\x1a\xaf\xca\xf1\x84\t\x13\xea\xb1\x18&x\x94\xd3\xa7O\x0f\xab\x8e\x87\xffo\xff\x1cPs5\xc7@\xbd\x18 \x00\x08\x00\x02\xa0\xc6\x02`\xc7\x1dwt\xd9i/M\xad\xe0_\xaf\xcdH\xf3\xd0\x9c\xf1"\x00\x9aS\xc7\xfe\xf2\xb8\xc5\x16[\x84G\x1f}\xb4\x97U\xc8\x8f[]\x81+\xae\xb8\x82\x00\xa8q\x0f\xd3\xdfy\xe6q\xed]\xd7\xd4;\xfdz\x13\x00\x04\x00\x01P\xe3\xcd3~\xdc\x9f\xaf\x95+\xd0\x1d\xfc\xf7\xdbo?\x8da\x8d\xd9\xd6\x804\xd6\x80\x10\x00\x8d\xd5\xadQ\xde\xbe\xf8\xc5/\xae\xbc\x00\xf9\xbf\xb6V`\xfe\xfc\xf9\xd6y\xeb<\x060\x80\x81\x92\x0c\x10\x00\x04\x00\x01Pr\xd24\xda(\xb6\xe2\xf7N;\xed\xb4\xb66[U~1\xc1\xbf\xbd\xc1\xa7\x15<{\xce\x81\x8f!\x010\xf0\x1a\xf6\x97\xc3\xf8\xde\xff\x87\x1ez\xa8\xca\xcbb\xf2\xc7\xf6\xc3\x1f\xfeP\xe3_\xe3\x1e\xa6\xbfs\xcd\xe3\xda\xb7\xae\xa9u\x1e\xb5&\x00\x08\x00\x02\xa0\xc6\x9b\xe7Yg\x9d\x95|\x83\xb7\xa6\x13\x14\xfc\xf3\xd8\xacRmJb\x88\xdcu\xd7]\xc3\x81\x07\x1e\x18\x0e?\xfc\xf0\xf0\xc9O~2L\x9c81\xc4\xb9\x1d\xbf\xcf<\xf3\xcc0y\xf2\xe4p\xfa\xe9\xa7\x87SN9%\x1c{\xec\xb1\xe1\x90C\x0e\t\xa3F\x8d\n#F\x8cX-\xfc\x10\x00\xed\x9b\x0fq,|u\xb6\x02\xb7\xdf~\xfbjs \xd5\xb5\xc2y\xb5on\xab\xb5Z\xa7\xce\x00\x01@\x00\x10\x00\x04@g;\xb8\x06_]\xf0\xb7A\xd7i\x83\x1e2dH\x18=zt\x887\x8c\xbb\xf0\xc2\x0b\xc3\xad\xb7\xde\x1a~\xf7\xbb\xdf\x85\xa5K\x9768\x03\xfe\xef\xd7\x1e{\xec\xb1\xb0h\xd1\xa2\xf0\xedo\x7f;L\x9a4)L\x9b6m@\xcf\xd7\xe8/?\xf0\xc0\x03\xd9\x05\xb1\x85\x0b\x176Z.\xbf\xd7\xa4\n\xdcu\xd7]\xd9qW\xa7u\xcf\xb1\xda\xa71PM\x06\x08\x00\x02\x80\x00 \x00\x9a\xd4\x8a\xb5\xe7i\x04\xffjn&6\xf9\x95\xc7e\xe8\xd0\xa1a\xec\xd8\xb1\xe1\x1b\xdf\xf8F\xf8\xc9O~\x12\x16/^\xdc\x9e\t\xd2\xa1W\xc9M\x00\xec\xb0\xc3\x0ea\xd9\xb2e\x1d\xaa\xb6\x97\xed\xae@\x9c_\xd6\x9e\x95\xd7\x1e\xf5P\x0f\x0c``M\x0c\x10\x00\x04\x00\x01@\x00t\xf7R\x95\xfeS\xf0\xb7\xa1\xadiC\xeb\xf4\xdf\xc7K\xf2O8\xe1\x84p\xd5UW\x85\xa7\x9ez\xaa\xd2\xf3\xa9\xd9\x07\x97\x9b\x008\xf5\xd4S\x9b]B\xcf\xd7@\x05\xbe\xf2\x95\xaf\x10\x005\xeea:\xbdf{}}E\xae\x0c\x10\x00\x04\x00\x01P\xe3\xcd3\x87{\x00\x08\xfe6\xe8*o\xd0\xc3\x86\r\x0b\xc7\x1f\x7f|\x88\x97\x83/Y\xb2\xa4\x81\x08\x93\xc6\xaf\xe4&\x00\x16,X\x90\xc6\xc0\xd5\xfc,\xe2[j\xaa\xbc>86\xfb\x17\x060PE\x06\x08\x00\x02\x80\x00 \x00*\xd9\x02v\x07\xff}\xf7\xddW\x83WcF\xab\xb8\xf15\xe3\x98\xf6\xdak\xaf0}\xfa\xf4\xf0\xf8\xe3\x8fWr\xfe\xb4\xfb\xa0r\x12\x00\xf1\xc6\x8dO?\xfdt\xbbK\xec\xf5z\xa8\xc0G?\xfaQ\xfb\x83\xfd\x01\x03\x18\xc0@I\x06\x08\x00\x02\x80\x00(9i\x9a\x11\x1e\x9a\xf5\x1c)^\x01\xd0\x1d\xfc\xf7\xdbo?\x0bz\x8d\xd9l\x16\xe3U{\x9e\xc3\x0e;,\xc4\x8f\x1e\xf3\xb5r\x05r\x12\x00\xf1f\x8e\xbe\xaaQ\x81=\xf6\xd8\xc3>a\x9f\xc0\x00\x060P\x92\x01\x02\x80\x00 \x00JN\x9a*\x05\x92\x94\x04\x80\xe0\xef2\xb9*\xcd\xadU\x8f\xe5\xc8#\x8f\x0cw\xdcqG5RO\x05\x8f"\'\x01\xf0\xb9\xcf}\xae\x82#\x90\xdf!\xc5\x1bk\xae:O\xfd\xbf}\x04\x03\x18\xc0\xc0\x9a\x19 \x00\x08\x00\x02\x80\x00\xe8h\xe7(\xf8\xafy\xa1\xb6\x99u\xaeF\x07\x1f|p\xb8\xe7\x9e{::G\xea\xf0\xe29\t\x80\xf8\xd6\x0f_\x9d\xaf\xc0\xed\xb7\xdfN\x00\xd4\xb8\x7f\xb1\xafun_S{\xb5\'\x00\x08\x00\x02\xa0\xc6\x1bh\x9d\xaf\x00\x10\xfcm@UnBv\xdai\xa7\xf0\xfd\xef\x7f?\xacX\xb1\xa2\xf3I\xa7\x06G\x90\x93\x00\xb8\xf6\xdakk0"\xe9\x1f\xe2\xf9\xe7\x9fO\x00\xd4\xb8\x7f\xa9\xf2\xfa\xef\xd8\xf4\'\xa93@\x00\x10\x00\x04@\x8d7\xd0:\n\x00\xc1\xdf\xc6Z\xe5\x8du\xc8\x90!\xe1\xec\xb3\xcf\x0e\xcf>\xfbl\xfa\t\xaa\x89g\x98\x93\x00\xb8\xf3\xce;\x9bX9O\xd5h\x05\x0e=\xf4P\x02\xa0\xc6\xfdK\x95\xf7\x01\xc7\xa6OI\x9d\x01\x02\x80\x00 \x00j\xbc\x81\xd6I\x00\x08\xfe6\xd4\xaao\xa8\xf1\xce\xfe.\xf7o,\x8e\xe5$\x00~\xf9\xcb_6V$\xbf\xd5\xb4\n<\xf1\xc4\x13!\xca\xba\xaa\xaf)\x8e\xcf\xbe\x87\x01\x0cT\x91\x01\x02\x80\x00 \x00\x08\x80\xa65e==\x91\xe0o\xf3\xab\xe2\xe6\xb7\xea1M\x9a4)\xc4\x9b\x8a\xf9j\xac\x029\t\x80\x07\x1f|\xb0\xb1"\xf9\xad\xa6U\xe0;\xdf\xf9\x8e\xf0_\xe3\xdee\xd5\xf5\xd7\xff\xeb\x130\xd0^\x06\x08\x00\x02\x80\x00\xa8\xf1&Z\xe5+\x00\x04\xff\xf6.\xe66\xcf\xc6\xea\xbd\xed\xb6\xdb\x86\x1bo\xbc\xb1i\xc1$\xd7\'\xcaI\x00\xc4s\xf5\xd5\xd9\n\xc4\x9bsZ\xf3\x1a[\xf3\xd4M\xdd0\x80\x01\x02\x80\x00 \x00\x08\x80\xa6vr\x82\xbf\x8d\xa5.\xcd\xc5\xde{\xef\x1d\x84\xb9\xe6L\xff\x9c\x04\xc0\x7f\xfd\xd7\x7f5\xa7h\x9e\xa5\xa1\n\xdc\x7f\xff\xfd\xc2\x7f\x8d\xfb\x96\xba\xec\x0f\x8eS/\x932\x03\x04\x00\x01@\x00\xd4x#\xad\xd2\x15\x00\x82\xbf\xcd\xb2N\x9b\xe5\xb8q\xe3\xc2SO=\xd5P\x00\xf1K\xabW \'\x01p\xdbm\xb7\xad^\x00?i[\x05>\xfb\xd9\xcf\x12\x005\xee[\xea\xb4O8V}M\xaa\x0c\x10\x00\x04\x00\x01P\xe3\x8d\xb4\n\x02@\xf0\xb7A\xd6m\x83\xfc\xfc\xe7?\x1f\x96/_\xde\xb6\xc0\x92\xc3\x0b\xe5$\x00\xe2\xc7C\xfa\xeaL\x05\x1e~\xf8\xe10t\xe8P\x02\xa0\xc6}K\xdd\xf6\x0b\xc7\xab\xc7I\x91\x01\x02\x80\x00 \x00j\xbc\x91vR\x00\x08\xfe6\xc5:n\x8a_\xfc\xe2\x17\xc3\x8a\x15+:\x93^\x12~\xd5\x9c\x04\xc0\xd7\xbe\xf6\xb5\x84G\xb2\xda\xa7v\xf2\xc9\'\x0b\xff5\xeeY\xea\xb8g8f\xbdN\x8a\x0c\x10\x00\x04\x00\x01P\xe3\xcd\xb4\x13\x02@\xf0\xb7\x19\xd6u3<\xe3\x8c3\xaa\x9dnj|t9\t\x80c\x8f=\xb6\xc6#U\xdfC\x8f\x1f\xbf8x\xf0`\x02\xa0\xc6=K]\xf7\x0e\xc7\xad\xefI\x8d\x01\x02\x80\x00 \x00j\xbc\x99\xb6S\x00\x08\xfe6\xc0:o\x80\xc2\x7fk\x83_N\x02\xe0}\xef{_k\x8b\xe9\xd9{\xac\xc0\x87?\xfca\xe1\xbf\xc6\xfdJ\x9d\xf7\x0f\xc7\xae\xffI\x8d\x01\x02\x80\x00 \x00j\xbc\xa1\xc6\xcf.o\xf5\x97\xe0o\xe3\xab\xfb\xc6w\xdcq\xc7\x85\xc8\xb1\xaf\xd6U \'\x01\x10\xe7\xc3\xaf\x7f\xfd\xeb\xd6\x15\xd33\xafV\x81\xef}\xef{\xc2\x7f\x8d{\x95\xba\xef!\x8e_\x1f\x94\x1a\x03\x04\x00\x01@\x00\xd4xS\xfd\xe8G?\xbaZ\xa3\xd4\xac\x1f\x08\xfe6\xbc\x146\xbc1c\xc6\x84\xe7\x9e{\xaeY\xd3\xc2\xf3\xf4R\x81\xdc\x04\xc0\xf4\xe9\xd3{\xa9\x84\x1f7\xbb\x02\xbf\xfb\xdd\xef\xc2\xd6[oM\x00\xd4\xb8WIa/q\x0ez\xa2\x94\x18 \x00\x08\x00\x02\xa0\xc6\x9bjl\x8a\x9e\x7f\xfe\xf9\xa6\xf6[\x82\xbfM.\x95Mn\xe4\xc8\x91\xe1\xb1\xc7\x1ek\xea\xfc\xf0d=W 7\x01\x10\xc5\x92\xaf\xd6W`\xd9\xb2e\xe1\xe0\x83\x0f\x16\xfek\xdc\xa7\xa4\xb2\x9f8\x0f\xbdQJ\x0c\x10\x00\x04\x00\x01P\xf3\x8du\xce\x9c9M\xe9\xc2\x04\x7f\x9b[J\x9b[\xbcY\xd8\x9dw\xde\xd9\x94\xb9\xe1I\xd6\\\x81\xdc\x04@\x9c+\xff\xfd\xdf\xff\xbd\xe6\xc2x\xc4\x80*0e\xca\x14\xe1\xbf\xe6=JJ\xfb\x8as\xd1\'\xa5\xc2\x00\x01@\x00\x10\x005\xdf\\w\xdbm\xb7\xf0\xec\xb3\xcf6\xdcd\t\xfe6\xb4T6\xb4\x97\x9f\xc7\xb9\xe7\x9e\xdb\xf0\x9c\xf0\x8b\xe5+\x90\xa3\x00\x88\x1f)\xe9\xabu\x15X\xb0`\x81\xbb\xfe\xd7\xbc?y\xf9\x9a\xec\xbf\xf5\x1a\x18\xa8\x0e\x03\x04\x00\x01@\x00$\xb0\xc1\xc6\x9b\x9c-_\xbe\xbcT\'&\xf8Wg!\xb6)6w,\x0e:\xe8\xa0\x10/\x1d\xf6\xd5\xbe\n\xe4(\x00\xb6\xdaj\xab\xf0\xe7?\xff\xb9}E\xce\xe8\x95~\xf6\xb3\x9f\x85X_kcs\xd7F\xf5TO\x0c` 2@\x00\x10\x00\x04@\x02\x02 N\xe6\x8f}\xecc\xe1\xa9\xa7\x9eZc\x8b(\xf8[\xfcSn\x00\x86\r\x1b\x16b\x18\xf5\xd5\xde\n\xe4(\x00\xe2<\x9a\xfc\xe1\x0f\x87-\xb7\xdcRS\x95\xe0X\xdb\x94_\xda\x94\xa3\x00[\xbcxq&Q\xa9Z\xa7\xf9\xc7?\xfe1\xdb\xf5e\xdc\xb8q\xd5\x1a\x8c\x9a\x1e\xcd\xa3\x8f>\x1a\xf6\xde{\xefl9\xb2\x96\xbf\xb4\x96\xab\x85Z`\xa0\xb5\x0c\x10\x00\x04\x00\x01 \x14j\xb80\x90\x04\x03\xb3f\xcd\xaai\xf4\xa9\xffa?\xf9\xe4\x93I0\xd4h\xd39{\xf6\xec\xfa\x0fb\x07\xcf ^\xf6\xbf\xe7\x9e{f\xcdP\xa3\xec\xf9\xbd\xd6\x06%\xf5U\xdf\x14\x19 \x00\x08\x00\x02@\xf8\xd3ta\xa0\xf6\x0c\xec\xba\xeb\xaea\xe9\xd2\xa5\x1d\x8c0y\xbft|\xdbQ\x8aMR\x7f\xcfi\xf8\xf0\xe1>\x16\xb0\xc1)\xf0\xcb_\xfe2\x8c\x1c92k~\xfa\xcb\x99\xc7\t\xa3\x18\xc0@3\x18 \x00\x08\x00\x02@\xf8\xd3xa\xa0\xf6\x0c\xcc\x981\xa3\xc1\xf8\xe1\xd7\x9aQ\x81\xe7\x9e{\xae\xf6\x0c\r\xb4\xa9\xda}\xf7\xdd\xc3\xe3\x8f?\xde\x8crf\xf3\x1c7\xdcpC\x181bD\xf6\xec\x0c\x94=\xbf/\x14b\x00\x03e\x18 \x00\x08\x00\x02@\xf8\xd3|a\xa0\xd6\x0c\xc4\x7f}\xed\xcf\'`d\x93\xaa:p\xa2\xcf?\xff|\xad\x19*\xd38\xf5\xf5\xd81c\xc6\x84(C|\xad\xb9\x02S\xa7N\xc5\x8c\xbd\x07\x03\x18\xc0@\x07\x18 \x00\x08\x00\x02\xa0\x03\x13\xaf\xaf\x06\xd2\xdf\xb1\xb8\x18(\xc7\xc0\x84\t\x13\xd6\x9c6<\xa2\xa5\x15X\xb2d\x89&\xee\xc5\xbd\xe4\x98c\x8e\xf1v\x94>h{\xe2\x89\'\xc2q\xc7\x1d\x87\x17\xbd\x07\x060\x80\x81\x0e1@\x00\x10\x00\x04@\x87&\x9f\x90W.\xe4\xa9\x97z\xf5\xc6\xc0\xa2E\x8b\xfa\x88\x1b\xfe\xaa\x1d\x15\x88\xef\xe3\xeem|r\xfc\xf9\x11G\x1c\x11\x9e}\xf6\xd9v\x94\xbeV\xafq\xcb-\xb7\x84\xf8i\x1d92\xe1\x9c\xeda\x18\xc0@U\x18 \x00\x08\x00\x02\x80\x00\xd0\x8ca\xa0\xb6\x0c\xec\xb0\xc3\x0e\xe1\x85\x17^\xa8U\x08J\xf1`\x7f\xfa\xd3\x9f\xd6\x96\xa1V5d\x1f\xfc\xe0\x07\xdd\x13\xe0E\xd8\xe3\xc7s~\xf1\x8b_\xc4\x88\xbd\x06\x03\x18\xc0@\x05\x18 \x00\x08\x00\x02\xa0\x02\x13\xb1U\r\xa8\xe7e\x9bSg\xe0\xa4\x93NJ1O\xd7\xee\x9c\x16.\\\xa8\xa9\xeba/\x89\x1fmw\xef\xbd\xf7\xd6n<\x9by\xc0\xd7^{\xad\xbb\xfc\xf7\xc0F\xeak\xb3\xf3\xd3\x7f`\xa0\xba\x0c\x10\x00\x04\x00\x01`c\xd6\xb8c\xa0\xb6\x0c\xcc\x993\xa7\x99Y\xc5s5X\x81\xd9\xb3g\xd7\x96\xa1V7\xa9\xc3\x86\r\x0b\xd3\xa7O\x0f+V\xach\xb0\xba\xf5\xfc\xb5\xf8\xb6\x90\xb1c\xc7\xe2\xc2\xfe\x82\x01\x0c`\xa0b\x0c\x10\x00\x04\x00\x01P\xb1I\xd9\xeaf\xd4\xf3W\xd7\xc8\x1a\x9b\xf2c\xf3\x9b\xdf\xfc\xa6\x9e\xe9(\xb1\xa3\xfe\xf2\x97\xbf\xac\xc1[\xc3^2n\xdc\xb8\xf0\xd0C\x0f%6\xf2\xab\x9fN<\xc7SO=5\x0c\x192\x04\x13k`\xc2\x9a_~\xcdW35\xc3\xc0\xc0\x19 \x00\x08\x00\x02\xc0\x06\xadI\xc3@-\x19\x88\x9f\x1f\x9e\xdb\xbf\xaa\xae\x1e\xb7\xaa\xf1\x93\xa3\x8f>\xba\x96\x0c\xb5\xbb\x91\xdcb\x8b-\xc2\xd7\xbf\xfe\xf5\x10\xdf\x13\x9f\xda\xd7\x03\x0f<\x10N>\xf9\xe40t\xe8P,\xd8S0\x80\x01\x0cT\x98\x01\x02\x80\x00 \x00*\xb2\xed\xb6\xdb\x86\xc9\x93\'\x87\x9f\xfd\xecg}\x17\xbb\x83\x7f\xfb\xd4SO\x85\xb9s\xe7\x86O~\xf2\x93!\xde\xd8\xd0\xba\xd7\x99uO\xdd\xd5\x1d\x03\x18\x18(\x03\x04\x00\x01@\x00\x0c\xb0q\x1b\xe8$\xf4\xfb\x16r\x0c4\xc6\xc0\x94)S:\x18\x87\xbctw\x05\x8e8\xe2\x08a\xb0\x89\xfb\xc8^{\xed\x15"\xdb\xf1.\xfa\x9d\xfe\x8a7\xf4\xbb\xf4\xd2K\xc3\x91G\x1e\xe9\xbd\xfdM\x1cck~ck\xbe\xba\xa9\x1b\x06\x9a\xc3\x00\x01@\x00\x10\x006u\xcd;\x06j\xc9\xc0\xb7\xbe\xf5\xadN\xe7\xa3\xec_\xffO\x7f\xfa\x93\xf7~\xb7p\xfd\xd8q\xc7\x1dC|\xab\xcb\xacY\xb3B\xbc\xc9^+oz\x19\xefG\xf0\xf3\x9f\xff\xbc\xeb\xb5>\xf7\xb9\xcf\x85\xddv\xdb\xad\x96\xeb\x82\x80\xd0\x9c\x80\xa0\x8e\xea\x88\x81t\x19 \x00\x08\x00\x02\xa0\x85\xcd\x9b\xc53\xdd\xc5\xd3\xd8v~l\xa7N\x9d\x9a}\x00\xeft\x01.\xbc\xf0B!\xb1\x8d{\xc8\xf0\xe1\xc3C\xbc\xf9e\x0c\xe8\xd3\xa7O\x0f\xd7_\x7f}\x887\xe1\x8b\x1f\x87\x19/\xd1\xef\xeb+~\xf2\xc0\xef\x7f\xff\xfb\xf0_\xff\xf5_a\xd1\xa2E\xe1{\xdf\xfb^\xd7\x95\x06\xf1\x06~\xa3G\x8fvY\x7f\x1b\xc7\xd1\xfe\xd1\xf9\xfd\xc3\x18\x18\x83\x9c\x19 \x00\x08\x00\x02\xc0\xa6\xaf\x81\xc7@-\x198\xf7\xdcs\xfb\xca;\xfe\xae\xc5\x15\x88\xff\x1a=j\xd4\xa8Z\xb2\x93j\xe3\x17\xdf\x9b\xbf\xcd6\xdb\x84\xed\xb6\xdb.l\xbf\xfd\xf6!~:C\xfc\xf6\x9e}a\'U\xe6\x9d\x17\xb61P\x9e\x01\x02\x80\x00 \x00\x84?\r<\x06j\xc9\xc0\xa4I\x93Z\x1cq=}_\x15\xb8\xf1\xc6\x1bk\xc9\x8df\xb1|\xb3\xa8fj\x86\x01\x0c` \x1d\x06\x08\x00\x02\x80\x00\x10\xfe4\xf1\x18\xa8%\x03\xc7\x1e{l_\xf9\xd4\xdf\xb5\xb8\x02\x07\x1f|p-\xb9\xd1\xc4\xa6\xd3\xc4\x1aKc\x89\x01\x0c`\xa0<\x03\x04\x00\x01@\x00\x08\x7f\x9ax\x0c\xd4\x92\x81}\xf6\xd9\xa7\xc5\x11\xd7\xd3\xf7V\x81\x9bo\xbe\xb9\x96\xcch\x14\xcb7\x8aj\xa6f\x18\xc0\x00\x06\xd2b\x80\x00 \x00\x08\x00\xe1O#\x8f\x81Z20d\xc8\x90\xf0\x97\xbf\xfc\xa5\xb7\x8c\xea\xe7-\xaa\xc0\xf2\xe5\xcb\xc3\x01\x07\x1cPKf4\xb1i5\xb1\xc6\xd3xb\x00\x03\x18(\xcf\x00\x01@\x00\x10\x00\xc2\x9fF\x1e\x03\xb5e\xe0\x8e;\xeehQ\xcc\xf5\xb4\xbdU\xe0\x92K.\xa9-/\x1a\xc5\xf2\x8d\xa2\x9a\xa9\x19\x060\x80\x81\xb4\x18 \x00\x08\x00\x02@\xf8\xd3\xccc\xa0\xb6\x0c\x9c\x7f\xfe\xf9\xbd\xe5T?oA\x05\x1e}\xf4\xd1\xae\xbb\xcck\x06\xd3j\x06\x8d\xa7\xf1\xc4\x00\x060\x90\x0f\x03\x04\x00\x01@\x00\x08\x7f\xb5\r\x7f6\xab|6\xab\xde\xc6\xfaC\x1f\xfaP\x0bb\xae\xa7\xec\xa9\x02/\xbc\xf0B\x18;v\xac\xf5\xc2\x9e\x81\x01\x0c`\x00\x03\x18\xa81\x03\x04\x00\x01@\x00\xd4x\x02\xf7\x16\x8a\xfc\\0\xce\x89\x81G\x1ey\xa4\xa7\xbc\xeagM\xae\xc0\xb4i\xd34|\xf6\x0b\x0c`\x00\x03\x18\xc0@\xcd\x19 \x00\x08\x00\x02\xa0\xe6\x938\xa7\xa0\xe7\\\x89\x8d\x9e\x18\xf8\xe67\xbf\xd9\xe4\xa8\xeb\xe9V\xad\xc0}\xf7\xdd\x17\x86\r\x1b\xa6\xe9\xb3_`\x00\x03\x18\xc0\x00\x06j\xce\x00\x01@\x00\x10\x005\x9f\xc4=\x05"?\x13\x94sb`\xe4\xc8\x91a\xe9\xd2\xa5\xabfV\xff\xdf\xa4\n\xfc\xe9O\x7f\n\xb1\xc691\xe5\\\xad\xa1\x18\xc0\x00\x060\x90*\x03\x04\x00\x01@\x00\x10\x00\x1a{\x0c\xd4\x9e\x81\xef\x7f\xff\xfbM\x8a\xbb\x9e\xe6\xe5\x15X\xbcxq\x183fL\xed\xf9H\xb5\x89s^\x02\n\x060\x80\x01\x0c\x94e\x80\x00 \x00\x08\x00\xe1Os\x8f\x81\xda30j\xd4(W\x01\xbc<\xb97\xe1\xbf\xe3M\xff>\xfe\xf1\x8f\xd7\x9e\x8d\xb2\x8d\x91\xc7k\xa61\x80\x01\x0c` e\x06\x08\x00\x02\x80\x00\x10\xfe4\xf8\x18H\x82\x81\x8b/\xbe\xb8\t\xb1\xd7S\xc4\n\xc4\xf0\x7f\xca)\xa7$\xc1E\xcaM\x9cs\x13R0\x80\x01\x0c`\xa0,\x03\x04\x00\x01@\x00\x08\x7f\x9a|\x0c$\xc1\xc0\xd6[o\x1d\xe2\xe7\xd4\xfb\x1aX\x05b\xf8?\xf9\xe4\x93\x93`\xa2lS\xe4\xf1\x1ai\x0c`\x00\x03\x18H\x9d\x01\x02\x80\x00 \x00\x84?\x8d>\x06\x92a\xe0\xa8\xa3\x8e\n+V\xac\x18X\x02\xce\xf8\xb7\x97/_\x1eN<\xf1\xc4dxH\xbd\x89s~\x82\n\x060\x80\x01\x0c\x94e\x80\x00 \x00\x08\x00\xe1O\xb3\x8f\x81\xa4\x18\xb8\xe8\xa2\x8b2\x8e\xf0\x8d\x9fz\xbc\xe1\xdfq\xc7\x1d\x97\x14\x0be\x9b"\x8f\xd7Hc\x00\x03\x18\xc0@\xea\x0c\x10\x00\x04\x00\x01 \xfci\xf81\x90\x14\x03C\x86\x0c\t7\xddtS\xe3I8\xc3\xdf|\xe4\x91G\xc2\xfb\xdf\xff\xfe\xa48H\xbd\x81s~B\n\x060\x80\x01\x0c4\xc2\x00\x01@\x00\x10\x00\xc2\x9f\xa6\x1f\x03\xc910b\xc4\x88p\xdf}\xf7e\x18\xe5\xcb\x9f\xf2]w\xdd\x15\xde\xf7\xbe\xf7%\xc7@#M\x91\xdf\xd1Lc\x00\x03\x18\xc0@\xea\x0c\x10\x00\x04\x00\x01 \xfci\xfc1\x90$\x03;\xed\xb4Sx\xf0\xc1\x07\xcb\'\xe2L~#\xde+!\xbe]b\xd8\xb0aI\x8e\x7f\xea\r\x9c\xf3\x13R0\x80\x01\x0c`\xa0\x11\x06\x08\x00\x02\x80\x00\x10\xfe4\xff\x18H\x96\x81\xf8/\xdb?\xff\xf9\xcf3\x89\xf4\xfd?\xcd\xdf\xff\xfe\xf7\xe1\xf0\xc3\x0fOv\xdc\x1bi\x88\xfc\x8eF\x1a\x03\x18\xc0\x00\x06r`\x80\x00 \x00\x08\x00\xe1O\x08\xc0@\xd2\x0c\xc4\x8f\x07\\\xb4hQ\xff\xd3q\xe2\x8f\xbc\xea\xaa\xab\xc2\xb6\xdbn\x9b\xf4\x98\xe7\xd0\xc09GA\x05\x03\x18\xc0\x00\x06\x1aa\x80\x00 \x00\x08\x00\xe1O\x10\xc0@\xf2\x0c\xc4\xcb\xdcg\xce\x9c\x99x\xb4\xef\xfb\xf4~\xfd\xeb_\x87q\xe3\xc6%?\xd6\x8d4C~G\x13\x8d\x01\x0c`\x00\x03\xb90@\x00\x10\x00\x04\x80\xf0\'\x10` \x1b\x06\x8e=\xf6\xd8\xf0\xe4\x93O\xf6\x9d\x94\x13\xfb\xdb\xa7\x9f~:\x9cy\xe6\x99a\xe8\xd0\xa1\xd9\x8cs.M\x9c\xf3\x14X0\x80\x01\x0c`\xa0,\x03\x04\x00\x01@\x00\x08\x7fB\x01\x06\xb2b`\x97]v\t\xb7\xdcrKb1\x7f\xf5\xd3y\xee\xb9\xe7\xc2\xc5\x17_\xec\x0e\xff\xe6wV\xf3\xbbl#\xec\xf1\xc2\x13\x060\x90\x1b\x03\x04\x00\x01@\x00h\x0e5\x87\x18\xc8\x92\x81\x8f}\xecc\xe17\xbf\xf9\xcd\xea\xc9\xb9\xe6?y\xf6\xd9g\xc3\xf4\xe9\xd3\x05\x7f\xf3:\xcby\x9d[#\xef|\x85W\x0c`\xa0,\x03\x04\x00\x01@\x00h\x125\x89\x18\xc8\x96\x81xo\x80\xb3\xcf>;<\xfe\xf8\xe35\x8f\xfd!\xc4;\xfbO\x992%l\xbf\xfd\xf6\xd9\x8eg\xd9&\xc8\xe35\xce\x18\xc0\x00\x060\x90\x1b\x03\x04\x00\x01@\x00\x08\x7f\xc2\x02\x06\xb2g`\xcb-\xb7\x0c\x93&M\xaa\xdd\x15\x01\xcb\x96-\x0b\x0b\x16,\x08G\x1f}t\x18\x11\xce=\xf7\xdc0w\xee\xdc\xf0\xe3\x1f\xff8\xfc\xeaW\xbf\xea\xba\xa1`\xbcL\xbf\xb7\xaf\xa5K\x97v=\xe6\x7f\xfe\xe7\x7f\xc2]w\xdd\xd5\x15\xf4\xe3]\xfb\'N\x9c\x18\x0e=\xf4\xd0\xb0\xddv\xdb\x99\x93\xd6e\x0c`\x00\x03\x18\xc0\x00\x06Z\xc2\x00\x01@\x00\x10\x00\x16\x97\x96,.)\x84<\xe7@V4\xca@\xbc\x97\xc06\xdbl\xd3\x15\xe6\xe3]\xf9\xdf\xfb\xde\xf7v},\xdf\xd6[om\xbeYs1\x80\x01\x0c`\x00\x03\x18\xe8\x18\x03\x04\x00\x01@\x00X\x80:\xb6\x005\x1a\xae\xfc\x9e`\x8e\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x81\xf2\x0c\x10\x00\x04\x00\x01@\x00\x10\x00\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x18\xc0@\x06\x0c\x10\x00\x04\x00\x01\x90\xc1DgG\xcb\xdbQ5S3\x0c`\x00\x03\x18\xc0\x00\x060\x80\x81\xd4\x18 \x00\x08\x00\x02\x80\x00`{1\x80\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x81\x0c\x18 \x00\x08\x00\x02 \x83\x89\x9e\x9a\xb9t>l<\x060\x80\x01\x0c`\x00\x03\x18\xc0\x00\x06\xca3@\x00\x10\x00\x04\x00\x01\xc0\xf6b\x00\x03\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x190@\x00\x10\x00\x04@\x06\x13\x9d\x1d-oG\xd5L\xcd0\x80\x01\x0c`\x00\x03\x18\xc0\x00\x06Rc\x80\x00 \x00\x08\x00\x02\x80\xed\xc5\x00\x060\x80\x01\x0c`\x00\x03\x18\xc0\x00\x062`\x80\x00 \x00\x08\x80\x0c&zj\xe6\xd2\xf9\xb0\xf1\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x18(\xcf\x00\x01@\x00\x10\x00\x04\x00\xdb\x8b\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x01\x0cd\xc0\x00\x01@\x00\x10\x00\x19Ltv\xb4\xbc\x1dU35\xc3\x00\x060\x80\x01\x0c`\x00\x03\x18H\x8d\x01\x02\x80\x00 \x00\x08\x00\xb6\x17\x03\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x18\xc8\x80\x01\x02\x80\x00 \x002\x98\xe8\xa9\x99K\xe7\xc3\xc6c\x00\x03\x18\xc0\x00\x060\x80\x01\x0c`\xa0<\x03\x04\x00\x01@\x00\x10\x00l/\x060\x80\x01\x0c`\x00\x03\x18\xc0\x00\x060\x90\x01\x03\x04\x00\x01@\x00d0\xd1\xd9\xd1\xf2vT\xcd\xd4\x0c\x03\x18\xc0\x00\x060\x80\x01\x0c` 5\x06\x08\x00\x02\x80\x00 \x00\xd8^\x0c`\x00\x03\x18\xc0\x00\x060\x80\x01\x0c` \x03\x06\x08\x00\x02\x80\x00\xc8`\xa2\xa7f.\x9d\x0f\x1b\x8f\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x81\xf2\x0c\x10\x00\x04\x00\x01@\x00\xb0\xbd\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x18\xc0@\x06\x0c\x10\x00\x04\x00\x01\x90\xc1DgG\xcb\xdbQ5S3\x0c`\x00\x03\x18\xc0\x00\x060\x80\x81\xd4\x18 \x00\x08\x00\x02\x80\x00`{1\x80\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x81\x0c\x18 \x00\x08\x00\x02 \x83\x89\x9e\x9a\xb9t>l<\x060\x80\x01\x0c`\x00\x03\x18\xc0\x00\x06\xca3@\x00\x10\x00\x04\x00\x01\xc0\xf6b\x00\x03\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x190@\x00\x10\x00\x04@\x06\x13\x9d\x1d-oG\xd5L\xcd0\x80\x01\x0c`\x00\x03\x18\xc0\x00\x06Rc\x80\x00 \x00\x08\x00\x02\x80\xed\xc5\x00\x060\x80\x01\x0c`\x00\x03\x18\xc0\x00\x062`\x80\x00 \x00\x08\x80\x0c&zj\xe6\xd2\xf9\xb0\xf1\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x18(\xcf\x00\x01@\x00\x10\x00\x04\x00\xdb\x8b\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x01\x0cd\xc0\x00\x01@\x00\x10\x00\x19Ltv\xb4\xbc\x1dU35\xc3\x00\x060\x80\x01\x0c`\x00\x03\x18H\x8d\x01\x02\x80\x00 \x00\x08\x00\xb6\x17\x03\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x18\xc8\x80\x01\x02\x80\x00 \x002\x98\xe8\xa9\x99K\xe7\xc3\xc6c\x00\x03\x18\xc0\x00\x060\x80\x01\x0c`\xa0<\x03\x04\x00\x01@\x00\x10\x00l/\x060\x80\x01\x0c`\x00\x03\x18\xc0\x00\x060\x90\x01\x03\x04\x00\x01@\x00d0\xd1\xd9\xd1\xf2vT\xcd\xd4\x0c\x03\x18\xc0\x00\x060\x80\x01\x0c` 5\x06\x08\x00\x02\x80\x00 \x00\xd8^\x0c`\x00\x03\x18\xc0\x00\x060\x80\x01\x0c` \x03\x06\x08\x00\x02\x80\x00\xc8`\xa2\xa7f.\x9d\x0f\x1b\x8f\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x81\xf2\x0c\x10\x00\x04\x00\x01@\x00\xb0\xbd\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x18\xc0@\x06\x0c\x10\x00\x04\x00\x01\x90\xc1DgG\xcb\xdbQ5S3\x0c`\x00\x03\x18\xc0\x00\x060\x80\x81\xd4\x18 \x00\x08\x00\x02\x80\x00`{1\x80\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x81\x0c\x18 \x00\x08\x00\x02 \x83\x89\x9e\x9a\xb9t>l<\x060\x80\x01\x0c`\x00\x03\x18\xc0\x00\x06\xca3@\x00\x10\x00\x04\x00\x01\xc0\xf6b\x00\x03\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x190@\x00\x10\x00\x04@\x06\x13\x9d\x1d-oG\xd5L\xcd0\x80\x01\x0c`\x00\x03\x18\xc0\x00\x06Rc\x80\x00 \x00\x08\x00\x02\x80\xed\xc5\x00\x060\x80\x01\x0c`\x00\x03\x18\xc0\x00\x062`\x80\x00 \x00\x08\x80\x0c&zj\xe6\xd2\xf9\xb0\xf1\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x18(\xcf\x00\x01@\x00\x10\x00\x04\x00\xdb\x8b\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x01\x0cd\xc0\x00\x01@\x00\x10\x00\x19Ltv\xb4\xbc\x1dU35\xc3\x00\x060\x80\x01\x0c`\x00\x03\x18H\x8d\x01\x02\x80\x00 \x00\x08\x00\xb6\x17\x03\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x18\xc8\x80\x01\x02\x80\x00 \x002\x98\xe8\xa9\x99K\xe7\xc3\xc6c\x00\x03\x18\xc0\x00\x060\x80\x01\x0c`\xa0<\x03\x04\x00\x01@\x00\x10\x00l/\x060\x80\x01\x0c`\x00\x03\x18\xc0\x00\x060\x90\x01\x03\x04\x00\x01@\x00d0\xd1\xd9\xd1\xf2vT\xcd\xd4\x0c\x03\x18\xc0\x00\x060\x80\x01\x0c` 5\x06\x08\x00\x02\x80\x00 \x00\xd8^\x0c`\x00\x03\x18\xc0\x00\x060\x80\x01\x0c` \x03\x06\x08\x00\x02\x80\x00\xc8`\xa2\xa7f.\x9d\x0f\x1b\x8f\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x81\xf2\x0c\x10\x00\x04\x00\x01@\x00\xb0\xbd\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x18\xc0@\x06\x0c\x10\x00\x04\x00\x01\x90\xc1DgG\xcb\xdbQ5S3\x0c`\x00\x03\x18\xc0\x00\x060\x80\x81\xd4\x18 \x00\x08\x00\x02\x80\x00`{1\x80\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x81\x0c\x18 \x00\x08\x00\x02 \x83\x89\x9e\x9a\xb9t>l<\x060\x80\x01\x0c`\x00\x03\x18\xc0\x00\x06\xca3@\x00\x10\x00\x04\x00\x01\xc0\xf6b\x00\x03\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x190@\x00\x10\x00\x04@\x06\x13\x9d\x1d-oG\xd5L\xcd0\x80\x01\x0c`\x00\x03\x18\xc0\x00\x06Rc\x80\x00 \x00\x08\x00\x02\x80\xed\xc5\x00\x060\x80\x01\x0c`\x00\x03\x18\xc0\x00\x062`\x80\x00 \x00\x08\x80\x0c&zj\xe6\xd2\xf9\xb0\xf1\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x18(\xcf\x00\x01@\x00\x10\x00\x04\x00\xdb\x8b\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x01\x0cd\xc0\x00\x01@\x00\x10\x00\x19Ltv\xb4\xbc\x1dU35\xc3\x00\x060\x80\x01\x0c`\x00\x03\x18H\x8d\x01\x02\x80\x00 \x00\x08\x00\xb6\x17\x03\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x18\xc8\x80\x01\x02\x80\x00 \x002\x98\xe8\xa9\x99K\xe7\xc3\xc6c\x00\x03\x18\xc0\x00\x060\x80\x01\x0c`\xa0<\x03\x04\x00\x01@\x00\x10\x00l/\x060\x80\x01\x0c`\x00\x03\x18\xc0\x00\x060\x90\x01\x03\x04\x00\x01@\x00d0\xd1\xd9\xd1\xf2vT\xcd\xd4\x0c\x03\x18\xc0\x00\x060\x80\x01\x0c` 5\x06\x08\x00\x02\x80\x00 \x00\xd8^\x0c`\x00\x03\x18\xc0\x00\x060\x80\x01\x0c` \x03\x06\x08\x00\x02\x80\x00\xc8`\xa2\xa7f.\x9d\x0f\x1b\x8f\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x81\xf2\x0c\x10\x00\x04\x00\x01@\x00\xb0\xbd\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x18\xc0@\x06\x0c\x10\x00\x04\x00\x01\x90\xc1DgG\xcb\xdbQ5S3\x0c`\x00\x03\x18\xc0\x00\x060\x80\x81\xd4\x18 \x00\x08\x00\x02\x80\x00`{1\x80\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x81\x0c\x18 \x00\x08\x00\x02 \x83\x89\x9e\x9a\xb9t>l<\x060\x80\x01\x0c`\x00\x03\x18\xc0\x00\x06\xca3@\x00\x10\x00\x04\x00\x01\xc0\xf6b\x00\x03\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x190@\x00\x10\x00\x04@\x06\x13\x9d\x1d-oG\xd5L\xcd0\x80\x01\x0c`\x00\x03\x18\xc0\x00\x06Rc\x80\x00 \x00\x08\x00\x02\x80\xed\xc5\x00\x060\x80\x01\x0c`\x00\x03\x18\xc0\x00\x062`\x80\x00 \x00\x08\x80\x0c&zj\xe6\xd2\xf9\xb0\xf1\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x18(\xcf\x00\x01@\x00\x10\x00\x04\x00\xdb\x8b\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x01\x0cd\xc0\x00\x01@\x00\x10\x00\x19Ltv\xb4\xbc\x1dU35\xc3\x00\x060\x80\x01\x0c`\x00\x03\x18H\x8d\x01\x02\x80\x00 \x00\x08\x00\xb6\x17\x03\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x18\xc8\x80\x01\x02\x80\x00 \x002\x98\xe8\xa9\x99K\xe7\xc3\xc6c\x00\x03\x18\xc0\x00\x060\x80\x01\x0c`\xa0<\x03\x04\x00\x01@\x00\x10\x00l/\x060\x80\x01\x0c`\x00\x03\x18\xc0\x00\x060\x90\x01\x03\x04\x00\x01@\x00d0\xd1\xd9\xd1\xf2vT\xcd\xd4\x0c\x03\x18\xc0\x00\x060\x80\x01\x0c` 5\x06\x08\x00\x02\x80\x00 \x00\xd8^\x0c`\x00\x03\x18\xc0\x00\x060\x80\x01\x0c` \x03\x06\x08\x00\x02\x80\x00\xc8`\xa2\xa7f.\x9d\x0f\x1b\x8f\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x81\xf2\x0c\x10\x00\x04\x00\x01@\x00\xb0\xbd\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x18\xc0@\x06\x0c\x10\x00\x04\x00\x01\x90\xc1DgG\xcb\xdbQ5S3\x0c`\x00\x03\x18\xc0\x00\x060\x80\x81\xd4\x18 \x00\x08\x00\x02\x80\x00`{1\x80\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x81\x0c\x18 \x00\x08\x00\x02 \x83\x89\x9e\x9a\xb9t>l<\x060\x80\x01\x0c`\x00\x03\x18\xc0\x00\x06\xca3@\x00\x10\x00\x04\x00\x01\xc0\xf6b\x00\x03\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x190@\x00\x10\x00\x04@\x06\x13\x9d\x1d-oG\xd5L\xcd0\x80\x01\x0c`\x00\x03\x18\xc0\x00\x06Rc\x80\x00 \x00\x08\x00\x02\x80\xed\xc5\x00\x060\x80\x01\x0c`\x00\x03\x18\xc0\x00\x062`\x80\x00 \x00\x08\x80\x0c&zj\xe6\xd2\xf9\xb0\xf1\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x18(\xcf\x00\x01@\x00\x10\x00\x04\x00\xdb\x8b\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x01\x0cd\xc0\x00\x01@\x00\x10\x00\x19Ltv\xb4\xbc\x1dU35\xc3\x00\x060\x80\x01\x0c`\x00\x03\x18H\x8d\x01\x02\x80\x00 \x00\x08\x00\xb6\x17\x03\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x18\xc8\x80\x01\x02\x80\x00 \x002\x98\xe8\xa9\x99K\xe7\xc3\xc6c\x00\x03\x18\xc0\x00\x060\x80\x01\x0c`\xa0<\x03\x04\x00\x01@\x00\x10\x00l/\x060\x80\x01\x0c`\x00\x03\x18\xc0\x00\x060\x90\x01\x03\x04\x00\x01@\x00d0\xd1\xd9\xd1\xf2vT\xcd\xd4\x0c\x03\x18\xc0\x00\x060\x80\x01\x0c` 5\x06\x08\x00\x02\x80\x00 \x00\xd8^\x0c`\x00\x03\x18\xc0\x00\x060\x80\x01\x0c` \x03\x06\x08\x00\x02\x80\x00\xc8`\xa2\xa7f.\x9d\x0f\x1b\x8f\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x81\xf2\x0c\x10\x00\x04\x00\x01@\x00\xb0\xbd\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x18\xc0@\x06\x0c\x10\x00\x04\x00\x01\x90\xc1DgG\xcb\xdbQ5S3\x0c`\x00\x03\x18\xc0\x00\x060\x80\x81\xd4\x18 \x00\x08\x00\x02\x80\x00`{1\x80\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x81\x0c\x18 \x00\x08\x00\x02 \x83\x89\x9e\x9a\xb9t>l<\x060\x80\x01\x0c`\x00\x03\x18\xc0\x00\x06\xca3@\x00\x10\x00\x04\x00\x01\xc0\xf6b\x00\x03\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x190@\x00\x10\x00\x04@\x06\x13\x9d\x1d-oG\xd5L\xcd0\x80\x01\x0c`\x00\x03\x18\xc0\x00\x06Rc\x80\x00 \x00\x08\x00\x02\x80\xed\xc5\x00\x060\x80\x01\x0c`\x00\x03\x18\xc0\x00\x062`\x80\x00 \x00\x08\x80\x0c&zj\xe6\xd2\xf9\xb0\xf1\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x18(\xcf\x00\x01@\x00\x10\x00\x04\x00\xdb\x8b\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x01\x0cd\xc0\x00\x01@\x00\x10\x00\x19Ltv\xb4\xbc\x1dU35\xc3\x00\x060\x80\x01\x0c`\x00\x03\x18H\x8d\x01\x02\x80\x00 \x00\x08\x00\xb6\x17\x03\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x18\xc8\x80\x01\x02\x80\x00 \x002\x98\xe8\xa9\x99K\xe7\xc3\xc6c\x00\x03\x18\xc0\x00\x060\x80\x01\x0c`\xa0<\x03\x04\x00\x01@\x00\x10\x00l/\x060\x80\x01\x0c`\x00\x03\x18\xc0\x00\x060\x90\x01\x03\x04\x00\x01@\x00d0\xd1\xd9\xd1\xf2vT\xcd\xd4\x0c\x03\x18\xc0\x00\x060\x80\x01\x0c` 5\x06\x08\x00\x02\x80\x00 \x00\xd8^\x0c`\x00\x03\x18\xc0\x00\x060\x80\x01\x0c` \x03\x06\x08\x00\x02\x80\x00\xc8`\xa2\xa7f.\x9d\x0f\x1b\x8f\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x81\xf2\x0c\x10\x00\x04\x00\x01@\x00\xb0\xbd\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x18\xc0@\x06\x0c\x10\x00\x04\x00\x01\x90\xc1DgG\xcb\xdbQ5S3\x0c`\x00\x03\x18\xc0\x00\x060\x80\x81\xd4\x18 \x00\x08\x00\x02\x80\x00`{1\x80\x01\x0c`\x00\x03\x18\xc0\x00\x060\x80\x81\x0c\x18 \x00\x08\x00\x02 \x83\x89\x9e\x9a\xb9t>l<\x060\x80\x01\x0c`\x00\x03\x18\xc0\x00\x06\xca3@\x00\x10\x00\x04\x00\x01\xc0\xf6b\x00\x03\x18\xc0\x00\x060\x80\x01\x0c`\x00\x03\x190@\x00\xb4F\x00\xfc\x7f\x00\x00\x00\xff\xffT\xaenr\x00\x00@\x00IDAT\xed\xddm\x8c\x9d\xe5y\xe0q\x8fMl\x02\xcc\xda\xc6\xc62\xd8\x18\x03\xf1KlG\xe2\xc5\x16R\xbet\xd9\xa4\x9b\x97f\x15@IPH\xf1\x07\xb6\x0b\x96 $awK$`c\xa4\r\xc9.I\x90\xa8\x16\xc5-\xe9\x06\x12ba!\xed\xa6\xc1\xd46.\xb6Aj\xa4j\xf3\xa2\xaa\xea\xc7\xdd\xa2m*E\xaa\xba\xbb\xc9\x97\xd2R={_f\x06\xc6\x83=\x1e\x1f\xdf\xc7~\xee\xe7\xfa\x8d4\x19;\xf3\xc2s\xee\xe7w?>\xd7\x7f\xce\x9cY\xf0\xdak\xafu}y]\xb4hQ\xb7`\xc1\x82\xe6_\xb7m\xdb\xd6}\xe0\x03\x1f\xf0j\r\x18`\x80\x01\x06\x18`\x80\x01\x06\x18`\x80\x81\x11\x0c\xc4L5\x84\xd90f\xdc\xbe\xcc\xdbq\x1c\x0b\xfat0\x02\x80h \x9c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x02\xc0x\xbeQ/\x00\x8c\xe1\x11\x07\x1e\x01\xe0\x82\xe5\x1f-\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x18\x18\xdd\x80\x00 \x004\xf3\x10\x10\x01`\xf4\x8d\xee"i\xed\x18`\x80\x01\x06\x18`\x80\x01\x06\x18`@\x00\x10\x00\x04\x80\x11~v\xc6\xc5\xd3\xc5\x93\x01\x06\x18`\x80\x01\x06\x18`\x80\x01\x06Z3 \x00\x08\x00\x02\x80\x00\xe0\tT\x18`\x80\x01\x06\x18`\x80\x01\x06\x18` \x81\x01\x01@\x00\x10\x00\x12l\xf4\xd6\xca\xa4\xe3U\xd3\x19`\x80\x01\x06\x18`\x80\x01\x06\x18\xa8o@\x00\x10\x00\x04\x00\x01@\xede\x80\x01\x06\x18`\x80\x01\x06\x18`\x80\x81\x04\x06\x04\x00\x01@\x00H\xb0\xd1\xd5\xd3\xfa\xf5\xd4\x9aZS\x06\x18`\x80\x01\x06\x18`\x80\x81\xd6\x0c\x08\x00\x02\x80\x00 \x00\xa8\xbd\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\x90\xc0\x80\x00 \x00\x08\x00\t6zke\xd2\xf1\xaa\xe9\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c\xd47 \x00\x08\x00\x02\x80\x00\xa0\xf62\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0@\x02\x03\x02\x80\x00 \x00$\xd8\xe8\xeai\xfdzjM\xad)\x03\x0c0\xc0\x00\x03\x0c0\xc0@k\x06\x04\x00\x01@\x00\x10\x00\xd4^\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x18H`@\x00\x10\x00\x04\x80\x04\x1b\xbd\xb52\xe9x\xd5t\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\xea\x1b\x10\x00\x04\x00\x01@\x00P{\x19`\x80\x01\x06\x18`\x80\x01\x06\x18` \x81\x01\x01@\x00\x10\x00\x12lt\xf5\xb4~=\xb5\xa6\xd6\x94\x01\x06\x18`\x80\x01\x06\x18`\xa05\x03\x02\x80\x00 \x00\x08\x00j/\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c$0 \x00\x08\x00\x02@\x82\x8d\xdeZ\x99t\xbcj:\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\xf5\r\x08\x00\x02\x80\x00 \x00\xa8\xbd\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\x90\xc0\x80\x00 \x00\x08\x00\t6\xbazZ\xbf\x9eZSk\xca\x00\x03\x0c0\xc0\x00\x03\x0c0\xd0\x9a\x01\x01@\x00\x10\x00\x04\x00\xb5\x97\x01\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x12\x18\x10\x00\x04\x00\x01 \xc1Fo\xadL:^5\x9d\x01\x06\x18`\x80\x01\x06\x18`\x80\x81\xfa\x06\x04\x00\x01@\x00\x10\x00\xd4^\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x18H`@\x00\x10\x00\x04\x80\x04\x1b]=\xad_O\xad\xa95e\x80\x01\x06\x18`\x80\x01\x06\x18h\xcd\x80\x00 \x00\x08\x00\x02\x80\xda\xcb\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\t\x0c\x08\x00\x02\x80\x00\x90`\xa3\xb7V&\x1d\xaf\x9a\xce\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0@}\x03\x02\x80\x00 \x00\x08\x00j/\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c$0 \x00\x08\x00\x02@\x82\x8d\xae\x9e\xd6\xaf\xa7\xd6\xd4\x9a2\xc0\x00\x03\x0c0\xc0\x00\x03\x0c\xb4f@\x00\x10\x00\x04\x00\x01@\xede\x80\x01\x06\x18`\x80\x01\x06\x18`\x80\x81\x04\x06\x04\x00\x01@\x00H\xb0\xd1[+\x93\x8eWMg\x80\x01\x06\x18`\x80\x01\x06\x18`\xa0\xbe\x01\x01@\x00\x10\x00\x04\x00\xb5\x97\x01\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x12\x18\x10\x00\x04\x00\x01 \xc1FWO\xeb\xd7SkjM\x19`\x80\x01\x06\x18`\x80\x01\x06Z3 \x00\x08\x00\x02\x80\x00\xa0\xf62\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0@\x02\x03\x02\x80\x00 \x00$\xd8\xe8\xad\x95I\xc7\xab\xa63\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0P\xdf\x80\x00 \x00\x08\x00\x02\x80\xda\xcb\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\t\x0c\x08\x00\x02\x80\x00\x90`\xa3\xab\xa7\xf5\xeb\xa95\xb5\xa6\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\xad\x19\x10\x00\x04\x00\x01@\x00P{\x19`\x80\x01\x06\x18`\x80\x01\x06\x18` \x81\x01\x01@\x00\x10\x00\x12l\xf4\xd6\xca\xa4\xe3U\xd3\x19`\x80\x01\x06\x18`\x80\x01\x06\x18\xa8o@\x00\x10\x00\x04\x00\x01@\xede\x80\x01\x06\x18`\x80\x01\x06\x18`\x80\x81\x04\x06\x04\x00\x01@\x00H\xb0\xd1\xd5\xd3\xfa\xf5\xd4\x9aZS\x06\x18`\x80\x01\x06\x18`\x80\x81\xd6\x0c\x08\x00\x02\x80\x00 \x00\xa8\xbd\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\x90\xc0\x80\x00 \x00\x08\x00\t6zke\xd2\xf1\xaa\xe9\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c\xd47 \x00\x08\x00\x02\x80\x00\xa0\xf62\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0@\x02\x03\x02\x80\x00 \x00$\xd8\xe8\xeai\xfdzjM\xad)\x03\x0c0\xc0\x00\x03\x0c0\xc0@k\x06\x04\x00\x01@\x00\x10\x00\xd4^\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x18H`@\x00\x10\x00\x04\x80\x04\x1b\xbd\xb52\xe9x\xd5t\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\xea\x1b\x10\x00\x04\x00\x01@\x00P{\x19`\x80\x01\x06\x18`\x80\x01\x06\x18` \x81\x01\x01@\x00\x10\x00\x12lt\xf5\xb4~=\xb5\xa6\xd6\x94\x01\x06\x18`\x80\x01\x06\x18`\xa05\x03\x02\x80\x00 \x00\x08\x00j/\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c$0 \x00\x08\x00\x02@\x82\x8d\xdeZ\x99t\xbcj:\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\xf5\r\x08\x00\x02\x80\x00 \x00\xa8\xbd\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\x90\xc0\x80\x00 \x00\x08\x00\t6\xbazZ\xbf\x9eZSk\xca\x00\x03\x0c0\xc0\x00\x03\x0c0\xd0\x9a\x01\x01@\x00\x10\x00\x04\x00\xb5\x97\x01\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x12\x18\x10\x00\x04\x00\x01 \xc1Fo\xadL:^5\x9d\x01\x06\x18`\x80\x01\x06\x18`\x80\x81\xfa\x06\x04\x00\x01@\x00\x10\x00\xd4^\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x18H`@\x00\x10\x00\x04\x80\x04\x1b]=\xad_O\xad\xa95e\x80\x01\x06\x18`\x80\x01\x06\x18h\xcd\x80\x00 \x00\x08\x00\x02\x80\xda\xcb\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\t\x0c\x08\x00\x02\x80\x00\x90`\xa3\xb7V&\x1d\xaf\x9a\xce\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0@}\x03\x02\x80\x00 \x00\x08\x00j/\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c$0 \x00\x08\x00\x02@\x82\x8d\xae\x9e\xd6\xaf\xa7\xd6\xd4\x9a2\xc0\x00\x03\x0c0\xc0\x00\x03\x0c\xb4f@\x00\x10\x00\x04\x00\x01@\xede\x80\x01\x06\x18`\x80\x01\x06\x18`\x80\x81\x04\x06\x04\x00\x01@\x00H\xb0\xd1[+\x93\x8eWMg\x80\x01\x06\x18`\x80\x01\x06\x18`\xa0\xbe\x01\x01@\x00\x10\x00\x04\x00\xb5\x97\x01\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x12\x18\x10\x00\x04\x00\x01 \xc1FWO\xeb\xd7SkjM\x19`\x80\x01\x06\x18`\x80\x01\x06Z3 \x00\x08\x00\x02\x80\x00\xa0\xf62\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0@\x02\x03\x02\x80\x00 \x00$\xd8\xe8\xad\x95I\xc7\xab\xa63\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0P\xdf\x80\x00 \x00\x08\x00\x02\x80\xda\xcb\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\t\x0c\x08\x00\x02\x80\x00\x90`\xa3\xab\xa7\xf5\xeb\xa95\xb5\xa6\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\xad\x19\x10\x00\x04\x00\x01@\x00P{\x19`\x80\x01\x06\x18`\x80\x01\x06\x18` \x81\x01\x01@\x00\x10\x00\x12l\xf4\xd6\xca\xa4\xe3U\xd3\x19`\x80\x01\x06\x18`\x80\x01\x06\x18\xa8o@\x00\x10\x00\x04\x00\x01@\xede\x80\x01\x06\x18`\x80\x01\x06\x18`\x80\x81\x04\x06\x04\x00\x01@\x00H\xb0\xd1\xd5\xd3\xfa\xf5\xd4\x9aZS\x06\x18`\x80\x01\x06\x18`\x80\x81\xd6\x0c\x08\x00\x02\x80\x00 \x00\xa8\xbd\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\x90\xc0\x80\x00 \x00\x08\x00\t6zke\xd2\xf1\xaa\xe9\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c\xd47 \x00\x08\x00\x02\x80\x00\xa0\xf62\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0@\x02\x03\x02\x80\x00 \x00$\xd8\xe8\xeai\xfdzjM\xad)\x03\x0c0\xc0\x00\x03\x0c0\xc0@k\x06\x04\x00\x01@\x00\x10\x00\xd4^\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x18H`@\x00\x10\x00\x04\x80\x04\x1b\xbd\xb52\xe9x\xd5t\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\xea\x1b\x10\x00\x04\x00\x01@\x00P{\x19`\x80\x01\x06\x18`\x80\x01\x06\x18` \x81\x01\x01@\x00\x10\x00\x12lt\xf5\xb4~=\xb5\xa6\xd6\x94\x01\x06\x18`\x80\x01\x06\x18`\xa05\x03\x02\x80\x00 \x00\x08\x00j/\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c$0 \x00\x08\x00\x02@\x82\x8d\xdeZ\x99t\xbcj:\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\xf5\r\x08\x00\x02\x80\x00 \x00\xa8\xbd\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\x90\xc0\x80\x00 \x00\x08\x00\t6\xbazZ\xbf\x9eZSk\xca\x00\x03\x0c0\xc0\x00\x03\x0c0\xd0\x9a\x01\x01@\x00\x10\x00\x04\x00\xb5\x97\x01\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x12\x18\x10\x00\x04\x00\x01 \xc1Fo\xadL:^5\x9d\x01\x06\x18`\x80\x01\x06\x18`\x80\x81\xfa\x06\x04\x00\x01@\x00\x10\x00\xd4^\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x18H`@\x00\x10\x00\x04\x80\x04\x1b]=\xad_O\xad\xa95e\x80\x01\x06\x18`\x80\x01\x06\x18h\xcd\x80\x00 \x00\x08\x00\x02\x80\xda\xcb\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\t\x0c\x08\x00\x02\x80\x00\x90`\xa3\xb7V&\x1d\xaf\x9a\xce\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0@}\x03\x02\x80\x00 \x00\x08\x00j/\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c$0 \x00\x08\x00\x02@\x82\x8d\xae\x9e\xd6\xaf\xa7\xd6\xd4\x9a2\xc0\x00\x03\x0c0\xc0\x00\x03\x0c\xb4f@\x00\x10\x00\x04\x00\x01@\xede\x80\x01\x06\x18`\x80\x01\x06\x18`\x80\x81\x04\x06\x04\x00\x01@\x00H\xb0\xd1[+\x93\x8eWMg\x80\x01\x06\x18`\x80\x01\x06\x18`\xa0\xbe\x01\x01@\x00\x10\x00\x04\x00\xb5\x97\x01\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x12\x18\x10\x00\x04\x00\x01 \xc1FWO\xeb\xd7SkjM\x19`\x80\x01\x06\x18`\x80\x01\x06Z3 \x00\x08\x00\x02\x80\x00\xa0\xf62\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0@\x02\x03\x02\x80\x00 \x00$\xd8\xe8\xad\x95I\xc7\xab\xa63\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0P\xdf\x80\x00 \x00\x08\x00\x02\x80\xda\xcb\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\t\x0c\x08\x00\x02\x80\x00\x90`\xa3\xab\xa7\xf5\xeb\xa95\xb5\xa6\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\xad\x19\x10\x00\x04\x00\x01@\x00P{\x19`\x80\x01\x06\x18`\x80\x01\x06\x18` \x81\x01\x01@\x00\x10\x00\x12l\xf4\xd6\xca\xa4\xe3U\xd3\x19`\x80\x01\x06\x18`\x80\x01\x06\x18\xa8o@\x00\x10\x00\x04\x00\x01@\xede\x80\x01\x06\x18`\x80\x01\x06\x18`\x80\x81\x04\x06\x04\x00\x01@\x00H\xb0\xd1\xd5\xd3\xfa\xf5\xd4\x9aZS\x06\x18`\x80\x01\x06\x18`\x80\x81\xd6\x0c\x08\x00\x02\x80\x00 \x00\xa8\xbd\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\x90\xc0\x80\x00 \x00\x08\x00\t6zke\xd2\xf1\xaa\xe9\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c\xd47 \x00\x08\x00\x02\x80\x00\xa0\xf62\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0@\x02\x03\x02\x80\x00 \x00$\xd8\xe8\xeai\xfdzjM\xad)\x03\x0c0\xc0\x00\x03\x0c0\xc0@k\x06\x04\x00\x01@\x00\x10\x00\xd4^\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x18H`@\x00\x10\x00\x04\x80\x04\x1b\xbd\xb52\xe9x\xd5t\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\xea\x1b\x10\x00\x04\x00\x01@\x00P{\x19`\x80\x01\x06\x18`\x80\x01\x06\x18` \x81\x01\x01@\x00\x10\x00\x12lt\xf5\xb4~=\xb5\xa6\xd6\x94\x01\x06\x18`\x80\x01\x06\x18`\xa05\x03\x02\x80\x00 \x00\x08\x00j/\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c$0 \x00\x08\x00\x02@\x82\x8d\xdeZ\x99t\xbcj:\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\xf5\r\x08\x00\x02\x80\x00 \x00\xa8\xbd\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\x90\xc0\x80\x00 \x00\x08\x00\t6\xbazZ\xbf\x9eZSk\xca\x00\x03\x0c0\xc0\x00\x03\x0c0\xd0\x9a\x01\x01@\x00\x10\x00\x04\x00\xb5\x97\x01\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x12\x18\x10\x00\x04\x00\x01 \xc1Fo\xadL:^5\x9d\x01\x06\x18`\x80\x01\x06\x18`\x80\x81\xfa\x06\x04\x00\x01@\x00\x10\x00\xd4^\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x18H`@\x00\x10\x00\x04\x80\x04\x1b]=\xad_O\xad\xa95e\x80\x01\x06\x18`\x80\x01\x06\x18h\xcd\x80\x00 \x00\x08\x00\x02\x80\xda\xcb\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\t\x0c\x08\x00\x02\x80\x00\x90`\xa3\xb7V&\x1d\xaf\x9a\xce\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0@}\x03\x02\x80\x00 \x00\x08\x00j/\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c$0 \x00\x08\x00\x02@\x82\x8d\xae\x9e\xd6\xaf\xa7\xd6\xd4\x9a2\xc0\x00\x03\x0c0\xc0\x00\x03\x0c\xb4f@\x00\x10\x00\x04\x00\x01@\xede\x80\x01\x06\x18`\x80\x01\x06\x18`\x80\x81\x04\x06\x04\x00\x01@\x00H\xb0\xd1[+\x93\x8eWMg\x80\x01\x06\x18`\x80\x01\x06\x18`\xa0\xbe\x01\x01@\x00\x10\x00\x04\x00\xb5\x97\x01\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x12\x18\x10\x00\x04\x00\x01 \xc1FWO\xeb\xd7SkjM\x19`\x80\x01\x06\x18`\x80\x01\x06Z3 \x00\x08\x00\x02\x80\x00\xa0\xf62\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0@\x02\x03\x02\x80\x00 \x00$\xd8\xe8\xad\x95I\xc7\xab\xa63\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0P\xdf\x80\x00 \x00\x08\x00\x02\x80\xda\xcb\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\t\x0c\x08\x00\x02\x80\x00\x90`\xa3\xab\xa7\xf5\xeb\xa95\xb5\xa6\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\xad\x19\x10\x00\x04\x00\x01@\x00P{\x19`\x80\x01\x06\x18`\x80\x01\x06\x18` \x81\x01\x01@\x00\x10\x00\x12l\xf4\xd6\xca\xa4\xe3U\xd3\x19`\x80\x01\x06\x18`\x80\x01\x06\x18\xa8o@\x00\x10\x00\x04\x00\x01@\xede\x80\x01\x06\x18`\x80\x01\x06\x18`\x80\x81\x04\x06\x04\x00\x01@\x00H\xb0\xd1\xd5\xd3\xfa\xf5\xd4\x9aZS\x06\x18`\x80\x01\x06\x18`\x80\x81\xd6\x0c\x08\x00\x02\x80\x00 \x00\xa8\xbd\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\x90\xc0\x80\x00 \x00\x08\x00\t6zke\xd2\xf1\xaa\xe9\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c\xd47 \x00\x08\x00\x02\x80\x00\xa0\xf62\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0@\x02\x03\x02\x80\x00 \x00$\xd8\xe8\xeai\xfdzjM\xad)\x03\x0c0\xc0\x00\x03\x0c0\xc0@k\x06\x04\x00\x01@\x00\x10\x00\xd4^\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x18H`@\x00\x10\x00\x04\x80\x04\x1b\xbd\xb52\xe9x\xd5t\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\xea\x1b\x10\x00\x04\x00\x01@\x00P{\x19`\x80\x01\x06\x18`\x80\x01\x06\x18` \x81\x01\x01@\x00\x10\x00\x12lt\xf5\xb4~=\xb5\xa6\xd6\x94\x01\x06\x18`\x80\x01\x06\x18`\xa05\x03\x02\x80\x00 \x00\x08\x00j/\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c$0 \x00\x08\x00\x02@\x82\x8d\xdeZ\x99t\xbcj:\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\xf5\r\x08\x00\x02\x80\x00 \x00\xa8\xbd\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\x90\xc0\x80\x00 \x00\x08\x00\t6\xbazZ\xbf\x9eZSk\xca\x00\x03\x0c0\xc0\x00\x03\x0c0\xd0\x9a\x01\x01@\x00\x10\x00\x04\x00\xb5\x97\x01\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x12\x18\x10\x00\x04\x00\x01 \xc1Fo\xadL:^5\x9d\x01\x06\x18`\x80\x01\x06\x18`\x80\x81\xfa\x06\x04\x00\x01@\x00\x10\x00\xd4^\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x18H`@\x00\x10\x00\x04\x80\x04\x1b]=\xad_O\xad\xa95e\x80\x01\x06\x18`\x80\x01\x06\x18h\xcd\x80\x00 \x00\x08\x00\x02\x80\xda\xcb\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\t\x0c\x08\x00\x02\x80\x00\x90`\xa3\xb7V&\x1d\xaf\x9a\xce\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0@}\x03\x02\x80\x00 \x00\x08\x00j/\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c$0 \x00\x08\x00\x02@\x82\x8d\xae\x9e\xd6\xaf\xa7\xd6\xd4\x9a2\xc0\x00\x03\x0c0\xc0\x00\x03\x0c\xb4f@\x00\x10\x00\x04\x00\x01@\xede\x80\x01\x06\x18`\x80\x01\x06\x18`\x80\x81\x04\x06\x04\x00\x01@\x00H\xb0\xd1[+\x93\x8eWMg\x80\x01\x06\x18`\x80\x01\x06\x18`\xa0\xbe\x01\x01@\x00\x10\x00\x04\x00\xb5\x97\x01\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x12\x18\x10\x00\x04\x00\x01 \xc1FWO\xeb\xd7SkjM\x19`\x80\x01\x06\x18`\x80\x01\x06Z3 \x00\x08\x00\x02\x80\x00\xa0\xf62\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0@\x02\x03\x02\x80\x00 \x00$\xd8\xe8\xad\x95I\xc7\xab\xa63\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0P\xdf\x80\x00 \x00\x08\x00\x02\x80\xda\xcb\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\t\x0c\x08\x00\x02\x80\x00\x90`\xa3\xab\xa7\xf5\xeb\xa95\xb5\xa6\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\xad\x19\x10\x00\x04\x00\x01@\x00P{\x19`\x80\x01\x06\x18`\x80\x01\x06\x18` \x81\x01\x01@\x00\x10\x00\x12l\xf4\xd6\xca\xa4\xe3U\xd3\x19`\x80\x01\x06\x18`\x80\x01\x06\x18\xa8o@\x00\x10\x00\x04\x00\x01@\xede\x80\x01\x06\x18`\x80\x01\x06\x18`\x80\x81\x04\x06\x04\x00\x01@\x00H\xb0\xd1\xd5\xd3\xfa\xf5\xd4\x9aZS\x06\x18`\x80\x01\x06\x18`\x80\x81\xd6\x0c\x08\x00\x02\x80\x00 \x00\xa8\xbd\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\x90\xc0\x80\x00 \x00\x08\x00\t6zke\xd2\xf1\xaa\xe9\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c\xd47 \x00\x08\x00\x02\x80\x00\xa0\xf62\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0@\x02\x03\x02\x80\x00 \x00$\xd8\xe8\xeai\xfdzjM\xad)\x03\x0c0\xc0\x00\x03\x0c0\xc0@k\x06\x04\x00\x01@\x00\x10\x00\xd4^\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x18H`@\x00\x10\x00\x04\x80\x04\x1b\xbd\xb52\xe9x\xd5t\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\xea\x1b\x10\x00\x04\x00\x01@\x00P{\x19`\x80\x01\x06\x18`\x80\x01\x06\x18` \x81\x01\x01@\x00\x10\x00\x12lt\xf5\xb4~=\xb5\xa6\xd6\x94\x01\x06\x18`\x80\x01\x06\x18`\xa05\x03\x02\x80\x00 \x00\x08\x00j/\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c$0 \x00\x08\x00\x02@\x82\x8d\xdeZ\x99t\xbcj:\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\xf5\r\x08\x00\x02\x80\x00 \x00\xa8\xbd\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\x90\xc0\x80\x00 \x00\x08\x00\t6\xbazZ\xbf\x9eZSk\xca\x00\x03\x0c0\xc0\x00\x03\x0c0\xd0\x9a\x01\x01@\x00\x10\x00\x04\x00\xb5\x97\x01\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x12\x18\x10\x00\x04\x00\x01 \xc1Fo\xadL:^5\x9d\x01\x06\x18`\x80\x01\x06\x18`\x80\x81\xfa\x06\x04\x00\x01@\x00\x10\x00\xd4^\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x18H`@\x00\x10\x00\x04\x80\x04\x1b]=\xad_O\xad\xa95e\x80\x01\x06\x18`\x80\x01\x06\x18h\xcd\x80\x00 \x00\x08\x00\x02\x80\xda\xcb\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\t\x0c\x08\x00\x02\x80\x00\x90`\xa3\xb7V&\x1d\xaf\x9a\xce\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0@}\x03\x02\x80\x00 \x00\x08\x00j/\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c$0 \x00\x08\x00\x02@\x82\x8d\xae\x9e\xd6\xaf\xa7\xd6\xd4\x9a2\xc0\x00\x03\x0c0\xc0\x00\x03\x0c\xb4f@\x00\x10\x00\x04\x00\x01@\xede\x80\x01\x06\x18`\x80\x01\x06\x18`\x80\x81\x04\x06\x04\x00\x01@\x00H\xb0\xd1[+\x93\x8eWMg\x80\x01\x06\x18`\x80\x01\x06\x18`\xa0\xbe\x01\x01@\x00\x10\x00\x04\x00\xb5\x97\x01\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x12\x18\x10\x00\x04\x00\x01 \xc1FWO\xeb\xd7SkjM\x19`\x80\x01\x06\x18`\x80\x01\x06Z3 \x00\x08\x00\x02\x80\x00\xa0\xf62\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0@\x02\x03\x02\x80\x00 \x00$\xd8\xe8\xad\x95I\xc7\xab\xa63\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0P\xdf\x80\x00 \x00\x08\x00\x02\x80\xda\xcb\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\t\x0c\x08\x00\x02\x80\x00\x90`\xa3\xab\xa7\xf5\xeb\xa95\xb5\xa6\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\xad\x19\x10\x00\x04\x00\x01@\x00P{\x19`\x80\x01\x06\x18`\x80\x01\x06\x18` \x81\x01\x01@\x00\x10\x00\x12l\xf4\xd6\xca\xa4\xe3U\xd3\x19`\x80\x01\x06\x18`\x80\x01\x06\x18\xa8o@\x00\x10\x00\x04\x00\x01@\xede\x80\x01\x06\x18`\x80\x01\x06\x18`\x80\x81\x04\x06\x04\x00\x01@\x00H\xb0\xd1\xd5\xd3\xfa\xf5\xd4\x9aZS\x06\x18`\x80\x01\x06\x18`\x80\x81\xd6\x0c\x08\x00\x02\x80\x00 \x00\xa8\xbd\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\x90\xc0\x80\x00 \x00\x08\x00\t6zke\xd2\xf1\xaa\xe9\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c\xd47 \x00\x08\x00\x02\x80\x00\xa0\xf62\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0@\x02\x03\x02\x80\x00 \x00$\xd8\xe8\xeai\xfdzjM\xad)\x03\x0c0\xc0\x00\x03\x0c0\xc0@k\x06\x04\x00\x01@\x00\x10\x00\xd4^\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x18H`@\x00\x10\x00\x04\x80\x04\x1b\xbd\xb52\xe9x\xd5t\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\xea\x1b\x10\x00\x04\x00\x01@\x00P{\x19`\x80\x01\x06\x18`\x80\x01\x06\x18` \x81\x01\x01@\x00\x10\x00\x12lt\xf5\xb4~=\xb5\xa6\xd6\x94\x01\x06\x18`\x80\x01\x06\x18`\xa05\x03\x02\x80\x00 \x00\x08\x00j/\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c$0 \x00\x08\x00\x02@\x82\x8d\xdeZ\x99t\xbcj:\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\xf5\r\x08\x00\x02\x80\x00 \x00\xa8\xbd\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\x90\xc0\x80\x00 \x00\x08\x00\t6\xbazZ\xbf\x9eZSk\xca\x00\x03\x0c0\xc0\x00\x03\x0c0\xd0\x9a\x01\x01@\x00\x10\x00\x04\x00\xb5\x97\x01\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x12\x18\x10\x00\x04\x00\x01 \xc1Fo\xadL:^5\x9d\x01\x06\x18`\x80\x01\x06\x18`\x80\x81\xfa\x06\x04\x00\x01@\x00\x10\x00\xd4^\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x18H`@\x00\x10\x00\x04\x80\x04\x1b]=\xad_O\xad\xa95e\x80\x01\x06\x18`\x80\x01\x06\x18h\xcd\x80\x00 \x00\x08\x00\x02\x80\xda\xcb\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\t\x0c\x08\x00\x02\x80\x00\x90`\xa3\xb7V&\x1d\xaf\x9a\xce\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0@}\x03\x02\x80\x00 \x00\x08\x00j/\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c$0 \x00\x08\x00\x02@\x82\x8d\xae\x9e\xd6\xaf\xa7\xd6\xd4\x9a2\xc0\x00\x03\x0c0\xc0\x00\x03\x0c\xb4f@\x00\x10\x00\x04\x00\x01@\xede\x80\x01\x06\x18`\x80\x01\x06\x18`\x80\x81\x04\x06\x04\x00\x01@\x00H\xb0\xd1[+\x93\x8eWMg\x80\x01\x06\x18`\x80\x01\x06\x18`\xa0\xbe\x01\x01@\x00\x10\x00\x04\x00\xb5\x97\x01\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x12\x18\x10\x00\x04\x00\x01 \xc1FWO\xeb\xd7SkjM\x19`\x80\x01\x06\x18`\x80\x01\x06Z3 \x00\x08\x00\x02\x80\x00\xa0\xf62\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0@\x02\x03\x02\x80\x00 \x00$\xd8\xe8\xad\x95I\xc7\xab\xa63\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0P\xdf\x80\x00 \x00\x08\x00\x02\x80\xda\xcb\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\t\x0c\x08\x00\x02\x80\x00\x90`\xa3\xab\xa7\xf5\xeb\xa95\xb5\xa6\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\xad\x19\x10\x00\x04\x00\x01@\x00P{\x19`\x80\x01\x06\x18`\x80\x01\x06\x18` \x81\x01\x01@\x00\x10\x00\x12l\xf4\xd6\xca\xa4\xe3U\xd3\x19`\x80\x01\x06\x18`\x80\x01\x06\x18\xa8o@\x00\x10\x00\x04\x00\x01@\xede\x80\x01\x06\x18`\x80\x01\x06\x18`\x80\x81\x04\x06\x04\x00\x01@\x00H\xb0\xd1\xd5\xd3\xfa\xf5\xd4\x9aZS\x06\x18`\x80\x01\x06\x18`\x80\x81\xd6\x0c\x08\x00\x02\x80\x00 \x00\xa8\xbd\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\x90\xc0\x80\x00 \x00\x08\x00\t6zke\xd2\xf1\xaa\xe9\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c\xd47 \x00\x08\x00\x02\x80\x00\xa0\xf62\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0@\x02\x03\x02\x80\x00 \x00$\xd8\xe8\xeai\xfdzjM\xad)\x03\x0c0\xc0\x00\x03\x0c0\xc0@k\x06\x04\x00\x01@\x00\x10\x00\xd4^\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x18H`@\x00\x10\x00\x04\x80\x04\x1b\xbd\xb52\xe9x\xd5t\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\xea\x1b\x10\x00\x04\x00\x01@\x00P{\x19`\x80\x01\x06\x18`\x80\x01\x06\x18` \x81\x01\x01@\x00\x10\x00\x12lt\xf5\xb4~=\xb5\xa6\xd6\x94\x01\x06\x18`\x80\x01\x06\x18`\xa05\x03\x02\x80\x00 \x00\x08\x00j/\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c$0 \x00\x08\x00\x02@\x82\x8d\xdeZ\x99t\xbcj:\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\xf5\r\x08\x00\x02\x80\x00 \x00\xa8\xbd\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\x90\xc0\x80\x00 \x00\x08\x00\t6\xbazZ\xbf\x9eZSk\xca\x00\x03\x0c0\xc0\x00\x03\x0c0\xd0\x9a\x01\x01@\x00\x10\x00\x04\x00\xb5\x97\x01\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x12\x18\x10\x00\x04\x00\x01 \xc1Fo\xadL:^5\x9d\x01\x06\x18`\x80\x01\x06\x18`\x80\x81\xfa\x06\x04\x00\x01@\x00\x10\x00\xd4^\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x18H`@\x00\x10\x00\x04\x80\x04\x1b]=\xad_O\xad\xa95e\x80\x01\x06\x18`\x80\x01\x06\x18h\xcd\x80\x00 \x00\x08\x00\x02\x80\xda\xcb\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\t\x0c\x08\x00\x02\x80\x00\x90`\xa3\xb7V&\x1d\xaf\x9a\xce\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0@}\x03\x02\x80\x00 \x00\x08\x00j/\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c$0 \x00\x08\x00\x02@\x82\x8d\xae\x9e\xd6\xaf\xa7\xd6\xd4\x9a2\xc0\x00\x03\x0c0\xc0\x00\x03\x0c\xb4f@\x00\x10\x00\x04\x00\x01@\xede\x80\x01\x06\x18`\x80\x01\x06\x18`\x80\x81\x04\x06\x04\x00\x01@\x00H\xb0\xd1[+\x93\x8eWMg\x80\x01\x06\x18`\x80\x01\x06\x18`\xa0\xbe\x01\x01@\x00\x10\x00\x04\x00\xb5\x97\x01\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x12\x18\x10\x00\x04\x00\x01 \xc1FWO\xeb\xd7SkjM\x19`\x80\x01\x06\x18`\x80\x01\x06Z3 \x00\x08\x00\x02\x80\x00\xa0\xf62\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0@\x02\x03\x02\x80\x00 \x00$\xd8\xe8\xad\x95I\xc7\xab\xa63\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0P\xdf\x80\x00 \x00\x08\x00\x02\x80\xda\xcb\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\t\x0c\x08\x00\x02\x80\x00\x90`\xa3\xab\xa7\xf5\xeb\xa95\xb5\xa6\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\xad\x19\x10\x00\x04\x00\x01@\x00P{\x19`\x80\x01\x06\x18`\x80\x01\x06\x18` \x81\x01\x01@\x00\x10\x00\x12l\xf4\xd6\xca\xa4\xe3U\xd3\x19`\x80\x01\x06\x18`\x80\x01\x06\x18\xa8o@\x00\x10\x00\x04\x00\x01@\xede\x80\x01\x06\x18`\x80\x01\x06\x18`\x80\x81\x04\x06\x04\x00\x01@\x00H\xb0\xd1\xd5\xd3\xfa\xf5\xd4\x9aZS\x06\x18`\x80\x01\x06\x18`\x80\x81\xd6\x0c\x08\x00\x02\x80\x00 \x00\xa8\xbd\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\x90\xc0\x80\x00 \x00\x08\x00\t6zke\xd2\xf1\xaa\xe9\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c\xd47 \x00\x08\x00\x02\x80\x00\xa0\xf62\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0@\x02\x03\x02\x80\x00 \x00$\xd8\xe8\xeai\xfdzjM\xad)\x03\x0c0\xc0\x00\x03\x0c0\xc0@k\x06\x04\x00\x01@\x00\x10\x00\xd4^\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x18H`@\x00\x10\x00\x04\x80\x04\x1b\xbd\xb52\xe9x\xd5t\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\xea\x1b\x10\x00\x04\x00\x01@\x00P{\x19`\x80\x01\x06\x18`\x80\x01\x06\x18` \x81\x01\x01@\x00\x10\x00\x12lt\xf5\xb4~=\xb5\xa6\xd6\x94\x01\x06\x18`\x80\x01\x06\x18`\xa05\x03\x02\x80\x00 \x00\x08\x00j/\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c$0 \x00\x08\x00\x02@\x82\x8d\xdeZ\x99t\xbcj:\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\xf5\r\x08\x00\x02\x80\x00 \x00\xa8\xbd\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\x90\xc0\x80\x00 \x00\x08\x00\t6\xbazZ\xbf\x9eZSk\xca\x00\x03\x0c0\xc0\x00\x03\x0c0\xd0\x9a\x01\x01@\x00\x10\x00\x04\x00\xb5\x97\x01\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x12\x18\x10\x00\x04\x00\x01 \xc1Fo\xadL:^5\x9d\x01\x06\x18`\x80\x01\x06\x18`\x80\x81\xfa\x06\x04\x00\x01@\x00\x10\x00\xd4^\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x18H`@\x00\x10\x00\x04\x80\x04\x1b]=\xad_O\xad\xa95e\x80\x01\x06\x18`\x80\x01\x06\x18h\xcd\x80\x00 \x00\x08\x00\x02\x80\xda\xcb\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\t\x0c\x08\x00\x02\x80\x00\x90`\xa3\xb7V&\x1d\xaf\x9a\xce\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0@}\x03\x02\x80\x00 \x00\x08\x00j/\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c$0 \x00\x08\x00\x02@\x82\x8d\xae\x9e\xd6\xaf\xa7\xd6\xd4\x9a2\xc0\x00\x03\x0c0\xc0\x00\x03\x0c\xb4f@\x00\x10\x00\x04\x00\x01@\xede\x80\x01\x06\x18`\x80\x01\x06\x18`\x80\x81\x04\x06\x04\x00\x01@\x00H\xb0\xd1[+\x93\x8eWMg\x80\x01\x06\x18`\x80\x01\x06\x18`\xa0\xbe\x01\x01@\x00\x10\x00\x04\x00\xb5\x97\x01\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x12\x18\x10\x00\x04\x00\x01 \xc1FWO\xeb\xd7SkjM\x19`\x80\x01\x06\x18`\x80\x01\x06Z3 \x00\x08\x00\x02\x80\x00\xa0\xf62\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0@\x02\x03\x02\x80\x00 \x00$\xd8\xe8\xad\x95I\xc7\xab\xa63\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0P\xdf\x80\x00 \x00\x08\x00\x02\x80\xda\xcb\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\t\x0c\x08\x00\x02\x80\x00\x90`\xa3\xab\xa7\xf5\xeb\xa95\xb5\xa6\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\xad\x19\x10\x00\x04\x00\x01@\x00P{\x19`\x80\x01\x06\x18`\x80\x01\x06\x18` \x81\x01\x01@\x00\x10\x00\x12l\xf4\xd6\xca\xa4\xe3U\xd3\x19`\x80\x01\x06\x18`\x80\x01\x06\x18\xa8o@\x00\x10\x00\x04\x00\x01@\xede\x80\x01\x06\x18`\x80\x01\x06\x18`\x80\x81\x04\x06\x04\x00\x01@\x00H\xb0\xd1\xd5\xd3\xfa\xf5\xd4\x9aZS\x06\x18`\x80\x01\x06\x18`\x80\x81\xd6\x0c\x08\x00\x02\x80\x00 \x00\xa8\xbd\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\x90\xc0\x80\x00 \x00\x08\x00\t6zke\xd2\xf1\xaa\xe9\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c\xd47 \x00\x08\x00\x02\x80\x00\xa0\xf62\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0@\x02\x03\x02\x80\x00 \x00$\xd8\xe8\xeai\xfdzjM\xad)\x03\x0c0\xc0\x00\x03\x0c0\xc0@k\x06\x04\x00\x01@\x00\x10\x00\xd4^\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x18H`@\x00\x10\x00\x04\x80\x04\x1b\xbd\xb52\xe9x\xd5t\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\xea\x1b\x10\x00\x04\x00\x01@\x00P{\x19`\x80\x01\x06\x18`\x80\x01\x06\x18` \x81\x01\x01@\x00\x10\x00\x12lt\xf5\xb4~=\xb5\xa6\xd6\x94\x01\x06\x18`\x80\x01\x06\x18`\xa05\x03\x02\x80\x00 \x00\x08\x00j/\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c$0 \x00\x08\x00\x02@\x82\x8d\xdeZ\x99t\xbcj:\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\xf5\r\x08\x00\x02\x80\x00 \x00\xa8\xbd\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\x90\xc0\x80\x00 \x00\x08\x00\t6\xbazZ\xbf\x9eZSk\xca\x00\x03\x0c0\xc0\x00\x03\x0c0\xd0\x9a\x01\x01@\x00\x10\x00\x04\x00\xb5\x97\x01\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x12\x18\x10\x00\x04\x00\x01 \xc1Fo\xadL:^5\x9d\x01\x06\x18`\x80\x01\x06\x18`\x80\x81\xfa\x06\x04\x00\x01@\x00\x10\x00\xd4^\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x18H`@\x00\x10\x00\x04\x80\x04\x1b]=\xad_O\xad\xa95e\x80\x01\x06\x18`\x80\x01\x06\x18h\xcd\x80\x00 \x00\x08\x00\x02\x80\xda\xcb\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\t\x0c\x08\x00\x02\x80\x00\x90`\xa3\xb7V&\x1d\xaf\x9a\xce\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0@}\x03\x02\x80\x00 \x00\x08\x00j/\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c$0 \x00\x08\x00\x02@\x82\x8d\xae\x9e\xd6\xaf\xa7\xd6\xd4\x9a2\xc0\x00\x03\x0c0\xc0\x00\x03\x0c\xb4f@\x00\x10\x00\x04\x00\x01@\xede\x80\x01\x06\x18`\x80\x01\x06\x18`\x80\x81\x04\x06\x04\x00\x01@\x00H\xb0\xd1[+\x93\x8eWMg\x80\x01\x06\x18`\x80\x01\x06\x18`\xa0\xbe\x01\x01@\x00\x10\x00\x04\x00\xb5\x97\x01\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x12\x18\x10\x00\x04\x00\x01 \xc1FWO\xeb\xd7SkjM\x19`\x80\x01\x06\x18`\x80\x01\x06Z3 \x00\x08\x00\x02\x80\x00\xa0\xf62\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0@\x02\x03\x02\x80\x00 \x00$\xd8\xe8\xad\x95I\xc7\xab\xa63\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0P\xdf\x80\x00 \x00\x08\x00\x02\x80\xda\xcb\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\t\x0c\x08\x00\x02\x80\x00\x90`\xa3\xab\xa7\xf5\xeb\xa95\xb5\xa6\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\xad\x19\x10\x00\x04\x00\x01@\x00P{\x19`\x80\x01\x06\x18`\x80\x01\x06\x18` \x81\x01\x01@\x00\x10\x00\x12l\xf4\xd6\xca\xa4\xe3U\xd3\x19`\x80\x01\x06\x18`\x80\x01\x06\x18\xa8o@\x00\x10\x00\x04\x00\x01@\xede\x80\x01\x06\x18`\x80\x01\x06\x18`\x80\x81\x04\x06\x04\x00\x01@\x00H\xb0\xd1\xd5\xd3\xfa\xf5\xd4\x9aZS\x06\x18`\x80\x01\x06\x18`\x80\x81\xd6\x0c\x08\x00\x02\x80\x00 \x00\xa8\xbd\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\x90\xc0\x80\x00 \x00\x08\x00\t6zke\xd2\xf1\xaa\xe9\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c\xd47 \x00\x08\x00\x02\x80\x00\xa0\xf62\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0@\x02\x03\x02\x80\x00 \x00$\xd8\xe8\xeai\xfdzjM\xad)\x03\x0c0\xc0\x00\x03\x0c0\xc0@k\x06\x04\x00\x01@\x00\x10\x00\xd4^\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x18H`@\x00\x10\x00\x04\x80\x04\x1b\xbd\xb52\xe9x\xd5t\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\xea\x1b\x10\x00\x04\x00\x01@\x00P{\x19`\x80\x01\x06\x18`\x80\x01\x06\x18` \x81\x01\x01@\x00\x10\x00\x12lt\xf5\xb4~=\xb5\xa6\xd6\x94\x01\x06\x18`\x80\x01\x06\x18`\xa05\x03\x02\x80\x00 \x00\x08\x00j/\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c$0 \x00$\x08\x00\x17^xa3C\xfe\x82\x05\x0bNy\xac[\xb7nuQJpQj\xad\xa2:^\xe5\x9f\x01\x06\x18`\x80\x01\x06\x18`\xa0\x15\x031S\xcd5s\xb5\xf2\xbe\x98q_{m<\xc3\xfc(_w\xc1(\x9f4\xae\xcf\x99\x9c\x9c\x1c\xc4I\xde\xb2e\x8b\x00 \x000\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03#\x1a\x88\x99\xaa\x95!\x7f\xae\xe3\x8c\x19w\\\xf3\xf3(_\xb7W\x01\xe0\xd2K/\x1d\xc4I\xde\xbcy\xb3\x8d>\xe2Fo\xa5H:N\xf5\x9c\x01\x06\x18`\x80\x01\x06\x18`\x80\x81\xf1\x19\x88\x99j\xae\xc1\xba\x95\xf7\xc5\x8c;\xca\xa0>\xae\xcf\xe9U\x00X\xb5j\xd5 N\xf2\xa6M\x9b\x04\x00\x01\x80\x01\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x18\x18\xd1@\xccT\xad\x0c\xf9s\x1dg\xcc\xb8\xe3\x1a\xe6G\xf9\xba\xbd\n\x00k\xd7\xae\x1d\xc4I\xde\xb0a\x83\x8d>\xe2FWQ\xc7WQ\xad\xad\xb5e\x80\x01\x06\x18`\x80\x01\x06\x18h\xc5@\xccTs\r\xd6\xad\xbc/f\xdcQ\x06\xf5q}N\xaf\x02\xc0\xfa\xf5\xeb\x07q\x92\xaf\xb9\xe6\x1a\x01@\x00`\x80\x01\x06\x18`\x80\x01\x06\x18`\x80\x01\x06F4\x103U+C\xfe\\\xc7\x193\xee\xb8\x86\xf9Q\xben\xaf\x02\xc0\xc6\x8d\x1b\x07q\x92\xd7\xad[g\xa3\x8f\xb8\xd1[)\x92\x8eS=g\x80\x01\x06\x18`\x80\x01\x06\x18``|\x06b\xa6\x9ak\xb0n\xe5}1\xe3\x8e2\xa8\x8f\xebsz\x15\x00n\xb8\xe1\x86A\x9c\xe45k\xd6\x08\x00\x02\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c00\xa2\x81\x98\xa9Z\x19\xf2\xe7:\xce\x98q\xc75\xcc\x8f\xf2u{\x15\x00n\xbe\xf9\xe6A\x9c\xe4\xd5\xabW\xdb\xe8#nt\x15u|\x15\xd5\xdaZ[\x06\x18`\x80\x01\x06\x18`\x80\x81V\x0c\xc4L5\xd7`\xdd\xca\xfbb\xc6\x1deP\x1f\xd7\xe7\xf4*\x00\xdcr\xcb-\x838\xc9\xf1\xab\x1eZ\xd9X\x8e\xd3?\x02\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\xd07\x03C\xf9\x15\xf11\xe3\x8ek\x98\x1f\xe5\xeb\xf6*\x00\xec\xdc\xb9s\x10\x01`rrR\x00\xf0\x08\x00\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x18``D\x031S\xb5\xf2]\xfe\xb9\x8e3f\xdcQ\x06\xf5q}N\xaf\x02\xc0\xfd\xf7\xdf?\x88\x93\xbcd\xc9\x12\x1b}\xc4\x8d\xde\xb7\xf2\xe8x\xd4p\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\xce\xbd\x81\x98\xa9\xe6\x1a\xac[y_\xcc\xb8\xe3\x1a\xe6G\xf9\xba\xbd\n\x00\x0f?\xfc\xf0 N\xf2\xc4\xc4\x84\x00 \x000\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03#\x1a\x88\x99\xaa\x95!\x7f\xae\xe3\x8c\x19w\x94A}\\\x9f\xd3\xab\x00\xf0\xf8\xe3\x8f\x0f\xe2$\x07\x80\xcd\x9b7\xdb\xec#nv\x85\xf5\xdc\x17Vkn\xcd\x19`\x80\x01\x06\x18`\x80\x01\x06\xfab f\xa9\xb9\x86\xea\x96\xde\x173\xee\xb8\x86\xf9Q\xben\xaf\x02\xc03\xcf<3\x98\x13\xbd~\xfdz\x01@\x00`\x80\x01\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\xce\xd0@\xccR-\r\xf9s\x1dk\xcc\xb8\xa3\x0c\xea\xe3\xfa\x9c^\x05\x80\x83\x07\x0f\x0e\xe6D\xfbU\x80\nj_\n\xaa\xe3`\x91\x01\x06\x18`\x80\x01\x06\x18`\xa0%\x03C\xf9\x15\x80\x11\x06b\xc6\x1d\xd70?\xca\xd7\xedU\x00\x88\x1b\xb0t\xe9\xd2AD\x80e\xcb\x96)}gX\xfaZ\xba(9V\xff\x882\xc0\x00\x03\x0c0\xc0\x00\x03\x0c00\x1e\x031K\xcd\xf5]\xf5V\xde\x17\xb3\xed(C\xfa8?\xa7w\x01`(?\xef\xe17\x01\x8c\xe7b\xe0"k]\x19`\x80\x01\x06\x18`\x80\x01\x06\x18\x18\xb6\x81\xa1\xfc\x06\x80\x98m\xc79\xcc\x8f\xf2\xb5{\x17\x00n\xbe\xf9\xe6A\xd4\x9e\xa8R[\xb6l\xf1(\x00\x8f\x02`\x80\x01\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\xe6i f\xa8V\xbe\xc3\x7f\xba\xe3\x8c\xd9v\x94!}\x9c\x9f\xd3\xbb\x00p\xc7\x1dw\x0c\xe6\x84{"\xc0a\x97I\xe5\xd9\xf9e\x80\x01\x06\x18`\x80\x01\x06\x18`\xa0\xae\x81!=\x01`\xcc\xb6\xe3\x1c\xe6G\xf9\xda\xbd\x0b\x00\x0f<\xf0\xc0`\x02\xc0\xaaU\xab\x94\xbey\x96>\x17\xce\xba\x17N\xebi=\x19`\x80\x01\x06\x18`\x80\x01\x06Z4\x103\xd4\xe9\xbe\xb3\xde\xca\xfbc\xb6\x1deH\x1f\xe7\xe7\xf4.\x00<\xf9\xe4\x93\x839\xe1\x17_|\xb1\x00 \x000\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\xf34\x103T+\x03\xfe\xe9\x8e3f\xdbq\x0e\xf3\xa3|\xed\xde\x05\x80\xfd\xfb\xf7\x0f\xe6\x84OLLt[\xb7n\xb5\xd9\xe7\xb9\xd9[,\x94\x8eYYg\x80\x01\x06\x18`\x80\x01\x06\x18`\xa0\x8e\x81\x98\x9db\x86:\xdd`\xdd\xca\xfbc\xb6\x1deH\x1f\xe7\xe7\xf4.\x00\xc4\x8d]\xb9r\xe5`N\xba\xe7\x01\xa8s1pQ\xb5\x8e\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c\x0c\xdb\xc0\x90~\xfe?f\xdaq\x0e\xf2\xa3~\xed^\x06\x80\x9bn\xbai0\x01`\xc5\x8a\x15\x1e\x01\xe0\x11\x00\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0\xc0i\x0c\xc4\xec\xd4\xcaw\xf7Ow\x9c1\xd3\x8e:\xa4\x8f\xf3\xf3z\x19\x00>\xf7\xb9\xcf\r\xe6\xc4/^\xbc\xd8F?\xcdFWr\x87]r\x9d_\xe7\x97\x01\x06\x18`\x80\x01\x06\x18``>\x06bv:\xdd`\xdd\xca\xfbc\xa6\x1d\xe7 ?\xea\xd7\xeee\x00x\xe4\x91G\x06s\xe2\x03\xe8\x86\r\x1bD\x00\x11\x80\x01\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x188\x85\x81\x98\x99Z\x19\xee\xe7s\x9c1\xd3\x8e:\xa4\x8f\xf3\xf3z\x19\x00\x9ey\xe6\x99A\x9d|\xbf\x0eP\xf1\x9cO\xf1\xf41\x9c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0cd50\xa4_\xff\x17\x81 f\xdaq\x0e\xf2\xa3~\xed^\x06\x80c\xc7\x8eu\x17]t\xd1`"\xc0\x92%K\x94\xbeS\x94\xbe\xac\x178\xb7\xdb?\xee\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c\xbcc f\xa6\xf9|g\xbd\x85\x8f\x89Y6f\xdaQ\x87\xf4q~^/\x03@\xdc\xe0\x1d;v\x0c\x06@ \xf5c\x00\xefln\x17:k\xc1\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\xd3\x06\x86\xf6\xf0\xff\x98e\xc79\xc4\x9f\xcd\xd7\xeem\x00\xb8\xeb\xae\xbb\x06\x15\x00.\xbb\xec2\x8f\x02\xf0(\x00\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x18``\x96\x81\x98\x95Z\xf8\xce\xfe|\x8f1f\xd9\xb3\x19\xd2\xc7\xf9\xb9\xbd\r\x00O<\xf1\xc4\xa0\x10\\p\xc1\x05\xdd\xb6m\xdbl\xf6Y\x9b}\xba\xfay\xab\x003\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\x90\xcf@\xccH1+\xcdw\xb8n\xe1\xe3b\x96\x1d\xe7\x10\x7f6_{\xc1\xab\xaf\xbe\xfa\x8fg\xf3\x05\xc6\xf5\xb9\x07\x0f\x1e\xec\x16.\\8(\x08W]u\x95\x00 \x000\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03S\x06bFja\xa8\x9f\xef1\xc6\x0c\x1b\xb3\xec\xb8\xe6\xe4\xb3\xf9\xba1\xfb\xc7#\x00~u6_d\x9c\x9f\xbbq\xe3\xc6Aa\x98\x9c\x9c\xb4\xd1]\xec\x19`\x80\x01\x06\x18`\x80\x01\x06\x18`\x80\x81)\x031#\xcdw\xb8n\xe1\xe3b\x86\x1d\xe7\x8c|\x96_\xfbW\x11\x00\xfe\xea,\xbf\xc8\xd8n\xe0\xa7?\xfd\xe9Aa\x08\xb0\x9b6m\xb2\xd9]\xf0\x19`\x80\x01\x06\x18`\x80\x01\x06\x18` \xbd\x81\x98\x8dZ\x18\xea\xcf\xe4\x18c\x86\xed\xeb|\x1d\xb3\x7f\xfc\x08\xc0O\xfaz\x80\xdf\xf8\xc67\x06\x07b\xc5\x8a\x15\xe97\xba\x9f\xed\xca\xf7\xb3]\xce\xb9s\xce\x00\x03\x0c0\xc0\x00\x03\x0c00\xdb@\xccFg2\\\xb7\xf0\xb11\xc3\xf6u\xbe\x8e\xd9?\x02\xc0\xc1\xbe\x1e\xe0\xe1\xc3\x87\xbb\xc5\x8b\x17\x0f\nE\xfcL\xc8\x96-[D\x00\xc5\x97\x01\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\xd2\x1a\x88\x99hh\xcf\xf9\x16\xb3k\xcc\xb0}\x9d\xafc\xf6\x8f\x00\xf0\x83\xbe\x1e`\x1c\xd7M7\xdd4\xa8\x00\x10\xd5j\xf5\xea\xd5i7\xfa\xec\xea\xe7\xefJ0\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\xf9\x0c\xc4L\xd4\xc2w\xf4\xcf\xe4\x18cv\xed\xf3l\x1d\xb3\x7f\x04\x80\'\xfb|\x90_\xf8\xc2\x17\x06\x07#~\xcd\xc5\xd6\xad[E\x00\xc5\x97\x01\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\xd2\x19\x88Yhh\xbf\xfa/BA\xcc\xae}\x9e\xadc\xf6\x8f\'\x01\xfcJ\x9f\x0f\xf2\x07?\xf8\xc1\xe0\x02@\xe0\xb8\xfc\xf2\xcb\xd3mte7_\xd9u\xce\x9ds\x06\x18`\x80\x01\x06\x18`\x80\x81\xd9\x06b\x16:\x93\xef\xac\xb7\xf2\xb11\xbb\xf6y\xb6\x8e\xd9?\x1e\x01p_\xcf\x0f\xb2\xbb\xe2\x8a+\x06\x07\xc4\xa3\x00\\\x08g_\x08\xfd\x9d\t\x06\x18`\x80\x01\x06\x18`\x80\x81\xa1\x1b\x18\xeaw\xffcf\xed\xfb\\\x1d\xb3\xff\x82c\xc7\x8e\xdd\xd6\xf7\x03\xbd\xfd\xf6\xdb\x07\x17\x00\xa2by.\x00\x17\xf8\xa1_\xe0\xdd>\xc6\x19`\x80\x01\x06\x18`\x80\x01\x06f\x1a\x18\xe2\xcf\xfe\xc7l\x173k\xdf\xe7\xea\xa3G\x8f\xde\x1a\x01\xe0\xfa\xbe\x1f\xe8\x9e={\x06\x19\x00\x16-Z\xe47\x02\xf8\x99/?\n\xc2\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0@\n\x03\xf1\xcc\xff1\x03\xb5\xf2\x90\xfe39\xce\x98Y\xfb>W\xc7\xec\x1f\xcf\x01\xb0\xbc\xef\x07\x1a\xc77\xd4R\x14\xbf\xfbrf\x11\xf3g\x85\x94\x01\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x86h f\x9f3\x19\xaa[\xf9\xd8\x98U[\x98\xa9\x8f\x1c9\xb2\xac\xac\xe9\x82x\x1e\x80\xff\xdb\xf7\x03\xbe\xe3\x8e;\x06\x89%Po\xd8\xb0A\x04P}\x19`\x80\x01\x06\x18`\x80\x01\x06\x18``\xb0\x06b\xe6\x89\xd9g\x88\xaf1\xab\xf6}\x9e\x8e\x99\xbf\xac\xfd[/\xe5`\x7f\xde\xf7\x03~\xfa\xe9\xa7\x07\x89\xa5\x9c\x81\xee\x92K.\x19\xecF\x1fb\xb9t\x9b\x14y\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\xce\xcc@\xcc<1\xfb\x0c\xf15f\xd5\xbe\xcf\xd3\xe5\xf8~\xf6\xd6\xf4_\xfe\xb7\xd4\x80\xff\xd6\xc0\x01wk\xd6\xac\x19$\x98\xd8\x04\xeb\xd6\xad\x13\x01\x14_\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x18\x18\x9c\x81\x98u\x868\xf8\xc7m\x8a\x19\xb5\x85Y:f\xfer\xbco\xbd\x94\xbf<\xd1\xc2A\xef\xdc\xb9s\xb0p\xe2\xd7\x02\xc6\x93b(\x89gV\x12\xad\x97\xf5b\x80\x01\x06\x18`\x80\x01\x06\x18`\xa0\xbf\x06b\xc6\x89Y\xa7L\x9e\x83|\x8d\x19\xb5\x85Y\xba\xcc\xfc\xdf\x9a\x9e\xff\xe3\x11\x00\xf7\xb5p\xd0\xcf?\xff\xfc \xd1Lo\x86\xe5\xcb\x97\x0b\x00\x8a/\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c\x0c\xc6@\xcc8\xd3\xf3\xce\x10\xdf\xc6\x8c\xda\xc2,\x1d3\xff\xcc\x00\xf0\x1b-\x1ct\x1c\xe3\xf6\xed\xdb\x07\r\xe8\xea\xab\xaf\x1e\xccfWb\xfb[b\x9d\x1b\xe7\x86\x01\x06\x18`\x80\x01\x06\x18``\xdc\x06b\xb6\x19\xe2\xd0?}\x9bb6me\x8e.\x01\xe07\xde\x0e\x00\x07\x0e\x1c\xb8\xb4\x95\x03\x7f\xf4\xd1G\x07\x8d\xc8\x8f\x02\xb8\x10\x8f\xfbB\xec\xeb3\xc6\x00\x03\x0c0\xc0\x00\x03\x0c00n\x03C\x7f\xe8\x7fD\x80\x98M[\x99\xa3c\xe6\x7f;\x00\xc4\x1f\xca\x81\xffu\x0b\x07\xff\xca+\xaftK\x97.\x1dt\x04\x88\xdb7\xee\r\xe9\xeb\xbb\xe83\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0\xc0\xb8\x0cd\x98\xd9b6ma\x86\x8eY\xff\x84\xe1\x7f*\x00\xbc\xd4\xc8\xc1w\xb7\xdf~\xfb\xa0\x03@9\x1f\xdd\xda\xb5kE\x00?\xfb\xc5\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\xcd\x19\x18\xf2oo\x8bY-^c&me~.\xc7\xf9R\xcc\xfc\'\xbc\x94\xff\xf3\xeb\xad\xdc\x80\xef\x7f\xff\xfb\x83\x0f\x00\x13\x13\x13\xdd\x86\r\x1b\x9a\xdb\xec\xe3*\x88\xbe\xae:\xcd\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0@\xff\r\xc4\x0c\x13\xb3L\x196\x07\xfd\x1a3i+\xf3s\xcc\xfa\'\x0c\xff\xf1\x97c\xc7\x8e}\xae\xa1\x1b\xd0\xed\xd8\xb1c\xd0\xa0b\xc3,^\xbc\xd8\xaf\x06T|E \x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x9a0\x10?\xf7\x1f3L\xcc2C~\x8dY\xb4\xa5\xd99f\xfdr>N|9r\xe4\xc8\xb6\x96n\xc4\xb7\xbe\xf5\xadA\xa3*g\xe7\xf8\xed\x9b\x9c\x9c\xec\xb6m\xdb\xd6\xc4\x86Wd\xfb_d\x9d#\xe7\x88\x01\x06\x18`\x80\x01\x06\x18``\x1c\x06bf\x89\xd9ez\x8e\x19\xf2\xdb\x98E[\x9a\x9dc\xd6?q\xfa/\x7f\xdb\xb7o\xdf\xa2r#~\xd5\xd2\r\xb9\xf6\xdakS\x00[\xb5j\x95\x00\xa0\xfa2\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0@o\r\xc4\xcc2\xe4\xa1\x7f\xfa\xb6\xc5\x0c\xda\xd2\xcc\x1c3~\xcc\xfa\xef\n\x00\xf1\x7f\x94w\x1ej\xe9\xc6<\xf4\xd0C)\x90\x05\xb6+\xaf\xbc\xb2\xb7\x9b}\x1c\x05\xd1\xd7T\xa6\x19`\x80\x01\x06\x18`\x80\x01\x06\x18h\xc3@\xcc*1\xb3dx\x8d\x19\xb4\xa5\x999f\xfcr^N\xfeR\xde\xf9\x95\x96nLy(C\xb7r\xe5\xca\x14\xd0\xe2\x894\xae\xb9\xe6\x1a\x11@\xf5e\x80\x01\x06\x18`\x80\x01\x06\x18`\x80\x81\xde\x18\x88\x19%\xc3\x93\xfe\x95\t\xfa\xf8\xec\x193hK3s\xcc\xf8\'\x9f\xfe\xcb\xff[\xde\xf9\xe1\xc6nL\xb7k\xd7\xae\x14\x01 \xc0-Z\xb4\xc8o\x06p\xb1\xef\xcd\xc5^\x91o\xa3\xc8;O\xce\x13\x03\x0c0\xc0\x00\x03\x0c\x8c\xcb@<\xe3\x7f\xcc(1\xabdx\x8d\xd9\xb3\xb5y9f\xfcrnN\xfeRj\xc6%\xaf\xbe\xfa\xea\x9b-\xdd\xa8\x03\x07\x0e\xa4y\xb2\x89r\xd6\xba\x0b.\xb8\xa0\xdb\xb4i\x93!P\x08`\x80\x01\x06\x18`\x80\x01\x06\x18`\x80\x81\xf3f f\x92\x98MbF\xc9\xf0\x1aOp\x18\xb3gK\xb3r\xcc\xf61\xe3\x9f|\xfa\x9f\xfa\x7f\xcb\x07\xfd\xa4\xa5\x1b\x15\xc7z\xcf=\xf7\xa4@7\xbd\xb1\xe2Wkl\xde\xbc\xf9\xbcm\xf6q\x15D_W\x9df\x80\x01\x06\x18`\x80\x01\x06\x18`\xa0\xff\x06b\x16\xc9\xf0\xeb\xfe\xa6\xe7\xafx\x1b3gksr\xcc\xf6Sc\xfe\xa9\xdf\x94\x0fz\xb2\xb5\x1bv\xe8\xd0\xa1n\xd9\xb2e\xa9"\xc0\x92%K\xba\xf7\xbf\xff\xfd"\x80\xea\xcb\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\xe7\xcc@\xcc 1\x8b\xcc\x1c\x8e\x87\xfe\xe7\x985c\xe6lmN\x8e\xd9\xfe\xd4\x93\xff\xd4{\xca\x07}\xb2\xb5\x1b\x16\xc7{\xef\xbd\xf7\xa6B\x18\x9b,6\x9eG\x02\xf4\xbf\x90\xaa\xd8\xce\x11\x03\x0c0\xc0\x00\x03\x0c0\xc0\xc0\x10\x0c\xc4\xec\x91m\xf8\x8f\xb9\xeb\xbe\xfb\xeekn\xf8\x8f\x199f\xfb\xd3\x06\x80\xf8\x19\x81\xf2\xc1\xff\xd0Z\x048|\xf8p\xb7b\xc5\x8at\x11 \x1ez\xe39\x01\xfc\x832\x84\x7fP\xdc\x06\x8e\x19`\x80\x01\x06\x18`\x80\x81\xfe\x1a\x88\x99#\xdb\xc3\xfec\xf8\x8f\x193f\xcd\xd6\xe6\xe3\x98\xe9O\xfb\xf3\xff\xd3u\xa0|\xf0+\r\xde\xc0\xeeK_\xfaR\xba\x00\x10(\xe3\xc97\xe2\x198]0\xfb{\xc1tn\x9c\x1b\x06\x18`\x80\x01\x06\x18`\x80\x81V\r\xc4\xac\x91\xe9\t\xffb\xc6\x9a~\x8d\x19\xb3\xc5\xd98f\xfa\xe9\xf9\xfe\xb4o\xcbC\x05\xfe}\x8b7\xf2\x95W^\xe9\xd6\xae]\xfb\xf6\xc9\x9a>i\x19\xde\xc6\xaf\xdf\x88\xdf\xc1\xd9\xeaE\xc5q\xfb\x07\x91\x01\x06\x18`\x80\x01\x06\x18`\x80\x81\xfe\x19\x88\x19#\xd3\xaf\xfa\x9b9;\xc6l\x193f\x8b\xb3q\xcc\xf4\xa7\x1d\xfc\xa7?\xa0\xd3\xff\xec\xb8\x11\xb3d\xcb\xb3p\xcc\xf2\xf3\x1a\xfag~\xd0\x9e={\xdeSn\xf4\xdf\xb5z\xc3_|\xf1\xc5\xf4\xe5* G\xbd\x8a\'\xafpQ\x1e\xc6E\xd9yt\x1e\x19`\x80\x01\x06\x18`\x80\x01\x06\xc6a f\x86\xcc?\xef?\x1d\x01\xe2\xd1\x0f\xfb\xf7\xefo9\x00\xfc]\xcc\xf23g\xfby\xff\xb9\x0c\xff\xdfi5\x00\xc4q?\xf8\xe0\x83\xe9\x1f\x050\ry\xe9\xd2\xa5~$\xc0C\xbf\x84 \x06\x18`\x80\x01\x06\x18`\x80\x01\x06N0\x10\x0f\xf9\x8fYazn\xc8\xfe\xf6\xcb_\xfer\xcb\xc3\x7f\x1c\xfbw\xe6=\xf0\xcf\xfe\xc0\xf2\xb3\x03\x1fi9\x00\xc4\xb1_\x7f\xfd\xf50\x97G\x02\xc4F\x8e\xdf\xddy\xf5\xd5W\x9f\xb0\xe1\xc7Q\x0f}MU\x9a\x01\x06\x18`\x80\x01\x06\x18`\x80\x81\xfe\x1b\x88\xd9 f\x84\xecC\xff\xf4\xed\x8f\xd9\xb1\xf5\xf97f\xf8\xd9s\xfd\xbc\xff^~u\xc0\x05\xe5\x0b\xfcm\xcb\x8b\xb0w\xef^?\xc72\x15\x00\xa6a/_\xbe\xdc\xa3\x01\x94_!\x88\x01\x06\x18`\x80\x01\x06\x18` \xa9\x81\xf8\xae\x7f\xcc\x04\xd3\xf3\x81\xb7o=\x7fZ\xcc\x8e-\xcf\xbe1\xbb\xc7\x0c?\xef\x81\xffd\x1fX\x16`O\xcb\x8b\x10\xc7\xbek\xd7.\xb8gE\x80(}\xeb\xd6\xads\xd1Oz\xd1W\xe4\xfb_\xe4\x9d#\xe7\x88\x01\x06\x18`\x80\x01\x06\xc6a f\x00\xdf\xf5\x7f\xeb\x11\xd23\xc3G\xcc\x8c\xad\xcf\xbd1\xbb\x9fl\xa6?\xa3\xff\xafT\x84\x0f\xb5\xbe\x10\xa5\x82\x1c\x7f6\xfc\x99\'\xd8\x9f\xdfB\x7f\xc9%\x97\xf8M\x01"\x80\x10\xc4\x00\x03\x0c0\xc0\x00\x03\x0c00p\x03\xf1\x0c\xffq\xdf\xdf\x1c\xf4\xee\xe1?\xd6&f\xc6\xd6\xe7\xde\x98\xdd\xcfh\xd8?\xd9\x07\xef\xde\xbd{aY\x88\xbfj}1\xbe\xf3\x9d\xeft\x8b\x16-\x02~\xd6#\x01\xa6/\x00+V\xac\xf0c\x01\x03\xbf\xe8\x8f\xa3 \xfb\x9a\xbe3\xc1\x00\x03\x0c0\xc0\x00\x03\x0c\xf4\xdb@<\xdc?\xee\xebO\xdf\xef\xf7\xf6\xc4\x00\x103b\xcc\x8a\xad\xcf\xbb1\xb3\xc7\xec~\xb2\x99\xfe\x8c\xff\xbfR\x12\xfe\xc3\x00\x16\xa4\xbb\xe7\x9e{\xc0?E\x00\x88\x0bA\xe0_\xbdzu\xb7u\xebV\x05X\x0c`\x80\x01\x06\x18`\x80\x01\x06\x18`\xa0a\x03q\x9f>\xee\xdb\xfb&\xe8\x89\x03\xff\xec\x00\x123\xe2\x10f\xdd2\xb3?r\xc6\x83\xfe\xa9>\xa1<\x1cbm\xf9\x82o\xb6\xbe0\xc7\x8e\x1d\xeb\xae\xbb\xee:\x11`\x8e\x08\x10\x1b"~&\xe8\xf2\xcb/\x17\x02\x1a\xbe\xe0+\xf1\xfd.\xf1\xce\x8f\xf3\xc3\x00\x03\x0c0\xc0\x00\x03\xe32\x10\x83\x7f\xdc\x97\xf7s\xfes\x0f\xfe1\xf7\xc4l\x183b\xebsn\xcc\xea1\xb3\x9fj\x9e\x1f\xe9\xff/_\xf4\xc5\xd6\x17&\x8e\xff\x85\x17^\xf0\xb3/\xa7\t\x00\xb1\x19\xe25.\x1aQ\r\xe3aC\xe3\xba@\xf9\xba\xfe\xf1c\x80\x01\x06\x18`\x80\x01\x06\x18`\xe0\xec\r\xc4}\xf6\xb8\xefn\xf0?\xfd\xe0\x1f\xb3N<\x1fB\xcc\x86C\x98qcV\x1fi\xc8\x9f\xeb\x93\xca\x17\xfd\xe4\x10\x16\'n\xc3\xa3\x8f>\xeaQ\x00\xf3\x8c\x00\xb19\x16.\\\xd8\xad\\\xb9\xb2\xdb\xb4i\x93\x10\xe0Q\x01\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c\xf4\xc8@\xdcG\x8f\xfb\xeaq\x9f=\xee\xbb{\x9d\xdf\x1a\xc4L8\x94\xf96f\xf5\xb9f\xf9\x91\xdeW\x1eRpA\xf9\xc2\x7f3\x94E\xfa\xe8G?js\x8cp\x81\x98\x9c\x9c\xec\xae\xba\xea\xaan\xdb\xb6m.\xfc=\xba\xf0\xab\xe6g_\xcd\xad\xa15d\x80\x01\x06\x18`\x80\x81V\x0c\xc4}\xf1\xb8O\x1e\xf7\xcd\r\xfc\xf3\x1b\xf8g\xaeS\xcc\x82C\x99kcF\x8fY}\xa4!\xfft\x9fT~>\xe2?\x0ee\xa1\x0e\x1d:\xd4\xad_\xbf\xde\x86\x19!\x02\xc4\xe6\x89\x87\x16]v\xd9e~\x85\xa0\x08 \x041\xc0\x00\x03\x0c0\xc0\x00\x03\x0c\x9c#\x03\xf1\xeb\xea\xe2>\xb8\x87\xf9\x9f\xf9\xd0?\x1d\x00b\x06\x8cYp(sm\xcc\xe8\xa7\x9b\xe3G~\x7f\xa9\x0b\x97\x97\x85zc(\x8b\xf5\xdcs\xcfu\x17]t\x91\x080b\x04\x98\xdeDK\x96,\xe9V\xadZ\xd5m\xdc\xb8\xd1\xc5\xff\x1c]\xfc[\xa9\xd3\x8e\xd3wR\x18`\x80\x01\x06\x18`\x80\x81\xb33\x10\xf7\xb1\xe3\xbev\xdc\xe7\x9e\xbe\xff\xed\xedh\x01 f\xbf\x98\x01\x872\xcf\xc6l\x1e3\xfa\xc8\x03\xfe|>\xb1\xfc\x07\xfe\xeb\x80\x16\xac{\xec\xb1\xc7l\xa4\xb3\x0c\x003/@\x8b\x17/>\xfe{F\xa3\xac\xf9u\x82gw\xb1\xf7\x8f\xa5\xf5c\x80\x01\x06\x18`\x80\x01\x06\xf2\x19\x88\xfb\xd0q_z\xc5\x8a\x15]\xdc\xb7\x9ey_\xdb\x9fG\x1b\xfc\xa7\xd7-f\xbf!\xcd\xb2e6\xff\xc3r\xdb\xc6\xfbR~\xbe`\xdb\x90\x16-n\xcb\x9dw\xdeicU\x8c\x00E\xe0\xf1\xf5\x9c\x98\x98\xe8.\xbe\xf8\xe2\xe3\xc52.b~\x9b@\xbe\x7f\xc0\xdciq\xce\x19`\x80\x01\x06\x18`\x80\x81\xb9\r\xc4}\xe4\xb8\xaf\x1c\xdf\xe5\x8f\xfb\xceq\x1fz\xfa\xfe\xb4\xb7g7\xf0\xcf\\\xbf\x98\xf9\x866\xc7\xc6l^n\xe3\xf8_Ji\xf8\xe3!-^\xfc\xee\xc7\x1d;v\xd8hc\x88\x00E\xe3\t\xeb\x1a\x0f]Z\xb6l\xd9\xf1_Q\x12\x17\xba\xcd\x9b7\xfb\xb1\x01?6\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\x90\xc2@\xdc\xf7\x8d\xfb\xc0\xf1\xeb\xfa\xe2>\xb1\x87\xf5\x9f8+\xcc\x9e\x1dj\xfd=f\xbd\x98\xf9\x864\xc3\xc6L>\xfe\xc9\x7f\xea\xbfP\x16\xee\xc3CZ\xbc\xb8-\xfb\xf7\xef\xef\xd6\xae]{\xc2\xb0Z\x0b\x9c\xaf3\xf7\xc6\x8e\xca\x19\x17\xbfx\x16\xd3K/\xbd\xf4\xf8\x05q\xcd\x9a5\xdd\xbau\xeb\xbak\xae\xb9\xe6\xf8\x13\r\xc6\xaf7\x89\x0bf\x14\xd2xXT<\xf3\xa9Wk\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0p\xbe\x0c\xc4}\xd2\xb8o\x1a\xf7Q\xe3\xbej\xfa\xa8\xcd\xde\xf0f/<\x9d?k\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0@b\x031\xd3\rqV\x8d\x19\xbc\xcc;\x13\xe7#\x00,(\xff\xf1[\x86\xb8\xa8q\x9bv\xed\xda\xe5\x82\x91\xf8\x82!"\x88(\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c\xb4i f\xb9\xa1\xce\xa91\x83\x17\x97\xe7\xef\xa5,\xec\xff\x18\xea\xe2\xdev\xdbm"\x80\x08\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c4b f\xb8\xa1\xce\xa71{\x9f\xbf\xc9\x7f\xea\xbf\\~\xfe\xe0\xe3C]\xe0RW\xba\x8f}\xecc6{#\x9b\xbd\x90t\xae\xac\x01\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03I\r\xc4\xec\x163\xdcP\xe7\xd3\x98\xbd\xcf{\x00\x88\x03(\x0b\xfc\xe3\xa1.\xf2\xd1\xa3G\xbb\x9bo\xbe\xd9E$\xe9EDT\x10U\x18`\x80\x01\x06\x18`\x80\x01\x06\x18\xe8\xbf\x81\x98\xd9bv\x1b\xea\\\x1a3wq\xd8\x8f\x97R"\xfe\xc5\x80\x17\xba;r\xe4H\xf7\xc1\x0f~P\x04\x10\x01\x18`\x80\x01\x06\x18`\x80\x01\x06\x18`\x80\x81\x9e\x19\x88Y-f\xb6!\xcf\xa41s\xf7c\xfa\x9f:\x8a\xf2P\x8b\x1f\x0ey\xc1\x0f\x1f>\xdc\xddx\xe3\x8d6{\xcf6{\xe1\xe7\x9cX\x03\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x92\x1a\x88\x19-f\xb5!\xcf\xa21k\xf7j\xf8\x8f\x83)\xc5\xe5}e\xd1\xdf\x18\xf2\xc2\x1f:tH\x04Hza\x11\x1a\x84\x16\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\xfae \x86\xff\x98\xd1\x86<\x83\xc6\x8c\x1d\xb3v\xb1\xd7\xbf\x97R&\x1e\x1f\xf8\xe2\x1f\xafK~\x1c\xa0_\x1b\xbf\xec\x04\xc5\xd7\x1a0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\x90\xc8@\xccdC\xff\xce\x7f\xcc\xd61c\xf7o\xf2\x9f:\xa2\x97^z\xe9\x9f\x95\x83\xfc\xe5\xd0#@\xfc|\x89\'\x064t\x0b\x0f\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0p\xee\r\xc4,\x163\xd9\xd0\xe7\xce\x98\xadc\xc6.\xc6\xfa\xfbR\n\xc5\xef$8\x11\xc7\x9fa\xd2\xaf\x08<\xf7\x9b\xbd\xc8Wv\xad\x01\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03I\r\xc4\x0c6\xf0g\xfb\x7f;l\xc4l\xdd\xdf\xc9\x7f\xea\xc8v\xef\xde\xbd\xb0\x1c\xe8O3D\x80r;\xbb\xdbn\xbb\xcd\xc5\'\xe9\xc5G\x8c\x10c\x18`\x80\x01\x06\x18`\x80\x01\x06\x188w\x06b\xf6\x8a\x19,\xc9\xac\xf9\xd3\x98\xad\x8b\xaf\xfe\xbf\x94\x87cl/\'\xe6\xcd\x0c\'&n\xe3\xae]\xbbD\x00\x11\x80\x01\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x18\x18\x93\x81\x98\xb9\xb2\xcc\x971K\xc7L\xdd\xff\xc9\x7f\xc6\x11\x96\x83\xfef\x96\x13\x14\xb7\xf3\xd1G\x1f\xed\x16/^l\xc3\x8fi\xc3\x17Z\xd6\xd6\x1a0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\x90\xcc@\xccX1ke\x9a-c\x96\x9e1Z\xb7\xf1\xc7\x1f\xfd\xe8G\x17\x95\x03\xff\x9f\x99N\xd4SO=\xd5-]\xba\xd4E)\xd9EI\x9c\x10g\x18`\x80\x01\x06\x18`\x80\x01\x06\x18\xa8o f\xab\x98\xb12\xcd\x941C\xc7,]<\xb5\xf7R\x9e\x9c\xe173\x9d\xac\xb8\xad{\xf7\xee\xed\xd6\xae]+\x02\x88\x00\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0\xc0\x88\x06b\xa6\x8a\xd9*\xdb<\x193t{\x93\xff\x8c#.\'\xec\x99l\'m\xff\xfe\xfd\xdd\x8e\x1d;l\xf6\x117{\xe1c\xed\xac\x01\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03I\r\xc4,\x153U\xb692f\xe7\x19\xa3t\x9b\x7f<|\xf8\xf0\x8arC~\x99\xed\xe4\x1d;v\xac\xbb\xf3\xce;]\xb4\x92^\xb4D\x0c\x11\x87\x01\x06\x18`\x80\x01\x06\x18`\x80\x8137\x103T\xccR\xd9\xe6\xc7\x98\x99cv.f\xda\x7f)7\xe6\x13\tO\xe0q\xb4_\xfd\xeaW\xbb\x8b.\xbaH\x08\x10\x02\x18`\x80\x01\x06\x18`\x80\x01\x06\x18`\x80\x81S\x18\x88\x99\xe9\xb1\xc7\x1e\xcb8\xf8O\xdf\xe6O\xb4?\xf9\xcf\xb8\x05\xa5\xe2\xfc\x97\xac\x11\xe0\xb9\xe7\x9e\xeb\xd6\xaf_o\xb3\x9fb\xb3\x17&\xd6\xc6\x1a0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\x90\xd4@\xccJ13e\x9d\x17cV\x9e1:\x0f\xe3\x8f\xfb\xf6\xed{oyF\xc3\xbf\xcczR\x0f\x1e<\xd8}\xe4#\x1fqQKzQ\x139D\x1e\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\xdem f\xa4\x98\x95\xb2\xce\x891#\xc7\xac\\l\x0c\xef\xe5\xc8\x91#\xd7\x95\x13\xfbF\xd6\x93\x1b\xb7{\xf7\xee\xdd\xdd%\x97\\"\x04\x08\x01\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\x90\xd6@\xccD1\x1be\x9e\rc6\x8e\x19yx\x93\xff\x8c[T\x1e\xde\xf0o\x93\x9f\xe4\xee\x85\x17^\xe8\xae\xbb\xee\xba\xb4\x9b\xbdpp\xdb\xad\x01\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03I\r\xc4,\x143Q\xf6\xb90f\xe3\x19\xa3\xf2`\xff8QN\xf4!\'\xfbXw\xf7\xddww\x8b\x16-r\xe1Kz\xe1\x13B\x84 \x06\x18`\x80\x01\x06\x18`\x80\x81L\x06b\xf6\x89\x19\xa8\x0c\xbe\xe9\x87\xff\x98\x89\xcb\xb9\x9f(\xaf\xc3\x7f)\x0fsXYn\xf0\xeb\xd9#@\xdc\xfe\xa7\x9f~\xba{\xdf\xfb\xde\'\x02\x88\x00\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c00X\x03\x1b6l8>\xfb\x98\x01_\x8b\xf8\xf1z\xcc\xc4\xc3\x9f\xfcg\xdc\xc2r\x83\xb7\x97\'<\xf8{\x00^\xeb\xcaZt\xbbv\xed\xea\x16/^<\xd8\r_N\xbd\xdbf\r\x18`\x80\x01\x06\x18`\x80\x01\x06\x18Hf f\x9c\x98ub\xe61\xfb\xbd\xd6\xc5\x0c\\\xd6b\xfb\x8c\xd18\xcf\x1f\xcbC?\xfe5\x04\xc7\x0b\xd0\xf1\xcd\xb0w\xef\xde\xee\xfa\xeb\xafwQLvQ\x14G\xc4!\x06\x18`\x80\x01\x06\x18`\x80\x81!\x1a\x88\xd9&f\x1c3\xdf;3_\xcc\xc0\xe5\\\xe7})\x05\xe4\xf7\x81x\x07D\xac\xc5\x83\x0f>\xd8MNN\n\x01B\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c4g f\x99\x98i\xccy\'\xcey1\xfb\xe6\x9d\xfc\xa7n\xf9K/\xbd\xb4\xa4,\xc4\x9f\xc1q"\x8e\x17_|\xb1\xbb\xf5\xd6[\xbb\x85\x0b\x176\xb7\xe1\xcb\xa9u\xcc\xd6\x80\x01\x06\x18`\x80\x01\x06\x18`\x80\x81d\x06bv\x89\x19&f\x19\xf3\xdd\x89\xf3]\xcc\xbc1\xfb\xa6\x0f\x00\xb1\x00\xe5g \xd6\x16 \xbf\x80\xe4D$\xb1\x1e\xcf>\xfbl\xb7}\xfbv\x17\xcfd\x17O\x11EDb\x80\x01\x06\x18`\x80\x01\x06\x18h\xc9@\xcc,1\xbb\x98\xe9\xde=\xd3\xc5\xac\x1b3o9\x9f^\xa6W\xa0,\xc8u\xa5\x8a\xfc\x1a\x98\x93\x82\xe9\xbe\xf6\xb5\xafuk\xd6\xac\x11\x02\x84\x00\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x18\xe8\x8d\x81\x98QbV1\xc7\x9d|\x8e\x8b\x197f\xdd\xe9\xb9\xd7\xdb\x19+p\xf4\xe8\xd1\x8f\x95\x05z\x13\x9e\x93\xe3y\xe5\x95W\xba/~\xf1\x8b\xdd\x8a\x15+z\xb3\xe1\xcb\xe9s,\xd6\x80\x01\x06\x18`\x80\x01\x06\x18`\x80\x81d\x06b&\x89\xd9$f\x14\xf3\xdb\xc9\xe7\xb7\x98mc\xc6\x9d1\xf2\xfa\xe3\xec\x15(\x8b\xb4\x0b\xa0\x93\x03\x9a^\x97\xc3\x87\x0fw\xf7\xde{o\xb7l\xd92\x17\xdad\x17Z\xc1Epb\x80\x01\x06\x18`\x80\x01\x06\x188\x9f\x06b\x06\x89Y$f\x92\xe9\xf9\xc4\xdb\x93\xcfo1\xdb\x96s\xe5\xe5t+P\x16\xea?CtrD3\xd7\xe5\xd0\xa1C\xdd\xddw\xdf\xed7\x06\x88\x00\xf9\x84\xf1\xcc\x00\x00\x0blIDATB\x10\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03c5\x10\xcf\xec\x1f\xb3G\xcc 3g\x12\x7f>\xf9\xdc\x163\xed\xe9\xe6^\xef\x7fg\x05&\n\xa4\xe7a:9\xa6\xd9\xebr\xe0\xc0\x81n\xd7\xae]~4\xc0E\x7f\xac\x17\xfd\xb2=}}k\xc0\x00\x03\x0c0\xc0\x00\x03\x0c$3\x10\x0f\xf5\x8fY#f\x8e\xd9s\x88\xbf\x9fr^{\xbe\xdcw\x9exg\xbc\xf5\xa7\xd3\xae\xc0\x9e={\xdeS\xaa\xc9\x8bP\x9d\x12\xd5\xbb6`yr\x89\xee\xa1\x87\x1e\xea\xae\xbd\xf6Z\x17\xe6d\x17\xe6\xb2\xa1\x9csk\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0P\xcd@\xcc\x141[\xc4\x8ca&\x9b\xffL\x163l\xcc\xb2\xa7\x1dx}\xc0\xbbW\xa0`\xbb\xb0,\xe0\x9f\x007\x7fp\xd3k\xf5\xcdo~\xd3\xaf\x0f\xf4\x0f@\xb5\x7f\x00\x04\x06\x81\x85\x01\x06\x18`\x80\x01\x06\x18\xc8a ~\x9d_\xcc\x12\xd3s\x85\xb7\xf3\x9f\xc5bv\x8d\x19\xb6\xec\x15/\xa3\xae\xc0\xc1\x83\x07/.\x0b\xf9\xa7\xe0\xcd\x1f\xde\xcc\xb5\xfa\xde\xf7\xbe\xd7\xdd~\xfb\xed\xdd\xd2\xa5K\r\x83\x82\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c\xbc\xcb@\xcc\n13\xc4\xec0s\x96\xf0\xe7\xf9\xcf`1\xb3\xc6\xec:\xea\xdc\xeb\xf3f\xac\xc0\xcb/\xbf\xbc\xb4,\xe8O\x01\x9c?\xc0\xd9k\x15\xbf\x9ec\xf7\xee\xdd\xdd\x8d7\xde\xf8\xae\r_\x96\xda\xffg\r\x18`\x80\x01\x06\x18`\x80\x01\x06\x18Hf f\x83\x98\x11\xfc*\xbf\xd1\xe7\xac\x98\xbbbV\x8d\x99u\xc6\x08\xeb\x8fg\xbb\x02\xe5\xa1\x14+\xcb\xe2\xfe\xc5\xec\xc1\xd6\xdf\xcf\x1c\xeb\xf3\xcf?\xdf\xed\xdc\xb9\xb3\xbb\xe2\x8a+\\\xe4\x93]\xe4\xc5\x1e\xb1\x8b\x01\x06\x18`\x80\x01\x06\x18\xc8m f\x80\x98\x05b&0K\x9d\xf9,u\x925\xfb\x8b\x98U\xcb\xbe\xf2R{\x05ba=\x12\xa0\n\xd2\xb77\xfb\xd3O?\xdd}\xf6\xb3\x9f\xedV\xaf^-\x06\x88\x01\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c\x0c\xd0@\xdc\xd7\x8f\xfb\xfcq\xdf\xff$\x03\xac\xff\xaf|\x17\x7f\x94u\x89\xd9\xd4\xf0_{\xea\x9f\xf5\xf5\xa6~\x1c\xc0s\x02\x8c\x88t.\xd8\xdf\xfe\xf6\xb7\xbb\xcf|\xe63\x1e\x190\xc0\x8b~\xd9F\xfe1\xb7\x06\x0c0\xc0\x00\x03\x0c0\xc0@"\x03\xf1\x9d\xfe\xb8o\x1f\xf7\xf1\xe7\x9a\x01\xbco\xe4\xe1\xffO=\xec\x7f\xd6\xb0>\xae\xbfN=1\xa0\xdf\x0e0\x86\x080}\x01x\xee\xb9\xe7\xba\xfb\xef\xbf\xbf\xbb\xe9\xa6\x9b\xba\xc5\x8b\x17\xfb\xc7"\xd1?\x16b\x81X\xc2\x00\x03\x0c0\xc0\x00\x03\x0c\xb4g \xee\xb3\xc7}\xf7\xb8\x0f\x1f\xf7\xe5\xa7\xef\xd7{;\xda\x80?\xd7\xba\x95\xef\xfc\xff\x89\'\xfc+W\x89s\xf9R\x1ej\x11\xbf"\xf0\xc5\xb9N\x8c\xf7\xd5\xc1~\xf8\xf0\xe1\xee\xf1\xc7\x1f\xef>\xf5\xa9Ou\x1b7n\xec\x16.\\(\x08\x08\x02\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c\x9cG\x03q\x9f<\xee\x9b\xc7}\xf4\xb8\xaf\x1e\xf7\xd9\xcd?u\xe6\x9f\xb9\xd61f\xd0\x98E\xcf\xe5\xec\xeb\xbf5\xb5\x02{\xf6\xecyO99\xcf\xcfu\x82\xbc\xaf\xfe&(\xb5\xab{\xe2\x89\'\xba\xbb\xee\xba\xab\xdb\xb1cG\xf7\xde\xf7\xbe\xd7\xc5\xff<^\xfc\xcbv\xb0\xfe\xd6\x80\x01\x06\x18`\x80\x01\x06\x18\x18\xb8\x81\xb8\xcf\x1d\xf7\xbd\xe3>x\xdc\x17\x8f\xfb\xe4f\x9d\xfa\xb3\xcei\xd6\xf4\xf9\x98A\r\xe4\xe7w\x05&J\x85y\xfc4\'\xca\xe6\x18\xe3\x8f\x0b\x1c;v\xac\xfb\xeew\xbf\xdb=\xf2\xc8#\xdd\x1dw\xdcq\xfc\xa1G+W\xae\xf4\x8f\xd0\xc0\xff\x11\x12\x1e\x84\x17\x06\x18`\x80\x01\x06\x18``<\x06\xe2\xbet<\x9c?\xee[\xc7}\xec\xb8\xaf\x1d\xf7\xb9\xcd<\xe7|\xe0\x7f{\xcdc\xe6,\xde\'\xca\xab\x97>\xac@9!\xbb\xca\xeb\x9b6\xc5\xf9\xdb\x14\xb3\xd7~\xff\xfe\xfd\xdd\x93O>\xd9=\xf0\xc0\x03\xc7\x9fu\xf4\xe6\x9bo\xee6m\xda\xd4-]\xbaT\x1c\x10\x07\x18`\x80\x01\x06\x18`\x80\x01\x06R\x1b\x88\xfb\xc4q\xdf8\xee#\xc73\xf4\xc7}\xe6\xb8\xef\x1c\xf7\xa1g\xdf\xaf\xf6\xf7\xf37\xe3\xc4\x8c\x19\xb3f\x1ff^\xc70k\x05J\x15\xfbx99\xbf\xb6A\xce\xdf\x06\x99\xef\xda\xc7C\x96\x9ey\xe6\x99\xe3?\xaf\xf4\xf0\xc3\x0fw\x9f\xff\xfc\xe7\x8f\xff>\xd2O~\xf2\x93\xc7/\x827\xdcp\xc3\xf1\x9fiZ\xbf~}\xb7f\xcd\x9an\xd5\xaaU\xdd\xf2\xe5\xcb\xbb\xc9\xc9\xc9\xee\xc2\x0b/\xec\x16-Z\xe4\xd5\x1a0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c\x9c7\x03q\x9f4\xee\x9b\xc6}\xd4\xb8\xaf\x1a\xf7Y\xe3\xbek\xfc\\~\xdc\x97\x8d\xc1>\xee\xdb\xee\xdc\xb9\xf3\xf8}\xdd\xb8\xcf\x1b?\xab\x1f\xf7\x81=|\xbf\xff\xf3J\xcc51[\xc6\x8c9k\xec\xf4\xd7>\xad@yB\x86\xeb\xca\xc9\xfa\xc5|\x07Q\x1f\xd7\xc6\xe6s\x9e\x9c\'\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x188\x87\x06~\x11\xb3e\x9ff]\xc7r\x8a\x15(\'jm\xa95\x7fv\x0eqx\x98\xce\x18\x9fc\xc0yt\xa1g\x80\x01\x06\x18`\x80\x01\x06\x18`\x80\x81se f\xc9\x98)O1n\xfa\xbf\xfb\xb8\x02/\xbd\xf4\xd2\x92r\xe2\xfe\xe0\\!\xf1\xdfqAb\x80\x01\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\xda6\x103d\xcc\x92}\x9cq\x1d\xd3\xa7\xd1\x15\xd8\xb7o\xdf{\xcb\x89\x7fJ\x04\x10\x01\x18`\x80\x01\x06\x18`\x80\x01\x06\x18`\x80\x81\x1c\x06b\x06\x8cY\xb0\xd11\xd6a\x9f\xed\n\x1c=z\xf4_\x95\xcd\xfeK\x1b>\xc7\x86w\x9e\x9dg\x06\x18`\x80\x01\x06\x18`\x80\x01\x06R\x1a\xf8e\xcc~g;?\xfa\xfc\x01\xac\xc0\xe1\xc3\x87W\x94\x12\xf4\xac\x0bA\xca\x0b\x81\x87j5\xf2P-\xfb\xd3\xfed\x80\x01\x06\x18`\x80\x01\x06\x18\x18\xc5@\xccz1\xf3\r`tu\x13j\xae@y\x12\x88\x7fYp\xfc\xafQP\xf9\x1c\x17#\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x18`\xa0?\x06b\xb6\x8b\x19\xaf\xe6\xcc\xe8k\rl\x05\x0e\x1edG\xc1\xf5\xb3\xf9\x00\xf31.D\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0@/\x0c\xfc,f\xb9q\xcd\x89\xbe\xee\x80W`\xf7\xee\xdd\x0bK9\xfa7e#{\x92@?\'\xee\xb9\x02\x18`\x80\x01\x06\x18`\x80\x01\x06\x18`\xa0\xbf\x06~\x19\xb3[\xccp\x03\x1eQ\xdd\xb4s\xb1\x02/\xbf\xfc\xf2\xd2\x12\x01\xbeQ^\xdfP\xf5zQ\xf5\\x\xfb{\xe1un\x9c\x1b\x06\x18`\x80\x01\x06\x18`\x80\x81si f\xb4o\xc4\xccv.fC\xff\x8dD+P\x9e@bC\xc1\xf5G"\x80\x08\xc0\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c\x9cw\x03\x7f\x143Z\xa2\x91\xd4M=\x1f+P\x1eZ\xf2\xa1\xb2\xd9\x7fl\xc3\x9f\xf7\r\x7f.\xcb\xa2\xff\x96\x92\xcd\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0@?\x0c\xfc8f\xb2\xf31\x0b\xfao&^\x81\x82\xee\xb7\xca\xebO\x84\x00!\x80\x01\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x18\x18\xaf\x81\x98\xbdb\x06K<\x82\xba\xe9}X\x81\xf2,\x93\xb7\x16\x88\x7fn\xc3\x8fw\xc3[_\xeb\xcb\x00\x03\x0c0\xc0\x00\x03\x0c0\xc0@>\x031k\xc5\xcc\xd5\x87\xd9\xcf1X\x81\xe9\x15\x98(?\x7fr{\xb9 \xfd\xdcE)\xdfE\xc99w\xce\x19`\x80\x01\x06\x18`\x80\x01\x06\x18\xa8n\xe0\xe71c\x95\x81kbz\xe8\xf2\xd6\n\xf4n\x05\xca\xc6\xffp\xa9T\x07\\\x00\xaa_\x00\xfc\xccU?~\xe6\xcayp\x1e\x18`\x80\x01\x06\x18`\x80\x01\x06\xc6f`j\x96\xfap\xef\x06=\x07d\x05\xe6Z\x81R\xab>P\xf0~\xb7\x84\x00\xbf>\xd0\x05rl\x17H\xa1Ihb\x80\x01\x06\x18`\x80\x01\x06\x18\x18\x80\x817bv\x8a\x19j\xae\x19\xcb\xfb\xac@\xefW\xe0\xd0\xa1CW\x14\xcc_-\xaf\x7f3\x80\x8di\x90\x153\x18`\x80\x01\x06\x18`\x80\x01\x06\x18`\xa0\x8a\x81\x98\x91bV\x8a\x99\xa9\xf7\x83\x9d\x03\xb4\x02g\xb2\x02G\x8e\x1c\xb9\xa0\xe0\xbe\xa5\xbc\xee/!\xe0\x9f\xc4\x00\xa5\x96\x01\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x12\x1a\xf8\xa7\x98\x89b6\x8a\x19\xe9Lf*\x1fk\x05\x9a\\\x81\xf2\xd0\x96+\xcbF\xffJy}=\xe1\x86\xafR\x0b\xad\x9b\x7f,\x19`\x80\x01\x06\x18`\x80\x01\x06\x18h\xca@\xcc>_\x89Y\xa8\xc9!\xceA[\x81\xb3]\x81\xdd\xbbw/,\x9b \x9e4\xf0\xf7\xcb\xeb\xdf\xba\x805u\x01\x132<\xf4\x8d\x01\x06\x18`\x80\x01\x06\x18`\x80\x819\x0c\xc4\x8c\x13\xb3N\xcc<1\xfb\x9c\xed\xfc\xe4\xf3\xad\xc0`V \x1e\xfeR6\xc6G\xcb\x06\xf9\xc3\xf2\xfa\x7f\xc4\x001\x80\x01\x06\x18`\x80\x01\x06\x18`\x80\x01\x06Z3\x10\xb3L\xcc4\xe5\xb8?\xea!\xfe\x83\x19W\xdd\x90q\xae\xc0\xbe}\xfb\x16\x97\r\xf3\x89\xf2\xfaty\xfd\xeb\xf2\xaa,Z\x03\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x18\xe8\xab\x81\x98Ybv\xf9D\xcc2\xe3\x9c\x95|m+0\xf8\x15\x98\xfa\x95\x82\xbf[6\xd4\x91\xf2\xfa\x0f\xe5\xb5\xaf\x1b\xdfq97\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c\x0c\xdf@\xcc$G\xcaw\xfa\x7f\xd7\xaf\xee\x1b\xfc8\xea\x06\x9e\xcf\x15\xf8\xe1\x0f\x7f8Y6Z\xfc6\x81\xdf+\xaf?-\xafo\n\x02\x82\x08\x03\x0c0\xc0\x00\x03\x0c0\xc0\x00\x03\x0c\x8c\xcb@\xcc\x1cS\xb3G\xcc \xb7\xc4Lr>g"\xffm+\x90v\x05b\xf3\x1d=z\xf47Ky\xdb]6\xfc\xcb\xe5\xf5W\xe3\xda\xf8\xbe\xae\x7fT\x18`\x80\x01\x06\x18`\x80\x01\x06\x18Ha f\x8a\x97c\xc6\x88Y\xc3\xc0\x9fv\xdct\xc3\xfb\xbe\x02\xe5gn\x16M\xfd\xc8\xc0o\x97:\xf7\x9f\xca\xeb\x1f\x97\xcd\xfb\x0b\x17\xea\x14\x17j\x0f\xb7\x1b\xfe\xc3\xed\x9cc\xe7\x98\x01\x06\x18`\x80\x01\x06j\x1b\xf8E\xcc\x0cS\xb3\xc3o\xc7,\x113E\xdf\xe7\x1e\xc7g\x05\xac\xc0\x1c+p\xe0\xc0\x81K\xcb\xb3p\xfe\xf3\xb2\xb1\xef+\xafO\x94\xd7\xff^\xa2\xc0\xcf\xcb\xeb\xff\x13\x07\xc4\x01\x06\x18`\x80\x01\x06\x18`\x80\x01\x06\x06m \xee\xf3\xff\xee\xdb\xdfP\xde\xb7|\x883\x8c\xdbt\xe6+\xf0\xff\x01\xdb\xc4Fz~\xbf&2\x00\x00\x00\x00IEND\xaeB`\x82' ================================================ FILE: maestro/jit_funcs.py ================================================ import warnings import numpy as np from maestro import config from maestro.config import print_to_logfile try: from numba import jit except: # pylint: disable=bare-except jit = lambda x: x print_to_logfile("Numba not installed. Visualization will be slower.") try: from numba.core.errors import NumbaWarning warnings.simplefilter("ignore", category=NumbaWarning) except: # pylint: disable=bare-except pass @jit def lerp(start, stop, t): return start + t * (stop - start) @jit(forceobj=True) def bin_average(arr: np.ndarray, n, include_remainder=False, func=None): if func is None: func = np.max remainder = arr.shape[1] % n if remainder == 0: return func(arr.reshape(arr.shape[0], -1, n), axis=1) avg_head = func(arr[:, :-remainder].reshape(arr.shape[0], -1, n), axis=1) if include_remainder: avg_tail = func( arr[:, -remainder:].reshape(arr.shape[0], -1, remainder), axis=1 ) return np.concatenate((avg_head, avg_tail), axis=1) return avg_head @jit(forceobj=True) def render( num_bins, freqs: np.ndarray, frame, visualizer_height, mono=None, include_remainder=None, func=None, ): """ mono: True: forces one-channel visualization False: forces two-channel visualization None: if freqs[0] == freqs[1], one-channel, else two """ if func is None: func = np.max if mono is None: mono = np.array_equal(freqs[0], freqs[1]) if not mono: gap_bins = 1 if num_bins % 2 else 2 num_bins = (num_bins - 1) // 2 else: gap_bins = 0 freqs[0, :, frame] = (freqs[0, :, frame] + freqs[1, :, frame]) / 2 num_vertical_block_sizes = len(config.VERTICAL_BLOCKS) - 1 freqs = np.round( bin_average( freqs[:, :, frame], num_bins, ( (freqs.shape[-2] % num_bins) > num_bins / 2 if include_remainder is None else include_remainder ), func=func, ) / 80 * visualizer_height * num_vertical_block_sizes ) arr = np.zeros((int(not mono) + 1, visualizer_height, num_bins)) for b in range(num_bins): bin_height = freqs[0, b] h = 0 while bin_height > num_vertical_block_sizes: arr[0, h, b] = num_vertical_block_sizes bin_height -= num_vertical_block_sizes h += 1 arr[0, h, b] = bin_height if not mono: bin_height = freqs[1, b] h = 0 while bin_height > num_vertical_block_sizes: arr[1, h, b] = num_vertical_block_sizes bin_height -= num_vertical_block_sizes h += 1 arr[1, h, b] = bin_height res = [] for h in range(visualizer_height - 1, -1, -1): s = "" for b in range(num_bins): if mono: s += config.VERTICAL_BLOCKS[arr[0, h, b]] else: s += config.VERTICAL_BLOCKS[arr[0, h, num_bins - b - 1]] if not mono: s += " " * gap_bins for b in range(num_bins): s += config.VERTICAL_BLOCKS[arr[1, h, b]] res.append(s) return res ================================================ FILE: maestro/mac_presence.py ================================================ # BIG thanks to @othalan on StackOverflow for this # adapted from https://stackoverflow.com/questions/69965175/pyobjc-accessing-mpnowplayinginfocenter from maestro.helpers import print_to_logfile # pylint: disable=unused-import # pylint: disable=no-name-in-module,import-error from AppKit import ( NSImage, NSObject, # NSMakeRect, # NSCompositingOperationSourceOver, # NSCompositingOperationCopy, ) from Foundation import NSMutableDictionary from MediaPlayer import ( MPNowPlayingInfoCenter, MPNowPlayingInfoPropertyElapsedPlaybackTime, MPRemoteCommandCenter, MPMediaItemArtwork, MPMediaItemPropertyTitle, MPMediaItemPropertyArtist, MPMediaItemPropertyPlaybackDuration, MPMediaItemPropertyArtwork, # MPMusicPlaybackState, MPMusicPlaybackStatePlaying, MPMusicPlaybackStatePaused, # MPMusicPlaybackStateStopped, ) from PyObjCTools import AppHelper class AppDelegate(NSObject): # so Python doesn't bounce in the dock def applicationDidFinishLaunching_(self, _aNotification): pass def sayHello_(self, _sender): pass def app_helper_loop(): # ns_application = NSApplication.sharedApplication() # logo_ns_image = NSImage.alloc().initByReferencingFile_( # "./maestro_icon.png" # ) # ns_application.setApplicationIconImage_(logo_ns_image) # # we must keep a reference to the delegate object ourselves, # # NSApp.setDelegate_() doesn't retain it. A local variable is # # enough here. # delegate = AppDelegate.alloc().init() # NSApp().setDelegate_(delegate) AppHelper.runEventLoop() # pylint: enable class MockQueue: # enable testing this file def put(self, *args, **kwargs): pass def empty(self): return True class MockInt: def __init__(self) -> None: self._value = 0 @property def value(self): return self._value @value.setter def value(self, value): self._value = value class MacNowPlaying: def __init__(self): # get the remote command center # ... which is how the OS sends commands to the application self.cmd_center = MPRemoteCommandCenter.sharedCommandCenter() # get the now playing info center # ... which is how this application notifies MacOS of what is playing self.info_center = MPNowPlayingInfoCenter.defaultCenter() # enable command handlers self.cmd_center.playCommand().addTargetWithHandler_(self.play_handler) self.cmd_center.pauseCommand().addTargetWithHandler_(self.pause_handler) self.cmd_center.togglePlayPauseCommand().addTargetWithHandler_( self.toggle_handler ) self.cmd_center.nextTrackCommand().addTargetWithHandler_( self.next_handler ) self.cmd_center.previousTrackCommand().addTargetWithHandler_( self.prev_handler ) self.cmd_center.seekBackwardCommand().addTargetWithHandler_( self.seek_backward_handler ) self.cmd_center.seekForwardCommand().addTargetWithHandler_( self.seek_forward_handler ) self.cmd_center.changePlaybackPositionCommand().addTargetWithHandler_( self.change_position_handler ) # NOTE: disabling these handlers shows prev/next track buttons instead # NOTE: in the control center and touch bar self.cmd_center.skipForwardCommand().addTargetWithHandler_( self.seek_forward_handler ) self.cmd_center.skipBackwardCommand().addTargetWithHandler_( self.seek_backward_handler ) # self.cmd_center.stopCommand().addTargetWithHandler_(self.stop) self.title_queue = MockQueue() self.artist_queue = MockQueue() self.paused = False self.pos = 0 self.length = 0 self.q = MockQueue() self.cover = None self.title = "" self.artist = "" self._cover = None def play_handler(self, _event): """ Handle an external 'playCommand' event. """ if self.info_center.playbackState() == MPMusicPlaybackStatePaused: self.q.put(" ") return 0 def pause_handler(self, _event): """ Handle an external 'pauseCommand' event. """ if self.info_center.playbackState() == MPMusicPlaybackStatePlaying: self.q.put(" ") return 0 def toggle_handler(self, _event): """ Handle an external 'togglePlayPauseCommand' event. """ self.q.put(" ") return 0 def next_handler(self, _event): """ Handle an external 'nextTrackCommand' event. """ self.q.put("n") return 0 def prev_handler(self, _event): """ Handle an external 'previousTrackCommand' event. """ self.q.put("b") return 0 def seek_backward_handler(self, _event): """ Handle an external 'seekBackwardCommand' event. """ self.pos -= 10 return 0 def seek_forward_handler(self, _event): """ Handle an external 'seekForwardCommand' event. """ self.pos += 10 return 0 def change_position_handler(self, event): # get time from event time = round(event.positionTime()) self.pos = time return 0 def stop(self): """ Call this method to update 'Now Playing' state to stopped """ self.q.put("q") return 0 def pause(self): """ Call this method to update 'Now Playing' state to paused """ self.info_center.setPlaybackState_(MPMusicPlaybackStatePaused) return 0 def resume(self): """ Call this method to update 'Now Playing' state to playing """ self.info_center.setPlaybackState_(MPMusicPlaybackStatePlaying) return 0 def update(self): """ Call this method to update the 'Now Playing' info """ nowplaying_info = NSMutableDictionary.dictionary() if not self.artist_queue.empty(): while not self.artist_queue.empty(): self.artist = "" c = self.artist_queue.get() while c != "\n": self.artist += c c = self.artist_queue.get() if not self.title_queue.empty(): while not self.title_queue.empty(): self.title = "" c = self.title_queue.get() while c != "\n": self.title += c c = self.title_queue.get() # Set basic track information nowplaying_info[MPMediaItemPropertyTitle] = self.title nowplaying_info[MPMediaItemPropertyArtist] = self.artist nowplaying_info[MPMediaItemPropertyPlaybackDuration] = self.length nowplaying_info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = self.pos if self.cover is not None: # print_to_logfile("cover len: {}".format(len(self.cover))) img = NSImage.alloc().initWithData_(self.cover) # def resize(size): # new = NSImage.alloc().initWithSize_(size) # new.lockFocus() # img.drawInRect_fromRect_operation_fraction_( # NSMakeRect(0, 0, size.width, size.height), # NSMakeRect(0, 0, img.size().width, img.size().height), # NSCompositingOperationCopy, # 1.0, # ) # new.unlockFocus() # return new art = MPMediaItemArtwork.alloc().initWithBoundsSize_requestHandler_( img.size(), lambda size: img ) # print_to_logfile("artwork size: {}".format(img.size())) nowplaying_info[MPMediaItemPropertyArtwork] = self._cover = art self.cover = None else: if self._cover is not None: nowplaying_info[MPMediaItemPropertyArtwork] = self._cover # Set the metadata information for the 'Now Playing' service self.info_center.setNowPlayingInfo_(nowplaying_info) if self.paused: self.pause() else: self.resume() # self.info_center.setObject_for_key_(self.artist, "artist") # self.info_center.setObject_for_key_(self.length, "length") # self.info_center.setObject_for_key_(self.pos, "pos") # # self.info_center.title = title # # self.info_center.artist = artist # # self.info_center.length = length # # self.info_center.pos = pos # nowplaying_info = NSMutableDictionary.dictionary() # nowplaying_info[MPMediaItemPropertyTitle] = title # nowplaying_info[MPMediaItemPropertyArtist] = artist # nowplaying_info[MPMediaItemPropertyPlaybackDuration] = length # nowplaying_info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = pos # # self.info_center.setNowPlayingInfo_(nowplaying_info) # if paused: # self.info_center.setPlaybackState_(MPMusicPlaybackStatePaused) # else: # self.info_center.setPlaybackState_(MPMusicPlaybackStatePlaying) return 0 ================================================ FILE: maestro/main.py ================================================ # pylint: disable=possibly-used-before-assignment # region imports import curses import multiprocessing import os import sys import threading import click import msgspec from collections import defaultdict from queue import Queue from random import randint from shutil import move, copy, rmtree from time import sleep, time from maestro import config from maestro import helpers from maestro.__version__ import VERSION from maestro.config import print_to_logfile # pylint: disable=unused-import # endregion # region utility functions/classes def _play( stdscr, playlist, volume, loop, clip_mode, reshuffle, update_discord, visualize, stream, username, password, lyrics, translated_lyrics, ): helpers.init_curses(stdscr) can_mac_now_playing = False if sys.platform == "darwin": try: from maestro.mac_presence import ( MacNowPlaying, AppDelegate, app_helper_loop, ) # pylint: disable=no-name-in-module,import-error from AppKit import ( NSApp, NSApplication, # NSApplicationDelegate, NSApplicationActivationPolicyProhibited, NSDate, NSRunLoop, ) # pylint: enable mac_now_playing = MacNowPlaying() can_mac_now_playing = True except ( Exception # pylint: disable=bare-except,broad-except ) as mac_import_err: print_to_logfile("macOS PyObjC import error:", mac_import_err) if loop: next_playlist = playlist[:] helpers.bounded_shuffle(next_playlist, reshuffle) else: next_playlist = None player = helpers.PlaybackHandler( stdscr, playlist, clip_mode, visualize, stream, (username, password) if username and password else (None, None), lyrics, translated_lyrics, ) player.volume = volume if can_mac_now_playing: player.can_mac_now_playing = True player.mac_now_playing = mac_now_playing player.mac_now_playing.title_queue = Queue() player.mac_now_playing.artist_queue = Queue() player.mac_now_playing.q = Queue() ns_application = NSApplication.sharedApplication() ns_application.setActivationPolicy_( NSApplicationActivationPolicyProhibited ) # NOTE: keep ref to delegate object, setDelegate_ doesn't retain delegate = AppDelegate.alloc().init() NSApp().setDelegate_(delegate) app_helper_process = multiprocessing.Process( daemon=True, target=app_helper_loop, ) app_helper_process.start() if update_discord: player.threaded_initialize_discord() prev_volume = volume while player.i in range(len(player.playlist)): player.playback.load_file(player.song_path) if player.song.set_clip in player.song.clips: player.clip = player.song.clips[player.song.set_clip] else: player.clip = (0, player.playback.duration) player.paused = False player.lyrics = ( player.song.parsed_override_lyrics or player.song.parsed_lyrics ) player.lyric_pos = None if player.lyrics is not None: player.lyrics_scroller = helpers.Scroller( len(player.lyrics), player.screen_height - 1 ) player.translated_lyrics = player.song.parsed_translated_lyrics player.playback.play() player.set_volume(volume) player.update_metadata() # latter is clip-agnostic, former is clip-aware player.duration = player.playback.duration if player.clip_mode: clip_start, clip_end = player.clip player.duration = clip_end - clip_start player.seek(clip_start) start_time = pause_start = time() player.last_timestamp = player.playback.curr_pos next_song = 1 # -1 if going back, 0 if restarting, +1 if next song player.restarting = False while True: if not player.playback.active or ( player.clip_mode and player.playback.curr_pos > player.clip[1] ): next_song = not player.looping_current_song break # fade in first 2 seconds of clip if ( player.clip_mode and clip_start > 0.01 # if clip doesn't start at beginning and clip_end - clip_start > 5 # if clip is longer than 5 secs and player.playback.curr_pos < clip_start + 2 ): player.set_volume( player.volume * (player.playback.curr_pos - clip_start) / 2 ) else: player.set_volume(player.volume) if player.can_mac_now_playing: # macOS Now Playing event loop try: if player.update_now_playing: player.mac_now_playing.update() player.update_now_playing = False NSRunLoop.currentRunLoop().runUntilDate_( NSDate.dateWithTimeIntervalSinceNow_(0.05) ) except Exception as e: print_to_logfile("macOS Now Playing error:", e) player.can_mac_now_playing = False if ( player.can_mac_now_playing and not player.mac_now_playing.q.empty() ): c = player.mac_now_playing.q.get() if c in "nN": if player.i == len(player.playlist) - 1 and not loop: pass else: next_song = 1 player.playback.stop() break elif c in "bB": if player.i == 0: pass else: next_song = -1 player.playback.stop() break elif c in "rR": player.playback.stop() next_song = 0 break elif c in "qQ": player.ending = True break elif c == " ": player.paused = not player.paused if player.paused: player.playback.pause() pause_start = time() else: player.playback.resume() start_time += time() - pause_start if player.can_mac_now_playing: player.mac_now_playing.paused = player.paused if player.paused: player.mac_now_playing.pause() else: player.mac_now_playing.resume() player.update_now_playing = True player.update_screen() else: c = player.stdscr.getch() # int next_c = player.stdscr.getch() while next_c != -1: c, next_c = next_c, player.stdscr.getch() if c != -1: try: ch = chr(c) if ch in "\b\x7f": c = curses.KEY_DC elif ch in "\r\n": c = curses.KEY_ENTER except (ValueError, OverflowError): ch = None if c == curses.KEY_UP: player.scroll_backward() player.update_screen() elif c == curses.KEY_DOWN: player.scroll_forward() player.update_screen() elif c == curses.KEY_SLEFT: if player.want_lyrics: player.lyrics_width += 1 player.update_screen() elif c == curses.KEY_SRIGHT: if player.want_lyrics: player.lyrics_width -= 1 player.update_screen() elif c == 337: # SHIFT + UP if player.scroller.pos > 0: if player.scroller.pos == player.i: player.i -= 1 elif player.scroller.pos == player.i + 1: player.i += 1 ( player.playlist[player.scroller.pos], player.playlist[player.scroller.pos - 1], ) = ( player.playlist[player.scroller.pos - 1], player.playlist[player.scroller.pos], ) player.scroller.scroll_backward() elif c == 336: # SHIFT + DOWN if player.scroller.pos < player.scroller.num_lines - 1: if player.scroller.pos == player.i: player.i += 1 elif player.scroller.pos == player.i - 1: player.i -= 1 ( player.playlist[player.scroller.pos], player.playlist[player.scroller.pos + 1], ) = ( player.playlist[player.scroller.pos + 1], player.playlist[player.scroller.pos], ) player.scroller.scroll_forward() else: if player.prompting is None: if c == curses.KEY_LEFT: player.seek( player.playback.curr_pos - config.SCRUB_TIME ) player.update_screen() elif c == curses.KEY_RIGHT: player.seek( player.playback.curr_pos + config.SCRUB_TIME ) player.update_screen() elif c == curses.KEY_ENTER: if player.focus == 0: # pylint: disable=invalid-unary-operand-type # -2 because pos can be 0 next_song = -(player.scroller.pos) - 2 player.playback.stop() break elif player.focus == 1: if ( helpers.is_timed_lyrics(player.lyrics) and player.lyric_pos is not None ): player.seek( player.lyrics[player.lyric_pos].time ) player.snap_back() player.update_screen() elif c == curses.KEY_DC: if len(player.playlist) > 1: player.scroller.num_lines -= 1 if ( player.scroller.pos == player.i ): # deleted current song next_song = 3 player.playback.stop() break deleted_song = player.playlist[ player.scroller.pos ] del player.playlist[player.scroller.pos] if loop: for i in range(len(next_playlist)): if next_playlist[i] == deleted_song: del next_playlist[i] break # deleted song before current if player.scroller.pos < player.i: player.i -= 1 # deleted last song if ( player.scroller.pos == player.scroller.num_lines ): player.scroller.pos -= 1 player.scroller.refresh() elif c == 27: # ESC key if player.show_help: player.show_help = False player.update_screen() elif ch is not None: if ch in "nN": if ( not player.i == len(player.playlist) - 1 or loop ): next_song = 1 player.playback.stop() break elif ch in "bB": if player.i != 0: next_song = -1 player.playback.stop() break elif ch in "rR": player.restarting = True player.playback.stop() next_song = 0 break elif ch in "lL": player.looping_current_song = ( player.looping_current_song + 1 ) % len(config.LOOP_MODES) player.update_screen() elif ch in "cC": player.clip_mode = not player.clip_mode if player.clip_mode: clip_start, clip_end = player.clip player.duration = clip_end - clip_start if ( player.playback.curr_pos < clip_start or player.playback.curr_pos > clip_end ): player.seek(clip_start) else: player.duration = ( player.playback.duration ) player.update_screen() elif ch in "pP": player.snap_back() player.update_screen() elif ch in "gG": if loop: player.playback.stop() next_song = 2 break elif ch in "eE": player.ending = not player.ending player.update_screen() elif ch in "qQ": player.ending = True break elif ch in "dD": if player.want_discord: player.want_discord = False if player.discord_rpc is not None: player.discord_rpc.close() player.discord_connected = 0 else: def f(): player.initialize_discord() player.update_discord_metadata() player.update_stream_metadata() threading.Thread( target=f, daemon=True, ).start() elif ch in "iI": player.prompting = ( "", 0, config.PROMPT_MODES["insert"], ) curses.curs_set(True) screen_size = player.stdscr.getmaxyx() player.scroller.resize(screen_size[0] - 3) player.update_screen() elif ch in "aA": player.prompting = ( "", 0, config.PROMPT_MODES["append"], ) curses.curs_set(True) screen_size = player.stdscr.getmaxyx() player.scroller.resize(screen_size[0] - 3) player.update_screen() elif ch == ",": player.prompting = ( "", 0, config.PROMPT_MODES["tag"], ) curses.curs_set(True) screen_size = player.stdscr.getmaxyx() player.scroller.resize(screen_size[0] - 3) player.update_screen() elif ch in "mM": if player.volume == 0: player.volume = prev_volume else: player.volume = 0 player.update_screen() elif ch in "vV": player.want_vis = not player.want_vis player.update_screen() elif ch in "sS": player.want_stream = not player.want_stream if player.want_stream: if player.username is not None: threading.Thread( target=player.update_stream_metadata, daemon=True, ).start() player.ffmpeg_process.start() else: player.ffmpeg_process.terminate() player.update_discord_metadata() player.update_screen() elif ch == " ": player.paused = not player.paused if player.paused: player.playback.pause() pause_start = time() else: player.playback.resume() start_time += time() - pause_start if player.can_mac_now_playing: player.mac_now_playing.paused = ( player.paused ) if player.paused: player.mac_now_playing.pause() else: player.mac_now_playing.resume() player.update_now_playing = True player.update_screen() elif ch in "[-": player.volume = max( 0, player.volume - config.VOLUME_STEP ) player.update_screen() prev_volume = player.volume elif ch in "]=": player.volume = min( 100, player.volume + config.VOLUME_STEP ) player.update_screen() prev_volume = player.volume elif ch in "yY": player.want_lyrics = not player.want_lyrics elif ch in "oO": helpers.SONG_DATA.load() helpers.SONGS.load() for song in player.playlist: song.reset() if ( player.song.set_clip in player.song.clips ): player.clip = player.song.clips[ player.song.set_clip ] else: player.clip = ( 0, player.playback.duration, ) player.duration = ( player.clip[1] - player.clip[0] ) player.lyrics = ( player.song.parsed_override_lyrics or player.song.parsed_lyrics ) if player.lyrics is not None: player.lyrics_scroller = ( helpers.Scroller( len(player.lyrics), player.screen_height - 1, ) ) player.translated_lyrics = ( player.song.parsed_translated_lyrics ) from keyring.errors import NoKeyringError try: player.username = helpers.get_username() player.password = helpers.get_password() except NoKeyringError: pass player.update_screen() elif ch in "hH": player.show_help = not player.show_help elif ch in "tT": player.want_translated_lyrics = ( not player.want_translated_lyrics and player.want_lyrics ) elif ch in "fF": player.prompting = ( "", 0, config.PROMPT_MODES["find"], ) curses.curs_set(True) screen_size = player.stdscr.getmaxyx() player.scroller.resize(screen_size[0] - 3) player.update_screen() elif ch in "{_": player.snap_back() player.focus = 0 elif ch in "}+": player.snap_back() player.focus = 1 else: if c == curses.KEY_LEFT: # pylint: disable=unsubscriptable-object player.prompting = ( player.prompting[0], max(player.prompting[1] - 1, 0), player.prompting[2], ) player.update_screen() elif c == curses.KEY_RIGHT: # pylint: disable=unsubscriptable-object player.prompting = ( player.prompting[0], min( player.prompting[1] + 1, len(player.prompting[0]), ), player.prompting[2], ) player.update_screen() elif c == curses.KEY_DC: # pylint: disable=unsubscriptable-object player.prompting_delete_char() player.update_screen() elif c == curses.KEY_ENTER: # pylint: disable=unsubscriptable-object # fmt: off if ( player.prompting[2] == config.PROMPT_MODES["tag"] ): tags = set(player.prompting[0].split(",")) for song in player.playlist: song.tags |= tags player.prompting = None curses.curs_set(False) player.scroller.resize(screen_size[0] - 2) player.update_screen() elif player.prompting[2] == config.PROMPT_MODES["find"]: try: song = helpers.CLICK_SONG(player.prompting[0]) i = player.playlist.index(song) if i != -1: player.scroller.pos = i player.prompting = None curses.curs_set(False) player.scroller.resize(screen_size[0] - 2) player.update_screen() except click.BadParameter: pass else: try: song = helpers.CLICK_SONG(player.prompting[0]) if player.prompting[2] == config.PROMPT_MODES["insert"]: player.playlist.insert( player.scroller.pos + 1, song, ) inserted_pos = player.scroller.pos + 1 if player.i > player.scroller.pos: player.i += 1 else: player.playlist.append(song) inserted_pos = len(player.playlist) - 1 if loop: if reshuffle >= 0: next_playlist.insert(randint(max(0, inserted_pos-reshuffle), min(len(playlist)-1, inserted_pos+reshuffle)), song) elif reshuffle == -1: next_playlist.insert(randint(0, len(playlist) - 1), song) player.scroller.num_lines += 1 player.prompting = None curses.curs_set(False) player.scroller.resize(screen_size[0] - 2) player.update_screen() except click.BadParameter: pass elif c == 27: # ESC key player.prompting = None curses.curs_set(False) player.scroller.resize(screen_size[0] - 2) player.update_screen() elif ch is not None: player.prompting = ( # pylint: disable=unsubscriptable-object player.prompting[0][: player.prompting[1]] + ch + player.prompting[0][ player.prompting[1] : ], player.prompting[1] + 1, player.prompting[2], ) player.update_screen() if ( player.can_mac_now_playing ): # sync macOS Now Playing pos with playback pos if ( abs(player.mac_now_playing.pos - player.playback.curr_pos) > 1 ): player.seek(player.mac_now_playing.pos) player.update_screen() else: player.mac_now_playing.pos = round(player.playback.curr_pos) progress_bar_width = player.stdscr.getmaxyx()[1] - 18 frame_duration = min( ( 1 if progress_bar_width < config.MIN_PROGRESS_BAR_WIDTH else player.duration / (progress_bar_width * 8) ), 1 / config.FPS if player.want_vis else 1, ) if ( abs(player.playback.curr_pos - player.last_timestamp) > frame_duration ): player.last_timestamp = player.playback.curr_pos player.update_screen() sleep(0.01) # NOTE: so CPU usage doesn't fly through the roof if player.paused: time_listened = pause_start - start_time else: time_listened = time() - start_time # region update stats def stats_update(s: helpers.Song, t: float): s.listen_times[config.CUR_YEAR] += t s.listen_times["total"] += t threading.Thread( target=stats_update, args=(player.song, time_listened), daemon=True ).start() # endregion if player.ending and not player.restarting: player.quit() if player.can_mac_now_playing: app_helper_process.terminate() return if next_song == -1: if player.i == player.scroller.pos: player.scroller.scroll_backward() player.i -= 1 elif next_song == 1: if player.i == len(player.playlist) - 1: if loop: next_next_playlist = next_playlist[:] if reshuffle: helpers.bounded_shuffle(next_next_playlist, reshuffle) player.playlist, next_playlist = ( next_playlist, next_next_playlist, ) player.i = -1 player.scroller.pos = 0 else: return else: if player.i == player.scroller.pos: player.scroller.scroll_forward() player.i += 1 elif next_song == 0: if player.looping_current_song == config.LOOP_MODES["one"]: player.looping_current_song = config.LOOP_MODES["none"] elif next_song <= -2: # user pos -> -(pos + 2) player.i = -next_song - 2 elif next_song == 2: # next page next_next_playlist = next_playlist[:] if reshuffle: helpers.bounded_shuffle(next_next_playlist, reshuffle) player.playlist, next_playlist = ( next_playlist, next_next_playlist, ) player.i = 0 player.scroller.pos = 0 elif next_song == 3: # deleted current song deleted_song = player.playlist[player.i] del player.playlist[player.i] if loop: for i in range(len(next_playlist)): if next_playlist[i] == deleted_song: del next_playlist[i] break # endregion @click.group(context_settings={"help_option_names": ["-h", "--help"]}) @click.pass_context def cli(ctx: click.Context): """A command line interface for playing music.""" # ~/.maestro-files if not os.path.exists(config.MAESTRO_DIR): os.makedirs(config.MAESTRO_DIR) # ensure config.SETTINGS has all settings update_settings_file = False if not os.path.exists(config.SETTINGS_FILE): config.settings = config.DEFAULT_SETTINGS update_settings_file = True else: with open(config.SETTINGS_FILE, "r", encoding="utf-8") as f: s = f.read() if s: config.settings = msgspec.json.decode(s) for key in config.DEFAULT_SETTINGS: if key not in config.settings: config.settings[key] = config.DEFAULT_SETTINGS[key] update_settings_file = True else: config.settings = config.DEFAULT_SETTINGS update_settings_file = True # ~/.maestro-files/songs.json if not os.path.exists(config.SONGS_INFO_PATH): if os.path.exists(config.OLD_SONGS_INFO_PATH): if ctx.invoked_subcommand == "migrate": return ctx.fail( "Legacy song data detected. Please run 'maestro migrate' to convert the old songs file to the new format." ) with open(config.SONGS_INFO_PATH, "x", encoding="utf-8") as _: pass # ~/.maestro-files/songs/ if not os.path.exists(config.settings["song_directory"]): os.makedirs(config.settings["song_directory"]) t = time() if t - config.settings["last_version_sync"] > 24 * 60 * 60: # 1 day config.settings["last_version_sync"] = t update_settings_file = True try: import requests response = requests.get( "https://pypi.org/pypi/maestro-music/json", timeout=5 ) latest_version = response.json()["info"]["version"] if helpers.versiontuple(latest_version) > helpers.versiontuple( VERSION ): click.secho( f"A new version of maestro is available. Run 'pip install --upgrade maestro-music' to update to version {latest_version}.", fg="yellow", ) except Exception as e: print_to_logfile("Failed to check for updates:", e) # ensure config.SETTINGS_FILE is up to date if update_settings_file: import safer with safer.open(config.SETTINGS_FILE, "wb") as g: g.write(msgspec.json.encode(config.settings)) # ensure config.LOGFILE is not too large (1 MB) t = time() if os.path.exists(config.LOGFILE) and os.path.getsize(config.LOGFILE) > 1e6: # move to backup backup_path = os.path.join(config.OLD_LOG_DIR, f"maestro-{int(t)}.log") os.makedirs(os.path.dirname(backup_path), exist_ok=True) move(config.LOGFILE, backup_path) # delete old log files if os.path.exists(config.OLD_LOG_DIR): for file in os.listdir(config.OLD_LOG_DIR): if file.endswith(".log"): if t - int(file.split(".")[0].split("-")[1]) > 24 * 60 * 60: os.remove(os.path.join(config.OLD_LOG_DIR, file)) @cli.command() @click.argument("path_", metavar="PATH_OR_URL") @click.argument("tags", nargs=-1) @click.option( "-M/-nM", "--move/--no-move", "move_", default=False, help="Move file from PATH to maestro's internal song database instead of copying.", ) @click.option( "-n", "--name", type=str, help="What to name the song, if you don't want to use the title from Youtube/Spotify/filename. Do not include an extension (e.g. '.wav'). Ignored if adding multiple songs.", ) @click.option( "-R/-nR", "--recursive/--no-recursive", "recurse", default=False, help="If PATH is a folder, add songs in subfolders.", ) @click.option( "-Y/-nY", "--youtube/--no-youtube", default=False, help="Add a song from a YouTube or YouTube Music URL.", ) @click.option( "-S/-nS", "--spotify/--no-spotify", default=False, help="Add a song from Spotify (track URL, album URL, playlist URL, artist URL, or search query).", ) @click.option( "-f", "--format", "format_", type=click.Choice(("wav", "mp3", "flac", "vorbis")), help="Specify the format of the song if downloading from YouTube, YouTube Music, or Spotify URL.", default="mp3", show_default=True, ) @click.option( "-P/-nP", "--playlist/--no-playlist", "playlist_", default=False, help="If song URL passed is from a YouTube playlist, download all the songs. If the URL points directly to a playlist, this flag is unncessary.", ) @click.option( "-m", "--metadata", "metadata_pairs", default=None, help="Add metadata to the song. If adding multiple songs, the metadata is added to each song. The format is 'key1:value1|key2:value2|...'.", ) @click.option( "-a", "--artist", help="Specify the artist. If also specified in '-m/--metadata', this takes precedence.", ) @click.option( "-b", "--album", help="Specify the album. If also specified in '-m/--metadata', this takes precedence.", ) @click.option( "-c", "--album-artist", help="Specify the album artist. If also specified in '-m/--metadata', this takes precedence.", ) @click.option( "-nD/-D", "--skip-dupes/--no-skip-dupes", default=False, help="Skip adding song names that are already in the database. If not passed, 'copy' is appended to any duplicate names.", ) @click.option( "-L/-nL", "--lyrics/--no-lyrics", default=True, help="Search for and download lyrics for the song.", ) @click.option( "-q", "--audio-quality", type=click.Choice( ( "auto", # "disable", "8k", "16k", "24k", "32k", "40k", "48k", "64k", "80k", "96k", "112k", "128k", "160k", "192k", "224k", "256k", "320k", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", ), case_sensitive=False, ), default="auto", help="Specify the audio quality to download. 0 is the best quality, 9 is the worst. 'auto' is the default (5). You can also specify a bitrate (e.g. '128k').", ) @click.option( "--crop/--no-crop", default=True, help="Crop the album art to a square if no square art is found (YouTube/YT Music only).", ) @click.option( "--cookies-browser", help="Browser to extract cookies from for downloading from YouTube/YouTube Music. ", ) def add( path_, tags, move_, name, recurse, youtube, spotify, format_, playlist_, metadata_pairs, artist, album, album_artist, skip_dupes, lyrics, audio_quality, crop, cookies_browser, ): """ Add a new song. Adds the audio file located at PATH. If PATH is a folder, adds all files in PATH (including files in subfolders if '-R/--recursive' is passed). If '-M/--move' is passed, the file is moved from PATH to maestro's internal song database instead of copied. If the '-Y/--youtube' flag is passed, PATH is treated as a YouTube or YouTube Music URL instead of a file path. If the '-S/--spotify' flag is passed, PATH is treated as a Spotify track URL, album URL, playlist URL, artist URL, or search query instead of a file path. The default format for downloading from YouTube, YouTube Music, or Spotify is 'mp3'. The '-f/--format' option can be used to specify the format: 'wav', 'mp3', 'flac', or 'vorbis' (.ogg container). If adding only one song, the '-n/--name' option can be used to specify the name of the song. Do not include an extension (e.g. '.wav'). The '-m/--metadata' option can be used to add metadata to the song. It takes a string of the format 'key1:value1|key2:value2|...'. If adding multiple songs, the metadata is added to each song. Possible editable metadata keys are: album, albumartist, artist, artwork, comment, compilation, composer, discnumber, genre, lyrics, totaldiscs, totaltracks, tracknumber, tracktitle, year, isrc If the '-nD/--skip-dupes' flag is passed, song names that are already in the database are skipped. If not passed, 'copy' is appended to any duplicate names. """ paths = None if not (youtube or spotify or os.path.exists(path_)): click.secho( f"The path '{path_}' does not exist. To download from a YouTube or YouTube Music URl, pass the '-Y/--youtube' flag. To download from a Spotify URl, pass the '-S/--spotify' flag.", fg="red", ) return if youtube or spotify: if youtube and spotify: click.secho( "Cannot pass both '-Y/--youtube' and '-S/--spotify' flags.", fg="red", ) return if youtube: from spotdl.utils.ffmpeg import get_ffmpeg_path from yt_dlp import YoutubeDL if get_ffmpeg_path() is None: click.secho( "FFmpeg is not installed: you can install it globally or run 'maestro download-ffmpeg' to download it internally.", fg="red", ) return ytdl_settings = { "noplaylist": not playlist_, "postprocessors": [ { "key": "FFmpegMetadata", "add_metadata": True, }, { "key": "FFmpegExtractAudio", "preferredcodec": format_, "preferredquality": ( audio_quality if not audio_quality.lower().endswith("k") else audio_quality[:-1] ), }, ], "outtmpl": { "default": os.path.join( config.MAESTRO_DIR, "%(title)s.%(ext)s" ) }, "ffmpeg_location": str(get_ffmpeg_path()), } if cookies_browser: ytdl_settings["cookiefile"] = os.path.join(config.MAESTRO_DIR, "cookies.txt") ytdl_settings["cookiesfrombrowser"] = (cookies_browser,) with YoutubeDL(ytdl_settings) as ydl: info = ydl.extract_info(path_, download=True) if "entries" in info: for e in info["entries"]: helpers.yt_embed_artwork(e, crop) else: helpers.yt_embed_artwork(info, crop) else: from spotdl import ( console_entry_point as original_spotdl_entry_point, ) from spotdl.utils.ffmpeg import FFmpegError if format_ == "vorbis": # for spotdl only format_ = "ogg" cwd = os.getcwd() os.chdir(config.MAESTRO_DIR) def spotdl_entry_point(args): original_argv = sys.argv sys.argv = args try: original_spotdl_entry_point() except Exception as e: os.chdir(cwd) raise e finally: sys.argv = original_argv try: spotdl_entry_point( [ "download", path_, "--output", "{title}.{output-ext}", "--format", format_, "--headless", "--bitrate", audio_quality, ], ) except FFmpegError: click.secho( "FFmpeg is not installed: you can install it globally or run 'maestro download-ffmpeg' to download it internally.", fg="red", ) return paths = [] for fname in os.listdir(config.MAESTRO_DIR): for f in config.EXTS: if fname.endswith(f): raw_path = os.path.join(config.MAESTRO_DIR, fname) sanitized_path = raw_path.replace("|", "-") os.rename(raw_path, sanitized_path) paths.append(sanitized_path) if fname.endswith(".part"): # delete incomplete downloads os.remove(os.path.join(config.MAESTRO_DIR, fname)) move_ = True if paths is None: # not downloading from YouTube or Spotify if os.path.isdir(path_): # get all songs to be added paths = [] if recurse: for dirpath, _, fnames in os.walk(path_): for fname in fnames: if os.path.splitext(fname)[1].lower() in config.EXTS: paths.append(os.path.join(dirpath, fname)) else: for fname in os.listdir(path_): if os.path.splitext(fname)[1].lower() in config.EXTS: full_path = os.path.join(path_, fname) if os.path.isfile(full_path): paths.append(full_path) if len(paths) == 0: click.secho( f"No songs found in '{path_}'.", fg="red", ) return else: paths = [path_] if not paths: click.secho("No songs to add.", fg="red") return if name is not None: # renaming if len(paths) > 1: click.secho( "Cannot pass '-n/--name' option when adding multiple songs.", fg="yellow", ) ext = os.path.splitext(paths[0])[1].lower() if not os.path.isdir(paths[0]) and ext not in config.EXTS: click.secho(f"'{ext}' is not supported.", fg="red") return new_path = os.path.join(config.MAESTRO_DIR, name + ext) # move/copy to config.MAESTRO_DIR (avoid name conflicts) if move_: move(paths[0], new_path) else: copy(paths[0], new_path) paths = [new_path] move_ = True # always move (from temp loc in config.MAESTRO_DIR) if renaming if metadata_pairs is not None: # convert from "key:value|key:value" to [("key", "value")] metadata_pairs = [ tuple(pair.strip().split(":")) for pair in metadata_pairs.split("|") ] keys_to_ignore = set() for key, value in metadata_pairs: if key not in config.METADATA_KEYS or key.startswith("#"): click.secho( f"'{key}' is not a valid editable metadata key.", fg="red" ) keys_to_ignore.add(key) elif key == "artist" and artist is not None: keys_to_ignore.add(key) elif key == "album" and album is not None: keys_to_ignore.add(key) elif key == "albumartist" and album_artist is not None: keys_to_ignore.add(key) metadata_pairs = list( filter(lambda t: t[0] not in keys_to_ignore, metadata_pairs) ) abc_opts = list( filter( lambda t: t[1] is not None, [ ("artist", artist), ("album", album), ("albumartist", album_artist), ], ) ) if abc_opts: metadata_pairs = metadata_pairs or [] metadata_pairs.extend(abc_opts) for path in paths: ext = os.path.splitext(path)[1].lower() if not os.path.isdir(path) and ext not in config.EXTS: click.secho(f"'{ext}' is not supported.", fg="red") continue song_fname = os.path.split(path)[1] song_title = os.path.splitext(song_fname)[0] dest_path = os.path.join(config.settings["song_directory"], song_fname) for song in helpers.SONGS: if song.song_title == song_title: if skip_dupes: click.secho( f"Song with name '{song_title}' already exists, skipping.", fg="yellow", ) os.remove(path) else: click.secho( f"Song with name '{song_title}' already exists, 'copy' will be appended to the song name.", fg="yellow", ) song_fname = song_title + " copy" + ext dest_path = os.path.join( config.settings["song_directory"], song_fname ) break if move_: move(path, dest_path) else: copy(path, dest_path) song = helpers.SONG_DATA.add_song(dest_path, tags) if metadata_pairs is not None: for path in paths: for key, value in metadata_pairs: song.set_metadata(key, value) if lyrics: import syncedlyrics try: # pylint: disable=unexpected-keyword-arg lyrics = syncedlyrics.search( f"{song.artist} - {song_title}", allow_plain_format=True ) except TypeError as e: print_to_logfile( f"TypeError with allow_plain_format=True in syncedlyrics.search: {e}" ) try: lyrics = syncedlyrics.search(song_title) except Exception as e: # pylint: disable=redefined-outer-name click.secho( f'Failed to download lyrics for "{song_title}": {e}', fg="red", ) except Exception as e: click.secho( f'Failed to download lyrics for "{song_title}": {e}', fg="red", ) else: if lyrics: click.secho( f'Downloaded lyrics for "{song_title}".', fg="green" ) song.raw_lyrics = lyrics else: click.secho( f'No lyrics found for "{song_title}".', fg="yellow" ) if not tags: tags_string = "" elif len(tags) == 1: tags_string = f" and tag '{tags[0]}'" else: tags_string = f" and tags {', '.join([repr(tag) for tag in tags])}" click.secho( f"Added song '{song.song_file}' with ID {song.song_id}" + tags_string + f" and metadata (artist: {song.artist}, album: {song.album}, albumartist: {song.album_artist}).", fg="green", ) @cli.command() @click.argument("args", required=True, nargs=-1) @click.option( "-F/-nF", "--force/--no-force", default=False, help="Skip confirmation prompt(s).", ) @click.option( "-T/-nT", "--tag/--no-tag", default=False, help="If passed, treat all arguments as tags, deleting every ocurrence of each tag.", ) def remove(args, force, tag): """Remove tag(s) or song(s).""" if not tag: songs: set[helpers.Song] = {helpers.CLICK_SONG(v) for v in args} if not force: char = input( f"Are you sure you want to delete {helpers.pluralize(len(songs), 'song')}? [y/n] " ) if char.lower() != "y": print("Did not delete.") return for song in songs: if os.path.exists(song.song_path): os.remove(song.song_path) elif not force: click.secho( f"Warning: Song file '{song.song_path}' (ID {song.song_id}) not found. Would you still like to delete the song from the database? [y/n] ", fg="yellow", nl=False, ) if input().lower() != "y": click.echo( f'Skipping song "{song.song_title}" (ID {song.song_id}).' ) continue click.secho( f'Removed song "{song.song_title}" (ID {song.song_id}).', fg="green", ) song.remove_from_data() else: tags_to_remove = set(args) if not force: char = input( f"Are you sure you want to delete {helpers.pluralize(len(tags_to_remove), 'tag')}? [y/n] " ) if char.lower() != "y": print("Did not delete.") return for song in helpers.SONGS: song.tags -= tags_to_remove click.secho( f"Deleted all occurrences of {helpers.pluralize(len(tags_to_remove), 'tag')}.", fg="green", ) @cli.command(name="tag") @click.argument("songs", type=helpers.CLICK_SONG, required=True, nargs=-1) @click.option( "-t", "--tag", "tags", help="Tags to add.", multiple=True, ) def tag_(songs, tags): """Add tags to songs.""" if tags: tags = set(tags) for song in songs: song.tags |= tags click.secho( f"Added {helpers.pluralize(len(tags), 'tag')} to {helpers.pluralize(len(songs), 'song')}.", fg="green", ) else: click.secho("No tags passed.", fg="red") @cli.command() @click.argument("songs", type=helpers.CLICK_SONG, required=True, nargs=-1) @click.option( "-t", "--tag", "tags", help="Tags to remove.", multiple=True, ) @click.option("-A/-nA", "--all/--no-all", "all_", default=False) @click.option("-F/-nF", "--force/--no-force", default=False) def untag(songs, tags, all_, force): """Remove tags from songs. Tags that a song doesn't have will be ignored. Passing the '-A/--all' flag will remove all tags from each song, unless TAGS is passed (in which case the flag is ignored). Prompts for confirmation unless the '-F/--force' flag is passed.""" if tags: tags = set(tags) for song in songs: song.tags -= tags click.secho( f"Removed any occurrences of {helpers.pluralize(len(tags), 'tag')} from {helpers.pluralize(len(songs), 'song')}.", fg="green", ) else: if not all_: click.secho( "No tags passed—to remove all tags, pass the '-A/--all' flag.", fg="red", ) else: if ( force or input( f"Are you sure you want to remove all tags from all {helpers.pluralize(len(songs), 'song')}? [y/n] " ).lower() == "y" ): for song in songs: song.tags.clear() click.secho( f"Removed all tags from all {helpers.pluralize(len(songs), 'song')}.", fg="green", ) @cli.command() @click.argument("tags", nargs=-1) @click.option( "-e", "--exclude-tags", "exclude_tags", help="Exclude songs with these tags.", multiple=True, ) @click.option( "-a", "--artist", "artists", help="Filter by artist.", multiple=True, ) @click.option( "-b", "--album", "albums", help="Filter by album.", multiple=True, ) @click.option( "-c", "--album-artist", "album_artists", help="Filter by album artist.", multiple=True, ) @click.option( "-s", "--shuffle", "shuffle_", type=click.IntRange(-1, None), default=0, help="How to shuffle the queue on the first run. -1: random shuffle, 0: no shuffle, any other integer N: random shuffle with the constraint that each song is no farther than N spots away from its starting position.", ) @click.option( "-r", "--reshuffle", "reshuffle", type=click.IntRange(-1, None), default=0, help="How to shuffle the queue on every run after the first. -1: random reshuffle, 0: no reshuffle, any other integer N: random reshuffle with the constraint that each song is no farther than N spots away from its previous position.", ) @click.option( "-R/-nR", "--reverse/--no-reverse", "reverse", default=False, help="Play songs in reverse (most recently added first).", ) @click.option( "-o", "--only", "only", type=helpers.CLICK_SONG, multiple=True, help="Play only this/these song(s) (can be passed multiple times, e.g. 'maestro play -o 1 -o 17'). TAGS arguments are ignored", ) @click.option( "-v", "--volume", "volume", type=click.IntRange(0, 100), default=100, show_default=True, ) @click.option( "-L/-nL", "--loop/--no-loop", "loop", default=False, help="Loop the queue.", ) @click.option( "-C/-nC", "--clips/--no-clips", "clips", default=False, help="Start in clip mode. Can be toggled with 'c'.", ) @click.option( "-D/-nD", "--discord/--no-discord", "discord", default=False, help="Discord rich presence. Ignored if required dependencies are not installed. Will fail silently and retry every time the song changes if Discord connection fails (e.g. Discord not open).", ) @click.option( "-M/-nM", "--match-all/--no-match-all", "match_all", default=False, help="Play songs that match all tags, not any.", ) @click.option( "-V/-nV", "--visualize/--no-visualize", "visualize", default=False, help="Visualize the song being played. Ignored if required dependencies are not installed.", ) @click.option( "-S/-nS", "--stream/--no-stream", "stream", default=False, help="Stream to maestro-music.vercel.app/listen-along/[USERNAME].", ) @click.option( "-Y/-nY", "--lyrics/--no-lyrics", "lyrics", default=False, help="Show lyrics.", ) @click.option( "-T/-nT", "--translated-lyrics/--no-translated-lyrics", "translated_lyrics", default=False, help="Show translated lyrics (ignored if '-Y/--lyrics' is not passed).", ) @click.option( "-X/-nX", "--combine-artists/--no-combine-artists", "combine_artists", default=True, is_flag=True, help="Count artists as album artists and vice versa.", ) def play( tags, exclude_tags, artists, albums, album_artists, shuffle_, reshuffle, reverse, only, volume, loop, clips, discord, match_all, visualize, stream, lyrics, translated_lyrics, combine_artists, ): """Play your songs. If tags are passed, any song matching any tag will be in your queue, unless the '-M/--match-all' flag is passed, in which case every tag must be matched. \b \x1b[1mSPACE\x1b[0m\tpause/play \x1b[1mb\x1b[0m\t\tgo [b]ack to previous song \x1b[1mr\x1b[0m\t\t[r]eplay song \x1b[1mn\x1b[0m\t\tskip to [n]ext song \x1b[1ml\x1b[0m\t\t[l]oop the current song once ('l' in status bar). press again to loop infinitely ('L' in status bar). press once again to turn off looping \x1b[1mc\x1b[0m\t\ttoggle [c]lip mode \x1b[1mv\x1b[0m\t\ttoggle [v]isualization \x1b[1mLEFT\x1b[0m\trewind 5s \x1b[1mRIGHT\x1b[0m\tfast forward 5s \x1b[1m[/-\x1b[0m\t\tdecrease volume \x1b[1m]/=\x1b[0m\t\tincrease volume (] has issues on Windows) \x1b[1mm\x1b[0m\t\t[m]ute/unmute \x1b[1me\x1b[0m\t\t[e]nd the song player after the current song finishes (indicator in status bar, 'e' to cancel) \x1b[1mq\x1b[0m\t\t[q]uit the song player immediately \x1b[1mUP/DOWN\x1b[0m\tto scroll through the queue/lyrics (mouse scrolling should also work) \x1b[1mSHIFT+UP/DOWN\x1b[0m\tmove the selected song up/down in the queue \x1b[1mENTER\x1b[0m\tplay the selected song/seek to selected lyric \x1b[1mp\x1b[0m\t\tsna[p] back to the currently [p]laying song/lyric \x1b[1mg\x1b[0m\t\tgo to the next pa[g]e/loop of the queue (ignored if not repeating queue) \x1b[1mBACKSPACE/DELETE\x1b[0m\tdelete the selected (not necessarily currently playing!) song from the queue \x1b[1md\x1b[0m\t\ttoggle [D]iscord rich presence \x1b[1ma\x1b[0m\t\t[a]dd a song to the end of the queue (opens a prompt to enter the song name or ID: ENTER to confirm, ESC to cancel) \x1b[1mi\x1b[0m\t\t[i]nsert a song in the queue after the selected song (opens a prompt like 'a') \x1b[1m,\x1b[0m\t\tadd ([comma]-separated) tag(s) to all songs in the queue. (opens a prompt like 'a') \x1b[1ms\x1b[0m\t\ttoggle [s]tream (streams to maestro-music.vercel.app/listen-along/[USERNAME]), requires login \x1b[1my\x1b[0m\t\ttoggle l[y]rics \x1b[1mt\x1b[0m\t\ttoggle [t]ranslated lyrics (if available, ignored if lyrics mode is off) \x1b[1m{/_\x1b[0m\t\tfocus playlist \x1b[1m}/+\x1b[0m\t\tfocus lyrics (} has issues on Windows) \x1b[1mSHIFT+LEFT/RIGHT[0m\tincrease/decrease width of lyrics window \x1b[1mo\x1b[0m\t\trel[o]ad song data (useful if you've changed e.g lyrics, tags, or metadata while playing) \x1b[1m?\x1b[0m\t\ttoggle this help message \x1b[1mf\x1b[0m\t\t[f]ind a song in the queue (opens a prompt like 'a') \b song color indicates mode: \x1b[1;34mblue\x1b[0m\t\tnormal \x1b[1;33myellow\x1b[0m\tlooping current song (once or repeatedly) \b progress bar color indicates status: \x1b[1;33myellow\x1b[0m\tnormal (or current song doesn't have a clip) \x1b[1;35mmagenta\x1b[0m\tplaying clip For the color vision deficient, both modes also have indicators in the status bar. """ playlist = [] exclude_tags = set(exclude_tags) if only: playlist = list(only) else: playlist.extend( helpers.filter_songs( set(tags), exclude_tags, artists, albums, album_artists, match_all, combine_artists, ) ) # song files not found songs_not_found: list[helpers.Song] = [] for i in range(len(playlist)): if not os.path.exists(playlist[i].song_path): songs_not_found.append(playlist[i]) playlist[i] = None playlist: list[helpers.Song] = sorted( list(filter(lambda song: song is not None, playlist)), key=lambda song: song.song_id, ) helpers.bounded_shuffle(playlist, shuffle_) if reverse: playlist.reverse() if not playlist: click.secho("No songs found matching criteria.", fg="red") else: from keyring.errors import NoKeyringError try: username = helpers.get_username() password = helpers.get_password() if stream: if username is None or password is None: if username is None: click.secho("Username not found.", fg="red") if password is None: click.secho("Password not found.", fg="red") click.secho( "Please log in using 'maestro login' to stream.", fg="red", ) return elif discord: if username is None or password is None: if username is None: click.secho("Username not found.", fg="yellow") if password is None: click.secho("Password not found.", fg="yellow") click.secho( "Log in using 'maestro login' to enable album art in the Discord rich presence.", fg="yellow", ) username = None password = None except NoKeyringError as e: if stream: click.secho( f"No keyring available. Cannot stream without login.\n{e}", fg="red", ) if discord: click.secho( f"No keyring available. Cannot show album art in Discord rich presence without login.\n{e}", fg="yellow", ) username = None password = None curses.wrapper( _play, playlist, volume, loop, clips, reshuffle, discord, visualize, stream, username, password, lyrics, translated_lyrics and lyrics, ) if songs_not_found: click.secho("Song files not found:", fg="red") for song in songs_not_found: click.secho(f"\t{song.song_path} (ID {song.song_id})", fg="red") @cli.command() @click.option( "-T/-nT", "--tag/--no-tag", "renaming_tag", default=False, help="If passed, rename tag instead of song (treat the arguments as tags).", ) # NOTE: original is not forced to be a int so that tags can be renamed @click.argument("original") @click.argument("new_name") def rename(original, new_name, renaming_tag): """ Rename a song or tag. Renames the song with the ID ORIGINAL to NEW_NAME. The extension of the song (e.g. '.wav', '.mp3') is preserved—do not include it in the name. If the '-T/--tag' flag is passed, treats ORIGINAL as a tag, renaming all ocurrences of it to NEW_NAME—doesn't check if the tag NEW_NAME already exists, so be careful! """ if not renaming_tag: original = helpers.CLICK_SONG(original) original_song_title = original.song_title original.song_title = new_name click.secho( f'Renamed song "{original_song_title}" (ID: {original.song_id}) to "{new_name}".', fg="green", ) else: for song in helpers.SONGS: if original in song.tags: song.tags.remove(original) song.tags.add(new_name) click.secho( f"Renamed all ocurrences of tag '{original}' to '{new_name}'.", fg="green", ) @cli.command() @click.argument("phrase") @click.option( "-T/-nT", "--tag/--no-tag", "searching_for_tags", default=False, help="Searches for matching tags instead of song names.", ) def search(phrase, searching_for_tags): """Search for song names (or tags with '-T/--tag' flag). All songs/tags starting with PHRASE will appear before songs/tags containing but not starting with PHRASE. This search is case-insensitive.""" if not searching_for_tags: results = helpers.search_song(phrase) if not any(results): click.secho("No results found.", fg="red") return for song in sum(results, []): helpers.print_entry(song, highlight=phrase) num_results = len(results[0]) + len(results[1]) + len(results[2]) click.secho( f"Found {helpers.pluralize(num_results, 'song')}.", fg="green", ) else: phrase = phrase.lower() results = ( set(), set(), set(), ) # is, starts, contains but does not start for song in helpers.SONGS: for tag in song.tags: tag_lower = tag.lower() if tag_lower == phrase: results[0].add(tag) elif tag_lower.startswith(phrase): results[1].add(tag) elif phrase in tag_lower: results[2].add(tag) if not any(results): click.secho("No results found.", fg="red") return for tag in sum(map(list, results), []): tag = tag.replace(phrase, click.style(phrase, fg="yellow"), 1) click.echo(tag) num_results = len(results[0]) + len(results[1]) + len(results[2]) click.secho( f"Found {helpers.pluralize(num_results, 'tag')}.", fg="green" ) @cli.command(name="list") @click.argument("search_tags", metavar="TAGS", nargs=-1) @click.option( "-e", "--exclude-tags", "exclude_tags", multiple=True, help="Exclude songs/tags matching these tags.", ) @click.option( "-s", "--sort", "sort_", type=click.Choice( ( "none", "name", "n", "secs-listened", "s", "duration", "d", "times-listened", "t", "artist", "a", "album", "b", "album-artist", "c", ) ), help="Sort by song name, seconds listened, duration or times listened (seconds listened divided by song duration). Increasing order, default is by ID for songs and no order for tags.", default="none", show_default=True, ) @click.option( "-R/-nR", "--reverse/--no-reverse", "reverse_", default=False, help="Reverse the sorting order (decreasing instead of increasing). For example, 'maestro list -s s -R -t 5' will show the top 5 most-listened songs by seconds listened.", ) @click.option( "-T/-nT", "--list-tags/--no-list-tags", "listing_tags", default=False, help="List tags matching TAGS instead of songs.", ) @click.option( "-y", "--year", "year", help="Show time listened for a specific year, instead of the total. Passing 'cur' will show the time listened for the current year.", ) @click.option("-t", "--top", "top", type=int, help="Show the top n songs/tags.") @click.option( "-M/-nM", "--match-all/--no-match-all", "match_all", default=False, help="Shows songs that match all criteria instead of any criteria. Ignored if '-t/--tag' is passed.", ) @click.option( "-a", "--artist", "artists", multiple=True, help="Filter by artist(s) (fuzzy search); can pass multiple.", ) @click.option( "-b", "--album", "albums", multiple=True, help="Filter by album (fuzzy search); can pass multiple.", ) @click.option( "-c", "--album-artist", "album_artists", multiple=True, help="Filter by album artist (fuzzy search); can pass multiple.", ) @click.option( "-A/-nA", "--list-artists/--no-list-artists", "listing_artists", is_flag=True, help="Show artists instead of songs.", ) @click.option( "-B/-nB", "--list-albums/--no-list-albums", "listing_albums", is_flag=True, help="Show albums instead of songs.", ) @click.option( "-C/-nC", "--list-album-artist/--no-list-album-artist", "listing_album_artists", is_flag=True, help="Show album artists instead of songs.", ) @click.option( "-X/-nX", "--combine-artists/--no-combine-artists", "combine_artists", is_flag=True, default=True, help="Count artists as album artists and vice versa. Ignored if neither '-A/--list-artists' nor '-C/--list-album-artists' is passed.", ) def list_( search_tags, exclude_tags, listing_tags, year, sort_, top, reverse_, match_all, artists, albums, album_artists, listing_artists, listing_albums, listing_album_artists, combine_artists, ): """List songs or tags. Output format: ID, name, duration, listen time, times listened, [clip-start, clip-end] if clip exists, comma-separated tags if any If the '-T/--list-tags' flag is passed, tags will be listed instead of songs. If any of the '-A/--list-artists', '-B/--list-albums', or '-C/--list-album-artists' flags are passed, the respective fields will be listed instead of songs. Output format: tag/artist/album/album artist, duration, listen time, times listened """ if top is not None: if top < 1: click.secho( "The option '-t/--top' must be a positive number.", fg="red" ) return if ( sum( [ listing_artists, listing_albums, listing_album_artists, listing_tags, ] ) > 1 ): click.secho( "Only one of '-A/--show-artist', '-B/--show-album', '-C/--show-album-artist', or '-T/--tag' can be passed.", fg="red", ) return if year is None: year = "total" elif year == "cur": year = config.CUR_YEAR else: if not year.isdigit(): click.secho("Year must be a number or 'cur'.", fg="red") return year = int(year) search_tags = set(search_tags) exclude_tags = set(exclude_tags) num_lines = 0 if listing_tags: search_tags -= exclude_tags tags = defaultdict(lambda: [0, 0]) for song in helpers.SONGS: search_criteria = ( ( ( any( artist.lower() in song.artist.lower() + ( f", {song.album_artist.lower()}" if combine_artists else "" ) for artist in artists ) ), artists, ), ( ( any( album.lower() in song.album.lower() for album in albums ) ), albums, ), ( ( any( album_artist.lower() in song.album_artist.lower() + ( ", " + song.artist.lower() if combine_artists else "" ) for album_artist in album_artists ) ), album_artists, ), ) search_criteria = tuple( c[0] for c in filter(lambda t: t[1], search_criteria) ) if match_all: search_criteria = not search_criteria or all(search_criteria) else: search_criteria = not search_criteria or any(search_criteria) for tag in song.tags: if (not search_tags or tag in search_tags) and search_criteria: tags[tag][0] += song.listen_times.get(year, 0) tags[tag][1] += song.duration tags = list(tags.items()) if sort_ != "none": if sort_ in ("name", "n"): sort_key = lambda t: t[0].lower() elif sort_ in ("secs-listened", "s"): sort_key = lambda t: t[1][0] elif sort_ in ("duration", "d"): sort_key = lambda t: t[1][1] elif sort_ in ("times-listened", "t"): sort_key = lambda t: t[1][0] / t[1][1] tags.sort(key=sort_key) # pylint: disable=used-before-assignment if reverse_: tags.reverse() for tag, (listen_time, total_duration) in tags: click.echo( f"{tag} {click.style(helpers.format_seconds(total_duration, show_decimal=True, digital=False), fg='bright_black')} {click.style(helpers.format_seconds(listen_time, show_decimal=True, digital=False), fg='yellow')} {click.style('%.2f'%(listen_time/total_duration), fg='green')}" ) num_lines += 1 if top is not None and num_lines == top: break return if listing_artists or listing_albums or listing_album_artists: abcs = defaultdict(lambda: [0, 0]) if combine_artists: artists += album_artists album_artists = artists for song in helpers.filter_songs( search_tags, exclude_tags, artists, albums, album_artists, match_all, combine_artists, ): if listing_artists or (combine_artists and listing_album_artists): for artist in song.artist.split(", "): if artists: for search_artist in artists: if search_artist.lower() in artist.lower(): abcs[artist][0] += song.listen_times.get( year, 0 ) abcs[artist][1] += song.duration break else: abcs[artist][0] += song.listen_times.get(year, 0) abcs[artist][1] += song.duration if listing_albums: if albums: for album in albums: if album.lower() in song.album.lower(): abcs[album][0] += song.listen_times.get(year, 0) abcs[album][1] += song.duration else: abcs[song.album][0] += song.listen_times.get(year, 0) abcs[song.album][1] += song.duration if listing_album_artists or (combine_artists and listing_artists): for album_artist in song.album_artist.split(", "): if album_artists: for search_album_artist in album_artists: if ( search_album_artist.lower() in album_artist.lower() ): abcs[album_artist][0] += song.listen_times.get( year, 0 ) abcs[album_artist][1] += song.duration break else: abcs[album_artist][0] += song.listen_times.get(year, 0) abcs[album_artist][1] += song.duration abcs = list(abcs.items()) if sort_ != "none": if sort_ in ("name", "n"): sort_key = lambda t: t[0].lower() elif sort_ in ("secs-listened", "s"): sort_key = lambda t: t[1][0] elif sort_ in ("duration", "d"): sort_key = lambda t: t[1][1] elif sort_ in ("times-listened", "t"): sort_key = lambda t: t[1][0] / t[1][1] abcs.sort(key=sort_key) if reverse_: abcs.reverse() for abc, (listen_time, total_duration) in abcs: click.echo( f"{abc} {click.style(helpers.format_seconds(total_duration, show_decimal=True, digital=False), fg='bright_black')} {click.style(helpers.format_seconds(listen_time, show_decimal=True, digital=False), fg='yellow')} {click.style('%.2f'%(listen_time/total_duration), fg='green')}" ) num_lines += 1 if top is not None and num_lines == top: break return songs = helpers.filter_songs( search_tags, exclude_tags, artists, albums, album_artists, match_all, combine_artists, ) if sort_ == "none": sort_key = lambda song: song.song_id if sort_ in ("name", "n"): sort_key = lambda song: song.song_title.lower() elif sort_ in ("secs-listened", "s"): sort_key = lambda song: song.listen_times.get(year, 0) elif sort_ in ("duration", "d"): sort_key = lambda song: song.duration elif sort_ in ("times-listened", "t"): sort_key = lambda song: song.listen_times.get(year, 0) / song.duration songs.sort(key=sort_key, reverse=reverse_) no_results = True for song in songs: helpers.print_entry(song, year=year) num_lines += 1 no_results = False if top is not None and num_lines == top: break if no_results and not any([search_tags, artists, albums, album_artists]): click.secho( "No songs found. Use 'maestro add' to add a song.", fg="red" ) elif no_results: click.secho("No songs found matching criteria.", fg="red") @cli.command() @click.option( "-y", "--year", "year", help="Show time listened for a specific year, instead of the total. Passing 'cur' will show the time listened for the current year.", ) @click.argument("songs", type=helpers.CLICK_SONG, nargs=-1, required=True) def entry(songs, year): """ View the details for specific song(s). \b Output format: ID, name, duration, listen time, times listened, [clip-start, clip-end] if clip exists, comma-separated tags if any artist - album (album artist) """ if year is None: year = "total" elif year == "cur": year = config.CUR_YEAR else: if not year.isdigit(): click.secho("Year must be a number.", fg="red") return year = int(year) for song in songs: helpers.print_entry(song, year=year) @cli.command() @click.argument("song", required=True) @click.option( "-T/-nT", "--title/--no-title", "title", default=False, help="Treat SONG as a song title to search on YT Music instead of an existing maestro song.", ) def recommend(song, title): """ Get recommendations from YT Music based on song titles. Recommends songs (possibly explicit) using the YouTube Music API that are similar to SONG to listen to. If the '-T/--title' flag is passed, maestro directly searches up SONG on YouTube Music, rather than trying to find a matching entry in your maestro songs.""" try: from ytmusicapi import YTMusic except ImportError: click.secho( "The 'recommend' command requires the 'ytmusicapi' package to be installed. Run 'pip install ytmusicapi' to install it.", fg="red", ) return ytmusic = YTMusic() if title: results = ytmusic.search(song, filter="songs") else: song = helpers.CLICK_SONG(song) results = ytmusic.search( os.path.splitext(song.song_title)[0], filter="songs" ) yt_music_playlist = ytmusic.get_watch_playlist(results[0]["videoId"]) click.echo("Recommendations for ", nl=False) click.secho( yt_music_playlist["tracks"][0]["title"] + " ", fg="blue", nl=False, ) click.secho( f"(https://music.youtube.com/watch?v={yt_music_playlist['tracks'][0]['videoId']})", fg="bright_black", nl=False, ) click.echo(":") for track in yt_music_playlist["tracks"][1:]: click.secho(track["title"] + " ", fg="blue", bold=True, nl=False) click.secho( f"https://music.youtube.com/watch?v={track['videoId']}", fg="bright_black", ) @cli.command(name="clips") @click.argument("songs", required=True, type=helpers.CLICK_SONG, nargs=-1) def clips_(songs: tuple[helpers.Song]): """ List the clips for song(s). Output format: clip name: start time, end time The set clip for the song is bolded and highlighted in magenta. """ for song in songs: if not song.clips: click.secho( f'No clips for "{song.song_title}" (ID {song.song_id}).', ) continue click.echo("Clips for ", nl=False) click.secho(song.song_title, fg="blue", bold=True, nl=False) click.echo(f" (ID {song.song_id}):") def style_clip_name(clip_name, song): if clip_name == song.set_clip: return click.style(clip_name, bold=True, fg="magenta") else: return click.style(clip_name) if "default" in song.clips: click.echo( f"\t{style_clip_name('default', song)}: {song.clips['default'][0]}, {song.clips['default'][1]}" ) for clip_name, (start, end) in song.clips.items(): if clip_name == "default": continue click.echo(f"\t{style_clip_name(clip_name, song)}: {start}, {end}") @cli.command(name="clip") @click.argument("song", required=True, type=helpers.CLICK_SONG) @click.argument("start", required=False, type=float, default=None) @click.argument("end", required=False, type=float, default=None) @click.option( "-n", "--name", "name", help="Name of the clip.", default="default", ) @click.option( "-E/-nE", "--editor/--no-editor", "editor", default=True, help="Open the clip editor, even if START and END are passed. Ignored if neither START nor END are passed.", ) def clip_(song: helpers.Song, name, start, end, editor): """ Create or edit a clip for a song. Sets the clip (with name passed to '-n/--name' or 'default' if not passed) for SONG to the time range START to END (in seconds). If END is not passed, the clip will be from START to the end of the song. If neither START nor END are passed, a clip editor will be opened, in which you can move the start and end of the clip around using the arrow keys while listening to the song. If the '-E/--editor' flag is passed, the clip editor will be opened even if START and END are passed; this is useful if you want to fine-tune the clip. \b The editor starts out editing the start of the clip. \x1b[1mt\x1b[0m to toggle between editing the start and end of the clip. \x1b[1mLEFT/RIGHT\x1b[0m will move whichever clip end you are editing by 0.1 seconds, snap the current playback to that clip end (to exactly the clip start if editing start, end-1 if editing end), and pause. \x1b[1mSHIFT+LEFT/RIGHT\x1b[0m will move whichever clip end you are editing by 1 second, snap the current playback to that clip end, and pause. \x1b[1mSPACE\x1b[0m will play/pause. \x1b[1mENTER\x1b[0m will exit the editor and save the clip. \x1b[1mq\x1b[0m will exit the editor without saving the clip. """ if start is not None: if start < 0: click.secho("START must be a positive number.", fg="red") return if end is not None and end < 0: click.secho("END must be a positive number.", fg="red") return if start is None: # clip editor start, end = curses.wrapper(helpers.clip_editor, song, name) if start is None: click.secho( f"No change in clip '{name}' for \"{song.song_title}\" (ID {song.song_id}).", fg="green", ) return editor = False # hacky fix for end being past end of song sometimes end = min(end, song.duration) if end is None: end = song.duration if start > song.duration: click.secho("START must not be more than the song duration.", fg="red") return if end > song.duration: click.secho("END must not be more than the song duration.", fg="red") return if start > end: click.secho("START must not be more than END.", fg="red") return if editor: start, end = curses.wrapper(helpers.clip_editor, song, name, start, end) if start is None: click.secho( f"No change in clip '{name}' for \"{song.song_title}\" (ID {song.song_id}).", fg="green", ) return if name in song.clips: click.secho( "Modified ", nl=False, fg="green", ) else: click.secho( "Created ", nl=False, fg="green", ) click.secho( f'clip "{name}" for "{song.song_title}" (ID {song.song_id}): {start} to {end}.', fg="green", ) song.clips[name] = (start, end) @cli.command() @click.argument("songs", type=helpers.CLICK_SONG, nargs=-1, required=False) @click.option( "-n", "--name", "names", help="Name(s) of the clip(s) to remove.", multiple=True, ) @click.option( "-A/-nA", "--all/--no-all", "all_", default=False, help="Remove clips for all songs. Ignores SONG_IDS.", ) @click.option( "-F/-nF", "--force/--no-force", "force", default=False, help="Skip confirmation prompt.", ) def unclip(songs: tuple[helpers.Song], names, all_, force): """ Remove clips from song(s). Removes any clips with names passed to '-n/--name' from each song in SONGS. If no names are passed, removes the clip 'default' from each song. If the '-A/--all' flag is passed, at most one of SONGS or '-n/--name' must be passed. If name(s) are passed, all clips with those names will be removed. If SONGS are passed, all clips for each song will be removed. Prompts for confirmation unless '-F/--force' is passed. """ if all_: if songs and names: click.secho( "The '-A/--all' flag cannot be passed with both SONGS and '-n/--name'.", fg="red", ) return if songs: if not force: click.echo( f'Are you sure you want to remove all clips from {helpers.pluralize(len(songs), "song")}? This cannot be undone. [y/n] ', nl=False, ) if input().lower() != "y": return elif names: if not force: click.echo( f"Are you sure you want to remove all clips with names {', '.join(names)} for all songs? This cannot be undone. [y/n] ", nl=False, ) if input().lower() != "y": return songs = helpers.SONGS else: click.secho( "The '-A/--all' flag must be passed with either SONGS or '-n/--name'.", fg="red", ) return if not (songs or names): click.secho( "No songs or clip names passed—to remove all clips from all songs, pass the '-A/--all' flag.", fg="red", ) return if not (names or all_): names = ("default",) for song in songs: if not names: song.clips.clear() else: for name in names: if name in song.clips: del song.clips[name] if not names: click.secho( f"Removed all clips from {helpers.pluralize(len(songs), 'song')}.", fg="green", ) else: click.secho( f"Removed {helpers.pluralize(len(names), 'clip', False)} {', '.join(map(repr, names))} from {helpers.pluralize(len(songs), 'song')}.", fg="green", ) @cli.command(name="set-clip") @click.argument("songs", type=helpers.CLICK_SONG, nargs=-1, required=False) @click.argument("name", required=True) @click.option( "-F/-nF", "--force/--no-force", "force", default=False, help="Skip confirmation prompt.", ) def set_clip(songs: tuple[helpers.Song], name, force): """ Set the clip for song(s). Sets the clip for each song in SONGS to NAME; 'maestro play' will play this clip in clip mode. If no SONGS are passed, sets the clip for all songs. This prompts for confirmation unless '-F/--force' is passed. """ if not songs: songs = helpers.SONGS if not force: click.echo( f"Are you sure you want to set the clip for all {helpers.pluralize(len(songs), 'song')} to '{name}'? This cannot be undone. [y/n] ", nl=False, ) if input().lower() != "y": return for song in songs: song.set_clip = name click.secho( f"Set clip for {helpers.pluralize(len(songs), 'song')} to '{name}'.", fg="green", ) @cli.command() @click.argument("songs", type=helpers.CLICK_SONG, required=True, nargs=-1) @click.option("-m", "--metadata", "pairs", type=str, required=False) @click.option( "-a", "--artist", help="Artist of the song. Overrides '-m/--metadata'.", ) @click.option( "-b", "--album", help="Album of the song. Overrides '-m/--metadata'.", ) @click.option( "-c", "--album-artist", help="Album artist of the song. Overrides '-m/--metadata'.", ) def metadata(songs: tuple[helpers.Song], pairs, artist, album, album_artist): """ View or edit the metadata for songs. If no options are passed, prints the metadata for each song in SONGS. If '-m/--metadata' is passed, sets the metadata for the each song in SONGS to the key-value pairs in -m/--metadata. The option should be passed as a string of the form 'key1:value1|key2:value2|...'. Possible editable metadata keys are: album, albumartist, artist, comment, compilation, composer, discnumber, genre, lyrics, totaldiscs, totaltracks, tracknumber, tracktitle, year, isrc """ valid_pairs = None if any([artist, album, album_artist, pairs]): if pairs: pairs = [ tuple(pair.strip().split(":", 1)) for pair in pairs.split("|") ] else: pairs = [] valid_pairs = set(pairs[:]) for key, value in pairs: if ( key not in config.METADATA_KEYS or key.startswith("#") or key == "artwork" ): click.secho( f"'{key}' is not a valid editable metadata key.", fg="yellow", ) valid_pairs.remove((key, value)) elif key == "artist" and artist is not None: valid_pairs.remove((key, value)) elif key == "album" and album is not None: valid_pairs.remove((key, value)) elif key == "albumartist" and album_artist is not None: valid_pairs.remove((key, value)) if artist is not None: valid_pairs.add(("artist", artist)) if album is not None: valid_pairs.add(("album", album)) if album_artist is not None: valid_pairs.add(("albumartist", album_artist)) if not valid_pairs: click.secho("No valid metadata keys passed.", fg="red") return click.secho(f"Valid pairs: {valid_pairs}", fg="green") for song in songs: if not os.path.exists(song.song_path): click.secho( f'Song file "{song.song_title}" (ID {song.song_id}) not found.', fg="red", ) continue if valid_pairs: for key, value in valid_pairs: song.set_metadata(key, value) else: click.echo("Metadata for ", nl=False) click.secho(song.song_title, fg="blue", bold=True, nl=False) click.echo(f" (ID {song.song_id}):") for key in config.METADATA_KEYS: try: click.echo( f"\t{key if not key.startswith('#') else key[1:]}: {song.get_metadata(key)}" ) except KeyError: pass @cli.command(name="dir") @click.argument("directory", type=click.Path(file_okay=False), required=False) def dir_(directory): """ Change the directory where maestro looks for songs. NOTE: This does not move any songs. It only changes where maestro looks for songs. You will have to move the songs yourself. If no argument is passed, prints the current directory. """ if directory is None: click.echo(config.settings["song_directory"]) return if not os.path.exists(directory): os.makedirs(directory) with open(config.SETTINGS_FILE, "rb+") as settings_file: settings = msgspec.json.decode(settings_file.read()) settings["song_directory"] = directory settings_file.seek(0) settings_file.write(msgspec.json.encode(settings)) settings_file.truncate() click.secho(f"Changed song directory to {directory}.", fg="green") @cli.command(name="version") def version(): """ Currently installed maestro version (PyPI version of the `maestro-music` package). """ click.echo(f"{VERSION}") @cli.command(name="login") @click.argument("username", required=False, default=None, type=str) def login(username): """ Log in to maestro. Required for listen-along streaming and album covers in the Discord rich presence. The USERNAME argument is optional; if not passed, you will be prompted for your username. Will log out from existing username if a new one is passed. """ helpers.login(username) @cli.command(name="logout") @click.option( "-f/-nF", "--force/--no-force", "force", default=False, help="Skip confirmation prompt.", ) def logout(force): """ Log out of maestro. """ if not force: click.echo("Are you sure you want to log out? [y/n] ", nl=False) if input().lower() != "y": return import keyring try: username = keyring.get_password("maestro-music", "username") keyring.delete_password("maestro-music", "username") click.secho(f"Logged out user '{username}'.", fg="green") except keyring.errors.PasswordDeleteError: click.secho("No user logged in.", fg="red") try: keyring.delete_password("maestro-music", "password") click.secho("Deleted password.", fg="green") except keyring.errors.PasswordDeleteError: click.secho("No password saved.", fg="red") @cli.command(name="signup") @click.argument("username", required=False, default=None, type=str) @click.option("-l/-nL", "--login/--no-login", "login_", default=True) def signup(username, login_): """ Create a new maestro account. Required for listen-along streaming and album covers in the Discord rich presence. The USERNAME argument is optional; if not passed, you will be prompted for a username. If the '-nL/--no-login' flag is passed, you will not be logged in after creating the account. You can still log in later using 'maestro login'. """ helpers.signup(username, None, login_) @cli.command(name="clear-logs") def clear_logs(): """ Clear all logs stored by maestro. """ try: os.remove(config.LOGFILE) try: rmtree(config.OLD_LOG_DIR) except FileNotFoundError: pass click.secho("Cleared logs.", fg="green") except FileNotFoundError: click.secho("No logs found.", fg="yellow") @cli.command(name="download-ffmpeg") def download_ffmpeg(): """ Download ffmpeg locally. A global or local FFmpeg install is required for clip editing. """ from spotdl.utils.ffmpeg import download_ffmpeg as spotdl_download_ffmpeg spotdl_download_ffmpeg() @cli.command() def migrate(): """ Migrate the maestro database to the latest version. """ if not os.path.exists(config.OLD_SONGS_INFO_PATH): click.secho( "Legacy files not found, no migration necessary.", fg="yellow" ) return d = {} with open(config.OLD_SONGS_INFO_PATH, "r", encoding="utf-8") as f: for line in f.readlines(): if not line.strip(): continue details = line.split("|") song_id = details[0] song_file = details[1] tags = details[2].split(",") clip = list(map(float, details[3].split())) if details[3] else None d[song_id] = { "filename": song_file, "tags": tags, "clips": ( { "default": clip, } if clip else {} ), "stats": {}, } for path in os.listdir(config.OLD_STATS_DIR): if not path.endswith(".txt"): continue with open( os.path.join(config.OLD_STATS_DIR, path), "r", encoding="utf-8" ) as f: year = os.path.splitext(path)[0] if year.isdigit(): year = int(year) for line in f.readlines(): if not line.strip(): continue details = line.split("|") song_id = details[0] stats = float(details[1]) d[song_id]["stats"][year] = stats import safer with safer.open(config.SONGS_INFO_PATH, "wb") as f: f.write(msgspec.json.encode(d)) # move old files to old-data old_data_dir = os.path.join(config.MAESTRO_DIR, "old-data/") os.makedirs(old_data_dir, exist_ok=True) move(config.OLD_SONGS_INFO_PATH, old_data_dir) move(config.OLD_STATS_DIR, old_data_dir) click.secho( f"Legacy files '{config.OLD_SONGS_INFO_PATH}' and '{config.OLD_STATS_DIR}' were moved to '{old_data_dir}', which can be safely deleted after confirming that all song data was moved to '{config.SONGS_INFO_PATH}'.", fg="green", ) @cli.command(name="format-data") @click.argument("indent", type=int, default=4) def format_data(indent: int): """ Format the song data file to be more human-readable. The INDENT argument specifies the number of spaces to indent each level of the JSON file. """ import safer with open(config.SONGS_INFO_PATH, "r", encoding="utf-8") as f: data = msgspec.json.decode(f.read()) with safer.open(config.SONGS_INFO_PATH, "wb") as f: f.write(msgspec.json.format(msgspec.json.encode(data), indent=indent)) click.secho( f"Formatted '{config.SONGS_INFO_PATH}' with an indent of {indent}.", fg="green", ) @cli.command(name="lyrics") @click.argument("songs", required=False, type=helpers.CLICK_SONG, nargs=-1) @click.option( "-U/-nU", "--update/--no-update", "updating", default=False, help="Update lyrics for song(s).", ) @click.option( "-R/-nR", "--remove/--no-remove", "removing", default=False, help="Remove lyrics for song(s).", ) @click.option( "-O/-nO", "--override/--no-override", "override", default=True, ) @click.option( "-T/-nT", "--translated/--no-translated", "translated", default=True, ) @click.option( "-n", "--name", "name", help="Name to use instead of the song title." ) @click.option( "-o", "--offset", "offset", type=float, default=0, help="Offset lyrics (secs); positive means later, negative means earlier.", ) @click.option( "-F/-nF", "--force/--no-force", "force", default=False, help="Skip confirmation prompt.", ) @click.option( "-A/-nA", "--all/--no-all", "all_", default=False, help="Update lyrics for all songs.", ) def lyrics_( songs: tuple[helpers.Song], updating, removing, override, translated, name, offset, force, all_, ): """ Display or update the lyrics for song(s). Shows the overridden lyrics if they exist instead of the embedded metadata lyrics. To view the embedded lyrics, you can use 'maestro metadata'. Any translated lyrics are also shown by default if available; turn them off with '-nT/--no-translated'. Updates (embedded metadata, not any overridden) lyrics if '-U/--update' is passed, downloading synced lyrics if available. If the song has lyrics, prompts for confirmation before updating unless '-F/--force' is passed. If '-R/--remove' is passed, removes the override lyrics for each song (prompts for confirmation). '-R/--remove' can only remove the overridden lyrics, not the embedded lyrics. Use 'maestro metadata -m "lyrics:"' to remove embedded lyrics. If '-A/--all' is passed, update/remove/view lyrics for all songs. Errors if SONGS are passed with '-A/--all'. Prompts for confirmation unless '-F/--force' is passed. Ignored if not updating. If the '-n/--name' flag is passed, uses NAME as the song title to search for lyrics instead of the actual song title. Ignored if not updating. If the '-o/--offset' flag is passed, offsets the lyrics by OFFSET seconds. Updates override lyrics if they exist by default; pass '-O/--no-override' to update only the embedded lyrics instead. Ignored if not updating. """ if updating and removing: click.secho( "Cannot pass both '-U/--update' and '-R/--remove'.", fg="red", ) return if len(songs) > 1 and name: click.secho( "Cannot pass a name when updating lyrics for multiple songs.", fg="red", ) return if not (songs or all_): click.secho( "No songs passed. Use '-A/--all' to update lyrics for all songs.", fg="red", ) return if songs and all_: click.secho( "Cannot pass songs with '-A/--all'.", fg="red", ) return if all_: if not force: click.echo( f"Are you sure you want to {'update override' if updating else ('remove override' if removing else ('show' if not offset else 'offset the'))} lyrics for all songs? [y/n] ", nl=False, ) if input().lower() != "y": return songs = helpers.SONGS force = True if not (updating or removing): # viewing if offset: songs_valid = set(songs) for song in songs: parsed_lyrics = song.parsed_lyrics parsed_override_lyrics = song.parsed_override_lyrics embedded_is_timed = helpers.is_timed_lyrics(parsed_lyrics) override_is_timed = helpers.is_timed_lyrics( parsed_override_lyrics ) if not (embedded_is_timed or override_is_timed): click.secho( f'No timed lyrics found for "{song.song_title}" (ID: {song.song_id}).', fg="yellow", ) songs_valid.remove(song) continue if embedded_is_timed: parsed_lyrics.offset += int(offset * 1000) song.raw_lyrics = parsed_lyrics.toLRC() if override_is_timed: parsed_override_lyrics.offset += int(offset * 1000) song.raw_override_lyrics = parsed_override_lyrics.toLRC() click.secho( f"Offset the lyrics for {helpers.pluralize(len(songs_valid), 'song')} by {offset} seconds.", fg="green", ) return for song in songs: show_lyrics = True lyrics = song.parsed_override_lyrics or song.parsed_lyrics translated_lyrics = song.parsed_translated_lyrics if lyrics is None and translated_lyrics is None: click.secho( f'No lyrics found for "{song.song_title}" (ID: {song.song_id}).', fg="yellow", ) show_lyrics = False show_translated = translated and translated_lyrics is not None if show_lyrics and show_translated: click.echo("Lyrics for ", nl=False) click.secho(song.song_title, fg="blue", bold=True, nl=False) click.echo(f" (ID {song.song_id}):") is_timed = helpers.is_timed_lyrics(lyrics) for i in range(len(lyrics)): time_str = "" if is_timed: time_str = f"\t[{helpers.format_seconds(lyrics[i].time, show_decimal=True)}] " click.secho( f"{time_str}{lyrics[i].text}", fg="cyan", ) else: click.secho( "\t" + lyrics[i], fg="cyan", ) if i < len(translated_lyrics): click.echo( "\t" + " " * len(time_str) + helpers.get_lyric(translated_lyrics[i]), ) elif show_lyrics: helpers.display_lyrics(lyrics, song) elif show_translated: helpers.display_lyrics( translated_lyrics, song, "translated", ) return import syncedlyrics for song in songs: if removing: if override: if not force: click.echo( f'Are you sure you want to remove override lyrics for "{song.song_title}" (ID {song.song_id})? This cannot be undone. [y/n] ', nl=False, ) if force or input().lower() == "y": song.raw_override_lyrics = None click.secho( f'Removed override lyrics for "{song.song_title}" (ID {song.song_id}).', fg="green", ) if translated: if not force: click.echo( f'Are you sure you want to remove translated lyrics for "{song.song_title}" (ID {song.song_id})? This cannot be undone. [y/n] ', nl=False, ) if force or input().lower() == "y": song.raw_translated_lyrics = None click.secho( f'Removed translated lyrics for "{song.song_title}" (ID {song.song_id}).', fg="green", ) continue song_title = name or f"{song.artist} - {song.song_title}" if song.raw_lyrics: if not force: click.echo( f'Lyrics already exist for "{song.song_title}" (ID {song.song_id}). Overwrite? [y/n] ', nl=False, ) if input().lower() != "y": continue try: # pylint: disable=unexpected-keyword-arg lyrics = syncedlyrics.search(song_title, allow_plain_format=True) except TypeError as e: print_to_logfile( f"TypeError with allow_plain_format=True in syncedlyrics.search: {e}" ) try: lyrics = syncedlyrics.search(song_title) except Exception as f: click.secho( f'Failed to download lyrics for "{song_title}": {f}', fg="red", ) except Exception as e: click.secho( f'Failed to download lyrics for "{song_title}": {e}', fg="red", ) else: if lyrics: click.secho( f'Downloaded lyrics for "{song.song_title}" (ID {song.song_id})' + (f' using name "{name}"' if name else "") + ".", fg="green", ) song.raw_lyrics = lyrics else: click.secho(f'No lyrics found for "{song_title}".', fg="yellow") @cli.command(name="translit") @click.argument("songs", required=True, type=helpers.CLICK_SONG, nargs=-1) @click.option( "-l", "--lang", type=click.Choice( ("japanese", "german") + config.INDIC_SCRIPTS, case_sensitive=False ), help="Language-specific transliteration support.", ) @click.option( "-S/-nS", "--save-override/--no-save-override", "override", default=False, help="Add override lyrics.", ) @click.option( "-F/-nF", "--force/--no-force", "force", default=False, help="Skip confirmation prompt.", ) def transliterate(songs, lang, override, force): """ Romanize foreign-script song lyrics. This is NOT translation, but rather converting the script to a more readable form using the 'unidecode' package. For example, "తెలుగు" would be transliterated to "telugu". If '-S/--save-override' is passed, adds the transliterated lyrics as an override for each song to maestro's internal data (prompts for confirmation if override already exists unless '-F/--force' is passed). This retains the original lyric metadata while allowing maestro to display the transliterated lyrics instead in 'maestro play'. Unidecode is not perfect and may not work well with all languages; for example, 'ä' becomes 'a' instead of 'ae', and Japanese characters are treated as Chinese characters (although maestro uses 'pykakasi' as a workaround for Japanese). If you're having issues, you can try explicitly passing the "-l/--lang" option with either "japanese" or "german" to improve transliteration. The former will skip unidecode and use pykakasi only, while the latter will replace 'ä', 'ö', 'ü' with 'ae', 'oe', 'ue' before running unidecode. Indic scripts are also supported using the 'indic_transliteration' package: bengali, assamese, modi (i.e. Marathi), malayalam, devanagari, sinhala, tibetan, gurmukhi (i.e. Punjabi), tamil, balinese, thai, burmese, telugu, kannada, gujarati, urdu, lao, javanese, manipuri, oriya, khmer """ if not songs: click.secho("No songs passed.", fg="red") return import re from unidecode import unidecode ja_regex = re.compile( "[\u3000-\u303f\u3040-\u309f\u30a0-\u30ff\uff00-\uff9f\u4e00-\u9faf\u3400-\u4dbf]" ) for song in songs: lyrics = song.parsed_lyrics if lyrics is None: click.secho( f'No lyrics found for "{song.song_title}" (ID: {song.song_id}).', fg="red", ) return if lang == "de": for i in range(len(lyrics)): helpers.set_lyric( lyrics, i, helpers.get_lyric(lyrics[i]) .replace("ä", "ae") .replace("ö", "oe") .replace("ü", "ue"), ) if lang == "ja" or ja_regex.search(song.raw_lyrics): from pykakasi import kakasi kks = kakasi() for i in range(len(lyrics)): helpers.set_lyric( lyrics, i, " ".join( [ t["hepburn"] for t in kks.convert(helpers.get_lyric(lyrics[i])) ] ), ) if lang in config.INDIC_SCRIPTS: from itertools import groupby from indic_transliteration import sanscript for i in range(len(lyrics)): transliterated = "" for is_indic, word in groupby( helpers.get_lyric(lyrics[i]).split(), lambda w: all( c not in "abcdefghijklmnopqrstuvwxyz" for c in w.lower() ), ): word = " ".join(word) if is_indic: word = ( sanscript.transliterate(word, lang, "iast") .replace("c", "ch") .replace("ā", "aa") .replace("t", "th") .replace("ṭ", "t") .replace("ī", "ee") .replace("ū", "oo") .replace("è", "e") .replace("ò", "o") .replace("ṣ", "sh") .replace("ś", "sh") .replace("ḍ", "d") .replace("ṇ", "n") ) word = re.sub(r"ṃ(?=\w)", "n", word) word = word.replace("ṃ", "m") transliterated += word + " " helpers.set_lyric(lyrics, i, transliterated) if lang != "ja" and lang not in config.INDIC_SCRIPTS: for i in range(len(lyrics)): helpers.set_lyric( lyrics, i, unidecode(helpers.get_lyric(lyrics[i])) ) if override: if song.raw_override_lyrics is not None: if not force: click.echo( f'Override lyrics already exist for "{song.song_title}" (ID {song.song_id}), do you want to replace them? This action cannot be undone. [y/n] ', nl=False, ) if input().lower() != "y": continue if helpers.is_timed_lyrics(lyrics): song.raw_override_lyrics = lyrics.toLRC() else: song.raw_override_lyrics = "\n".join(lyrics) click.secho( f'Added override lyrics for "{song.song_title}" (ID {song.song_id}).', fg="green", ) else: helpers.display_lyrics(lyrics, song, "transliterated") @cli.command() @click.argument("songs", required=True, type=helpers.CLICK_SONG, nargs=-1) @click.option( "-S/-nS", "--save/--no-save", "save_", default=False, help="Save translated lyrics.", ) @click.option( "-f", "--from", "from_langs", help="Language(s) to translate from.", multiple=True, default=("auto",), ) @click.option( "-t", "--to", "to_lang", help="Language to translate to.", default="English", ) @click.option( "-F/-nF", "--force/--no-force", "force", default=False, help="Skip confirmation prompt.", ) @click.option( "-R/-nR", "--remove/--no-remove", "remove_", default=False, help="Remove translated lyrics.", ) def translate(songs, save_, from_langs, to_lang, force, remove_): """ Translate song lyrics using the 'translatepy' package. If '-S/--save' is passed, saves the translated lyrics to be used by 'maestro play' (displayed with original lyrics). If a translated lyric file already exists, prompts for confirmation before overwriting unless '-F/--force' is passed. If '-R/--remove' is passed, removes the translated lyrics for each song (prompts for confirmation unless '-F/--force' is passed). Default translation is from 'auto' to 'English', but you can specify the languages using the '-f/--from' and '-t/--to' options. Multiple '-f/--from' options can be passed, and 'maestro translate' will attempt to detect the language of each word/phrase. The first language in '-f/--from' is used as the default language if a word/phrase is detected as a language that was not passed. """ if not songs: click.secho("No songs passed.", fg="red") return if remove_ and save_: click.secho( "Cannot pass both '-R/--remove' and '-S/--save'.", fg="red", ) return import translatepy.translators from itertools import groupby from translatepy import Translator, Language from translatepy.exceptions import TranslatepyException translator = Translator( [ translatepy.translators.GoogleTranslateV2, translatepy.translators.GoogleTranslateV1, translatepy.translators.YandexTranslate, translatepy.translators.ReversoTranslate, translatepy.translators.DeeplTranslate, translatepy.translators.LibreTranslate, translatepy.translators.TranslateComTranslate, translatepy.translators.MyMemoryTranslate, ] ) for i, service in enumerate(translator.services): # pylint: disable=protected-access translator._instantiate_translator(service, translator.services, i) AUTO_LANG = Language("auto") from_langs = list(from_langs) for i in range(len(from_langs)): from_langs[i] = Language(from_langs[i]) to_lang = Language(to_lang) if len(from_langs) > 1: for lang in from_langs: if lang.id == AUTO_LANG.id: click.secho( "Cannot pass 'auto' with other languages in '-f/--from'.", ) return for song in songs: if remove_: song.raw_translated_lyrics = None click.secho( f'Removed translated lyrics for "{song.song_title}" (ID {song.song_id}).', fg="green", ) continue lyrics = song.parsed_lyrics if lyrics is None: click.secho( f'No lyrics found for "{song.song_title}" (ID: {song.song_id}).', fg="red", ) return translated_lyrics = [helpers.get_lyric(lyric) for lyric in lyrics] for i in range(len(lyrics)): lyric = helpers.get_lyric(lyrics[i]) if not lyric: continue if len(from_langs) == 1 and from_langs[0].id != AUTO_LANG.id: try: translated_lyrics[i] = translator.translate( lyric, to_lang, from_langs[0] ).result except TranslatepyException as e: print_to_logfile(f"TranslatepyException on {lyric}: {e}") else: words = [[w, "auto"] for w in lyric.split()] for word in words: try: word[1] = translator.language(word[0]).result if from_langs[0].id != AUTO_LANG.id: for lang in from_langs: if lang.id == word[1].id: break else: word[1] = from_langs[0] except TranslatepyException as e: print_to_logfile( f"TranslatepyException on {word[0]}: {e}" ) translated_lyrics[i] = "" for lang, phrase in groupby(words, lambda x: x[1].id): phrase = " ".join([w[0] for w in phrase]) if lang != to_lang.id: try: click.secho(phrase, fg="blue", nl=False) phrase = translator.translate( phrase, to_lang, lang ).result print(" ->", phrase) except TranslatepyException as e: print_to_logfile( f"TranslatepyException on {phrase}: {e}" ) for service in translator.services: service.clean_cache() translated_lyrics[i] += phrase + " " if save_: if song.raw_translated_lyrics is not None: if not force: click.echo( f'Translated lyrics already exist for "{song.song_title}" (ID {song.song_id}), do you want to replace them? This action cannot be undone. [y/n] ', nl=False, ) if input().lower() != "y": continue song.raw_translated_lyrics = "\n".join(translated_lyrics).strip() click.secho( f'Added translated lyrics for "{song.song_title}" (ID {song.song_id}).', fg="green", ) else: helpers.display_lyrics(translated_lyrics, song, "translated") @cli.command() def user(): """ Display the currently logged-in user. """ import keyring try: click.echo(keyring.get_password("maestro-music", "username")) try: keyring.get_password("maestro-music", "password") except keyring.errors.KeyringError: click.secho("No password saved.", fg="red") except keyring.errors.KeyringError: click.secho("No user logged in.", fg="yellow") if __name__ == "__main__": # check if frozen if getattr(sys, "frozen", False): multiprocessing.freeze_support() # click passes ctx, no param needed cli() # pylint: disable=no-value-for-parameter ================================================ FILE: readme.md ================================================ # maestro [![PyPI downloads](https://static.pepy.tech/badge/maestro-music)](https://pepy.tech/project/maestro-music) [![PyPI version](https://badge.fury.io/py/maestro-music.svg)](https://badge.fury.io/py/maestro-music) [![Support server](https://img.shields.io/discord/1117677384846544896.svg?color=7289da&label=maestro-cli&logo=discord)](https://discord.gg/AW8fh2QEav) `maestro` is a command-line tool to play songs (or any audio, really) in the terminal. ![](https://github.com/PrajwalVandana/maestro-cli/raw/master/data/player.png) Check out the [Discord server](https://discord.gg/AW8fh2QEav)! ## Features - [cross-platform](#platforms) - [add songs](#adding-songs) from YouTube, YouTube Music, or Spotify - [stream your music](#streaming) - [lyrics](#lyrics) - romanize foreign-language lyrics - translate lyrics - [clips](#clips) - filter by [tags](#tags) - [listen statistics](#listen-statistics) - [shuffle](#shuffling) (along with precise control over the behavior of shuffling when repeating) - [audio visualization](#visualization) directly in the terminal - [Discord integration](#discord-status) - Now Playing Center integration on macOS (allows headphone controls) ![](https://github.com/PrajwalVandana/maestro-cli/raw/master/data/now_playing.png) - [music discovery](#music-discovery) ## Technical Details [Visualization Breakdown](https://github.com/PrajwalVandana/maestro-cli/blob/master/data/maestro_vis.pdf) [Listen-along Streaming Breakdown](https://github.com/PrajwalVandana/maestro-cli/blob/master/data/maestro_listen_along.pdf) ## Installation ### Using `pip` Make sure you have Python 3 and `pip` installed. First, run ``` pip install maestro-music ``` **NOTE**: `pip install maestro` and `pip install maestro-cli` will NOT work, they are totally unrelated PyPI packages. Now, if you want to be able to directly download songs from YouTube or Spotify, you'll need to install [FFmpeg](https://ffmpeg.org/download.html). You can download FFmpeg yourself globally, or locally with `maestro download-ffmpeg`. ### Download executable Using Python and `pip` is the preferred way; executables may be slower/have bugs, and `pip` makes updating way easier. However, the releases page does have executables/installers for macOS, Windows, and Linux. #### macOS Download the `.pkg` file corresponding to your Mac's architecture; Apple Silicon (M1, M2, M3, etc.) or Intel. Right click on the file in Finder and click "Open" (double clicking won't work). The installation may be a bit slow, and the first run of `maestro` will probably be slow as well. #### Windows Download and install using `maestro-installer.exe`. #### Linux Built on Ubuntu; should work on other Linux distros too. Download and unzip `maestro-ubuntu.tar.gz`. This should unzip a folder named `dist`, which contains a single folder named `maestro`. Inside `maestro` will be another folder, `_internal`, and two scripts: `maestro` and `install-maestro`. Assuming you unzipped inside `Downloads`, run ```bash cd Downloads/dist/maestro sudo ./install-maestro ``` You can then safely delete `dist`. ## Known Issues If you get a segmentation fault when running `maestro play` on macOS, it may be caused by an issue with the Python installation that comes bundled with macOS, as Apple uses an old version of `ncurses` for the `curses` module. To fix this, you can install Python directly from the [Python website](python.org/downloads), which should fix the issue. If you have issues on Linux, try `sudo apt-get install python3-dev python3-dbus`. ## Platforms Tested heavily on macOS, lightly on Windows and (Ubuntu) Linux. `maestro` was coded to be cross-platform, but if there are any problems, please open an issue (or PR if you know how to fix it!). You can also join the [Discord server](https://discord.gg/AW8fh2QEav) and ask for help there. `maestro` *should* work on any 3.x version of Python, but I coded it on 3.12 and don't test on earlier versions. Supports `.mp3`, `.wav`, `.flac`, and `.ogg` (Ogg Vorbis). ## Usage Run `maestro -h` to get a list of commands. Run `maestro -h` to get comprehensive help for that command—the below is just an overview. Click `h` in the player session to get a list of commands (scrollable). `maestro` uses the concept of a positive integer **song ID** to uniquely refer to each song; any place where `maestro` expects a song ID should also allow a search phrase—if only one song matches, `maestro` will infer the song ID. ### Adding Songs You can add a song from a file or folder with `maestro add `. To add songs in subfolders as well, pass the `-R`/`--recursive` flag. Pass the `-Y`/`--youtube` flag to download from a YouTube or YouTube Music URL instead of a file path. This requires FFmpeg. Passing a YouTube Music **song** URL (not "Video") is recommended, as passing "Video"s (i.e. just normal YouTube videos) can sometimes mess up the artist/album data (this can always be fixed manually with the `maestro metadata` command, though). Pass the `-S`/`--spotify` flag to download from a Spotify URL instead of a file path. This also requires installing FFmpeg. Pass the `-P` or `--playlist` flag to download an entire YouTube playlist from a song URL with a playlist component, e.g. https://www.youtube.com/watch?v=V1Z586zoeeE&list=PLfSdF_HSSu55q-5p-maISZyr19erpZsTo. The `-P` flag is unnecessary if the URL points directly to a playlist, e.g. https://www.youtube.com/playlist?list=PLfSdF_HSSu55q-5p-maISZyr19erpZsTo. By default, `maestro add` copies the file to its internal database (`~/.maestro-files`), but you can pass the `-M` or `--move` flag to move the file instead. You can also change the folder where the songs are stored with `maestro dir`. ### Tags Playlists don't exist—`maestro` uses **tags**. For example, let's say you want to be able to listen to all your rap songs together. Instead of adding them all to a playlist, run `maestro tag -t rap`. Then `maestro play rap` will play all the songs you've added the `rap` tag to. Basically, if song `s` has tag `t`, then you can think of song `s` as belonging to the playlist defined by tag `t`. The advantage of tags over playlists is that you can combine tags; `maestro play A B` will play only songs tagged `A` or `B` (add the `-M/--match-all` flag to play only songs tagged `A` *and* `B`). ### Listen Statistics `maestro` also tracks your listen time—total and by year. You can see this with `maestro list` and/or `maestro entry`. For example, to see your top 10 listened songs this year (by average number of times listened; note that this is NOT the number of times the song was played, but rather the total listen time for that song divided by the duration), run `maestro list --reverse --sort times-listened --top 10 --year cur`—replace 'cur' with e.g. '2020' to get the listen times for 2020 instead. ![](https://github.com/PrajwalVandana/maestro-cli/raw/master/data/list.png) ### Clips Ever been listening to music, and you're skipping every song because you keep getting bored of them? You like the songs, you're just not in the mood to listen to all of them entirely. Introducing clips, something I've always wished the big companies like Spotify and YouTube Music would do. Use `maestro clip ` to define a clip for any song with a start and end timestamp (or use the clip editor for fine-grained control with `maestro clip `), then `maestro play -C` to play in "clip mode" (can also be toggled in the player session with the `c` key)—this will play the clips for each song (or the entire song if there's no clip). Now you can listen to only the best parts of your music! By default, `maestro clip` creates a clip named 'default'; you can add additional clips with the `--name` option: ```bash maestro clip --name clip1 maestro set-clip clip1 ``` The `maestro set-clip` command will set 'clip1' as the clip to be played in clip mode instead of 'default'. ### Lyrics `maestro add` will automatically attempt to download lyrics (synced if possible) for the song. You can romanize foreign-language lyrics with `maestro translit --save`, which will save the romanization as an override `.lrc` file (the original lyrics will still be preserved in the metadata of the song's file, but the override will be used). You can add a translation for a song with `maestro translate --save`, which can then be shown with the lyrics using `maestro play --lyrics --translated-lyrics`. Not passing `--save` to either command will print the lyrics instead of saving them. Press `y` in the player session to toggle lyrics, `t` to toggle translated lyrics. To scroll through lyrics, change focus to the lyrics window with `}` (you can change focus back to the queue with `{`). ### Shuffling `maestro play` accepts two shuffle options, `-s`/`--shuffle` and `-r`/`--reshuffle`. The first is for shuffling the song before the player session starts, and the second is for reshuffling the queue when it loops (the `-r` option is ignored if you don't also pass the `-L`/`--loop` flag to loop the queue). The default for both is `0`, i.e. no shuffling. To shuffle completely randomly, pass `-1`; otherwise, passing any positive integer `n` will ensure that each song is no more than `n` positions away in the queue from its previous position. ### Visualization Run `maestro play --visualize` or click `v` in the player session to show the visualizer. ### Discord Status Run `maestro play --discord` or click `d` in the player session to show the currently playing song in your Discord status (requires the Discord app to be open). Hovering over the image will show the album name. To show album art, requires signing up/logging in with `maestro signup`/`maestro login`. ### Streaming If you're logged in as `user123`, run `maestro play --stream` (or click `s` in the player session) to stream your music to `maestro-music.vercel.app/listen-along/user123`. This will show up as a "Listen Along" button on your Discord status too, if the Discord status is enabled (some versions of the Discord app don't show buttons on your own status, but it should show for everyone else). ![](https://github.com/PrajwalVandana/maestro-cli/raw/master/data/stream.png) ### Music Discovery Use `maestro recommend ` to recommend similar songs (searches up the song name on YouTube Music). ================================================ FILE: requirements.txt ================================================ click # CLI handling just_playback # Audio playback music-tag # Metadata handling pillow # Image processing (required by music-tag for album art) pypresence >= 4.3.0 # Discord Rich Presence yt-dlp # YouTube downloads spotdl # Spotify downloads ytmusicapi # Song recommendation (experimental) librosa # Audio processing numba # JIT compilation numpy ~= 1.26 # 2.0.0 breaks librosa windows-curses; sys_platform == 'win32' # Windows curses support keyring # Credential storage requests # HTTP requests msgspec # Faster JSON serialization syncedlyrics # Search for synced lyrics pylrc # LRC file parsing safer # safe file writing grapheme # Unicode grapheme clusters unidecode # transliteration # NOTE: GPL v3 pykakasi # Japanese transliteration # NOTE: GPL v3 translatepy # Translation # NOTE: GPL v3 indic_transliteration # Indic transliteration # PyObjC dependencies for macOS pyobjc-core; sys_platform == 'darwin' pyobjc-framework-ApplicationServices; sys_platform == 'darwin' pyobjc-framework-AVFoundation; sys_platform == 'darwin' pyobjc-framework-Cocoa; sys_platform == 'darwin' pyobjc-framework-CoreAudio; sys_platform == 'darwin' pyobjc-framework-CoreMedia; sys_platform == 'darwin' pyobjc-framework-MediaPlayer; sys_platform == 'darwin' pyobjc-framework-Quartz; sys_platform == 'darwin' ================================================ FILE: scripts/add_album_art.py ================================================ """ Add album art to songs that don't have it, using the title of the song to search Spotify for the album art. Can fail sometimes; the 'custom_album_art.py' script offers more control over the process, but requires manual non-CLI input. Usage: python add_album_art.py """ import json import os import sys import subprocess import music_tag import requests DIR = sys.argv[1] # REPLACE WITH THE PATH TO YOUR SONGS DIRECTORY for path in os.listdir(DIR): fname, ext = os.path.splitext(path) if ext not in (".mp3", ".flac", ".ogg", ".wav"): continue m = music_tag.load_file(os.path.join(DIR, path)) if m["artwork"]: continue subprocess.run( [ "spotdl", "save", fname, "--save-file", "temp.spotdl", ], check=True, ) with open("temp.spotdl", "r") as f: # pylint: disable=unspecified-encoding m["artwork"] = requests.get( json.load(f)[0]["cover_url"], timeout=5 ).content m.save() ================================================ FILE: scripts/custom_album_art.py ================================================ """ Usage: python custom_album_art.py """ import json import os import subprocess import sys import music_tag import requests from yt_dlp import YoutubeDL remove_paths = [] # song paths to remove artwork from youtubeURLs = {} # {filename: YouTube/YT Music URL to download artwork from} spotifyURLs = {} # {filename: Spotify URL to download artwork from} DIR = sys.argv[1] # REPLACE WITH THE PATH TO YOUR SONGS DIRECTORY def yt_embed_artwork(path_, yt_dlp_info): yt_dlp_info["thumbnails"].sort(key=lambda d: d["preference"]) best_thumbnail = yt_dlp_info["thumbnails"][-1] # default thumbnail if "width" not in best_thumbnail: # diff so that any square thumbnail is chosen best_thumbnail["width"] = 0 best_thumbnail["height"] = -1 for thumbnail in yt_dlp_info["thumbnails"][:-1]: if "height" in thumbnail and ( thumbnail["height"] == thumbnail["width"] and (best_thumbnail["width"] != best_thumbnail["height"]) or ( thumbnail["height"] >= best_thumbnail["height"] and (thumbnail["width"] >= best_thumbnail["width"]) and ( (best_thumbnail["width"] != best_thumbnail["height"]) or thumbnail["width"] == thumbnail["height"] ) ) ): best_thumbnail = thumbnail image_url = best_thumbnail["url"] response = requests.get(image_url, timeout=5) image_data = response.content m_ = music_tag.load_file(path_) m_["artwork"] = image_data m_.save() for path, url in spotifyURLs.items(): subprocess.run( [ "spotdl", "save", url, "--save-file", "temp.spotdl", ], check=True, ) with open("temp.spotdl", "r") as f: # pylint: disable=unspecified-encoding m = music_tag.load_file(os.path.join(DIR, path)) m["artwork"] = requests.get( json.load(f)[0]["cover_url"], timeout=5 ).content m.save() for path, url in youtubeURLs.items(): with YoutubeDL() as ydl: info = ydl.extract_info(url, download=False) yt_embed_artwork(os.path.join(DIR, path), info) for path in remove_paths: m = music_tag.load_file(os.path.join(DIR, path)) m["artwork"] = None m.save() ================================================ FILE: scripts/rename_tracktitles.py ================================================ """ Rename the 'tracktitle' metadata property of all songs to the name of the song file. Sufficiently new versions of maestro will automatically do this when adding and renaming songs. Usage: python rename_tracktitles.py """ import os import sys import music_tag DIR = sys.argv[1] for path in os.listdir(DIR): fname, ext = os.path.splitext(path) if ext not in (".mp3", ".flac", ".ogg", ".wav"): continue m = music_tag.load_file(os.path.join(DIR, path)) m["tracktitle"] = fname m.save() ================================================ FILE: setup.py ================================================ from os.path import normpath import re from setuptools import setup, find_packages d = {} with open(normpath("maestro/__version__.py"), encoding="utf-8") as version_file: exec(version_file.read(), d) # pylint: disable=exec-used VERSION = d["VERSION"] _INLINE_COMMENT_RE = re.compile(r"\s+#") def read_requirements(path: str) -> list[str]: requirements: list[str] = [] with open(path, encoding="utf-8") as f: for raw_line in f: line = raw_line.strip() if not line or line.startswith("#"): continue if line.startswith(("-r ", "--requirement ")): _, included_path = line.split(maxsplit=1) requirements.extend(read_requirements(included_path)) continue if line.startswith(("-c ", "--constraint ")): continue match = _INLINE_COMMENT_RE.search(line) if match: line = line[: match.start()].rstrip() if line: requirements.append(line) return requirements install_requires = read_requirements("requirements.txt") def main() -> None: setup( name="maestro-music", version=VERSION, author="Prajwal Vandana", url="https://github.com/PrajwalVandana/maestro-cli", description="A simple command line tool to play songs (or any audio files, really).", long_description=open("readme.md", encoding="utf-8").read(), license="MIT", license_files=["LICENSE"], long_description_content_type="text/markdown", keywords=[ "music", "sound", "audio", "music-player", "cli", "ogg", "vorbis", "ogg vorbis", "flac", "mp3", "wav", "spotify", "youtube", "audio-visualization", "audio-visualizer", ], packages=find_packages(include=["maestro"]), install_requires=install_requires, entry_points={ "console_scripts": [ "maestro = maestro.main:cli", ], }, ) if __name__ == "__main__": main() ================================================ FILE: specs/maestro-mac.spec ================================================ # -*- mode: python ; coding: utf-8 -*- from PyInstaller.utils.hooks import collect_all datas = [] binaries = [] hiddenimports = [] tmp_ret = collect_all('ytmusicapi') datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] tmp_ret = collect_all('pykakasi') datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] tmp_ret = collect_all('indic_transliteration') datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] tmp_ret = collect_all('grapheme') datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] block_cipher = None a = Analysis( ['../maestro/main.py'], pathex=[], binaries=binaries, datas=datas, hiddenimports=hiddenimports, hookspath=[], hooksconfig={}, runtime_hooks=[], excludes=[], win_no_prefer_redirects=False, win_private_assemblies=False, cipher=block_cipher, noarchive=False, ) pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) exe = EXE( pyz, a.scripts, [], exclude_binaries=True, name='maestro', debug=False, bootloader_ignore_signals=False, strip=False, upx=True, console=True, disable_windowed_traceback=False, argv_emulation=False, target_arch=None, codesign_identity=None, entitlements_file=None, ) coll = COLLECT( exe, a.binaries, a.zipfiles, a.datas, strip=False, upx=True, upx_exclude=[], name='maestro', ) ================================================ FILE: specs/maestro-ubuntu.spec ================================================ # -*- mode: python ; coding: utf-8 -*- from PyInstaller.utils.hooks import collect_all datas = [] binaries = [] hiddenimports = [] tmp_ret = collect_all('ytmusicapi') datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] tmp_ret = collect_all('pykakasi') datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] tmp_ret = collect_all('indic_transliteration') datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] tmp_ret = collect_all('grapheme') datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] tmp_ret = collect_all('indic_transliteration') datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] tmp_ret = collect_all('grapheme') datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] block_cipher = None a = Analysis( ['../maestro/main.py'], pathex=[], binaries=binaries, datas=datas, hiddenimports=hiddenimports, hookspath=[], hooksconfig={}, runtime_hooks=[], excludes=[], win_no_prefer_redirects=False, win_private_assemblies=False, cipher=block_cipher, noarchive=False, ) pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) exe = EXE( pyz, a.scripts, [], exclude_binaries=True, name='maestro', debug=False, bootloader_ignore_signals=False, strip=False, upx=True, console=True, disable_windowed_traceback=False, argv_emulation=False, target_arch=None, codesign_identity=None, entitlements_file=None, ) coll = COLLECT( exe, a.binaries, a.zipfiles, a.datas, strip=False, upx=True, upx_exclude=[], name='maestro', ) ================================================ FILE: specs/maestro-windows.spec ================================================ # -*- mode: python ; coding: utf-8 -*- from PyInstaller.utils.hooks import collect_all datas = [] binaries = [] hiddenimports = [] tmp_ret = collect_all('ytmusicapi') datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] tmp_ret = collect_all('pykakasi') datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] tmp_ret = collect_all('indic_transliteration') datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] tmp_ret = collect_all('grapheme') datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] block_cipher = None a = Analysis( ['../maestro/main.py'], pathex=[], binaries=binaries, datas=datas, hiddenimports=hiddenimports, hookspath=[], hooksconfig={}, runtime_hooks=[], excludes=[], win_no_prefer_redirects=False, win_private_assemblies=False, cipher=block_cipher, noarchive=False, ) pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) exe = EXE( pyz, a.scripts, [], exclude_binaries=True, name='maestro', debug=False, bootloader_ignore_signals=False, strip=False, upx=True, console=True, disable_windowed_traceback=False, argv_emulation=False, target_arch=None, codesign_identity=None, entitlements_file=None, ) coll = COLLECT( exe, a.binaries, a.zipfiles, a.datas, strip=False, upx=True, upx_exclude=[], name='maestro', ) ================================================ FILE: uninstall-scripts/unix ================================================ #!/bin/sh MAESTRO_BUNDLE_LOC=/usr/local/bin/maestro-bundle MAESTRO_SYMLINK_LOC=/usr/local/bin/maestro # Remove bundle files echo Removing maestro bundle files at $MAESTRO_BUNDLE_LOC rm -rf $MAESTRO_BUNDLE_LOC || echo Failed to remove maestro bundle files at $MAESTRO_BUNDLE_LOC, try running with sudo # Remove symlink echo Removing symlink at $MAESTRO_SYMLINK_LOC rm -rf $MAESTRO_SYMLINK_LOC || echo Failed to remove maestro symlink at $MAESTRO_SYMLINK_LOC, try running with sudo echo "Uninstall complete; maestro data at ~/.maestro-files has not been deleted"