From 2d8f7aeb4e92518c9352f6339a5d4821450fb86a Mon Sep 17 00:00:00 2001 From: Kyle Sanderson Date: Wed, 19 Oct 2022 12:52:31 -0700 Subject: [PATCH] feat(releases): retry failed downloads (#491) * feat(download): implement parsing and retry * feat: retry torrent file downloads * refactor: error handling downloadtorrentfile * feat: add tests for download torrent file * build: add runs-on self-hosted * build: add runs-on self-hosted --- .github/workflows/release.yml | 6 +- internal/action/deluge.go | 4 +- internal/action/qbittorrent.go | 14 +- internal/action/rtorrent.go | 2 +- internal/action/transmission.go | 3 +- internal/client/http.go | 153 +++++-------- internal/domain/release.go | 91 +++++--- internal/domain/release_test.go | 216 ++++++++++++++++++ ...nux-2011.08.19-netinstall-i686.iso.torrent | Bin 0 -> 12792 bytes internal/filter/service.go | 3 +- 10 files changed, 349 insertions(+), 143 deletions(-) create mode 100644 internal/domain/testdata/archlinux-2011.08.19-netinstall-i686.iso.torrent diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 483dda9..cb40c38 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ permissions: jobs: web: name: Build web - runs-on: ubuntu-latest + runs-on: self-hosted steps: - name: Checkout uses: actions/checkout@v3 @@ -44,7 +44,7 @@ jobs: goreleaserbuild: name: Build Go binaries if: github.event_name == 'pull_request' - runs-on: ubuntu-latest + runs-on: self-hosted needs: web steps: - name: Checkout @@ -82,7 +82,7 @@ jobs: goreleaser: name: Build & publish binaries and images if: startsWith(github.ref, 'refs/tags/') - runs-on: ubuntu-latest + runs-on: self-hosted needs: web steps: - name: Checkout diff --git a/internal/action/deluge.go b/internal/action/deluge.go index 0ce2d37..ae9f0d5 100644 --- a/internal/action/deluge.go +++ b/internal/action/deluge.go @@ -117,7 +117,7 @@ func (s *service) delugeV1(client *domain.DownloadClient, action domain.Action, } if release.TorrentTmpFile == "" { - if err = release.DownloadTorrentFile(); err != nil { + if err := release.DownloadTorrentFile(); err != nil { s.log.Error().Err(err).Msgf("could not download torrent file for release: %v", release.TorrentName) return nil, err } @@ -206,7 +206,7 @@ func (s *service) delugeV2(client *domain.DownloadClient, action domain.Action, } if release.TorrentTmpFile == "" { - if err = release.DownloadTorrentFile(); err != nil { + if err := release.DownloadTorrentFile(); err != nil { s.log.Error().Err(err).Msgf("could not download torrent file for release: %v", release.TorrentName) return nil, err } diff --git a/internal/action/qbittorrent.go b/internal/action/qbittorrent.go index fa431f7..a530601 100644 --- a/internal/action/qbittorrent.go +++ b/internal/action/qbittorrent.go @@ -68,8 +68,7 @@ func (s *service) qbittorrent(action domain.Action, release domain.Release) ([]s } if release.TorrentTmpFile == "" { - err = release.DownloadTorrentFile() - if err != nil { + if err := release.DownloadTorrentFile(); err != nil { return nil, errors.Wrap(err, "error downloading torrent file for release: %v", release.TorrentName) } } @@ -279,11 +278,12 @@ func (s *service) reannounceTorrent(qb *qbittorrent.Client, action domain.Action // Check if status not working or something else // https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#get-torrent-trackers -// 0 Tracker is disabled (used for DHT, PeX, and LSD) -// 1 Tracker has not been contacted yet -// 2 Tracker has been contacted and is working -// 3 Tracker is updating -// 4 Tracker has been contacted, but it is not working (or doesn't send proper replies) +// +// 0 Tracker is disabled (used for DHT, PeX, and LSD) +// 1 Tracker has not been contacted yet +// 2 Tracker has been contacted and is working +// 3 Tracker is updating +// 4 Tracker has been contacted, but it is not working (or doesn't send proper replies) func isTrackerStatusOK(trackers []qbittorrent.TorrentTracker) bool { for _, tracker := range trackers { if tracker.Status == qbittorrent.TrackerStatusDisabled { diff --git a/internal/action/rtorrent.go b/internal/action/rtorrent.go index 97ef720..2ce44aa 100644 --- a/internal/action/rtorrent.go +++ b/internal/action/rtorrent.go @@ -28,7 +28,7 @@ func (s *service) rtorrent(action domain.Action, release domain.Release) ([]stri var rejections []string if release.TorrentTmpFile == "" { - if err = release.DownloadTorrentFile(); err != nil { + if err := release.DownloadTorrentFile(); err != nil { s.log.Error().Err(err).Msgf("could not download torrent file for release: %v", release.TorrentName) return nil, err } diff --git a/internal/action/transmission.go b/internal/action/transmission.go index 4ba9a8b..fb6b3d5 100644 --- a/internal/action/transmission.go +++ b/internal/action/transmission.go @@ -28,8 +28,7 @@ func (s *service) transmission(action domain.Action, release domain.Release) ([] var rejections []string if release.TorrentTmpFile == "" { - err = release.DownloadTorrentFile() - if err != nil { + if err := release.DownloadTorrentFile(); err != nil { s.log.Error().Err(err).Msgf("could not download torrent file for release: %v", release.TorrentName) return nil, err } diff --git a/internal/client/http.go b/internal/client/http.go index 6e55b70..27572cb 100644 --- a/internal/client/http.go +++ b/internal/client/http.go @@ -1,14 +1,15 @@ package client import ( - "errors" "io" "net/http" "os" "time" - "github.com/anacrolix/torrent/metainfo" + "github.com/autobrr/autobrr/pkg/errors" + "github.com/anacrolix/torrent/metainfo" + "github.com/avast/retry-go" "github.com/rs/zerolog/log" ) @@ -35,58 +36,6 @@ func NewHttpClient() *HttpClient { } } -func (c *HttpClient) DownloadFile(url string, opts map[string]string) (*DownloadFileResponse, error) { - if url == "" { - return nil, errors.New("download_file: url can't be empty") - } - - // Create tmp file - tmpFile, err := os.CreateTemp("", "autobrr-") - if err != nil { - log.Error().Stack().Err(err).Msg("error creating temp file") - return nil, err - } - defer tmpFile.Close() - - // Get the data - resp, err := http.Get(url) - if err != nil { - log.Error().Stack().Err(err).Msgf("error downloading file from %v", url) - return nil, err - } - defer resp.Body.Close() - - // retry logic - - if resp.StatusCode != http.StatusOK { - log.Error().Stack().Err(err).Msgf("error downloading file from: %v - bad status: %d", url, resp.StatusCode) - return nil, err - } - - // Write the body to file - _, err = io.Copy(tmpFile, resp.Body) - if err != nil { - log.Error().Stack().Err(err).Msgf("error writing downloaded file: %v", tmpFile.Name()) - return nil, err - } - - // remove file if fail - - res := DownloadFileResponse{ - Body: &resp.Body, - FileName: tmpFile.Name(), - } - - if res.FileName == "" || res.Body == nil { - log.Error().Stack().Err(err).Msgf("tmp file error - empty body: %v", url) - return nil, errors.New("error downloading file, no tmp file") - } - - log.Debug().Msgf("successfully downloaded file: %v", tmpFile.Name()) - - return &res, nil -} - func (c *HttpClient) DownloadTorrentFile(url string, opts map[string]string) (*DownloadTorrentFileResponse, error) { if url == "" { return nil, errors.New("download_file: url can't be empty") @@ -100,47 +49,63 @@ func (c *HttpClient) DownloadTorrentFile(url string, opts map[string]string) (*D } defer tmpFile.Close() - // Get the data - resp, err := http.Get(url) + res := &DownloadTorrentFileResponse{} + // try request and if fail run 3 retries + err = retry.Do(func() error { + resp, err := http.Get(url) + if err != nil { + return errors.New("error downloading file: %q", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return errors.New("error downloading file bad status: %d", resp.StatusCode) + } + + nuke := func() { + tmpFile.Seek(0, io.SeekStart) + tmpFile.Truncate(0) + } + + // Write the body to file + _, err = io.Copy(tmpFile, resp.Body) + if err != nil { + nuke() + return errors.New("error writing downloaded file: %v | %q", tmpFile.Name(), err) + } + + meta, err := metainfo.Load(resp.Body) + if err != nil { + nuke() + return errors.New("metainfo could not load file contents: %v | %q", tmpFile.Name(), err) + } + + res = &DownloadTorrentFileResponse{ + MetaInfo: meta, + TmpFileName: tmpFile.Name(), + } + + if res.TmpFileName == "" || res.MetaInfo == nil { + nuke() + return errors.New("tmp file error - empty body") + } + + if len(res.MetaInfo.InfoBytes) < 1 { + nuke() + return errors.New("could not read infohash") + } + + log.Debug().Msgf("successfully downloaded file: %v", tmpFile.Name()) + return nil + }, + //retry.OnRetry(func(n uint, err error) { c.log.Printf("%q: attempt %d - %v\n", err, n, url) }), + retry.Delay(time.Second*5), + retry.Attempts(3), + retry.MaxJitter(time.Second*1)) + if err != nil { - log.Error().Stack().Err(err).Msgf("error downloading file from %v", url) - return nil, err - } - defer resp.Body.Close() - - // retry logic - - if resp.StatusCode != http.StatusOK { - log.Error().Stack().Err(err).Msgf("error downloading file from: %v - bad status: %d", url, resp.StatusCode) - return nil, err + res = nil } - // Write the body to file - _, err = io.Copy(tmpFile, resp.Body) - if err != nil { - log.Error().Stack().Err(err).Msgf("error writing downloaded file: %v", tmpFile.Name()) - return nil, err - } - - meta, err := metainfo.Load(resp.Body) - if err != nil { - log.Error().Stack().Err(err).Msgf("metainfo could not load file contents: %v", tmpFile.Name()) - return nil, err - } - - // remove file if fail - - res := DownloadTorrentFileResponse{ - MetaInfo: meta, - TmpFileName: tmpFile.Name(), - } - - if res.TmpFileName == "" || res.MetaInfo == nil { - log.Error().Stack().Err(err).Msgf("tmp file error - empty body: %v", url) - return nil, errors.New("error downloading file, no tmp file") - } - - log.Debug().Msgf("successfully downloaded file: %v", tmpFile.Name()) - - return &res, nil + return res, err } diff --git a/internal/domain/release.go b/internal/domain/release.go index 23b8ec4..4038d97 100644 --- a/internal/domain/release.go +++ b/internal/domain/release.go @@ -17,6 +17,7 @@ import ( "github.com/autobrr/autobrr/pkg/errors" "github.com/anacrolix/torrent/metainfo" + "github.com/avast/retry-go" "github.com/dustin/go-humanize" "github.com/moistari/rls" "golang.org/x/net/publicsuffix" @@ -222,6 +223,8 @@ func (r *Release) ParseString(title string) { return } +var ErrUnrecoverableError = errors.New("unrecoverable error") + func (r *Release) ParseReleaseTagsString(tags string) { // trim delimiters and closest space re := regexp.MustCompile(`\| |/ |, `) @@ -292,9 +295,10 @@ func (r *Release) DownloadTorrentFile() error { client := &http.Client{ Transport: customTransport, Jar: jar, + Timeout: time.Second * 45, } - req, err := http.NewRequest("GET", r.TorrentURL, nil) + req, err := http.NewRequest(http.MethodGet, r.TorrentURL, nil) if err != nil { return errors.Wrap(err, "error downloading file") } @@ -305,19 +309,6 @@ func (r *Release) DownloadTorrentFile() error { req.Header.Set("Cookie", r.RawCookie) } - // Get the data - resp, err := client.Do(req) - if err != nil { - return errors.Wrap(err, "error downloading file") - } - defer resp.Body.Close() - - // retry logic - - if resp.StatusCode != http.StatusOK { - return errors.New("error downloading torrent (%v) file (%v) from '%v' - status code: %d", r.TorrentName, r.TorrentURL, r.Indexer, resp.StatusCode) - } - // Create tmp file tmpFile, err := os.CreateTemp("", "autobrr-") if err != nil { @@ -325,29 +316,65 @@ func (r *Release) DownloadTorrentFile() error { } defer tmpFile.Close() - // Write the body to file - _, err = io.Copy(tmpFile, resp.Body) - if err != nil { - return errors.Wrap(err, "error writing downloaded file: %v", tmpFile.Name()) - } + errFunc := retry.Do(func() error { + // Get the data + resp, err := client.Do(req) + if err != nil { + return errors.Wrap(err, "error downloading file") + } + defer resp.Body.Close() - meta, err := metainfo.LoadFromFile(tmpFile.Name()) - if err != nil { - return errors.Wrap(err, "metainfo could not load file contents: %v", tmpFile.Name()) - } + if resp.StatusCode != http.StatusOK { + unRecoverableErr := errors.Wrap(ErrUnrecoverableError, "unrecoverable error downloading torrent (%v) file (%v) from '%v' - status code: %d", r.TorrentName, r.TorrentURL, r.Indexer, resp.StatusCode) - torrentMetaInfo, err := meta.UnmarshalInfo() - if err != nil { - return errors.Wrap(err, "metainfo could not unmarshal info from torrent: %v", tmpFile.Name()) - } + if resp.StatusCode == 401 || resp.StatusCode == 403 || resp.StatusCode == 404 || resp.StatusCode == 405 { + return retry.Unrecoverable(unRecoverableErr) + } - r.TorrentTmpFile = tmpFile.Name() - r.TorrentHash = meta.HashInfoBytes().String() - r.Size = uint64(torrentMetaInfo.TotalLength()) + return errors.New("unexpected status: %v", resp.StatusCode) + } - // remove file if fail + resetTmpFile := func() { + tmpFile.Seek(0, io.SeekStart) + tmpFile.Truncate(0) + } - return nil + // Write the body to file + if _, err := io.Copy(tmpFile, resp.Body); err != nil { + resetTmpFile() + return errors.Wrap(err, "error writing downloaded file: %v", tmpFile.Name()) + } + + meta, err := metainfo.LoadFromFile(tmpFile.Name()) + if err != nil { + resetTmpFile() + return errors.Wrap(err, "metainfo could not load file contents: %v", tmpFile.Name()) + } + + torrentMetaInfo, err := meta.UnmarshalInfo() + if err != nil { + resetTmpFile() + return errors.Wrap(err, "metainfo could not unmarshal info from torrent: %v", tmpFile.Name()) + } + + hashInfoBytes := meta.HashInfoBytes().Bytes() + if len(hashInfoBytes) < 1 { + resetTmpFile() + return errors.New("could not read infohash") + } + + r.TorrentTmpFile = tmpFile.Name() + r.TorrentHash = meta.HashInfoBytes().String() + r.Size = uint64(torrentMetaInfo.TotalLength()) + + return nil + }, + retry.Delay(time.Second*3), + retry.Attempts(3), + retry.MaxJitter(time.Second*1), + ) + + return errFunc } func (r *Release) addRejection(reason string) { diff --git a/internal/domain/release_test.go b/internal/domain/release_test.go index 1285a45..b5a3649 100644 --- a/internal/domain/release_test.go +++ b/internal/domain/release_test.go @@ -1,8 +1,15 @@ package domain import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" "testing" + "time" + "github.com/rs/zerolog" "github.com/stretchr/testify/assert" ) @@ -630,3 +637,212 @@ func TestRelease_ParseString(t *testing.T) { }) } } + +var trackerLessTestTorrent = `d7:comment19:This is just a test10:created by12:Johnny Bravo13:creation datei1430648794e8:encoding5:UTF-84:infod6:lengthi1128e4:name12:testfile.bin12:piece lengthi32768e6:pieces20:Õˆë =‘UŒäiÎ^æ °Eâ?ÇÒe5:nodesl35:udp://tracker.openbittorrent.com:8035:udp://tracker.openbittorrent.com:80ee` + +func TestRelease_DownloadTorrentFile(t *testing.T) { + // disable logger + zerolog.SetGlobalLevel(zerolog.Disabled) + + mux := http.NewServeMux() + ts := httptest.NewServer(mux) + defer ts.Close() + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.RequestURI, "401") { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte("unauthorized")) + return + } + if strings.Contains(r.RequestURI, "403") { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte("forbidden")) + return + } + if strings.Contains(r.RequestURI, "404") { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte("not found")) + return + } + if strings.Contains(r.RequestURI, "405") { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte("method not allowed")) + return + } + + if strings.Contains(r.RequestURI, "file.torrent") { + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/x-bittorrent") + payload, _ := os.ReadFile("testdata/archlinux-2011.08.19-netinstall-i686.iso.torrent") + w.Write(payload) + return + } + + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("internal error")) + }) + + type fields struct { + ID int64 + FilterStatus ReleaseFilterStatus + Rejections []string + Indexer string + FilterName string + Protocol ReleaseProtocol + Implementation ReleaseImplementation + Timestamp time.Time + GroupID string + TorrentID string + TorrentURL string + TorrentTmpFile string + TorrentDataRawBytes []byte + TorrentHash string + TorrentName string + Size uint64 + Title string + Category string + Categories []string + Season int + Episode int + Year int + Resolution string + Source string + Codec []string + Container string + HDR []string + Audio []string + AudioChannels string + Group string + Region string + Language string + Proper bool + Repack bool + Website string + Artists string + Type string + LogScore int + IsScene bool + Origin string + Tags []string + ReleaseTags string + Freeleech bool + FreeleechPercent int + Bonus []string + Uploader string + PreTime string + Other []string + RawCookie string + AdditionalSizeCheckRequired bool + FilterID int + Filter *Filter + ActionStatus []ReleaseActionStatus + } + tests := []struct { + name string + fields fields + wantErr assert.ErrorAssertionFunc + }{ + { + name: "401", + fields: fields{ + Indexer: "mock-indexer", + TorrentName: "Test.Release-GROUP", + TorrentURL: fmt.Sprintf("%v/%v", ts.URL, 401), + }, + wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { + if err != nil { + return true + } + return false + }, + }, + { + name: "403", + fields: fields{ + Indexer: "mock-indexer", + TorrentName: "Test.Release-GROUP", + TorrentURL: fmt.Sprintf("%v/%v", ts.URL, 403), + }, + wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { + if err != nil { + return true + } + return false + }, + }, + { + name: "ok", + fields: fields{ + Indexer: "mock-indexer", + TorrentName: "Test.Release-GROUP", + TorrentURL: fmt.Sprintf("%v/%v", ts.URL, "file.torrent"), + }, + wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { + if err != nil { + return true + } + return false + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Release{ + ID: tt.fields.ID, + FilterStatus: tt.fields.FilterStatus, + Rejections: tt.fields.Rejections, + Indexer: tt.fields.Indexer, + FilterName: tt.fields.FilterName, + Protocol: tt.fields.Protocol, + Implementation: tt.fields.Implementation, + Timestamp: tt.fields.Timestamp, + GroupID: tt.fields.GroupID, + TorrentID: tt.fields.TorrentID, + TorrentURL: tt.fields.TorrentURL, + TorrentTmpFile: tt.fields.TorrentTmpFile, + TorrentDataRawBytes: tt.fields.TorrentDataRawBytes, + TorrentHash: tt.fields.TorrentHash, + TorrentName: tt.fields.TorrentName, + Size: tt.fields.Size, + Title: tt.fields.Title, + Category: tt.fields.Category, + Categories: tt.fields.Categories, + Season: tt.fields.Season, + Episode: tt.fields.Episode, + Year: tt.fields.Year, + Resolution: tt.fields.Resolution, + Source: tt.fields.Source, + Codec: tt.fields.Codec, + Container: tt.fields.Container, + HDR: tt.fields.HDR, + Audio: tt.fields.Audio, + AudioChannels: tt.fields.AudioChannels, + Group: tt.fields.Group, + Region: tt.fields.Region, + Language: tt.fields.Language, + Proper: tt.fields.Proper, + Repack: tt.fields.Repack, + Website: tt.fields.Website, + Artists: tt.fields.Artists, + Type: tt.fields.Type, + LogScore: tt.fields.LogScore, + IsScene: tt.fields.IsScene, + Origin: tt.fields.Origin, + Tags: tt.fields.Tags, + ReleaseTags: tt.fields.ReleaseTags, + Freeleech: tt.fields.Freeleech, + FreeleechPercent: tt.fields.FreeleechPercent, + Bonus: tt.fields.Bonus, + Uploader: tt.fields.Uploader, + PreTime: tt.fields.PreTime, + Other: tt.fields.Other, + RawCookie: tt.fields.RawCookie, + AdditionalSizeCheckRequired: tt.fields.AdditionalSizeCheckRequired, + FilterID: tt.fields.FilterID, + Filter: tt.fields.Filter, + ActionStatus: tt.fields.ActionStatus, + } + tt.wantErr(t, r.DownloadTorrentFile(), fmt.Sprintf("DownloadTorrentFile()")) + }) + } +} diff --git a/internal/domain/testdata/archlinux-2011.08.19-netinstall-i686.iso.torrent b/internal/domain/testdata/archlinux-2011.08.19-netinstall-i686.iso.torrent new file mode 100644 index 0000000000000000000000000000000000000000..9ce7748aaa46f90aa56c29699b763896fc08af91 GIT binary patch literal 12792 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%0D*f1Euc8hO)={iLER+R%jSr zbL?P%?sN9biGTMOc+jTymO*29v25GHp=j6tnmOR_rn-m6z@fahu>T1C$DoHn9ibNX z*eg~Ttn+(w|66S+C>rVr;r+R1z*4xk%+XRhVD3;6zmtWr72MC&5y+vddr^YH z01N^QutNS)7wV7Q>%i4LI2?@u?ScPG+dJsddq4zwe;#0=f9jS8_I}0%0Hd(lfTC@& zqXDatW7P0Bd)5%po(iGAs|fYyGCFJlzbs>k+#h)WDD1zc^LH-nkqA2owv_*{c{^;` zdmPz-u(M__OdI=sdun_40{LXIqwVLPjwDv-pmiK-!PfPtV*b$%T@Y|9bA;{jWi5P= zg~PS25C{tt!V(RDIUY44e6ZI(eEb)Q`+G?k<{yXyA^#@Z8r%B-b{7AhV3C6a|6*C_ zznudPX!>Od4*QRj1b9HgzVu-jj1>U>Qwj(a-_M-vZx4>~`CuP$NDLf=L1Tv?01p0l z+~Iuv<@8_Rh@)Hr54g04BG5=%#D13c#@$|-{vzvt(f?k!NGwfza>Srv0Q*0jTSN{T z=`UjdkOkH}8Un*^zU=_WfPV*jNY~yO@krr_9GzKC z2qX-fNPyijgZk)j1h|0U77*;9#~hQzzjO!vAB1?2!p`L4mM$ivchw20P4;Z+yT5gL_DO1QHFyw%vpIhpm!7 zbo)b{9|Q%k$GEy;%L8ovkMItPX@6DhWWd(YuQM0g>EF|E*w|6lAPa;Oc8pkHCmG`B zY3*3#fyKS}(6$GNpT#fxZ>{-IEc=Z9z6TCKAG3Y``<(t6p)C~R2*7~;%QO%^aP^Q$ z6viF}`FUzPQUZq#f2w zf3kHjQ1;HdR_Hwkt*|c*z}EZ5j@?`xZVP|<2>`*tFzm+M2?T}iqyM1Ef6N(y|5imu z=@I-NVKD4?vHL0Kzp#lFkR=8N`$sJtWz`O`|Njn-e