pax_global_header 0000666 0000000 0000000 00000000064 14773255151 0014524 g ustar 00root root 0000000 0000000 52 comment=a8ee38880fa8ec21eeab188756fdc082b46eda76
sptlrx-1.2.3/ 0000775 0000000 0000000 00000000000 14773255151 0013063 5 ustar 00root root 0000000 0000000 sptlrx-1.2.3/.github/ 0000775 0000000 0000000 00000000000 14773255151 0014423 5 ustar 00root root 0000000 0000000 sptlrx-1.2.3/.github/workflows/ 0000775 0000000 0000000 00000000000 14773255151 0016460 5 ustar 00root root 0000000 0000000 sptlrx-1.2.3/.github/workflows/release.yml 0000664 0000000 0000000 00000001145 14773255151 0020624 0 ustar 00root root 0000000 0000000 name: goreleaser
on:
push:
tags:
- '*'
permissions:
contents: write
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
-
name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24.1'
-
name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
sptlrx-1.2.3/.gitignore 0000664 0000000 0000000 00000000504 14773255151 0015052 0 ustar 00root root 0000000 0000000 # Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
dist/
# Built executable
sptlrx
# IDE files
.vscode/ sptlrx-1.2.3/.goreleaser.yaml 0000664 0000000 0000000 00000000677 14773255151 0016167 0 ustar 00root root 0000000 0000000 before:
hooks:
- go mod tidy
builds:
- env:
- CGO_ENABLED=0
goos:
- linux
- windows
- darwin
- freebsd
goarch:
- "386"
- amd64
- arm64
- arm
ignore:
- goos: windows
goarch: arm
checksum:
name_template: "checksums.txt"
snapshot:
name_template: "{{ incpatch .Version }}-next"
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
sptlrx-1.2.3/LICENSE 0000664 0000000 0000000 00000002046 14773255151 0014072 0 ustar 00root root 0000000 0000000 MIT License
Copyright (c) 2022 Denis
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.
sptlrx-1.2.3/README.md 0000664 0000000 0000000 00000014673 14773255151 0014355 0 ustar 00root root 0000000 0000000
Synchronized lyrics in your terminal
## Features
- Compatible with Spotify, MPD, Mopidy, MPRIS and browsers.
- Works well with long lines & Unicode characters.
- Easy to customize.
- Allows piping to stdout.
- Single binary & cross-plaftorm.
## Installation
**Linux**
- Arch Linux ([@BachoSeven](https://github.com/BachoSeven))
```
yay -S sptlrx-bin
```
- NixOS ([@MoritzBoehme](https://github.com/MoritzBoehme))
```
nix-env -iA nixos.sptlrx
```
or if using nixpkgs
```
nix-env -iA nixpkgs.sptlrx
```
- Other
```
curl -sSL instl.sh/raitonoberu/sptlrx/linux | bash
```
**Windows**
```
iwr instl.sh/raitonoberu/sptlrx/windows | iex
```
**macOS**
```
curl -sSL instl.sh/raitonoberu/sptlrx/macos | bash
```
You can also download the binary from the [Releases](https://github.com/raitonoberu/sptlrx/releases/latest) page or [build it yourself](./building.md).
## Configuration
Config file will be created at the first launch. On Linux it's located in `~/.config/sptlrx/config.yaml`. Run `sptlrx -h` to see the full path.
Show config contents (with descriptions)
```yaml
### Global settings ###
# Your Spotify cookie. Only needed if you are going to use Spotify as a player.
cookie: ""
# Player that will be used. Possible values: spotify, mpd, mopidy, mpris.
player: spotify
# Host of lyrics API to be used in case the cookie is not provided.
host: lyricsapi.vercel.app
# Whether to ignore errors instead of showing them.
ignoreErrors: true
# Interval of the internal timer. Determines how often the terminal will be updated.
timerInterval: 200
# Interval for checking the position. Doesn't really affect the precision.
updateInterval: 2000
### Style settings ###
style:
# Horizontal alignment of lines. Possible values: left, center, right.
hAlignment: center
# Style of the lines before the current one.
before:
# The colors can be either in HEX format, or ANSI 0-255.
background: ""
foreground: ""
bold: true
italic: false
underline: false
strikethrough: false
blink: false
faint: false
# Style of the current line.
current:
# The colors can be either in HEX format, or ANSI 0-255.
background: ""
foreground: ""
bold: true
italic: false
underline: false
strikethrough: false
blink: false
faint: false
# Style of the lines after the current one.
after:
# The colors can be either in HEX format, or ANSI 0-255.
background: ""
foreground: ""
bold: false
italic: false
underline: false
strikethrough: false
blink: false
faint: true
### Pipe settings ###
pipe:
# Maximum line length. 0 - unlimited.
length: 0
# How to handle overflowing strings. Possible values: word, none, ellipsis.
overflow: word
### MPD settings ###
mpd:
# MPD server address with port.
address: 127.0.0.1:6600
# MPD server password (if any).
password: ""
### Mopidy settings ###
mopidy:
# Mopidy server address with port.
address: 127.0.0.1:6680
### MPRIS settings ###
mpris:
# Whitelist of MPRIS players. First available is used if empty.
players: []
### Browser extension settings ###
browser:
# Port on which the server will be started.
port: 8974
### Local lyrics source ###
local:
# Folder for scanning .lrc files. Example: "~/Music".
folder: ""
```
### Spotify
```yaml
# config.yaml
cookie:
player: spotify
```
If you want to use Spotify as your player or lyrics source, you need to specify your cookie.
1. Open your browser.
2. Press F12, open the `Network` tab and go to [open.spotify.com](https://open.spotify.com/).
3. Click on the first request to `open.spotify.com`.
4. Scroll down to the `Request Headers`, right click the `cookie` field and select `Copy value`.
5. Paste it to your config.
You can also set the `SPOTIFY_COOKIE` environment variable or pass the `--cookie` flag.
**TREAT YOUR COOKIE LIKE A PASSWORD AND NEVER SHARE IT**
### MPD
```yaml
# config.yaml
player: mpd
mpd:
address: 127.0.0.1:6600
password: ""
```
MPD server will be used as a player.
### Mopidy
```yaml
# config.yaml
player: mopidy
mopidy:
address: 127.0.0.1:6680
```
Mopidy server will be used as a player.
### MPRIS
```yaml
# config.yaml
player: mpris
mpris:
players: []
```
Linux only. System player that supports MPRIS protocol will be used. You can also specify a whitelist of players to use, example: `players: [rhythmbox, spotifyd, ncspot]`. Run `playerctl -l` to get the names.
### Browser
```yaml
# config.yaml
player: browser
browser:
port: 8974
```
You need to install a [browser extension](https://wnp.keifufu.dev/extension/getting-started). If you don't change the default port, no further configuration is required. Otherwise, create a custom adapter in the extension settings. **You can only run one instance on one port.**
### Local
```yaml
# config.yaml
local:
folder: ""
```
If you want to use your local collection of `.lrc` files to display lyrics, specify the folder to scan. The application will use files with the most similar name. All other lyrics sources will be disabled.
## Information
### Source
If you specify your Spotify cookie, the lyrics will be fetched using your account. Otherwise, the API [hosted by me](https://github.com/raitonoberu/lyricsapi) will be used. It is also possible to host your own API or use local `.lrc` files.
### Piping
Run `sptlrx pipe` to start printing the current lines to stdout. This can be used in various status bars and other applications.
### Flags
You can pass flags to override the style parameters defined in the config. Example:
```sh
sptlrx --current "bold,#FFDFD3,#957DAD" --before "104,faint,italic" --after "104,faint"
```
List of allowed styles: `bold`, `italic`, `underline`, `strikethrough`, `blink`, `faint`. The colors can be either in HEX format, or ANSI 0-255. The first color represents the foreground, the second represents the background.
Run `sptlrx --help` to see all the flags.
## License
**MIT License**, see [LICENSE](./LICENSE) for additional information.
sptlrx-1.2.3/building.md 0000664 0000000 0000000 00000000452 14773255151 0015203 0 ustar 00root root 0000000 0000000 ## Building sptlrx
Make sure you have [Go 1.18+](https://go.dev/) installed.
### Clone the repository
```sh
git clone https://github.com/raitonoberu/sptlrx
cd sptlrx
```
### Fetch dependencies
```sh
go get
```
### Build it
```sh
go build -ldflags '-w -s'
```
### Run it
```sh
./sptlrx
```
sptlrx-1.2.3/cmd/ 0000775 0000000 0000000 00000000000 14773255151 0013626 5 ustar 00root root 0000000 0000000 sptlrx-1.2.3/cmd/pipe.go 0000664 0000000 0000000 00000003335 14773255151 0015116 0 ustar 00root root 0000000 0000000 package cmd
import (
"fmt"
"strings"
"github.com/raitonoberu/sptlrx/config"
"github.com/raitonoberu/sptlrx/lyrics"
"github.com/raitonoberu/sptlrx/pool"
"github.com/muesli/reflow/wordwrap"
"github.com/muesli/reflow/wrap"
"github.com/spf13/cobra"
)
var pipeCmd = &cobra.Command{
Use: "pipe",
Short: "Start printing the current lines to stdout",
RunE: func(cmd *cobra.Command, args []string) error {
conf, err := loadConfig(cmd)
if err != nil {
return fmt.Errorf("couldn't load config: %w", err)
}
player, err := loadPlayer(conf)
if err != nil {
return fmt.Errorf("couldn't load player: %w", err)
}
provider, err := loadProvider(conf, player)
if err != nil {
return fmt.Errorf("couldn't load provider: %w", err)
}
ch := make(chan pool.Update)
go pool.Listen(player, provider, conf, ch)
for update := range ch {
printUpdate(update, conf)
}
return nil
},
}
func printUpdate(update pool.Update, conf *config.Config) {
if update.Err != nil {
if !conf.IgnoreErrors {
fmt.Println(update.Err.Error())
}
return
}
if update.Lines == nil || !lyrics.Timesynced(update.Lines) {
fmt.Println("")
return
}
line := update.Lines[update.Index].Words
if conf.Pipe.Length == 0 {
fmt.Println(line)
return
}
switch conf.Pipe.Overflow {
case "none":
s := wrap.String(line, conf.Pipe.Length)
fmt.Println(strings.Split(s, "\n")[0])
case "word":
s := wordwrap.String(line, conf.Pipe.Length)
fmt.Println(strings.Split(s, "\n")[0])
case "ellipsis":
s := wrap.String(line, conf.Pipe.Length)
lines := strings.Split(s, "\n")
if len(lines) == 1 {
fmt.Println(lines[0])
return
}
s = wrap.String(lines[0], conf.Pipe.Length-3)
fmt.Println(strings.Split(s, "\n")[0] + "...")
}
}
sptlrx-1.2.3/cmd/root.go 0000664 0000000 0000000 00000012222 14773255151 0015137 0 ustar 00root root 0000000 0000000 package cmd
import (
"errors"
"fmt"
"os"
"strings"
"github.com/raitonoberu/sptlrx/config"
"github.com/raitonoberu/sptlrx/lyrics"
"github.com/raitonoberu/sptlrx/player"
"github.com/raitonoberu/sptlrx/pool"
"github.com/raitonoberu/sptlrx/services/hosted"
"github.com/raitonoberu/sptlrx/services/local"
"github.com/raitonoberu/sptlrx/services/spotify"
"github.com/raitonoberu/sptlrx/ui"
tea "github.com/charmbracelet/bubbletea"
"github.com/spf13/cobra"
)
const banner = `
_ _
___ _ __ | |_ | | _ __ __ __
/ __|| '_ \ | __|| || '__|\ \/ /
\__ \| |_) || |_ | || | > <
|___/| .__/ \__||_||_| /_/\_\
|_|
`
const help = ` 1. Open your browser.
2. Press F12, open the 'Network' tab and go to open.spotify.com.
3. Click on the first request to open.spotify.com.
4. Scroll down to the 'Request Headers', right click the 'cookie' field and select 'Copy value'.
5. Paste it into your config file.`
var (
FlagCookie string
FlagPlayer string
FlagConfig string
FlagStyleBefore string
FlagStyleCurrent string
FlagStyleAfter string
FlagHAlignment string
FlagVerbose bool
)
var rootCmd = &cobra.Command{
Use: "sptlrx",
Short: "Synchronized lyrics in your terminal",
Long: "A CLI app that shows time-synchronized lyrics in your terminal",
Version: "v1.2.3",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
conf, err := loadConfig(cmd)
if err != nil {
return fmt.Errorf("couldn't load config: %w", err)
}
player, err := loadPlayer(conf)
if err != nil {
return fmt.Errorf("couldn't load player: %w", err)
}
provider, err := loadProvider(conf, player)
if err != nil {
return fmt.Errorf("couldn't load provider: %w", err)
}
ch := make(chan pool.Update)
go pool.Listen(player, provider, conf, ch)
_, err = tea.NewProgram(
&ui.Model{
Channel: ch,
Config: conf,
},
tea.WithAltScreen(),
).Run()
return err
},
}
func loadConfig(cmd *cobra.Command) (*config.Config, error) {
if cmd.Flags().Changed("config") {
// custom config path
config.Path = FlagConfig
}
conf, err := config.Load()
if err != nil {
if cmd.Flags().Changed("config") || !errors.Is(err, os.ErrNotExist) {
return nil, err
}
// create new config
conf = config.New()
fmt.Print(banner + "\n")
fmt.Printf("Config file location: %s\n", config.Path)
config.Save(conf)
}
if envCookie := os.Getenv("SPOTIFY_COOKIE"); envCookie != "" {
conf.Cookie = envCookie
}
if FlagCookie != "" {
conf.Cookie = FlagCookie
}
if FlagVerbose {
conf.IgnoreErrors = false
}
if cmd.Flags().Changed("player") {
conf.Player = FlagPlayer
}
if cmd.Flags().Changed("before") {
conf.Style.Before = parseStyleFlag(FlagStyleBefore)
}
if cmd.Flags().Changed("current") {
conf.Style.Current = parseStyleFlag(FlagStyleCurrent)
}
if cmd.Flags().Changed("after") {
conf.Style.After = parseStyleFlag(FlagStyleAfter)
}
if cmd.Flags().Changed("halign") {
conf.Style.HAlignment = FlagHAlignment
}
return conf, nil
}
func loadPlayer(conf *config.Config) (player.Player, error) {
player, err := config.GetPlayer(conf)
if err != nil {
if errors.Is(err, spotify.ErrInvalidCookie) {
fmt.Println("If you want to use Spotify as your player, you need to set up your cookie.")
fmt.Println(help)
}
return nil, err
}
return player, nil
}
func loadProvider(conf *config.Config, player player.Player) (lyrics.Provider, error) {
if conf.Local.Folder != "" {
return local.New(conf.Local.Folder)
}
if conf.Cookie == "" {
return hosted.New(conf.Host), nil
}
if spt, ok := player.(*spotify.Client); ok {
// use existing spotify client
return spt, nil
}
// create new spotify client
return spotify.New(conf.Cookie)
}
func parseStyleFlag(value string) config.Style {
var style config.Style
for _, part := range strings.Split(value, ",") {
switch part {
case "bold":
style.Bold = true
case "italic":
style.Italic = true
case "underline":
style.Underline = true
case "strikethrough":
style.Strikethrough = true
case "blink":
style.Blink = true
case "faint":
style.Faint = true
default:
if style.Foreground == "" {
style.Foreground = part
} else if style.Background == "" {
style.Background = part
}
}
}
return style
}
func init() {
rootCmd.PersistentFlags().StringVarP(&FlagCookie, "cookie", "c", "", "your cookie")
rootCmd.PersistentFlags().StringVarP(&FlagPlayer, "player", "p", "spotify", "what player to use")
rootCmd.PersistentFlags().StringVar(&FlagConfig, "config", config.Path, "path to config file")
rootCmd.Flags().StringVar(&FlagStyleBefore, "before", "bold", "style of the lines before the current one")
rootCmd.Flags().StringVar(&FlagStyleCurrent, "current", "bold", "style of the current line")
rootCmd.Flags().StringVar(&FlagStyleAfter, "after", "faint", "style of the lines after the current one")
rootCmd.Flags().StringVar(&FlagHAlignment, "halign", "center", "initial horizontal alignment (left/center/right)")
rootCmd.PersistentFlags().BoolVarP(&FlagVerbose, "verbose", "v", false, "force print errors")
rootCmd.AddCommand(pipeCmd)
}
func Execute() {
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}
sptlrx-1.2.3/config/ 0000775 0000000 0000000 00000000000 14773255151 0014330 5 ustar 00root root 0000000 0000000 sptlrx-1.2.3/config/config.go 0000664 0000000 0000000 00000010212 14773255151 0016120 0 ustar 00root root 0000000 0000000 package config
import (
"fmt"
"github.com/raitonoberu/sptlrx/player"
"github.com/raitonoberu/sptlrx/services/browser"
"github.com/raitonoberu/sptlrx/services/mopidy"
"github.com/raitonoberu/sptlrx/services/mpd"
"github.com/raitonoberu/sptlrx/services/mpris"
"github.com/raitonoberu/sptlrx/services/spotify"
"os"
"path"
"strconv"
"strings"
gloss "github.com/charmbracelet/lipgloss"
"github.com/creasty/defaults"
"gopkg.in/yaml.v2"
)
var Directory string
var Path string
func init() {
d, err := os.UserConfigDir()
if err != nil {
panic(err)
}
Directory = path.Join(d, "sptlrx")
Path = path.Join(Directory, "config.yaml")
}
type Config struct {
Cookie string `yaml:"cookie"`
Player string `default:"spotify" yaml:"player"`
Host string `default:"lyricsapi.vercel.app" yaml:"host"`
IgnoreErrors bool `default:"true" yaml:"ignoreErrors"`
TimerInterval int `default:"200" yaml:"timerInterval"`
UpdateInterval int `default:"2000" yaml:"updateInterval"`
Style struct {
HAlignment string `default:"center" yaml:"hAlignment"`
Before Style `default:"{\"bold\": true}" yaml:"before"`
Current Style `default:"{\"bold\": true}" yaml:"current"`
After Style `default:"{\"faint\": true}" yaml:"after"`
} `yaml:"style"`
Pipe struct {
Length int `yaml:"length"`
Overflow string `default:"word" yaml:"overflow"`
} `yaml:"pipe"`
Mpd struct {
Address string `default:"127.0.0.1:6600" yaml:"address"`
Password string `yaml:"password"`
} `yaml:"mpd"`
Mopidy struct {
Address string `default:"127.0.0.1:6680" yaml:"address"`
} `yaml:"mopidy"`
Mpris struct {
Players []string `default:"[]" yaml:"players"`
} `yaml:"mpris"`
Browser struct {
Port int `default:"8974" yaml:"port"`
} `yaml:"browser"`
Local struct {
Folder string `yaml:"folder"`
} `yaml:"local"`
}
func New() *Config {
var config = &Config{}
defaults.Set(config)
return config
}
func Load() (*Config, error) {
file, err := os.Open(Path)
if err != nil {
return nil, err
}
defer file.Close()
var config = &Config{}
err = yaml.NewDecoder(file).Decode(config)
return config, err
}
func Save(config *Config) error {
err := os.MkdirAll(Directory, os.ModePerm)
if err != nil {
return err
}
file, err := os.Create(Path)
if err != nil {
return err
}
defer file.Close()
return yaml.NewEncoder(file).Encode(config)
}
// https://stackoverflow.com/a/56080478
func (c *Config) UnmarshalYAML(f func(interface{}) error) error {
defaults.Set(c)
type plain Config
if err := f((*plain)(c)); err != nil {
return err
}
return nil
}
type Style struct {
Background string `yaml:"background"`
Foreground string `yaml:"foreground"`
Bold bool `yaml:"bold"`
Italic bool `yaml:"italic"`
Underline bool `yaml:"underline"`
Strikethrough bool `yaml:"strikethrough"`
Blink bool `yaml:"blink"`
Faint bool `yaml:"faint"`
}
func (s Style) Parse() gloss.Style {
var style gloss.Style
if s.Background != "" && validateColor(s.Background) {
style = style.Background(gloss.Color(s.Background))
style.ColorWhitespace(false)
}
if s.Foreground != "" && validateColor(s.Foreground) {
style = style.Foreground(gloss.Color(s.Foreground))
}
if s.Bold {
style = style.Bold(true)
}
if s.Italic {
style = style.Italic(true)
}
if s.Underline {
style = style.Underline(true)
}
if s.Strikethrough {
style = style.Strikethrough(true)
}
if s.Blink {
style = style.Blink(true)
}
if s.Faint {
style = style.Faint(true)
}
return style
}
func validateColor(color string) bool {
if _, err := strconv.Atoi(color); err == nil {
return true
}
if strings.HasPrefix(color, "#") {
return true
}
return false
}
// GetPlayer returns a player based on config values
func GetPlayer(conf *Config) (player.Player, error) {
switch conf.Player {
case "spotify":
return spotify.New(conf.Cookie)
case "mpd":
return mpd.New(conf.Mpd.Address, conf.Mpd.Password), nil
case "mopidy":
return mopidy.New(conf.Mopidy.Address), nil
case "mpris":
return mpris.New(conf.Mpris.Players)
case "browser":
return browser.New(conf.Browser.Port)
}
return nil, fmt.Errorf("unknown player: \"%s\"", conf.Player)
}
sptlrx-1.2.3/demo.gif 0000664 0000000 0000000 00000631532 14773255151 0014510 0 ustar 00root root 0000000 0000000 GIF89a X1
!!"$ #!$"%'#$'%)',2(.+// - ,!+! H"+"/$".$"1$"6%"3%#5%$8&$0&%6'$3'$5(%4(&2)&5*(8+)5,)9,*6.+<.,90-<1.?2/=30A42>52C63B85F86B;8G<9J<:H=;J=;P=;L?OB?MDAPDASECZEElFDXGDUGEYHDWHETHEVHFZIFWIG[IIxJFZJGVJGXJH[JH\KHYKIWMJ\NKZNL`OL]OMaOP|PL`PM\PM^PN`PNdQMcQN]QN_SP`URbUSgUSiVRfVSdVSVThVTjWTcWTeWTgYVg[V[Xg[Xj\Zh^Z^[k_\m`]l`]`^ta^oa_sb^tb_pb_b`vbarbatc_sc`oebsebwfcrfctfdxfdzgduge{hevhezie{ifwifyig}jgujh|jh~khyki}kilh|lizli{ljxlj~ljmimj{mkmkml}nj~nk|nlznlol}olonplpm~pnpnqn}rnrospurwtyvzw|y}z~{}ôƸȻ̽ !NETSCAPE2.0 ! , X
!!"$ #!$"%'#$'%)',2(.+// - ,!+! H"+"/$".$"1$"6%"3%#5%$8&$0&%6'$3'$5(%4(&2)&5*(8+)5,)9,*6.+<.,90-<1.?2/=30A42>52C63B85F86B;8G<9J<:H=;J=;P=;L?OB?MDAPDASECZEElFDXGDUGEYHDWHETHEVHFZIFWIG[IIxJFZJGVJGXJH[JH\KHYKIWMJ\NKZNL`OL]OMaOP|PL`PM\PM^PN`PNdQMcQN]QN_SP`URbUSgUSiVRfVSdVSVThVTjWTcWTeWTgYVg[V[Xg[Xj\Zh^Z^[k_\m`]l`]`^ta^oa_sb^tb_pb_b`vbarbatc_sc`oebsebwfcrfctfdxfdzgduge{hevhezie{ifwifyig}jgujh|jh~khyki}kilh|lizli{ljxlj~ljmimj{mkmkml}nj~nk|nlznlol}olonplpm~pnpnqn}rnrospurwtyvzw|y}z~{}ôƸȻ̽ ?H*\ȰÇ#JHŋ3jȱǏ CIɓ(S\ɲ˗0cʜI͛8sɳϟ@
JѣH*]ʴӧPJJիXjʵׯ`ÊKٳhӪ]˶۷pʝKݻx˷߿LÈ+^̸ǐ#KL˘3k̹ϠCMӨS^ͺװc˞M۸sͻNȓ+_μУKNسkνËOӫ_Ͼ˟O_t (h`ς6`?(Vhfv ($h(&B :(ch8<@)$.3BC6PF)TViE( ;H&W)dih
v`)Dp&pΩgwpf