From 12f7ff31ad24efbd974cbbc10bf17d6b92f60f16 Mon Sep 17 00:00:00 2001 From: Prayuj Tuli Date: Tue, 2 Jun 2026 01:11:15 -0400 Subject: [PATCH 1/2] refactor: swap Spotify for iTunes, restructure providers as adapters, drop Python Replace the Spotify metadata source with the auth-free iTunes Search API and reorganize providers into ports-and-adapters packages (interface.go + impl/ + mocks/) so the api/pool layers depend on interfaces and domain models instead of SDK types. - metadata: new itunes adapter (parallel song/album/artist search, full-track album lookup, artwork upscaling); Spotify package and zmb3/spotify + oauth2 deps removed; SPOTIFY_* env dropped from nimbus.yaml - adapters: youtube, filesystem, lrclib, store moved under internal/adapters with interface/impl/mocks; consumers wired via interfaces - youtube search: replaced python ytmusicapi script with yt-dlp ytsearch + Go-side title/artist/duration ranking; deleted scripts/yt-music.py and Python from the Dockerfile - models/constants: provider-neutral Track/Album/Artist/SearchResult and EntityType replace Spotify-coupled types Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 7 +- cmd/main.go | 32 +- go.mod | 2 - go.sum | 378 ------------------ .../filesystem/impl}/filesystem.go | 24 +- internal/adapters/filesystem/interface.go | 19 + internal/adapters/filesystem/mocks/mock.go | 31 ++ internal/adapters/itunes/impl/itunes.go | 292 ++++++++++++++ internal/adapters/itunes/interface.go | 23 ++ internal/adapters/itunes/mocks/mock.go | 47 +++ internal/adapters/lrclib/impl/lrclib.go | 131 ++++++ internal/adapters/lrclib/interface.go | 43 ++ internal/adapters/lrclib/mocks/mock.go | 22 + .../store => adapters/store/impl}/store.go | 41 +- internal/adapters/store/interface.go | 32 ++ internal/adapters/store/mocks/mock.go | 41 ++ internal/adapters/youtube/impl/youtube.go | 220 ++++++++++ internal/adapters/youtube/interface.go | 16 + internal/adapters/youtube/mocks/mock.go | 30 ++ internal/api/api.go | 55 ++- internal/api/helpers.go | 122 +++--- internal/constants/constants.go | 8 +- internal/models/models.go | 38 ++ internal/pool/pool.go | 14 +- internal/providers/lrclib/lrclib.go | 73 ---- internal/providers/lrclib/models.go | 85 ---- internal/providers/providers.go | 3 - internal/providers/spotify/spotify.go | 132 ------ internal/providers/youtube/youtube.go | 79 ---- nimbus.yaml | 4 - scripts/yt-music.py | 39 -- 31 files changed, 1117 insertions(+), 966 deletions(-) rename internal/{providers/filesystem => adapters/filesystem/impl}/filesystem.go (83%) create mode 100644 internal/adapters/filesystem/interface.go create mode 100644 internal/adapters/filesystem/mocks/mock.go create mode 100644 internal/adapters/itunes/impl/itunes.go create mode 100644 internal/adapters/itunes/interface.go create mode 100644 internal/adapters/itunes/mocks/mock.go create mode 100644 internal/adapters/lrclib/impl/lrclib.go create mode 100644 internal/adapters/lrclib/interface.go create mode 100644 internal/adapters/lrclib/mocks/mock.go rename internal/{providers/store => adapters/store/impl}/store.go (71%) create mode 100644 internal/adapters/store/interface.go create mode 100644 internal/adapters/store/mocks/mock.go create mode 100644 internal/adapters/youtube/impl/youtube.go create mode 100644 internal/adapters/youtube/interface.go create mode 100644 internal/adapters/youtube/mocks/mock.go delete mode 100644 internal/providers/lrclib/lrclib.go delete mode 100644 internal/providers/lrclib/models.go delete mode 100644 internal/providers/providers.go delete mode 100644 internal/providers/spotify/spotify.go delete mode 100644 internal/providers/youtube/youtube.go delete mode 100644 scripts/yt-music.py diff --git a/Dockerfile b/Dockerfile index 0370fc0..6b0f7cf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,17 +8,14 @@ RUN apk update && apk add -U make RUN make build -FROM python:3.12-alpine +FROM alpine:3.20 WORKDIR /app COPY --from=build /app/bin/audio-scraper /app/audio-scraper -COPY ./scripts /app/scripts RUN apk update && \ - apk add -U yt-dlp-core ffmpeg - -RUN pip install ytmusicapi eyed3 + apk add -U yt-dlp ffmpeg EXPOSE 8080 diff --git a/cmd/main.go b/cmd/main.go index 7554b32..09b5dc2 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -9,15 +9,15 @@ import ( "github.com/gorilla/mux" + filesystemimpl "audio-scraper/internal/adapters/filesystem/impl" + itunesimpl "audio-scraper/internal/adapters/itunes/impl" + lrclibimpl "audio-scraper/internal/adapters/lrclib/impl" + storeimpl "audio-scraper/internal/adapters/store/impl" + youtubeimpl "audio-scraper/internal/adapters/youtube/impl" "audio-scraper/internal/api" "audio-scraper/internal/constants" "audio-scraper/internal/logger" "audio-scraper/internal/pool" - "audio-scraper/internal/providers/filesystem" - "audio-scraper/internal/providers/lrclib" - "audio-scraper/internal/providers/spotify" - "audio-scraper/internal/providers/store" - "audio-scraper/internal/providers/youtube" ) func main() { @@ -29,15 +29,11 @@ func main() { } log.Info("started server", "host", "0.0.0.0", "port", port) - sp, err := spotify.NewSpotifyProvider(os.Getenv("SPOTIFY_CLIENT_ID"), os.Getenv("SPOTIFY_CLIENT_SECRET")) - if err != nil { - log.Error("failed to initialize Spotify provider", "error", err) - return - } - st := store.NewStoreProvider(log) - yt := youtube.NewYTProvider() - lrc := lrclib.NewLrclibProvider() - fs, err := filesystem.NewFSProvider(os.Getenv("MUSIC_HOME"), lrc) + md := itunesimpl.New() + st := storeimpl.New(log) + yt := youtubeimpl.New() + lrc := lrclibimpl.New() + fs, err := filesystemimpl.New(os.Getenv("MUSIC_HOME"), lrc) if err != nil { log.Error("failed to initialize filesystem provider", "error", err) return @@ -54,10 +50,10 @@ func main() { FS: fs, }) h := api.NewHandlers(&api.Deps{ - Log: log, - Spotify: sp, - Store: st, - Queue: q, + Log: log, + Metadata: md, + Store: st, + Queue: q, }) router := mux.NewRouter() router.HandleFunc("/", h.HealthHandler).Methods("GET") diff --git a/go.mod b/go.mod index fdf4ac6..df117d9 100644 --- a/go.mod +++ b/go.mod @@ -7,8 +7,6 @@ require ( github.com/faiface/beep v1.1.0 github.com/google/uuid v1.6.0 github.com/gorilla/mux v1.8.1 - github.com/zmb3/spotify/v2 v2.4.3 - golang.org/x/oauth2 v0.33.0 ) require ( diff --git a/go.sum b/go.sum index 6343d36..0faaafb 100644 --- a/go.sum +++ b/go.sum @@ -1,54 +1,7 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= -cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= -cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= -cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= -cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= -cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= -cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= -cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= -cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= -cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= -cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= -cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= -cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= -cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= -cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= -cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= -cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= -cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= -cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= -cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= -cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= -cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= -cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= -cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= -cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= -cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= -cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= -cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= -cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= -cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= -dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/DATA-DOG/go-sqlmock v1.3.3/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI= github.com/bogem/id3v2/v2 v2.1.4/go.mod h1:l+gR8MZ6rc9ryPTPkX77smS5Me/36gxkMgDayZ9G1vY= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/d4l3k/messagediff v1.2.2-0.20190829033028-7e0a312ae40b/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo= -github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/faiface/beep v1.1.0 h1:A2gWP6xf5Rh7RG/p9/VAW2jRSDEGQm5sbOb38sf5d4c= github.com/faiface/beep v1.1.0/go.mod h1:6I8p6kK2q4opL/eWb+kAkk38ehnTunWeToJB+s51sT4= github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= @@ -56,78 +9,18 @@ github.com/gdamore/tcell v1.3.0/go.mod h1:Hjvr+Ofd+gLglo7RYKxxnzCBmev3BzsS67MebK github.com/go-audio/audio v1.0.0/go.mod h1:6uAu0+H2lHkwdGsAY+j2wHPNPpPoeg5AaEFh9FlA+Zs= github.com/go-audio/riff v1.0.0/go.mod h1:l3cQwc85y79NQFCRB7TiPoNiaijp6q8Z0Uv38rVG498= github.com/go-audio/wav v1.0.0/go.mod h1:3yoReyQOsiARkvPl3ERCi8JFjihzG6WhjYpZCf5zAWE= -github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= -github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= -github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= -github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= -github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/hajimehoshi/go-mp3 v0.3.0 h1:fTM5DXjp/DL2G74HHAs/aBGiS9Tg7wnp+jkU38bHy4g= github.com/hajimehoshi/go-mp3 v0.3.0/go.mod h1:qMJj/CSDxx6CGHiZeCgbiq2DSUkbK0UbtXShQcnfyMM= github.com/hajimehoshi/oto v0.6.1/go.mod h1:0QXGEkbuJRohbJaxr7ZQSxnju7hEhseiPx2hrh6raOI= github.com/hajimehoshi/oto v0.7.1/go.mod h1:wovJ8WWMfFKvP587mhHgot/MBr4DnNy9m6EepeVGnos= -github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/icza/bitio v1.0.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A= github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA= github.com/jfreymuth/oggvorbis v1.0.1/go.mod h1:NqS+K+UXKje0FUYUPosyQ+XTVvjmVjps1aEZH1sumIk= github.com/jfreymuth/vorbis v1.0.0/go.mod h1:8zy3lUAm9K/rJJk223RKy6vjCZTWC61NA2QD06bfOE0= -github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= -github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1fYclkSPilDOKW0s= github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mewkiz/flac v1.0.7/go.mod h1:yU74UH277dBUpqxPouHSQIar3G1X/QIclVbFahSd1pU= @@ -135,308 +28,37 @@ github.com/mewkiz/pkg v0.0.0-20190919212034-518ade7978e2/go.mod h1:3E2FUC/qYUfM8 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/zmb3/spotify/v2 v2.4.3 h1:4divquzK2Mzo90XVIij4K7Z98Hf+6A3qPnksqtcDIuo= -github.com/zmb3/spotify/v2 v2.4.3/go.mod h1:XOV7BrThayFYB9AAfB+L0Q0wyxBuLCARk4fI/ZXCBW8= -go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= -go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= -go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= -golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= -golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= -golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= -golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= golang.org/x/image v0.0.0-20190220214146-31aff87c08e9/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= -golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= -golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= -golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= -golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= -golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= -golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20210810183815-faf39c7919d5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= -golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= -golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= -golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= -google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= -google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= -google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= -google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= -google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= -google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= -google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= -google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= -google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= -google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= -google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= -honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= -rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= -rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/internal/providers/filesystem/filesystem.go b/internal/adapters/filesystem/impl/filesystem.go similarity index 83% rename from internal/providers/filesystem/filesystem.go rename to internal/adapters/filesystem/impl/filesystem.go index 0ea986f..3389cee 100644 --- a/internal/providers/filesystem/filesystem.go +++ b/internal/adapters/filesystem/impl/filesystem.go @@ -1,4 +1,6 @@ -package filesystem +// Package filesystemimpl implements the filesystem.Provider port: it writes +// downloaded audio into a MUSIC_HOME/Artist/Album tree and tags the files. +package filesystemimpl import ( "context" @@ -14,27 +16,33 @@ import ( "github.com/bogem/id3v2/v2" + "audio-scraper/internal/adapters/filesystem" + "audio-scraper/internal/adapters/lrclib" "audio-scraper/internal/logger" "audio-scraper/internal/models" - "audio-scraper/internal/providers/lrclib" ) -type FSClient struct { +// Client is the local-filesystem library provider. +type Client struct { musicHome string - lrc *lrclib.LrclibClient + lrc lrclib.Provider } -func NewFSProvider(musicHome string, lrc *lrclib.LrclibClient) (*FSClient, error) { +var _ filesystem.Provider = (*Client)(nil) + +// New returns a filesystem provider rooted at musicHome, using lrc to resolve +// lyrics during tagging. +func New(musicHome string, lrc lrclib.Provider) (*Client, error) { if musicHome == "" { return nil, errors.New("missing MUSIC_HOME") } - return &FSClient{ + return &Client{ musicHome: musicHome, lrc: lrc, }, nil } -func (f *FSClient) InitializePath(ctx context.Context, job *models.DownloadJob) (string, error) { +func (f *Client) InitializePath(ctx context.Context, job *models.DownloadJob) (string, error) { log := logger.From(ctx) path := filepath.Join( f.musicHome, @@ -63,7 +71,7 @@ func (f *FSClient) InitializePath(ctx context.Context, job *models.DownloadJob) return outputPath, nil } -func (f *FSClient) TagFile(ctx context.Context, filePath string, job *models.DownloadJob) error { +func (f *Client) TagFile(ctx context.Context, filePath string, job *models.DownloadJob) error { log := logger.From(ctx) tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true}) if err != nil { diff --git a/internal/adapters/filesystem/interface.go b/internal/adapters/filesystem/interface.go new file mode 100644 index 0000000..bf527d3 --- /dev/null +++ b/internal/adapters/filesystem/interface.go @@ -0,0 +1,19 @@ +// Package filesystem defines the local library port: where downloaded audio is +// placed on disk and how it is tagged. The concrete implementation lives in the +// impl subpackage. +package filesystem + +import ( + "context" + + "audio-scraper/internal/models" +) + +// Provider manages on-disk placement and tagging of downloaded tracks. +type Provider interface { + // InitializePath creates the destination directory tree and returns the + // output file path for the job. + InitializePath(ctx context.Context, job *models.DownloadJob) (string, error) + // TagFile writes ID3 metadata, cover art and lyrics to the file at filePath. + TagFile(ctx context.Context, filePath string, job *models.DownloadJob) error +} diff --git a/internal/adapters/filesystem/mocks/mock.go b/internal/adapters/filesystem/mocks/mock.go new file mode 100644 index 0000000..d281153 --- /dev/null +++ b/internal/adapters/filesystem/mocks/mock.go @@ -0,0 +1,31 @@ +// Package filesystemmock provides a test double for filesystem.Provider. +package filesystemmock + +import ( + "context" + + "audio-scraper/internal/adapters/filesystem" + "audio-scraper/internal/models" +) + +// Mock implements filesystem.Provider; set the *Func fields to control behavior. +type Mock struct { + InitializePathFunc func(ctx context.Context, job *models.DownloadJob) (string, error) + TagFileFunc func(ctx context.Context, filePath string, job *models.DownloadJob) error +} + +var _ filesystem.Provider = (*Mock)(nil) + +func (m *Mock) InitializePath(ctx context.Context, job *models.DownloadJob) (string, error) { + if m.InitializePathFunc != nil { + return m.InitializePathFunc(ctx, job) + } + return "", nil +} + +func (m *Mock) TagFile(ctx context.Context, filePath string, job *models.DownloadJob) error { + if m.TagFileFunc != nil { + return m.TagFileFunc(ctx, filePath, job) + } + return nil +} diff --git a/internal/adapters/itunes/impl/itunes.go b/internal/adapters/itunes/impl/itunes.go new file mode 100644 index 0000000..8a13bb8 --- /dev/null +++ b/internal/adapters/itunes/impl/itunes.go @@ -0,0 +1,292 @@ +// Package itunesimpl implements the itunes.Provider port using Apple's public +// iTunes Search API. The API requires no authentication. +package itunesimpl + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "audio-scraper/internal/adapters/itunes" + "audio-scraper/internal/logger" + "audio-scraper/internal/models" +) + +const ( + searchURL = "https://itunes.apple.com/search" + lookupURL = "https://itunes.apple.com/lookup" + + // searchLimit caps results per entity kind; processSearchData trims further. + searchLimit = 15 + + // userAgent is sent to be a well-behaved client of the public API. + userAgent = "audio-scraper/1.0 (+https://github.com/audio-scraper)" +) + +// Client is the iTunes-backed metadata provider. +type Client struct { + http *http.Client +} + +// compile-time assertion that Client satisfies the port. +var _ itunes.Provider = (*Client)(nil) + +// New returns an iTunes metadata provider. +func New() *Client { + return &Client{ + http: &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{ + DialContext: (&net.Dialer{ + Timeout: 3 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + MaxIdleConns: 50, + MaxIdleConnsPerHost: 50, + ResponseHeaderTimeout: 5 * time.Second, + TLSHandshakeTimeout: 3 * time.Second, + }, + }, + } +} + +// itunesResult is a single entry in an iTunes API response. The same shape is +// reused for tracks, collections (albums) and artists; only a subset of fields +// is populated depending on wrapperType. +type itunesResult struct { + WrapperType string `json:"wrapperType"` + ArtistID int64 `json:"artistId"` + CollectionID int64 `json:"collectionId"` + TrackID int64 `json:"trackId"` + ArtistName string `json:"artistName"` + CollectionName string `json:"collectionName"` + TrackName string `json:"trackName"` + ReleaseDate string `json:"releaseDate"` + TrackNumber int `json:"trackNumber"` + TrackTimeMillis int `json:"trackTimeMillis"` + ArtworkURL100 string `json:"artworkUrl100"` +} + +type itunesResponse struct { + ResultCount int `json:"resultCount"` + Results []itunesResult `json:"results"` +} + +func (c *Client) Search(ctx context.Context, query string) (models.SearchResult, error) { + log := logger.From(ctx) + log.Info("performing itunes search", "query", query) + + var ( + result models.SearchResult + wg sync.WaitGroup + mu sync.Mutex + errs error + ) + record := func(err error) { + mu.Lock() + errs = errors.Join(errs, err) + mu.Unlock() + } + + wg.Add(3) + go func() { + defer wg.Done() + resp, err := c.get(ctx, searchURL, url.Values{ + "term": {query}, + "media": {"music"}, + "entity": {"song"}, + "limit": {strconv.Itoa(searchLimit)}, + }) + if err != nil { + record(err) + return + } + for _, r := range resp.Results { + if r.WrapperType == "track" { + result.Tracks = append(result.Tracks, toTrack(r)) + } + } + }() + go func() { + defer wg.Done() + resp, err := c.get(ctx, searchURL, url.Values{ + "term": {query}, + "media": {"music"}, + "entity": {"album"}, + "limit": {strconv.Itoa(searchLimit)}, + }) + if err != nil { + record(err) + return + } + for _, r := range resp.Results { + if r.WrapperType == "collection" { + result.Albums = append(result.Albums, toAlbum(r)) + } + } + }() + go func() { + defer wg.Done() + resp, err := c.get(ctx, searchURL, url.Values{ + "term": {query}, + "media": {"music"}, + "entity": {"musicArtist"}, + "limit": {strconv.Itoa(searchLimit)}, + }) + if err != nil { + record(err) + return + } + for _, r := range resp.Results { + if r.WrapperType == "artist" { + result.Artists = append(result.Artists, toArtist(r)) + } + } + }() + wg.Wait() + + if errs != nil { + log.Error("itunes search failed", "error", errs) + return models.SearchResult{}, errors.New("itunes search failed") + } + return result, nil +} + +func (c *Client) GetTrack(ctx context.Context, id string) (models.Track, error) { + log := logger.From(ctx) + log.Info("fetching itunes track", "track_id", id) + + resp, err := c.get(ctx, lookupURL, url.Values{"id": {id}, "entity": {"song"}}) + if err != nil { + return models.Track{}, err + } + for _, r := range resp.Results { + if r.WrapperType == "track" { + return toTrack(r), nil + } + } + return models.Track{}, fmt.Errorf("itunes: track %s not found", id) +} + +func (c *Client) GetAlbum(ctx context.Context, id string) (models.Album, error) { + log := logger.From(ctx) + log.Info("fetching itunes album", "album_id", id) + + resp, err := c.get(ctx, lookupURL, url.Values{"id": {id}, "entity": {"song"}}) + if err != nil { + return models.Album{}, err + } + + var album models.Album + found := false + for _, r := range resp.Results { + switch r.WrapperType { + case "collection": + album = toAlbum(r) + found = true + case "track": + album.Tracks = append(album.Tracks, toTrack(r)) + } + } + if !found { + return models.Album{}, fmt.Errorf("itunes: album %s not found", id) + } + return album, nil +} + +func (c *Client) GetArtist(ctx context.Context, id string) (models.Artist, error) { + log := logger.From(ctx) + log.Info("fetching itunes artist", "artist_id", id) + + resp, err := c.get(ctx, lookupURL, url.Values{"id": {id}, "entity": {"album"}}) + if err != nil { + return models.Artist{}, err + } + + var artist models.Artist + found := false + for _, r := range resp.Results { + switch r.WrapperType { + case "artist": + artist = toArtist(r) + found = true + case "collection": + artist.Albums = append(artist.Albums, toAlbum(r)) + } + } + if !found { + return models.Artist{}, fmt.Errorf("itunes: artist %s not found", id) + } + return artist, nil +} + +// get performs a GET against an iTunes endpoint and decodes the response. +func (c *Client) get(ctx context.Context, endpoint string, params url.Values) (*itunesResponse, error) { + u := endpoint + "?" + params.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", userAgent) + + res, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("itunes: unexpected status %d", res.StatusCode) + } + + var out itunesResponse + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + return nil, err + } + return &out, nil +} + +func toTrack(r itunesResult) models.Track { + return models.Track{ + ID: strconv.FormatInt(r.TrackID, 10), + Name: r.TrackName, + Album: r.CollectionName, + Artist: r.ArtistName, + ReleaseDate: r.ReleaseDate, + TrackNumber: r.TrackNumber, + Duration: r.TrackTimeMillis / 1000, + ArtworkURL: upscaleArtwork(r.ArtworkURL100), + } +} + +func toAlbum(r itunesResult) models.Album { + return models.Album{ + ID: strconv.FormatInt(r.CollectionID, 10), + Name: r.CollectionName, + Artist: r.ArtistName, + } +} + +func toArtist(r itunesResult) models.Artist { + return models.Artist{ + ID: strconv.FormatInt(r.ArtistID, 10), + Name: r.ArtistName, + } +} + +// upscaleArtwork swaps the default 100x100 thumbnail for a larger variant by +// rewriting the size segment in the artwork URL. +func upscaleArtwork(u string) string { + if u == "" { + return "" + } + return strings.Replace(u, "100x100bb", "600x600bb", 1) +} diff --git a/internal/adapters/itunes/interface.go b/internal/adapters/itunes/interface.go new file mode 100644 index 0000000..3efd601 --- /dev/null +++ b/internal/adapters/itunes/interface.go @@ -0,0 +1,23 @@ +// Package itunes defines the metadata provider port backed by the iTunes +// Search API. The concrete implementation lives in the impl subpackage and +// a test double in mocks. +package itunes + +import ( + "context" + + "audio-scraper/internal/models" +) + +// Provider is the metadata source contract: search for entities and resolve +// them to full details by ID. +type Provider interface { + // Search returns tracks, albums and artists matching the query. + Search(ctx context.Context, query string) (models.SearchResult, error) + // GetTrack resolves a single track by ID. + GetTrack(ctx context.Context, id string) (models.Track, error) + // GetAlbum resolves an album by ID with its tracks fully populated. + GetAlbum(ctx context.Context, id string) (models.Album, error) + // GetArtist resolves an artist by ID with album-level metadata only. + GetArtist(ctx context.Context, id string) (models.Artist, error) +} diff --git a/internal/adapters/itunes/mocks/mock.go b/internal/adapters/itunes/mocks/mock.go new file mode 100644 index 0000000..52812aa --- /dev/null +++ b/internal/adapters/itunes/mocks/mock.go @@ -0,0 +1,47 @@ +// Package itunesmock provides a test double for itunes.Provider. +package itunesmock + +import ( + "context" + + "audio-scraper/internal/adapters/itunes" + "audio-scraper/internal/models" +) + +// Mock implements itunes.Provider; set the *Func fields to control behavior. +type Mock struct { + SearchFunc func(ctx context.Context, query string) (models.SearchResult, error) + GetTrackFunc func(ctx context.Context, id string) (models.Track, error) + GetAlbumFunc func(ctx context.Context, id string) (models.Album, error) + GetArtistFunc func(ctx context.Context, id string) (models.Artist, error) +} + +var _ itunes.Provider = (*Mock)(nil) + +func (m *Mock) Search(ctx context.Context, query string) (models.SearchResult, error) { + if m.SearchFunc != nil { + return m.SearchFunc(ctx, query) + } + return models.SearchResult{}, nil +} + +func (m *Mock) GetTrack(ctx context.Context, id string) (models.Track, error) { + if m.GetTrackFunc != nil { + return m.GetTrackFunc(ctx, id) + } + return models.Track{}, nil +} + +func (m *Mock) GetAlbum(ctx context.Context, id string) (models.Album, error) { + if m.GetAlbumFunc != nil { + return m.GetAlbumFunc(ctx, id) + } + return models.Album{}, nil +} + +func (m *Mock) GetArtist(ctx context.Context, id string) (models.Artist, error) { + if m.GetArtistFunc != nil { + return m.GetArtistFunc(ctx, id) + } + return models.Artist{}, nil +} diff --git a/internal/adapters/lrclib/impl/lrclib.go b/internal/adapters/lrclib/impl/lrclib.go new file mode 100644 index 0000000..2944f3e --- /dev/null +++ b/internal/adapters/lrclib/impl/lrclib.go @@ -0,0 +1,131 @@ +// Package lrclibimpl implements the lrclib.Provider port using the lrclib.net +// public lyrics API. +package lrclibimpl + +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + "audio-scraper/internal/adapters/lrclib" + "audio-scraper/internal/logger" +) + +const lrclibBaseURL = "https://lrclib.net/api/get" + +// Client is the lrclib-backed lyrics provider. +type Client struct { + http *http.Client +} + +var _ lrclib.Provider = (*Client)(nil) + +// New returns a lrclib lyrics provider. +func New() *Client { + client := &http.Client{ + Timeout: 5 * time.Second, + Transport: &http.Transport{ + DialContext: (&net.Dialer{ + Timeout: 3 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + MaxIdleConns: 50, + MaxIdleConnsPerHost: 50, + ResponseHeaderTimeout: 5 * time.Second, + TLSHandshakeTimeout: 3 * time.Second, + }, + } + + return &Client{ + http: client, + } +} + +func (l *Client) FindLyrics(ctx context.Context, req *lrclib.LrclibRequest) (*lrclib.LRC, error) { + log := logger.From(ctx) + log.Info("starting lrclib search") + params := url.Values{} + params.Set("artist_name", req.Artist) + params.Set("track_name", req.Track) + params.Set("album_name", req.Album) + params.Set("duration", fmt.Sprintf("%d", req.Duration)) + + endpoint := fmt.Sprintf("%s?%s", lrclibBaseURL, params.Encode()) + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + res, err := l.http.Do(httpReq) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("lrclib: no lyrics found (%d)", res.StatusCode) + } + + var out lrclibResponse + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + return nil, err + } + + return out.toLRC(), nil +} + +type lrclibResponse struct { + ID int `json:"id"` + ArtistName string `json:"artistName"` + TrackName string `json:"trackName"` + AlbumName string `json:"albumName"` + Duration float32 `json:"duration"` + SyncedLyrics string `json:"syncedLyrics"` + PlainLyrics string `json:"plainLyrics"` +} + +func (r *lrclibResponse) toLRC() *lrclib.LRC { + lrc := &lrclib.LRC{ + Segments: nil, + Full: &r.PlainLyrics, + } + if strings.TrimSpace(r.SyncedLyrics) == "" { + return lrc + } + + re := regexp.MustCompile(`^\[(\d{2}:\d{2}(?:\.\d{1,3})?)\]\s*(.*)$`) + + lines := strings.Split(r.SyncedLyrics, "\n") + segments := make([]lrclib.LRCSegment, 0, len(lines)) + + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + m := re.FindStringSubmatch(line) + if len(m) != 3 { + // line doesn't match [mm:ss.xx] pattern – skip it. + continue + } + + segments = append(segments, lrclib.LRCSegment{ + Time: m[1], + Text: m[2], + }) + } + if len(segments) == 0 { + return lrc + } + + lrc.Segments = segments + return lrc +} diff --git a/internal/adapters/lrclib/interface.go b/internal/adapters/lrclib/interface.go new file mode 100644 index 0000000..198b7d6 --- /dev/null +++ b/internal/adapters/lrclib/interface.go @@ -0,0 +1,43 @@ +// Package lrclib defines the lyrics provider port and its contract types. The +// concrete implementation lives in the impl subpackage. +package lrclib + +import ( + "context" + "strings" +) + +// Provider fetches lyrics for a track. +type Provider interface { + FindLyrics(ctx context.Context, req *LrclibRequest) (*LRC, error) +} + +type LrclibRequest struct { + Artist string + Album string + Track string + Duration int +} + +type LRCSegment struct { + Time string + Text string +} + +type LRC struct { + Segments []LRCSegment + Full *string +} + +func (l *LRC) SyncedToText() string { + var b strings.Builder + for _, s := range l.Segments { + // Each line: [mm:ss.xx] text + b.WriteString("[") + b.WriteString(s.Time) + b.WriteString("] ") + b.WriteString(s.Text) + b.WriteRune('\n') + } + return b.String() +} diff --git a/internal/adapters/lrclib/mocks/mock.go b/internal/adapters/lrclib/mocks/mock.go new file mode 100644 index 0000000..1b48310 --- /dev/null +++ b/internal/adapters/lrclib/mocks/mock.go @@ -0,0 +1,22 @@ +// Package lrclibmock provides a test double for lrclib.Provider. +package lrclibmock + +import ( + "context" + + "audio-scraper/internal/adapters/lrclib" +) + +// Mock implements lrclib.Provider; set FindLyricsFunc to control behavior. +type Mock struct { + FindLyricsFunc func(ctx context.Context, req *lrclib.LrclibRequest) (*lrclib.LRC, error) +} + +var _ lrclib.Provider = (*Mock)(nil) + +func (m *Mock) FindLyrics(ctx context.Context, req *lrclib.LrclibRequest) (*lrclib.LRC, error) { + if m.FindLyricsFunc != nil { + return m.FindLyricsFunc(ctx, req) + } + return nil, nil +} diff --git a/internal/providers/store/store.go b/internal/adapters/store/impl/store.go similarity index 71% rename from internal/providers/store/store.go rename to internal/adapters/store/impl/store.go index dceeb98..2cdc220 100644 --- a/internal/providers/store/store.go +++ b/internal/adapters/store/impl/store.go @@ -1,38 +1,24 @@ -package store +// Package storeimpl implements the store.Provider port as an in-memory cache +// with TTL-based cleanup. +package storeimpl import ( "sync" "time" - "audio-scraper/internal/constants" + "audio-scraper/internal/adapters/store" "audio-scraper/internal/logger" ) const cleanupInterval = 30 * time.Minute const storeTTL = 10 * time.Minute -type Choice struct { - Type constants.SpotifyEntityType `json:"type"` - ID string `json:"id"` - Label string `json:"label"` -} - -type Choices []Choice - -func (choices Choices) FindByLabel(label string) *Choice { - for _, choice := range choices { - if choice.Label == label { - return &choice - } - } - return nil -} - type choiceItem struct { - choices Choices + choices store.Choices timestamp time.Time } +// Store is the in-memory request cache. type Store struct { log logger.Logger requestData map[string]choiceItem @@ -40,18 +26,21 @@ type Store struct { done chan struct{} } -func NewStoreProvider(l logger.Logger) *Store { - store := &Store{ +var _ store.Provider = (*Store)(nil) + +// New returns an in-memory request cache and starts its cleanup routine. +func New(l logger.Logger) *Store { + s := &Store{ log: l, requestData: make(map[string]choiceItem), done: make(chan struct{}), } - go store.cleanupRoutine() - return store + go s.cleanupRoutine() + return s } -func (s *Store) Set(key string, choices Choices) { +func (s *Store) Set(key string, choices store.Choices) { s.log.Info("storing data in store", "key", key) s.mu.Lock() defer s.mu.Unlock() @@ -61,7 +50,7 @@ func (s *Store) Set(key string, choices Choices) { } } -func (s *Store) Get(key string) (Choices, bool) { +func (s *Store) Get(key string) (store.Choices, bool) { s.log.Info("retrieving data from store", "key", key) s.mu.RLock() defer s.mu.RUnlock() diff --git a/internal/adapters/store/interface.go b/internal/adapters/store/interface.go new file mode 100644 index 0000000..7ae0f98 --- /dev/null +++ b/internal/adapters/store/interface.go @@ -0,0 +1,32 @@ +// Package store defines the request-cache port and its contract types. The +// concrete in-memory implementation lives in the impl subpackage. +package store + +import "audio-scraper/internal/constants" + +// Choice is a single selectable search result kept between the /search and +// /download requests. +type Choice struct { + Type constants.EntityType `json:"type"` + ID string `json:"id"` + Label string `json:"label"` +} + +type Choices []Choice + +func (choices Choices) FindByLabel(label string) *Choice { + for _, choice := range choices { + if choice.Label == label { + return &choice + } + } + return nil +} + +// Provider caches per-request choices with a TTL. +type Provider interface { + Set(key string, choices Choices) + Get(key string) (Choices, bool) + Delete(key string) + Shutdown() +} diff --git a/internal/adapters/store/mocks/mock.go b/internal/adapters/store/mocks/mock.go new file mode 100644 index 0000000..06226a2 --- /dev/null +++ b/internal/adapters/store/mocks/mock.go @@ -0,0 +1,41 @@ +// Package storemock provides a test double for store.Provider. +package storemock + +import ( + "audio-scraper/internal/adapters/store" +) + +// Mock implements store.Provider; set the *Func fields to control behavior. +type Mock struct { + SetFunc func(key string, choices store.Choices) + GetFunc func(key string) (store.Choices, bool) + DeleteFunc func(key string) + ShutdownFunc func() +} + +var _ store.Provider = (*Mock)(nil) + +func (m *Mock) Set(key string, choices store.Choices) { + if m.SetFunc != nil { + m.SetFunc(key, choices) + } +} + +func (m *Mock) Get(key string) (store.Choices, bool) { + if m.GetFunc != nil { + return m.GetFunc(key) + } + return nil, false +} + +func (m *Mock) Delete(key string) { + if m.DeleteFunc != nil { + m.DeleteFunc(key) + } +} + +func (m *Mock) Shutdown() { + if m.ShutdownFunc != nil { + m.ShutdownFunc() + } +} diff --git a/internal/adapters/youtube/impl/youtube.go b/internal/adapters/youtube/impl/youtube.go new file mode 100644 index 0000000..2bc3f94 --- /dev/null +++ b/internal/adapters/youtube/impl/youtube.go @@ -0,0 +1,220 @@ +// Package youtubeimpl implements the youtube.Provider port using yt-dlp for +// both searching (ytsearch) and downloading. +package youtubeimpl + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math" + "os" + "os/exec" + "strings" + + "github.com/faiface/beep/mp3" + + "audio-scraper/internal/adapters/youtube" + "audio-scraper/internal/logger" +) + +// searchResults is how many candidates ytsearch returns for ranking. +const searchResults = 8 + +// Client is the YouTube-backed audio provider. +type Client struct{} + +var _ youtube.Provider = (*Client)(nil) + +// New returns a YouTube audio provider. +func New() *Client { + return &Client{} +} + +// ytEntry is a single ytsearch result (flat-playlist mode). +type ytEntry struct { + ID string `json:"id"` + Title string `json:"title"` + Uploader string `json:"uploader"` + Channel string `json:"channel"` + Duration float64 `json:"duration"` +} + +type ytSearchResponse struct { + Entries []ytEntry `json:"entries"` +} + +func (y *Client) Search(ctx context.Context, track, artist string, duration int) (string, error) { + log := logger.From(ctx) + query := strings.TrimSpace(track + " " + artist) + log.Info("performing yt search", "query", query, "duration", duration) + + cmd := exec.CommandContext( + ctx, + "yt-dlp", + "--no-warnings", + "--flat-playlist", + "-J", + fmt.Sprintf("ytsearch%d:%s", searchResults, query), + ) + out, err := cmd.Output() + if err != nil { + log.Error("yt search command failed", "error", err) + return "", errors.New("yt search failed") + } + + var res ytSearchResponse + if err := json.Unmarshal(out, &res); err != nil { + log.Error("failed to parse yt search output", "error", err) + return "", errors.New("yt search failed") + } + if len(res.Entries) == 0 { + return "", errors.New("yt search returned no results") + } + + best := -1 + bestScore := math.Inf(-1) + for i, e := range res.Entries { + if e.ID == "" { + continue + } + if s := score(track, artist, duration, e); s > bestScore { + bestScore = s + best = i + } + } + if best == -1 { + return "", errors.New("yt search returned no usable results") + } + + selected := res.Entries[best] + url := "https://www.youtube.com/watch?v=" + selected.ID + log.Info("yt search selected", "url", url, "title", selected.Title, "score", bestScore) + return url, nil +} + +// score rates how well a search result matches the desired track. Higher is +// better. It combines title containment, artist presence and duration +// closeness, mirroring the intent of the previous similarity matcher. +func score(track, artist string, duration int, e ytEntry) float64 { + title := normalize(e.Title) + haystack := normalize(e.Title + " " + e.Uploader + " " + e.Channel) + wantTrack := normalize(track) + wantArtist := normalize(artist) + + var s float64 + + // Title match: exact containment is a strong signal, otherwise fall back to + // token overlap. + if wantTrack != "" && strings.Contains(title, wantTrack) { + s += 2.0 + } else { + s += jaccard(strings.Fields(title), strings.Fields(wantTrack)) + } + + // Artist match: fraction of artist tokens present in title/uploader/channel. + artistTokens := strings.Fields(wantArtist) + if len(artistTokens) > 0 { + hits := 0 + for _, tok := range artistTokens { + if strings.Contains(haystack, tok) { + hits++ + } + } + s += float64(hits) / float64(len(artistTokens)) + } + + // Duration match: heavily reward results close to the expected length, which + // filters out extended mixes, loops and snippets. + if duration > 0 && e.Duration > 0 { + ds := 1 - math.Abs(e.Duration-float64(duration))/float64(duration) + if ds < 0 { + ds = 0 + } + s += 2.0 * ds + } + + return s +} + +// normalize lowercases s and reduces it to space-separated alphanumeric tokens. +func normalize(s string) string { + var b strings.Builder + for _, r := range strings.ToLower(s) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + } else { + b.WriteRune(' ') + } + } + return strings.Join(strings.Fields(b.String()), " ") +} + +// jaccard returns the Jaccard similarity of two token slices (0..1). +func jaccard(a, b []string) float64 { + setA := make(map[string]struct{}, len(a)) + for _, x := range a { + setA[x] = struct{}{} + } + setB := make(map[string]struct{}, len(b)) + for _, x := range b { + setB[x] = struct{}{} + } + if len(setA) == 0 || len(setB) == 0 { + return 0 + } + + inter := 0 + for x := range setA { + if _, ok := setB[x]; ok { + inter++ + } + } + union := len(setA) + len(setB) - inter + return float64(inter) / float64(union) +} + +func (y *Client) Download(ctx context.Context, path, videoURL string) (int, error) { + log := logger.From(ctx) + log.Info("starting yt-dlp download", "path", path) + cmd := exec.CommandContext( + ctx, + "yt-dlp", + "-q", + "-x", + "--audio-quality", "0", + "--audio-format", "mp3", + "-o", path, + videoURL, + ) + output, err := cmd.CombinedOutput() + if err != nil { + log.Error("yt-dlp command failed", "error", err, "output", string(output)) + return -1, errors.New("yt-dlp download failed") + } + + duration, err := getFileDuration(path) + if err != nil { + log.Error("failed getting duration from mp3", "error", err) + } + return int(duration), nil +} + +func getFileDuration(path string) (float64, error) { + f, err := os.Open(path) + if err != nil { + return 0, err + } + defer f.Close() + + streamer, format, err := mp3.Decode(f) + if err != nil { + return 0, err + } + defer streamer.Close() + + samples := streamer.Len() + seconds := float64(samples) / float64(format.SampleRate) + + return seconds, nil +} diff --git a/internal/adapters/youtube/interface.go b/internal/adapters/youtube/interface.go new file mode 100644 index 0000000..44150ce --- /dev/null +++ b/internal/adapters/youtube/interface.go @@ -0,0 +1,16 @@ +// Package youtube defines the audio-source port: locate a track on YouTube and +// download it. The concrete implementation lives in the impl subpackage. +package youtube + +import "context" + +// Provider locates and downloads audio for a given track. +type Provider interface { + // Search returns a playable video URL for the given track. duration is the + // expected track length in seconds (0 if unknown) and is used to rank + // candidates; pass it to disambiguate between versions. + Search(ctx context.Context, track, artist string, duration int) (string, error) + // Download fetches the audio at videoURL to path and returns its duration + // in seconds (-1 if the duration could not be determined). + Download(ctx context.Context, path, videoURL string) (int, error) +} diff --git a/internal/adapters/youtube/mocks/mock.go b/internal/adapters/youtube/mocks/mock.go new file mode 100644 index 0000000..e959a44 --- /dev/null +++ b/internal/adapters/youtube/mocks/mock.go @@ -0,0 +1,30 @@ +// Package youtubemock provides a test double for youtube.Provider. +package youtubemock + +import ( + "context" + + "audio-scraper/internal/adapters/youtube" +) + +// Mock implements youtube.Provider; set the *Func fields to control behavior. +type Mock struct { + SearchFunc func(ctx context.Context, track, artist string, duration int) (string, error) + DownloadFunc func(ctx context.Context, path, videoURL string) (int, error) +} + +var _ youtube.Provider = (*Mock)(nil) + +func (m *Mock) Search(ctx context.Context, track, artist string, duration int) (string, error) { + if m.SearchFunc != nil { + return m.SearchFunc(ctx, track, artist, duration) + } + return "", nil +} + +func (m *Mock) Download(ctx context.Context, path, videoURL string) (int, error) { + if m.DownloadFunc != nil { + return m.DownloadFunc(ctx, path, videoURL) + } + return -1, nil +} diff --git a/internal/api/api.go b/internal/api/api.go index f8afa8e..48523e3 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -7,32 +7,31 @@ import ( "strings" "github.com/google/uuid" - spotifypkg "github.com/zmb3/spotify/v2" + "audio-scraper/internal/adapters/itunes" + "audio-scraper/internal/adapters/store" "audio-scraper/internal/constants" "audio-scraper/internal/logger" "audio-scraper/internal/models" "audio-scraper/internal/pool" - "audio-scraper/internal/providers/spotify" - "audio-scraper/internal/providers/store" ) type Deps struct { - Log logger.Logger - Spotify *spotify.SpotifyClient - Store *store.Store - Queue *pool.DownloadWorkerPool + Log logger.Logger + Metadata itunes.Provider + Store store.Provider + Queue *pool.DownloadWorkerPool } type Handlers struct { - log logger.Logger - spotify *spotify.SpotifyClient - store *store.Store - queue *pool.DownloadWorkerPool + log logger.Logger + metadata itunes.Provider + store store.Provider + queue *pool.DownloadWorkerPool } func NewHandlers(deps *Deps) *Handlers { - return &Handlers{log: deps.Log, spotify: deps.Spotify, store: deps.Store, queue: deps.Queue} + return &Handlers{log: deps.Log, metadata: deps.Metadata, store: deps.Store, queue: deps.Queue} } func (h *Handlers) HealthHandler(w http.ResponseWriter, r *http.Request) { @@ -61,20 +60,14 @@ func (h *Handlers) Search(w http.ResponseWriter, r *http.Request) { } log := log.With("query", query) - results, err := h.spotify.Search(logger.Into(ctx, log), query, spotifypkg.SearchTypeArtist|spotifypkg.SearchTypeAlbum|spotifypkg.SearchTypeTrack) + results, err := h.metadata.Search(logger.Into(ctx, log), query) if err != nil { - log.Error("spotify search failed", "error", err) - http.Error(w, "spotify search failed", http.StatusInternalServerError) - return - } - - choices, err := processSearchData(results, log) - if err != nil { - log.Error("processing search data failed", "error", err) - http.Error(w, "processing search data failed", http.StatusInternalServerError) + log.Error("metadata search failed", "error", err) + http.Error(w, "metadata search failed", http.StatusInternalServerError) return } + choices := processSearchData(results, log) allChoices = append(allChoices, choices...) } @@ -130,18 +123,18 @@ func (h *Handlers) Download(w http.ResponseWriter, r *http.Request) { clog.Info("processing choice") deps := addToQueueDeps{ - log: clog, - sp: h.spotify, - q: h.queue, + log: clog, + metadata: h.metadata, + q: h.queue, } switch c.Type { - case constants.SpotifyEntityTypeTrack: - addTrackToQueue(deps, reqID, spotifypkg.ID(c.ID)) - case constants.SpotifyEntityTypeAlbum: - addAlbumToQueue(deps, reqID, spotifypkg.ID(c.ID)) - case constants.SpotifyEntityTypeArtist: - addArtistToQueue(deps, reqID, spotifypkg.ID(c.ID)) + case constants.EntityTypeTrack: + addTrackToQueue(deps, reqID, c.ID) + case constants.EntityTypeAlbum: + addAlbumToQueue(deps, reqID, c.ID) + case constants.EntityTypeArtist: + addArtistToQueue(deps, reqID, c.ID) default: clog.Warn("unknown choice type", "type", c.Type) } diff --git a/internal/api/helpers.go b/internal/api/helpers.go index 34d4c08..4fabb8d 100644 --- a/internal/api/helpers.go +++ b/internal/api/helpers.go @@ -4,36 +4,23 @@ import ( "context" "fmt" - spotifypkg "github.com/zmb3/spotify/v2" - + "audio-scraper/internal/adapters/itunes" + "audio-scraper/internal/adapters/store" "audio-scraper/internal/constants" "audio-scraper/internal/logger" "audio-scraper/internal/models" "audio-scraper/internal/pool" - "audio-scraper/internal/providers/spotify" - "audio-scraper/internal/providers/store" ) -func processSearchData(result *spotifypkg.SearchResult, log logger.Logger) ([]store.Choice, error) { +func processSearchData(result models.SearchResult, log logger.Logger) []store.Choice { trackCount := 10 albumCount := 5 artistCount := 3 - var tracks []spotifypkg.FullTrack - var albums []spotifypkg.SimpleAlbum - var artists []spotifypkg.FullArtist - if result.Tracks != nil { - tracks = result.Tracks.Tracks - log.Debug("tracks found", "count", len(tracks)) - } - if result.Albums != nil { - albums = result.Albums.Albums - log.Debug("albums found", "count", len(albums)) - } - if result.Artists != nil { - artists = result.Artists.Artists - log.Debug("artists found", "count", len(artists)) - } + tracks := result.Tracks + albums := result.Albums + artists := result.Artists + log.Debug("search results", "tracks", len(tracks), "albums", len(albums), "artists", len(artists)) if len(albums) < albumCount { trackCount += albumCount - len(albums) @@ -48,79 +35,68 @@ func processSearchData(result *spotifypkg.SearchResult, log logger.Logger) ([]st var choices []store.Choice for i := 0; i < min(trackCount, len(tracks)); i++ { t := tracks[i] - artistName := "" - if len(t.Artists) > 0 { - artistName = t.Artists[0].Name - } - label := fmt.Sprintf("Track: %s - %s [%s]", t.Name, artistName, t.Album.Name) - - choice := store.Choice{ - Type: constants.SpotifyEntityTypeTrack, - ID: t.ID.String(), + label := fmt.Sprintf("Track: %s - %s [%s]", t.Name, t.Artist, t.Album) + choices = append(choices, store.Choice{ + Type: constants.EntityTypeTrack, + ID: t.ID, Label: label, - } - choices = append(choices, choice) + }) } for i := 0; i < min(albumCount, len(albums)); i++ { a := albums[i] - artistName := "" - if len(a.Artists) > 0 { - artistName = a.Artists[0].Name - } - label := fmt.Sprintf("Album: %s - %s", a.Name, artistName) - - choice := store.Choice{ - Type: constants.SpotifyEntityTypeAlbum, - ID: a.ID.String(), + label := fmt.Sprintf("Album: %s - %s", a.Name, a.Artist) + choices = append(choices, store.Choice{ + Type: constants.EntityTypeAlbum, + ID: a.ID, Label: label, - } - choices = append(choices, choice) + }) } for i := 0; i < min(artistCount, len(artists)); i++ { ar := artists[i] label := fmt.Sprintf("Artist: %s", ar.Name) - - choice := store.Choice{ - Type: constants.SpotifyEntityTypeArtist, - ID: ar.ID.String(), + choices = append(choices, store.Choice{ + Type: constants.EntityTypeArtist, + ID: ar.ID, Label: label, - } - choices = append(choices, choice) + }) } - return choices, nil + return choices } type addToQueueDeps struct { - log logger.Logger - sp *spotify.SpotifyClient - q *pool.DownloadWorkerPool + log logger.Logger + metadata itunes.Provider + q *pool.DownloadWorkerPool } -func addTrackToQueue(deps addToQueueDeps, requestID string, trackID spotifypkg.ID) { +func trackToJob(requestID string, t models.Track) models.DownloadJob { + return models.DownloadJob{ + RequestID: requestID, + TrackID: t.ID, + Track: t.Name, + Album: t.Album, + Artist: t.Artist, + ReleaseDate: t.ReleaseDate, + TrackNumber: t.TrackNumber, + Duration: t.Duration, + ThumbnailURL: t.ArtworkURL, + } +} + +func addTrackToQueue(deps addToQueueDeps, requestID string, trackID string) { ctx := context.Background() log := deps.log.With("track_id", trackID) log.Info("adding track to download queue") - track, err := deps.sp.GetTrack(logger.Into(ctx, log), spotifypkg.ID(trackID)) + track, err := deps.metadata.GetTrack(logger.Into(ctx, log), trackID) if err != nil { log.Error("failed to fetch track details", "error", err) return } - err = deps.q.Enqueue(ctx, models.DownloadJob{ - RequestID: requestID, - TrackID: trackID.String(), - Track: track.Name, - Album: track.Album.Name, - Artist: track.Artists[0].Name, - ReleaseDate: track.Album.ReleaseDate, - TrackNumber: int(track.TrackNumber), - Duration: int(track.Duration / 1000), - ThumbnailURL: track.Album.Images[0].URL, - }) - if err != nil { + if err := deps.q.Enqueue(ctx, trackToJob(requestID, track)); err != nil { log.Error("failed to add track to download queue", "error", err) return } @@ -128,27 +104,29 @@ func addTrackToQueue(deps addToQueueDeps, requestID string, trackID spotifypkg.I log.Info("track added to download queue successfully") } -func addAlbumToQueue(deps addToQueueDeps, requestID string, albumID spotifypkg.ID) { +func addAlbumToQueue(deps addToQueueDeps, requestID string, albumID string) { ctx := context.Background() log := deps.log.With("album_id", albumID) - album, err := deps.sp.GetAlbum(logger.Into(ctx, log), albumID) + album, err := deps.metadata.GetAlbum(logger.Into(ctx, log), albumID) if err != nil { log.Error("failed to fetch album details", "error", err) return } - for _, track := range album.Tracks.Tracks { - addTrackToQueue(deps, requestID, track.ID) + for _, track := range album.Tracks { + if err := deps.q.Enqueue(ctx, trackToJob(requestID, track)); err != nil { + log.Error("failed to add album track to download queue", "error", err, "track_id", track.ID) + } } log.Info("album added to download queue successfully") } -func addArtistToQueue(deps addToQueueDeps, requestID string, artistID spotifypkg.ID) { +func addArtistToQueue(deps addToQueueDeps, requestID string, artistID string) { ctx := context.Background() log := deps.log.With("artist_id", artistID) - artist, err := deps.sp.GetArtist(logger.Into(ctx, log), artistID) + artist, err := deps.metadata.GetArtist(logger.Into(ctx, log), artistID) if err != nil { log.Error("failed to fetch artist details", "error", err) return diff --git a/internal/constants/constants.go b/internal/constants/constants.go index 6a25d4c..9af3ac6 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -3,10 +3,10 @@ package constants const DownloadWorkerPoolSize = 5 -type SpotifyEntityType string +type EntityType string const ( - SpotifyEntityTypeTrack SpotifyEntityType = "track" - SpotifyEntityTypeAlbum SpotifyEntityType = "album" - SpotifyEntityTypeArtist SpotifyEntityType = "artist" + EntityTypeTrack EntityType = "track" + EntityTypeAlbum EntityType = "album" + EntityTypeArtist EntityType = "artist" ) diff --git a/internal/models/models.go b/internal/models/models.go index 8e079ac..dfce3d5 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -22,3 +22,41 @@ type DownloadJob struct { Duration int ThumbnailURL string } + +// Track is a provider-neutral representation of a single song. It carries the +// flat set of fields required to build a DownloadJob. +type Track struct { + ID string + Name string + Album string + Artist string + ReleaseDate string + TrackNumber int + Duration int + ArtworkURL string +} + +// Album is a provider-neutral album. Tracks is populated when an album is +// fetched directly (e.g. GetAlbum) and may be empty when the album only appears +// as a search result or within an artist's discography. +type Album struct { + ID string + Name string + Artist string + Tracks []Track +} + +// Artist is a provider-neutral artist. Albums carries album-level metadata only +// (no tracks); fetch each album individually to resolve its tracks. +type Artist struct { + ID string + Name string + Albums []Album +} + +// SearchResult aggregates the three entity kinds returned by a metadata search. +type SearchResult struct { + Tracks []Track + Albums []Album + Artists []Artist +} diff --git a/internal/pool/pool.go b/internal/pool/pool.go index b2432a0..27f3327 100644 --- a/internal/pool/pool.go +++ b/internal/pool/pool.go @@ -5,10 +5,10 @@ import ( "context" "sync" + "audio-scraper/internal/adapters/filesystem" + "audio-scraper/internal/adapters/youtube" "audio-scraper/internal/logger" "audio-scraper/internal/models" - "audio-scraper/internal/providers/filesystem" - "audio-scraper/internal/providers/youtube" ) type DownloadWorkerPool struct { @@ -16,8 +16,8 @@ type DownloadWorkerPool struct { workers int log logger.Logger - yt *youtube.YTClient - fs *filesystem.FSClient + yt youtube.Provider + fs filesystem.Provider wg sync.WaitGroup stop chan struct{} @@ -25,8 +25,8 @@ type DownloadWorkerPool struct { type Deps struct { Log logger.Logger - YT *youtube.YTClient - FS *filesystem.FSClient + YT youtube.Provider + FS filesystem.Provider } func NewDownloadWorkerPool( @@ -70,7 +70,7 @@ func (p *DownloadWorkerPool) worker(id int) { log.Info("processing download job") - videoURL, err := p.yt.Search(logger.Into(ctx, log), job.Track, job.Album, job.Artist) + videoURL, err := p.yt.Search(logger.Into(ctx, log), job.Track, job.Artist, job.Duration) if err != nil { log.Error("yt search failed", "error", err) continue diff --git a/internal/providers/lrclib/lrclib.go b/internal/providers/lrclib/lrclib.go deleted file mode 100644 index 694b05a..0000000 --- a/internal/providers/lrclib/lrclib.go +++ /dev/null @@ -1,73 +0,0 @@ -package lrclib - -import ( - "context" - "encoding/json" - "fmt" - "net" - "net/http" - "net/url" - "time" - - "audio-scraper/internal/logger" -) - -const lrclibBaseURL = "https://lrclib.net/api/get" - -type LrclibClient struct { - client *http.Client -} - -func NewLrclibProvider() *LrclibClient { - client := &http.Client{ - Timeout: 5 * time.Second, - Transport: &http.Transport{ - DialContext: (&net.Dialer{ - Timeout: 3 * time.Second, - KeepAlive: 30 * time.Second, - }).DialContext, - MaxIdleConns: 50, - MaxIdleConnsPerHost: 50, - ResponseHeaderTimeout: 5 * time.Second, - TLSHandshakeTimeout: 3 * time.Second, - }, - } - - return &LrclibClient{ - client: client, - } -} - -func (l *LrclibClient) FindLyrics(ctx context.Context, req *LrclibRequest) (*LRC, error) { - log := logger.From(ctx) - log.Info("starting lrclib search") - params := url.Values{} - params.Set("artist_name", req.Artist) - params.Set("track_name", req.Track) - params.Set("album_name", req.Album) - params.Set("duration", fmt.Sprintf("%d", req.Duration)) - - endpoint := fmt.Sprintf("%s?%s", lrclibBaseURL, params.Encode()) - - httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) - if err != nil { - return nil, err - } - - res, err := l.client.Do(httpReq) - if err != nil { - return nil, err - } - defer res.Body.Close() - - if res.StatusCode != http.StatusOK { - return nil, fmt.Errorf("lrclib: no lyrics found (%d)", res.StatusCode) - } - - var out lrclibResponse - if err := json.NewDecoder(res.Body).Decode(&out); err != nil { - return nil, err - } - - return out.ToLRC(), nil -} diff --git a/internal/providers/lrclib/models.go b/internal/providers/lrclib/models.go deleted file mode 100644 index 9d7411d..0000000 --- a/internal/providers/lrclib/models.go +++ /dev/null @@ -1,85 +0,0 @@ -package lrclib - -import ( - "regexp" - "strings" -) - -type LrclibRequest struct { - Artist string - Album string - Track string - Duration int -} - -type LRCSegment struct { - Time string - Text string -} - -type LRC struct { - Segments []LRCSegment - Full *string -} - -func (l *LRC) SyncedToText() string { - var b strings.Builder - for _, s := range l.Segments { - // Each line: [mm:ss.xx] text - b.WriteString("[") - b.WriteString(s.Time) - b.WriteString("] ") - b.WriteString(s.Text) - b.WriteRune('\n') - } - return b.String() -} - -type lrclibResponse struct { - ID int `json:"id"` - ArtistName string `json:"artistName"` - TrackName string `json:"trackName"` - AlbumName string `json:"albumName"` - Duration float32 `json:"duration"` - SyncedLyrics string `json:"syncedLyrics"` - PlainLyrics string `json:"plainLyrics"` -} - -func (r *lrclibResponse) ToLRC() *LRC { - lrc := &LRC{ - Segments: nil, - Full: &r.PlainLyrics, - } - if strings.TrimSpace(r.SyncedLyrics) == "" { - return lrc - } - - re := regexp.MustCompile(`^\[(\d{2}:\d{2}(?:\.\d{1,3})?)\]\s*(.*)$`) - - lines := strings.Split(r.SyncedLyrics, "\n") - segments := make([]LRCSegment, 0, len(lines)) - - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue - } - - m := re.FindStringSubmatch(line) - if len(m) != 3 { - // line doesn't match [mm:ss.xx] pattern – skip it. - continue - } - - segments = append(segments, LRCSegment{ - Time: m[1], - Text: m[2], - }) - } - if len(segments) == 0 { - return lrc - } - - lrc.Segments = segments - return lrc -} diff --git a/internal/providers/providers.go b/internal/providers/providers.go deleted file mode 100644 index 512723d..0000000 --- a/internal/providers/providers.go +++ /dev/null @@ -1,3 +0,0 @@ -// Package providers implements various third-party service providers. -// This includes clients for services like Spotify, YouTube, etc. -package providers diff --git a/internal/providers/spotify/spotify.go b/internal/providers/spotify/spotify.go deleted file mode 100644 index c56ccb8..0000000 --- a/internal/providers/spotify/spotify.go +++ /dev/null @@ -1,132 +0,0 @@ -package spotify - -import ( - "context" - "errors" - "sync" - "time" - - "github.com/zmb3/spotify/v2" - spotifyauth "github.com/zmb3/spotify/v2/auth" - "golang.org/x/oauth2/clientcredentials" - - "audio-scraper/internal/logger" -) - -type SpotifyClient struct { - config *clientcredentials.Config - client *spotify.Client - - tokenExpiry time.Time - - mu sync.RWMutex -} - -func NewSpotifyProvider(clientID string, clientSecret string) (*SpotifyClient, error) { - if clientID == "" { - return nil, errors.New("missing SPOTIFY_CLIENT_ID") - } - if clientSecret == "" { - return nil, errors.New("missing SPOTIFY_CLIENT_SECRET") - } - config := &clientcredentials.Config{ - ClientID: clientID, - ClientSecret: clientSecret, - TokenURL: spotifyauth.TokenURL, - } - - p := &SpotifyClient{config: config} - if err := p.refreshClient(context.Background()); err != nil { - return nil, err - } - return p, nil -} - -func newSpotifyClient(ctx context.Context, config *clientcredentials.Config) (*spotify.Client, time.Time, error) { - token, err := config.Token(ctx) - if err != nil { - return nil, time.Time{}, err - } - - httpClient := spotifyauth.New().Client(ctx, token) - client := spotify.New(httpClient) - return client, token.Expiry, nil -} - -func (p *SpotifyClient) refreshClient(ctx context.Context) error { - p.mu.Lock() - defer p.mu.Unlock() - - if p.client != nil && time.Now().Before(p.tokenExpiry.Add(-1*time.Minute)) { - return nil - } - - client, expiry, err := newSpotifyClient(ctx, p.config) - if err != nil { - return err - } - - p.client = client - p.tokenExpiry = expiry - return nil -} - -func (p *SpotifyClient) withClient(ctx context.Context) (*spotify.Client, error) { - p.mu.RLock() - client := p.client - expiry := p.tokenExpiry - p.mu.RUnlock() - - if client == nil || time.Now().After(expiry.Add(-1*time.Minute)) { - if err := p.refreshClient(ctx); err != nil { - return nil, err - } - - p.mu.RLock() - client = p.client - p.mu.RUnlock() - } - - return client, nil -} - -func (s *SpotifyClient) Search(ctx context.Context, query string, t spotify.SearchType, opts ...spotify.RequestOption) (*spotify.SearchResult, error) { - log := logger.From(ctx) - log.Info("performing spotify search", "query", query, "type", t) - client, err := s.withClient(ctx) - if err != nil { - log.Error("failed to acquire client") - } - return client.Search(ctx, query, t, opts...) -} - -func (s *SpotifyClient) GetTrack(ctx context.Context, id spotify.ID, opts ...spotify.RequestOption) (*spotify.FullTrack, error) { - log := logger.From(ctx) - log.Info("fetching spotify track", "track_id", id) - client, err := s.withClient(ctx) - if err != nil { - log.Error("failed to acquire client") - } - return client.GetTrack(ctx, id, opts...) -} - -func (s *SpotifyClient) GetAlbum(ctx context.Context, id spotify.ID, opts ...spotify.RequestOption) (*spotify.FullAlbum, error) { - log := logger.From(ctx) - log.Info("fetching spotify album", "album_id", id) - client, err := s.withClient(ctx) - if err != nil { - log.Error("failed to acquire client") - } - return client.GetAlbum(ctx, id, opts...) -} - -func (s *SpotifyClient) GetArtist(ctx context.Context, id spotify.ID, opts ...spotify.RequestOption) (*spotify.SimpleAlbumPage, error) { - log := logger.From(ctx) - log.Info("fetching spotify artist", "artist_id", id) - albumTypes := []spotify.AlbumType{spotify.AlbumTypeAlbum, spotify.AlbumTypeSingle, spotify.AlbumTypeAppearsOn, spotify.AlbumTypeCompilation} - client, err := s.withClient(ctx) - if err != nil { - log.Error("failed to acquire client") - } - return client.GetArtistAlbums(ctx, id, albumTypes, opts...) -} diff --git a/internal/providers/youtube/youtube.go b/internal/providers/youtube/youtube.go deleted file mode 100644 index 68dac40..0000000 --- a/internal/providers/youtube/youtube.go +++ /dev/null @@ -1,79 +0,0 @@ -package youtube - -import ( - "context" - "errors" - "os" - "os/exec" - - "github.com/faiface/beep/mp3" - - "audio-scraper/internal/logger" -) - -type YTClient struct{} - -func NewYTProvider() *YTClient { - return &YTClient{} -} - -func (y *YTClient) Search(ctx context.Context, track string, album string, artist string) (string, error) { - log := logger.From(ctx) - - log.Info("performing yt search", "track", track, "album", album, "artist", artist) - cmd := exec.Command("python3", "scripts/yt-music.py", track, album, artist) - - output, err := cmd.CombinedOutput() - if err != nil { - log.Error("yt search command failed", "error", err, "output", string(output)) - return "", errors.New("yt search failed") - } - - log.Info("yt search output", "output", string(output)) - return string(output), nil -} - -func (y *YTClient) Download(ctx context.Context, path string, videoURL string) (int, error) { - log := logger.From(ctx) - log.Info("starting yt-dlp download", "path", path) - cmd := exec.CommandContext( - ctx, - "yt-dlp", - "-q", - "-x", - "--audio-quality", "0", - "--audio-format", "mp3", - "-o", path, - videoURL, - ) - output, err := cmd.CombinedOutput() - if err != nil { - log.Error("yt-dlp command failed", "error", err, "output", string(output)) - return -1, errors.New("yt-dlp download failed") - } - - duration, err := getFileDuration(path) - if err != nil { - log.Error("failed getting duration from mp3", "error", err) - } - return int(duration), nil -} - -func getFileDuration(path string) (float64, error) { - f, err := os.Open(path) - if err != nil { - return 0, err - } - defer f.Close() - - streamer, format, err := mp3.Decode(f) - if err != nil { - return 0, err - } - defer streamer.Close() - - samples := streamer.Len() - seconds := float64(samples) / float64(format.SampleRate) - - return seconds, nil -} diff --git a/nimbus.yaml b/nimbus.yaml index 9550ada..6429239 100644 --- a/nimbus.yaml +++ b/nimbus.yaml @@ -10,7 +10,3 @@ services: env: - name: API_PORT value: 8080 - - name: SPOTIFY_CLIENT_ID - value: ${SPOTIFY_CLIENT_ID} - - name: SPOTIFY_CLIENT_SECRET - value: ${SPOTIFY_CLIENT_SECRET} diff --git a/scripts/yt-music.py b/scripts/yt-music.py deleted file mode 100644 index e0f92c2..0000000 --- a/scripts/yt-music.py +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env python3 - -from ytmusicapi import YTMusic -import difflib - -yt_music = YTMusic() - - -def find_yt_music_url(track_name, album_name, artist_name): - search_query = f'{track_name} {album_name} {artist_name}' - res = yt_music.search(search_query, filter='songs') - - best_match_index = -1 - best_match_ratio = 0.0 - for index, song in enumerate(res): - # print(song['title'], '-', song['album']['name'], '-', song['artists'][0]['name']) - title = song['title'] - album = song['album']['name'] - artists = [artist['name'] for artist in song['artists']] - artist_ratios = [difflib.SequenceMatcher(None, artist_name.lower(), artist.lower()).ratio() for artist in artists] - similarity_ratio = difflib.SequenceMatcher(None, track_name.lower(), title.lower()).ratio() - similarity_ratio += difflib.SequenceMatcher(None, album_name.lower(), album.lower()).ratio() + max(artist_ratios) - if similarity_ratio > best_match_ratio: - best_match_ratio = similarity_ratio - best_match_index = index - - if best_match_index == -1: - best_match_index = 0 - - return f'https://music.youtube.com/watch?v={res[best_match_index]["videoId"]}' - - -if __name__ == '__main__': - import sys - if len(sys.argv) != 4: - print('Usage: yt-music.py ') - sys.exit(1) - track_name, album_name, artist_name = sys.argv[1:] - print(find_yt_music_url(track_name, album_name, artist_name), end='') From b5f488a36370587fc3773dfe5c90ad58fb4ebe6f Mon Sep 17 00:00:00 2001 From: Prayuj Tuli Date: Tue, 2 Jun 2026 01:18:59 -0400 Subject: [PATCH 2/2] fix: adding youtube provider download optimizations --- .gitignore | 2 -- internal/adapters/youtube/impl/youtube.go | 3 +++ 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index f515fdf..b687de7 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,3 @@ bin/* !bin/.keep music/ - -venv/ diff --git a/internal/adapters/youtube/impl/youtube.go b/internal/adapters/youtube/impl/youtube.go index 2bc3f94..71a2471 100644 --- a/internal/adapters/youtube/impl/youtube.go +++ b/internal/adapters/youtube/impl/youtube.go @@ -184,6 +184,9 @@ func (y *Client) Download(ctx context.Context, path, videoURL string) (int, erro "-x", "--audio-quality", "0", "--audio-format", "mp3", + // The android player client avoids YouTube's SABR streaming, which + // otherwise 403s without a JS runtime. + "--extractor-args", "youtube:player_client=android", "-o", path, videoURL, )