├── .editorconfig ├── .gitattributes ├── .github └── workflows │ └── main.yml ├── .gitignore ├── Directory.Build.props ├── LICENSE.txt ├── README.md ├── SpotifyExplode.Demo.Cli ├── Program.cs └── SpotifyExplode.Demo.Cli.csproj ├── SpotifyExplode.Tests ├── SpotifyExplode.Tests.csproj ├── TrackSpecs.cs └── xunit.runner.json ├── SpotifyExplode.sln ├── SpotifyExplode ├── Albums │ ├── Album.cs │ ├── AlbumClient.cs │ ├── AlbumId.cs │ ├── AlbumIdJsonConverter.cs │ └── AlbumType.cs ├── Artists │ ├── Artist.cs │ ├── ArtistClient.cs │ ├── ArtistId.cs │ └── ArtistIdJsonConverter.cs ├── Common │ ├── ExternalIds.cs │ └── Image.cs ├── Constants.cs ├── Exceptions │ ├── RequestLimitExceededException.cs │ └── SpotifyExplodeException.cs ├── Http │ ├── Http.cs │ └── SpotifyHttp.cs ├── Playlists │ ├── Follower.cs │ ├── Item.cs │ ├── Playlist.cs │ ├── PlaylistClient.cs │ ├── PlaylistId.cs │ └── PlaylistIdJsonConverter.cs ├── Search │ ├── AlbumSearchResult.cs │ ├── ArtistSearchResult.cs │ ├── ISearchResult.cs │ ├── PlaylistSearchResult.cs │ ├── SearchClient.cs │ ├── SearchFilter.cs │ └── TrackSearchResult.cs ├── SpotifyClient.cs ├── SpotifyExplode.csproj ├── Tracks │ ├── Track.cs │ ├── TrackClient.cs │ ├── TrackId.cs │ └── TrackIdJsonConverter.cs ├── Users │ ├── User.cs │ ├── UserClient.cs │ ├── UserId.cs │ └── UserIdJsonConverter.cs └── Utils │ ├── Extensions │ ├── GenericExtensions.cs │ └── HttpExtensions.cs │ ├── JsonDefaults.cs │ ├── Polyfills.Streams.cs │ └── Randomizer.cs ├── favicon.ico ├── favicon.png └── favicon.svg /.editorconfig: -------------------------------------------------------------------------------- 1 | 2 | [*.{cs,vb}] 3 | #### Naming styles #### 4 | 5 | # Naming rules 6 | 7 | dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion 8 | dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface 9 | dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i 10 | 11 | dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion 12 | dotnet_naming_rule.types_should_be_pascal_case.symbols = types 13 | dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case 14 | 15 | dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion 16 | dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members 17 | dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case 18 | 19 | # Symbol specifications 20 | 21 | dotnet_naming_symbols.interface.applicable_kinds = interface 22 | dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected 23 | dotnet_naming_symbols.interface.required_modifiers = 24 | 25 | dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum 26 | dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected 27 | dotnet_naming_symbols.types.required_modifiers = 28 | 29 | dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method 30 | dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected 31 | dotnet_naming_symbols.non_field_members.required_modifiers = 32 | 33 | # Naming styles 34 | 35 | dotnet_naming_style.begins_with_i.required_prefix = I 36 | dotnet_naming_style.begins_with_i.required_suffix = 37 | dotnet_naming_style.begins_with_i.word_separator = 38 | dotnet_naming_style.begins_with_i.capitalization = pascal_case 39 | 40 | dotnet_naming_style.pascal_case.required_prefix = 41 | dotnet_naming_style.pascal_case.required_suffix = 42 | dotnet_naming_style.pascal_case.word_separator = 43 | dotnet_naming_style.pascal_case.capitalization = pascal_case 44 | 45 | dotnet_naming_style.pascal_case.required_prefix = 46 | dotnet_naming_style.pascal_case.required_suffix = 47 | dotnet_naming_style.pascal_case.word_separator = 48 | dotnet_naming_style.pascal_case.capitalization = pascal_case 49 | dotnet_style_operator_placement_when_wrapping = beginning_of_line 50 | tab_width = 4 51 | indent_size = 4 52 | end_of_line = crlf 53 | dotnet_style_null_propagation = true:error 54 | dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion 55 | dotnet_style_coalesce_expression = true:suggestion 56 | dotnet_style_prefer_auto_properties = true:silent 57 | dotnet_style_object_initializer = true:suggestion 58 | dotnet_style_collection_initializer = true:suggestion 59 | dotnet_style_prefer_simplified_boolean_expressions = true:suggestion 60 | dotnet_style_prefer_conditional_expression_over_assignment = true:silent 61 | dotnet_style_prefer_conditional_expression_over_return = true:silent 62 | dotnet_style_explicit_tuple_names = true:suggestion 63 | dotnet_style_prefer_inferred_tuple_names = true:suggestion 64 | dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion 65 | dotnet_style_prefer_compound_assignment = true:suggestion 66 | dotnet_style_prefer_simplified_interpolation = true:suggestion 67 | dotnet_style_namespace_match_folder = true:suggestion 68 | dotnet_style_readonly_field = true:suggestion 69 | dotnet_style_predefined_type_for_locals_parameters_members = true:silent 70 | dotnet_style_predefined_type_for_member_access = true:silent 71 | dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent 72 | dotnet_style_allow_multiple_blank_lines_experimental = true:silent 73 | dotnet_style_allow_statement_immediately_after_block_experimental = true:silent 74 | dotnet_code_quality_unused_parameters = all:suggestion 75 | dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent 76 | dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent 77 | dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent 78 | dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent 79 | dotnet_style_qualification_for_property = false:silent 80 | dotnet_style_qualification_for_field = false:silent 81 | dotnet_style_qualification_for_method = false:silent 82 | dotnet_style_qualification_for_event = false:silent 83 | 84 | [*.cs] 85 | csharp_indent_labels = one_less_than_current 86 | csharp_using_directive_placement = outside_namespace:silent 87 | csharp_prefer_simple_using_statement = true:suggestion 88 | csharp_prefer_braces = true:silent 89 | csharp_style_namespace_declarations = file_scoped:suggestion 90 | csharp_style_prefer_method_group_conversion = true:silent 91 | csharp_style_prefer_top_level_statements = true:silent 92 | csharp_style_expression_bodied_methods = false:silent 93 | csharp_style_expression_bodied_constructors = false:silent 94 | csharp_style_expression_bodied_operators = false:silent 95 | csharp_style_expression_bodied_properties = true:silent 96 | csharp_style_expression_bodied_indexers = true:silent 97 | csharp_style_expression_bodied_accessors = true:silent 98 | csharp_style_inlined_variable_declaration = true:suggestion 99 | csharp_style_deconstructed_variable_declaration = true:suggestion 100 | csharp_style_var_for_built_in_types = true:suggestion 101 | csharp_style_var_when_type_is_apparent = true:suggestion 102 | csharp_style_var_elsewhere = true:suggestion 103 | csharp_style_prefer_null_check_over_type_check = true:suggestion 104 | csharp_style_conditional_delegate_call = true:suggestion 105 | csharp_style_pattern_matching_over_as_with_null_check = true:warning 106 | csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true:silent 107 | csharp_space_around_binary_operators = before_and_after 108 | csharp_style_expression_bodied_lambdas = true:silent 109 | csharp_style_expression_bodied_local_functions = false:silent 110 | csharp_style_throw_expression = true:suggestion 111 | csharp_prefer_simple_default_expression = true:suggestion 112 | csharp_style_prefer_local_over_anonymous_function = true:suggestion 113 | csharp_style_prefer_index_operator = true:suggestion 114 | csharp_style_prefer_range_operator = true:suggestion 115 | csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion 116 | csharp_style_prefer_tuple_swap = true:suggestion 117 | csharp_style_prefer_utf8_string_literals = true:suggestion 118 | csharp_style_unused_value_assignment_preference = discard_variable:suggestion 119 | csharp_style_unused_value_expression_statement_preference = discard_variable:silent 120 | csharp_prefer_static_local_function = true:suggestion 121 | csharp_style_prefer_readonly_struct = true:suggestion 122 | csharp_style_allow_embedded_statements_on_same_line_experimental = true:silent 123 | csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true:silent 124 | csharp_style_prefer_switch_expression = true:warning 125 | csharp_style_prefer_pattern_matching = true:warning 126 | csharp_style_pattern_matching_over_is_with_cast_check = true:warning 127 | csharp_style_prefer_not_pattern = true:suggestion 128 | csharp_style_prefer_extended_property_pattern = true:suggestion 129 | csharp_style_prefer_primary_constructors = true:suggestion -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Set default behavior to automatically normalize line endings. 3 | ############################################################################### 4 | * text=auto 5 | 6 | ############################################################################### 7 | # Set default behavior for command prompt diff. 8 | # 9 | # This is need for earlier builds of msysgit that does not have it on by 10 | # default for csharp files. 11 | # Note: This is only used by command line 12 | ############################################################################### 13 | #*.cs diff=csharp 14 | 15 | ############################################################################### 16 | # Set the merge driver for project and solution files 17 | # 18 | # Merging from the command prompt will add diff markers to the files if there 19 | # are conflicts (Merging from VS is not affected by the settings below, in VS 20 | # the diff markers are never inserted). Diff markers may cause the following 21 | # file extensions to fail to load in VS. An alternative would be to treat 22 | # these files as binary and thus will always conflict and require user 23 | # intervention with every merge. To do so, just uncomment the entries below 24 | ############################################################################### 25 | #*.sln merge=binary 26 | #*.csproj merge=binary 27 | #*.vbproj merge=binary 28 | #*.vcxproj merge=binary 29 | #*.vcproj merge=binary 30 | #*.dbproj merge=binary 31 | #*.fsproj merge=binary 32 | #*.lsproj merge=binary 33 | #*.wixproj merge=binary 34 | #*.modelproj merge=binary 35 | #*.sqlproj merge=binary 36 | #*.wwaproj merge=binary 37 | 38 | ############################################################################### 39 | # behavior for image files 40 | # 41 | # image files are treated as binary by default. 42 | ############################################################################### 43 | #*.jpg binary 44 | #*.png binary 45 | #*.gif binary 46 | 47 | ############################################################################### 48 | # diff behavior for common document formats 49 | # 50 | # Convert binary document formats to text before diffing them. This feature 51 | # is only available from the command line. Turn it on by uncommenting the 52 | # entries below. 53 | ############################################################################### 54 | #*.doc diff=astextplain 55 | #*.DOC diff=astextplain 56 | #*.docx diff=astextplain 57 | #*.DOCX diff=astextplain 58 | #*.dot diff=astextplain 59 | #*.DOT diff=astextplain 60 | #*.pdf diff=astextplain 61 | #*.PDF diff=astextplain 62 | #*.rtf diff=astextplain 63 | #*.RTF diff=astextplain 64 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: windows-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | 11 | - name: Install .NET 12 | uses: actions/setup-dotnet@v4 13 | with: 14 | dotnet-version: 8.0.x 15 | 16 | - name: Restore dependencies 17 | run: dotnet restore 18 | 19 | - name: Build 20 | run: dotnet build --no-restore 21 | 22 | pack: 23 | if: ${{ github.event_name == 'push' && github.ref_type == 'tag' }} 24 | runs-on: windows-latest 25 | permissions: 26 | actions: write 27 | contents: read 28 | 29 | steps: 30 | - uses: actions/checkout@v4 31 | 32 | - name: Install .NET 33 | uses: actions/setup-dotnet@v4 34 | with: 35 | dotnet-version: 8.0.x 36 | 37 | - name: Get version information from tag 38 | id: get_version 39 | uses: battila7/get-version-action@v2 40 | 41 | - name: Pack 42 | run: > 43 | dotnet pack 44 | -p:ContinuousIntegrationBuild=true 45 | -c Release 46 | -p:Version=${{ steps.get_version.outputs.version-without-v }} 47 | 48 | - name: Upload artifacts 49 | uses: actions/upload-artifact@v4 50 | with: 51 | name: packages 52 | path: "**/*.nupkg" 53 | 54 | deploy: 55 | runs-on: windows-latest 56 | permissions: 57 | actions: read 58 | needs: 59 | - pack 60 | 61 | steps: 62 | - name: Install .NET 63 | uses: actions/setup-dotnet@v4 64 | with: 65 | dotnet-version: 8.0.x 66 | 67 | - name: Download artifacts 68 | uses: actions/download-artifact@v4 69 | with: 70 | name: packages 71 | 72 | - name: Push packages 73 | run: > 74 | dotnet nuget push **/*.nupkg 75 | --source "https://api.nuget.org/v3/index.json" 76 | --api-key ${{ secrets.NUGET_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Ww][Ii][Nn]32/ 27 | [Aa][Rr][Mm]/ 28 | [Aa][Rr][Mm]64/ 29 | bld/ 30 | [Bb]in/ 31 | [Oo]bj/ 32 | [Oo]ut/ 33 | [Ll]og/ 34 | [Ll]ogs/ 35 | 36 | # Visual Studio 2015/2017 cache/options directory 37 | .vs/ 38 | # Uncomment if you have tasks that create the project's static files in wwwroot 39 | #wwwroot/ 40 | 41 | # Visual Studio 2017 auto generated files 42 | Generated\ Files/ 43 | 44 | # MSTest test Results 45 | [Tt]est[Rr]esult*/ 46 | [Bb]uild[Ll]og.* 47 | 48 | # NUnit 49 | *.VisualState.xml 50 | TestResult.xml 51 | nunit-*.xml 52 | 53 | # Build Results of an ATL Project 54 | [Dd]ebugPS/ 55 | [Rr]eleasePS/ 56 | dlldata.c 57 | 58 | # Benchmark Results 59 | BenchmarkDotNet.Artifacts/ 60 | 61 | # .NET Core 62 | project.lock.json 63 | project.fragment.lock.json 64 | artifacts/ 65 | 66 | # ASP.NET Scaffolding 67 | ScaffoldingReadMe.txt 68 | 69 | # StyleCop 70 | StyleCopReport.xml 71 | 72 | # Files built by Visual Studio 73 | *_i.c 74 | *_p.c 75 | *_h.h 76 | *.ilk 77 | *.meta 78 | *.obj 79 | *.iobj 80 | *.pch 81 | *.pdb 82 | *.ipdb 83 | *.pgc 84 | *.pgd 85 | *.rsp 86 | *.sbr 87 | *.tlb 88 | *.tli 89 | *.tlh 90 | *.tmp 91 | *.tmp_proj 92 | *_wpftmp.csproj 93 | *.log 94 | *.vspscc 95 | *.vssscc 96 | .builds 97 | *.pidb 98 | *.svclog 99 | *.scc 100 | 101 | # Chutzpah Test files 102 | _Chutzpah* 103 | 104 | # Visual C++ cache files 105 | ipch/ 106 | *.aps 107 | *.ncb 108 | *.opendb 109 | *.opensdf 110 | *.sdf 111 | *.cachefile 112 | *.VC.db 113 | *.VC.VC.opendb 114 | 115 | # Visual Studio profiler 116 | *.psess 117 | *.vsp 118 | *.vspx 119 | *.sap 120 | 121 | # Visual Studio Trace Files 122 | *.e2e 123 | 124 | # TFS 2012 Local Workspace 125 | $tf/ 126 | 127 | # Guidance Automation Toolkit 128 | *.gpState 129 | 130 | # ReSharper is a .NET coding add-in 131 | _ReSharper*/ 132 | *.[Rr]e[Ss]harper 133 | *.DotSettings.user 134 | 135 | # TeamCity is a build add-in 136 | _TeamCity* 137 | 138 | # DotCover is a Code Coverage Tool 139 | *.dotCover 140 | 141 | # AxoCover is a Code Coverage Tool 142 | .axoCover/* 143 | !.axoCover/settings.json 144 | 145 | # Coverlet is a free, cross platform Code Coverage Tool 146 | coverage*.json 147 | coverage*.xml 148 | coverage*.info 149 | 150 | # Visual Studio code coverage results 151 | *.coverage 152 | *.coveragexml 153 | 154 | # NCrunch 155 | _NCrunch_* 156 | .*crunch*.local.xml 157 | nCrunchTemp_* 158 | 159 | # MightyMoose 160 | *.mm.* 161 | AutoTest.Net/ 162 | 163 | # Web workbench (sass) 164 | .sass-cache/ 165 | 166 | # Installshield output folder 167 | [Ee]xpress/ 168 | 169 | # DocProject is a documentation generator add-in 170 | DocProject/buildhelp/ 171 | DocProject/Help/*.HxT 172 | DocProject/Help/*.HxC 173 | DocProject/Help/*.hhc 174 | DocProject/Help/*.hhk 175 | DocProject/Help/*.hhp 176 | DocProject/Help/Html2 177 | DocProject/Help/html 178 | 179 | # Click-Once directory 180 | publish/ 181 | 182 | # Publish Web Output 183 | *.[Pp]ublish.xml 184 | *.azurePubxml 185 | # Note: Comment the next line if you want to checkin your web deploy settings, 186 | # but database connection strings (with potential passwords) will be unencrypted 187 | *.pubxml 188 | *.publishproj 189 | 190 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 191 | # checkin your Azure Web App publish settings, but sensitive information contained 192 | # in these scripts will be unencrypted 193 | PublishScripts/ 194 | 195 | # NuGet Packages 196 | *.nupkg 197 | # NuGet Symbol Packages 198 | *.snupkg 199 | # The packages folder can be ignored because of Package Restore 200 | **/[Pp]ackages/* 201 | # except build/, which is used as an MSBuild target. 202 | !**/[Pp]ackages/build/ 203 | # Uncomment if necessary however generally it will be regenerated when needed 204 | #!**/[Pp]ackages/repositories.config 205 | # NuGet v3's project.json files produces more ignorable files 206 | *.nuget.props 207 | *.nuget.targets 208 | 209 | # Microsoft Azure Build Output 210 | csx/ 211 | *.build.csdef 212 | 213 | # Microsoft Azure Emulator 214 | ecf/ 215 | rcf/ 216 | 217 | # Windows Store app package directories and files 218 | AppPackages/ 219 | BundleArtifacts/ 220 | Package.StoreAssociation.xml 221 | _pkginfo.txt 222 | *.appx 223 | *.appxbundle 224 | *.appxupload 225 | 226 | # Visual Studio cache files 227 | # files ending in .cache can be ignored 228 | *.[Cc]ache 229 | # but keep track of directories ending in .cache 230 | !?*.[Cc]ache/ 231 | 232 | # Others 233 | ClientBin/ 234 | ~$* 235 | *~ 236 | *.dbmdl 237 | *.dbproj.schemaview 238 | *.jfm 239 | *.pfx 240 | *.publishsettings 241 | orleans.codegen.cs 242 | 243 | # Including strong name files can present a security risk 244 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 245 | #*.snk 246 | 247 | # Since there are multiple workflows, uncomment next line to ignore bower_components 248 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 249 | #bower_components/ 250 | 251 | # RIA/Silverlight projects 252 | Generated_Code/ 253 | 254 | # Backup & report files from converting an old project file 255 | # to a newer Visual Studio version. Backup files are not needed, 256 | # because we have git ;-) 257 | _UpgradeReport_Files/ 258 | Backup*/ 259 | UpgradeLog*.XML 260 | UpgradeLog*.htm 261 | ServiceFabricBackup/ 262 | *.rptproj.bak 263 | 264 | # SQL Server files 265 | *.mdf 266 | *.ldf 267 | *.ndf 268 | 269 | # Business Intelligence projects 270 | *.rdl.data 271 | *.bim.layout 272 | *.bim_*.settings 273 | *.rptproj.rsuser 274 | *- [Bb]ackup.rdl 275 | *- [Bb]ackup ([0-9]).rdl 276 | *- [Bb]ackup ([0-9][0-9]).rdl 277 | 278 | # Microsoft Fakes 279 | FakesAssemblies/ 280 | 281 | # GhostDoc plugin setting file 282 | *.GhostDoc.xml 283 | 284 | # Node.js Tools for Visual Studio 285 | .ntvs_analysis.dat 286 | node_modules/ 287 | 288 | # Visual Studio 6 build log 289 | *.plg 290 | 291 | # Visual Studio 6 workspace options file 292 | *.opt 293 | 294 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 295 | *.vbw 296 | 297 | # Visual Studio LightSwitch build output 298 | **/*.HTMLClient/GeneratedArtifacts 299 | **/*.DesktopClient/GeneratedArtifacts 300 | **/*.DesktopClient/ModelManifest.xml 301 | **/*.Server/GeneratedArtifacts 302 | **/*.Server/ModelManifest.xml 303 | _Pvt_Extensions 304 | 305 | # Paket dependency manager 306 | .paket/paket.exe 307 | paket-files/ 308 | 309 | # FAKE - F# Make 310 | .fake/ 311 | 312 | # CodeRush personal settings 313 | .cr/personal 314 | 315 | # Python Tools for Visual Studio (PTVS) 316 | __pycache__/ 317 | *.pyc 318 | 319 | # Cake - Uncomment if you are using it 320 | # tools/** 321 | # !tools/packages.config 322 | 323 | # Tabs Studio 324 | *.tss 325 | 326 | # Telerik's JustMock configuration file 327 | *.jmconfig 328 | 329 | # BizTalk build output 330 | *.btp.cs 331 | *.btm.cs 332 | *.odx.cs 333 | *.xsd.cs 334 | 335 | # OpenCover UI analysis results 336 | OpenCover/ 337 | 338 | # Azure Stream Analytics local run output 339 | ASALocalRun/ 340 | 341 | # MSBuild Binary and Structured Log 342 | *.binlog 343 | 344 | # NVidia Nsight GPU debugger configuration file 345 | *.nvuser 346 | 347 | # MFractors (Xamarin productivity tool) working folder 348 | .mfractor/ 349 | 350 | # Local History for Visual Studio 351 | .localhistory/ 352 | 353 | # BeatPulse healthcheck temp database 354 | healthchecksdb 355 | 356 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 357 | MigrationBackup/ 358 | 359 | # Ionide (cross platform F# VS Code tools) working folder 360 | .ionide/ 361 | 362 | # Fody - auto-generated XML schema 363 | FodyWeavers.xsd -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 1.1.2 5 | Jerro 6 | Copyright (C) Jerry Berry 7 | latest 8 | enable 9 | nullable 10 | false 11 | CS1591 12 | 13 | 14 | 15 | 16 | annotations 17 | 18 | 19 | 20 | $(Company) 21 | spotify artist album playlist user tracks parse extract metadata info net core standard 22 | https://github.com/jerry08/SpotifyExplode 23 | ReadMe.md 24 | favicon.png 25 | LGPL-3.0-only 26 | true 27 | true 28 | true 29 | embedded 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 | SpotifyExplode 4 |

5 | 6 |

7 | 8 | 9 |

10 | 11 | **SpotifyExplode** is a library that provides an interface to query metadata of Spotify tracks, playlists, albums, artists and users as well as to download audio. 12 | 13 | ### 🌟STAR THIS REPOSITORY TO SUPPORT THE DEVELOPER AND ENCOURAGE THE DEVELOPMENT OF THIS PROJECT! 14 | 15 | 16 | ## Install 17 | 18 | - 📦 [NuGet](https://nuget.org/packages/SpotifyExplode): `dotnet add package SpotifyExplode` 19 | 20 | ## Usage 21 | 22 | **SpotifyExplode** exposes its functionality through a single entry point — the `SpotifyClient` class. 23 | Create an instance of this class and use the provided operations to send requests. 24 | 25 | ### Tracks 26 | 27 | #### Retrieving track metadata 28 | 29 | To retrieve the metadata associated with a Spotify track, call `Tracks.GetAsync(...)`: 30 | 31 | ```csharp 32 | using SpotifyExplode; 33 | 34 | var spotify = new SpotifyClient(); 35 | 36 | var track = await spotify.Tracks.GetAsync( 37 | "https://open.spotify.com/track/0VjIjW4GlUZAMYd2vXMi3b" 38 | ); 39 | 40 | var title = track.Title; 41 | var duration = track.DurationMs; 42 | ``` 43 | 44 | ### Playlists 45 | 46 | #### Retrieving playlist metadata 47 | 48 | You can get the metadata associated with a Spotify playlist by calling `Playlists.GetAsync(...)` method: 49 | 50 | ```csharp 51 | using SpotifyExplode; 52 | 53 | var spotify = new SpotifyClient(); 54 | 55 | //Get playlist info 56 | var playlist = await spotify.Playlists.GetAsync( 57 | "https://open.spotify.com/playlist/0tSYjDUflcozy78WwUFe6y" 58 | ); 59 | 60 | var title = playlist.Name; 61 | var artworkUrl = playlist.Followers; 62 | var tracks = playlist.Tracks; 63 | ... 64 | ``` 65 | 66 | #### Getting tracks included in a playlist 67 | 68 | To get the tracks included in a playlist, call `Playlists.GetTracksAsync(...)`: 69 | 70 | ```csharp 71 | using SpotifyExplode; 72 | 73 | var spotify = new SpotifyClient(); 74 | 75 | // Get all tracks in a playlist 76 | var tracks = await spotify.Playlists.GetAllTracksAsync( 77 | "https://open.spotify.com/playlist/0tSYjDUflcozy78WwUFe6y" 78 | ); 79 | 80 | // Get only the first 20 playlist tracks 81 | var tracksSubset = await spotify.Playlists.GetTracksAsync( 82 | "https://open.spotify.com/playlist/0tSYjDUflcozy78WwUFe6y", 83 | limit: 20 84 | ); 85 | 86 | //Setting offset 87 | var tracksSubset = await spotify.Playlists.GetTracksAsync( 88 | "https://open.spotify.com/playlist/0tSYjDUflcozy78WwUFe6y", 89 | offset: 3, 90 | limit: 20 91 | ); 92 | ``` 93 | 94 | ### Albums 95 | 96 | #### Retrieving album metadata 97 | 98 | You can get the metadata associated with a Spotify album by calling `Albums.GetAsync(...)` method: 99 | 100 | ```csharp 101 | using SpotifyExplode; 102 | 103 | var spotify = new SpotifyClient(); 104 | 105 | //Get album info with all tracks 106 | var album = await spotify.Albums.GetAsync( 107 | "https://open.spotify.com/album/336m0kejdM5Fkw2HUX46Bw?si=549f3fdb0cfd46e6" 108 | ); 109 | 110 | var title = album.Name; 111 | var artists = album.Artists; 112 | var tracks = album.Tracks; 113 | ... 114 | ``` 115 | 116 | #### Getting tracks included in an album 117 | 118 | To get the tracks included in a album, call `Albums.GetTracksAsync(...)`: 119 | 120 | ```csharp 121 | using SpotifyExplode; 122 | 123 | var spotify = new SpotifyClient(); 124 | 125 | // Get all tracks in a album 126 | var tracks = await spotify.Albums.GetAllTracksAsync( 127 | "https://open.spotify.com/album/336m0kejdM5Fkw2HUX46Bw?si=549f3fdb0cfd46e6" 128 | ); 129 | 130 | // Get only the first 20 album tracks 131 | var tracksSubset = await spotify.Albums.GetTracksAsync( 132 | "https://open.spotify.com/album/336m0kejdM5Fkw2HUX46Bw?si=549f3fdb0cfd46e6", 133 | limit: 20 134 | ); 135 | 136 | //Setting offset 137 | var tracksSubset = await spotify.Albums.GetTracksAsync( 138 | "https://open.spotify.com/album/336m0kejdM5Fkw2HUX46Bw?si=549f3fdb0cfd46e6", 139 | offset: 3, 140 | limit: 20 141 | ); 142 | ``` 143 | 144 | ### Artists 145 | 146 | #### Retrieving artist metadata 147 | 148 | You can get the metadata associated with a Spotify artist by calling `Artists.GetAsync(...)` method: 149 | 150 | ```csharp 151 | using SpotifyExplode; 152 | 153 | var spotify = new SpotifyClient(); 154 | 155 | //Get artist info 156 | var artist = await spotify.Artists.GetAsync( 157 | "https://open.spotify.com/artist/0bAsR2unSRpn6BQPEnNlZm?si=d3b6e78f96ce45b9" 158 | ); 159 | 160 | var title = artist.Name; 161 | var albums = artist.Albums; 162 | ``` 163 | 164 | #### Getting tracks included in an artist 165 | 166 | To get the tracks included in an artist, call `Artists.GetAlbumsAsync(...)`: 167 | 168 | ```csharp 169 | using SpotifyExplode; 170 | 171 | var spotify = new SpotifyClient(); 172 | 173 | // Get all albums in an artist 174 | var albums = await spotify.Artists.GetAllAlbumsAsync( 175 | "https://open.spotify.com/artist/0bAsR2unSRpn6BQPEnNlZm?si=d3b6e78f96ce45b9" 176 | ); 177 | 178 | // Get only the first 20 artist albums 179 | var albumsSubset = await spotify.Artists.GetAlbumsAsync( 180 | "https://open.spotify.com/artist/0bAsR2unSRpn6BQPEnNlZm?si=d3b6e78f96ce45b9", 181 | limit: 20 182 | ); 183 | 184 | //Setting offset 185 | var albumsSubset = await spotify.Artists.GetAlbumsAsync( 186 | "https://open.spotify.com/artist/0bAsR2unSRpn6BQPEnNlZm?si=d3b6e78f96ce45b9", 187 | offset: 3, 188 | limit: 20 189 | ); 190 | ``` 191 | 192 | ### Users 193 | 194 | #### Retrieving user metadata 195 | 196 | You can get the metadata associated with a Spotify user by calling `Users.GetAsync(...)` method: 197 | 198 | ```csharp 199 | using SpotifyExplode; 200 | 201 | var spotify = new SpotifyClient(); 202 | 203 | //Get user info 204 | var user = await spotify.Users.GetAsync( 205 | "https://open.spotify.com/user/xxu0yww90v07gbh9veqta7ze0" 206 | ); 207 | 208 | var name = user.DisplayName; 209 | var followers = user.Followers; 210 | var images = user.Images; 211 | ``` 212 | 213 | ### Searching 214 | You can execute a search query and get its results by calling `Search.GetResultsAsync(...)`. Each result may represent either an album, artist, track or playlist, so you need to apply pattern matching to handle the corresponding cases: 215 | 216 | ```csharp 217 | using SpotifyExplode; 218 | 219 | var spotify = new SpotifyClient(); 220 | 221 | foreach (var result in await spotify.Search.GetResultsAsync("banda neira")) 222 | { 223 | // Use pattern matching to handle different results (albums, artists, tracks, playlists) 224 | switch (result) 225 | { 226 | case TrackSearchResult track: 227 | { 228 | var id = track.Id; 229 | var title = track.Title; 230 | var duration = track.DurationMs; 231 | break; 232 | } 233 | case PlaylistSearchResult playlist: 234 | { 235 | var id = playlist.Id; 236 | var title = playlist.Name; 237 | break; 238 | } 239 | case AlbumSearchResult album: 240 | { 241 | var id = album.Id; 242 | var title = album.Name; 243 | var artists = album.Artists; 244 | var tracks = album.Tracks; 245 | break; 246 | } 247 | case ArtistSearchResult artist: 248 | { 249 | var id = artist.Id; 250 | var title = artist.Name; 251 | break; 252 | } 253 | } 254 | } 255 | ``` 256 | 257 | ### Downloading 258 | You can get the download url from a track by calling `Tracks.GetDownloadUrlAsync(...)`. 259 | 260 | ```csharp 261 | using SpotifyExplode; 262 | 263 | var spotify = new SpotifyClient(); 264 | 265 | var downloadUrl = await spotify.Tracks.GetDownloadUrlAsync( 266 | "https://open.spotify.com/track/0VjIjW4GlUZAMYd2vXMi3b" 267 | ); 268 | // Start download from the url provided... 269 | ``` 270 | 271 | ### Extras 272 | You can get a Youtube ID from a track by calling `Tracks.GetYoutubeIdAsync(...)`. 273 | 274 | ```csharp 275 | using SpotifyExplode; 276 | 277 | var spotify = new SpotifyClient(); 278 | 279 | var youtubeId = await spotify.Tracks.GetYoutubeIdAsync( 280 | "https://open.spotify.com/track/0VjIjW4GlUZAMYd2vXMi3b" 281 | ); 282 | 283 | var youtubeUrl = "https://youtube.com/watch?v=" + youtubeId; 284 | ``` 285 | -------------------------------------------------------------------------------- /SpotifyExplode.Demo.Cli/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using SpotifyExplode; 4 | using SpotifyExplode.Search; 5 | using SpotifyExplode.Tracks; 6 | 7 | namespace SpotifyExplode.DemoConsole; 8 | 9 | public static class Program 10 | { 11 | static async Task Main(string[] args) 12 | { 13 | Console.Title = "SpotifyExplode Demo"; 14 | 15 | var spotify = new SpotifyClient(); 16 | 17 | // Get the track ID 18 | Console.Write("Enter Spotify track ID or URL: "); 19 | var trackId = TrackId.Parse(Console.ReadLine() ?? ""); 20 | 21 | var track = await spotify.Tracks.GetAsync(trackId); 22 | 23 | Console.WriteLine($"Title: {track.Title}"); 24 | Console.WriteLine($"Duration (milliseconds): {track.DurationMs}"); 25 | Console.WriteLine($"{track.Album}"); 26 | Console.ReadLine(); 27 | } 28 | } -------------------------------------------------------------------------------- /SpotifyExplode.Demo.Cli/SpotifyExplode.Demo.Cli.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Exe 5 | net8.0 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /SpotifyExplode.Tests/SpotifyExplode.Tests.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net8.0 5 | true 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /SpotifyExplode.Tests/TrackSpecs.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using FluentAssertions; 3 | using Xunit; 4 | 5 | namespace SpotifyExplode.Tests; 6 | 7 | public class TrackSpecs 8 | { 9 | [Theory] 10 | [InlineData("0VjIjW4GlUZAMYd2vXMi3b")] 11 | public async Task I_can_get_a_download_url_from_a_track(string trackId) 12 | { 13 | // Arrange 14 | var spotify = new SpotifyClient(); 15 | 16 | // Act 17 | var results = await spotify.Tracks.GetDownloadUrlAsync(trackId); 18 | 19 | // Assert 20 | results.Should().NotBeNullOrWhiteSpace(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /SpotifyExplode.Tests/xunit.runner.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", 3 | "methodDisplayOptions": "all", 4 | "methodDisplay": "method" 5 | } -------------------------------------------------------------------------------- /SpotifyExplode.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.2.32505.173 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SpotifyExplode.Demo.Cli", "SpotifyExplode.Demo.Cli\SpotifyExplode.Demo.Cli.csproj", "{6B5917F9-46B6-4991-8E9A-CBFD7553B2DA}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SpotifyExplode", "SpotifyExplode\SpotifyExplode.csproj", "{F4D11A98-F9D8-4DEF-9093-3622DF14F1A4}" 9 | EndProject 10 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{DF6FA4E7-4D21-4ACD-932A-EA7E3AFF4CBF}" 11 | ProjectSection(SolutionItems) = preProject 12 | .editorconfig = .editorconfig 13 | Directory.Build.props = Directory.Build.props 14 | favicon.ico = favicon.ico 15 | favicon.png = favicon.png 16 | favicon.svg = favicon.svg 17 | README.md = README.md 18 | EndProjectSection 19 | EndProject 20 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SpotifyExplode.Tests", "SpotifyExplode.Tests\SpotifyExplode.Tests.csproj", "{E6131FD4-45B4-4CEC-9DAB-0DC0C7CCB182}" 21 | EndProject 22 | Global 23 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 24 | Debug|Any CPU = Debug|Any CPU 25 | Release|Any CPU = Release|Any CPU 26 | EndGlobalSection 27 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 28 | {6B5917F9-46B6-4991-8E9A-CBFD7553B2DA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 29 | {6B5917F9-46B6-4991-8E9A-CBFD7553B2DA}.Debug|Any CPU.Build.0 = Debug|Any CPU 30 | {6B5917F9-46B6-4991-8E9A-CBFD7553B2DA}.Release|Any CPU.ActiveCfg = Release|Any CPU 31 | {6B5917F9-46B6-4991-8E9A-CBFD7553B2DA}.Release|Any CPU.Build.0 = Release|Any CPU 32 | {F4D11A98-F9D8-4DEF-9093-3622DF14F1A4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 33 | {F4D11A98-F9D8-4DEF-9093-3622DF14F1A4}.Debug|Any CPU.Build.0 = Debug|Any CPU 34 | {F4D11A98-F9D8-4DEF-9093-3622DF14F1A4}.Release|Any CPU.ActiveCfg = Release|Any CPU 35 | {F4D11A98-F9D8-4DEF-9093-3622DF14F1A4}.Release|Any CPU.Build.0 = Release|Any CPU 36 | {E6131FD4-45B4-4CEC-9DAB-0DC0C7CCB182}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 37 | {E6131FD4-45B4-4CEC-9DAB-0DC0C7CCB182}.Debug|Any CPU.Build.0 = Debug|Any CPU 38 | {E6131FD4-45B4-4CEC-9DAB-0DC0C7CCB182}.Release|Any CPU.ActiveCfg = Release|Any CPU 39 | {E6131FD4-45B4-4CEC-9DAB-0DC0C7CCB182}.Release|Any CPU.Build.0 = Release|Any CPU 40 | EndGlobalSection 41 | GlobalSection(SolutionProperties) = preSolution 42 | HideSolutionNode = FALSE 43 | EndGlobalSection 44 | GlobalSection(ExtensibilityGlobals) = postSolution 45 | SolutionGuid = {FFA000F4-B136-4A27-8C7A-D17BBD903691} 46 | EndGlobalSection 47 | EndGlobal 48 | -------------------------------------------------------------------------------- /SpotifyExplode/Albums/Album.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics.CodeAnalysis; 4 | using System.Text.Json.Serialization; 5 | using SpotifyExplode.Artists; 6 | using SpotifyExplode.Common; 7 | using SpotifyExplode.Tracks; 8 | 9 | namespace SpotifyExplode.Albums; 10 | 11 | public class Album 12 | { 13 | [JsonPropertyName("id")] 14 | public AlbumId Id { get; set; } 15 | 16 | public string Url => $"https://open.spotify.com/track/{Id}"; 17 | 18 | [JsonPropertyName("label")] 19 | public string Label { get; set; } = default!; 20 | 21 | [JsonPropertyName("name")] 22 | public string Name { get; set; } = default!; 23 | 24 | [JsonPropertyName("album_type")] 25 | public AlbumType AlbumType { get; set; } 26 | 27 | [JsonPropertyName("popularity")] 28 | public int Popularity { get; set; } 29 | 30 | [JsonPropertyName("release_date")] 31 | public string ReleaseDateStr { get; set; } = default!; 32 | 33 | public DateTime? ReleaseDate => DateTime.TryParse(ReleaseDateStr, out var releaseDate) ? releaseDate : null; 34 | 35 | [JsonPropertyName("total_tracks")] 36 | public int TotalTracks { get; set; } 37 | 38 | [JsonIgnore] 39 | public List Tracks { get; set; } = default!; 40 | 41 | [JsonPropertyName("available_markets")] 42 | public List AvailableMarkets { get; set; } = default!; 43 | 44 | [JsonPropertyName("artists")] 45 | public List Artists { get; set; } = default!; 46 | 47 | [JsonPropertyName("images")] 48 | public List Images { get; set; } = []; 49 | 50 | [JsonPropertyName("genres")] 51 | public List Genres { get; set; } = default!; 52 | 53 | /// 54 | /// Known external IDs for the track. 55 | /// 56 | [JsonPropertyName("external_ids")] 57 | public ExternalIds? ExternalIds { get; set; } = default!; 58 | 59 | /// 60 | [ExcludeFromCodeCoverage] 61 | public override string ToString() => $"Album ({Name})"; 62 | } -------------------------------------------------------------------------------- /SpotifyExplode/Albums/AlbumClient.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Net.Http; 3 | using System.Text.Json; 4 | using System.Text.Json.Nodes; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using SpotifyExplode.Exceptions; 8 | using SpotifyExplode.Tracks; 9 | using SpotifyExplode.Utils; 10 | 11 | namespace SpotifyExplode.Albums; 12 | 13 | /// 14 | /// Operations related to Spotify albums. 15 | /// 16 | /// 17 | /// Initializes an instance of . 18 | /// 19 | public class AlbumClient(HttpClient http) 20 | { 21 | private readonly SpotifyHttp _spotifyHttp = new(http); 22 | 23 | /// 24 | /// Gets the metadata associated with the specified album. 25 | /// 26 | public async ValueTask GetAsync( 27 | AlbumId albumId, 28 | CancellationToken cancellationToken = default) 29 | { 30 | var response = await _spotifyHttp.GetAsync( 31 | $"https://api.spotify.com/v1/albums/{albumId}", 32 | cancellationToken 33 | ); 34 | 35 | var album = JsonSerializer.Deserialize(response, JsonDefaults.Options)!; 36 | 37 | var items = JsonNode.Parse(response)!["tracks"]?["items"]?.ToString(); 38 | if (!string.IsNullOrEmpty(items)) 39 | { 40 | var albumTracks = JsonSerializer.Deserialize>(items!, JsonDefaults.Options)!; 41 | albumTracks.ForEach(track => track.Album = album); 42 | album.Tracks = albumTracks; 43 | } 44 | 45 | return album; 46 | } 47 | 48 | /// 49 | /// Gets the metadata associated with the tracks in a specified album. 50 | /// 51 | public async ValueTask> GetTracksAsync( 52 | AlbumId albumId, 53 | int offset = Constants.DefaultOffset, 54 | int limit = Constants.DefaultLimit, 55 | CancellationToken cancellationToken = default) 56 | { 57 | if (limit is < Constants.MinLimit or > Constants.MaxLimit) 58 | throw new SpotifyExplodeException($"Limit must be between {Constants.MinLimit} and {Constants.MaxLimit}"); 59 | 60 | var response = await _spotifyHttp.GetAsync( 61 | $"https://api.spotify.com/v1/albums/{albumId}/tracks?offset={offset}&limit={limit}", 62 | cancellationToken 63 | ); 64 | 65 | var albumTracks = JsonNode.Parse(response)!["items"]!.ToString(); 66 | 67 | return JsonSerializer.Deserialize>(albumTracks, JsonDefaults.Options)!; 68 | } 69 | 70 | /// 71 | /// Gets the metadata associated with the tracks in a specified album. 72 | /// 73 | public async ValueTask> GetAllTracksAsync( 74 | AlbumId albumId, 75 | CancellationToken cancellationToken = default) 76 | { 77 | var albumtracks = new List(); 78 | 79 | var offset = 0; 80 | 81 | while (true) 82 | { 83 | var tracks = await GetTracksAsync( 84 | albumId, 85 | offset, 86 | Constants.MaxLimit, 87 | cancellationToken 88 | ); 89 | 90 | albumtracks.AddRange(tracks); 91 | 92 | if (tracks.Count < 4) 93 | break; 94 | 95 | offset += tracks.Count; 96 | } 97 | 98 | return albumtracks; 99 | } 100 | } -------------------------------------------------------------------------------- /SpotifyExplode/Albums/AlbumId.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.RegularExpressions; 3 | using SpotifyExplode.Utils.Extensions; 4 | 5 | namespace SpotifyExplode.Albums; 6 | 7 | /// 8 | /// Represents a syntactically valid Spotify album ID. 9 | /// 10 | public readonly partial struct AlbumId 11 | { 12 | /// 13 | /// Raw ID value. 14 | /// 15 | public string Value { get; } 16 | 17 | private AlbumId(string value) => Value = value; 18 | 19 | /// 20 | public override string ToString() => Value; 21 | } 22 | 23 | public partial struct AlbumId 24 | { 25 | private static bool IsValid(string albumId) 26 | { 27 | // Track IDs are always 22 characters 28 | if (albumId.Length != 22) 29 | return false; 30 | 31 | return !Regex.IsMatch(albumId, @"[^0-9a-zA-Z_\-]"); 32 | } 33 | 34 | private static string? TryNormalize(string? albumIdOrUrl) 35 | { 36 | if (string.IsNullOrWhiteSpace(albumIdOrUrl)) 37 | return null; 38 | 39 | // Id 40 | // 4yP0hdKOZPNshxUOjY0cZj 41 | if (IsValid(albumIdOrUrl!)) 42 | return albumIdOrUrl; 43 | 44 | // Regular URL 45 | // https://open.spotify.com/album/4yP0hdKOZPNshxUOjY0cZj 46 | var regularMatch = Regex.Match(albumIdOrUrl, @"spotify\..+?\/album\/([a-zA-Z0-9]+)").Groups[1].Value; 47 | if (!string.IsNullOrWhiteSpace(regularMatch) && IsValid(regularMatch)) 48 | return regularMatch; 49 | 50 | // Invalid input 51 | return null; 52 | } 53 | 54 | /// 55 | /// Attempts to parse the specified string as a track ID or URL. 56 | /// Returns null in case of failure. 57 | /// 58 | public static AlbumId? TryParse(string? albumIdOrUrl) => 59 | TryNormalize(albumIdOrUrl)?.Pipe(id => new AlbumId(id)); 60 | 61 | /// 62 | /// Parses the specified string as a Spotify track ID or URL. 63 | /// Throws an exception in case of failure. 64 | /// 65 | public static AlbumId Parse(string albumIdOrUrl) => 66 | TryParse(albumIdOrUrl) ?? 67 | throw new ArgumentException($"Invalid Spotify track ID or URL '{albumIdOrUrl}'."); 68 | 69 | /// 70 | /// Converts string to ID. 71 | /// 72 | public static implicit operator AlbumId(string albumIdOrUrl) => Parse(albumIdOrUrl); 73 | 74 | /// 75 | /// Converts ID to string. 76 | /// 77 | public static implicit operator string(AlbumId albumId) => albumId.ToString(); 78 | } 79 | 80 | public partial struct AlbumId : IEquatable 81 | { 82 | /// 83 | public bool Equals(AlbumId other) => StringComparer.Ordinal.Equals(Value, other.Value); 84 | 85 | /// 86 | public override bool Equals(object? obj) => obj is AlbumId other && Equals(other); 87 | 88 | /// 89 | public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Value); 90 | 91 | /// 92 | /// Equality check. 93 | /// 94 | public static bool operator ==(AlbumId left, AlbumId right) => left.Equals(right); 95 | 96 | /// 97 | /// Equality check. 98 | /// 99 | public static bool operator !=(AlbumId left, AlbumId right) => !(left == right); 100 | } -------------------------------------------------------------------------------- /SpotifyExplode/Albums/AlbumIdJsonConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.Json; 3 | using System.Text.Json.Serialization; 4 | 5 | namespace SpotifyExplode.Albums; 6 | 7 | internal class AlbumIdJsonConverter : JsonConverter 8 | { 9 | public override AlbumId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) 10 | { 11 | var val = reader.GetString(); 12 | var result = AlbumId.TryParse(val); 13 | 14 | return result ?? throw new InvalidOperationException($"Invalid JSON for type '{typeToConvert.FullName}'."); 15 | } 16 | 17 | public override void Write(Utf8JsonWriter writer, AlbumId value, JsonSerializerOptions options) 18 | { 19 | writer.WriteStringValue(value); 20 | } 21 | } -------------------------------------------------------------------------------- /SpotifyExplode/Albums/AlbumType.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.Serialization; 2 | 3 | namespace SpotifyExplode.Albums; 4 | 5 | /// 6 | /// Spotify's album types 7 | /// 8 | public enum AlbumType 9 | { 10 | /// 11 | /// Album 12 | /// 13 | [EnumMember(Value = "album")] 14 | Album, 15 | 16 | /// 17 | /// Single 18 | /// 19 | [EnumMember(Value = "single")] 20 | Single, 21 | 22 | /// 23 | /// Compilation 24 | /// 25 | [EnumMember(Value = "compilation")] 26 | Compilation 27 | } -------------------------------------------------------------------------------- /SpotifyExplode/Artists/Artist.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Diagnostics.CodeAnalysis; 3 | using System.Text.Json.Serialization; 4 | using SpotifyExplode.Common; 5 | using SpotifyExplode.Playlists; 6 | 7 | namespace SpotifyExplode.Artists; 8 | 9 | public class Artist 10 | { 11 | [JsonPropertyName("id")] 12 | //[JsonConverter(typeof(ArtistIdJsonConverter))] 13 | public ArtistId Id { get; set; } 14 | 15 | [JsonPropertyName("name")] 16 | public string Name { get; set; } = default!; 17 | 18 | [JsonPropertyName("followers")] 19 | public Follower Followers { get; set; } = default!; 20 | 21 | [JsonPropertyName("genres")] 22 | public List Genres { get; set; } = default!; 23 | 24 | [JsonPropertyName("images")] 25 | public List Images { get; set; } = []; 26 | 27 | [JsonPropertyName("popularity")] 28 | public int Popularity { get; set; } 29 | 30 | /// 31 | [ExcludeFromCodeCoverage] 32 | public override string ToString() => $"Artist ({Name})"; 33 | } -------------------------------------------------------------------------------- /SpotifyExplode/Artists/ArtistClient.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Net.Http; 3 | using System.Text.Json; 4 | using System.Text.Json.Nodes; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using SpotifyExplode.Albums; 8 | using SpotifyExplode.Exceptions; 9 | using SpotifyExplode.Utils; 10 | 11 | namespace SpotifyExplode.Artists; 12 | 13 | /// 14 | /// Operations related to Spotify artists. 15 | /// 16 | /// 17 | /// Initializes an instance of . 18 | /// 19 | public class ArtistClient(HttpClient http) 20 | { 21 | private readonly SpotifyHttp _spotifyHttp = new(http); 22 | 23 | /// 24 | /// Gets the metadata associated with the specified artist. 25 | /// 26 | public async ValueTask GetAsync( 27 | ArtistId artistId, 28 | CancellationToken cancellationToken = default) 29 | { 30 | var response = await _spotifyHttp.GetAsync( 31 | $"https://api.spotify.com/v1/artists/{artistId}", 32 | cancellationToken 33 | ); 34 | 35 | return JsonSerializer.Deserialize(response, JsonDefaults.Options)!; 36 | } 37 | 38 | /// 39 | /// Gets the metadata associated with the albums in a specified artist. 40 | /// 41 | public async ValueTask> GetAlbumsAsync( 42 | ArtistId artistId, 43 | AlbumType? albumType = null, 44 | int offset = Constants.DefaultOffset, 45 | int limit = Constants.DefaultLimit, 46 | CancellationToken cancellationToken = default) 47 | { 48 | if (limit is < Constants.MinLimit or > Constants.MaxLimit) 49 | throw new SpotifyExplodeException($"Limit must be between {Constants.MinLimit} and {Constants.MaxLimit}"); 50 | 51 | var response = await _spotifyHttp.GetAsync( 52 | $"https://api.spotify.com/v1/artists/{artistId}/albums?{(albumType is not null ? $"album_type={albumType.ToString()!.ToUpper()}&" : "")}offset={offset}&limit={limit}", 53 | cancellationToken 54 | ); 55 | 56 | var artistAlbums = JsonNode.Parse(response)!["items"]!.ToString(); 57 | 58 | return JsonSerializer.Deserialize>(artistAlbums, JsonDefaults.Options)!; 59 | } 60 | 61 | /// 62 | /// Gets the metadata associated with the albums in a specified artist. 63 | /// 64 | public async ValueTask> GetAllAlbumsAsync( 65 | ArtistId artistId, 66 | AlbumType? albumType = null, 67 | CancellationToken cancellationToken = default) 68 | { 69 | var artistAlbums = new List(); 70 | 71 | var offset = 0; 72 | 73 | while (true) 74 | { 75 | var albums = await GetAlbumsAsync( 76 | artistId, 77 | albumType, 78 | offset, 79 | Constants.MaxLimit, 80 | cancellationToken 81 | ); 82 | 83 | artistAlbums.AddRange(albums); 84 | 85 | if (albums.Count < 4) 86 | break; 87 | 88 | offset += albums.Count; 89 | } 90 | 91 | return artistAlbums; 92 | } 93 | } -------------------------------------------------------------------------------- /SpotifyExplode/Artists/ArtistId.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.RegularExpressions; 3 | using SpotifyExplode.Utils.Extensions; 4 | 5 | namespace SpotifyExplode.Artists; 6 | 7 | /// 8 | /// Represents a syntactically valid Spotify artist ID. 9 | /// 10 | public readonly partial struct ArtistId 11 | { 12 | /// 13 | /// Raw ID value. 14 | /// 15 | public string Value { get; } 16 | 17 | private ArtistId(string value) => Value = value; 18 | 19 | /// 20 | public override string ToString() => Value; 21 | } 22 | 23 | public partial struct ArtistId 24 | { 25 | private static bool IsValid(string artistId) 26 | { 27 | // Track IDs are always 22 characters 28 | if (artistId.Length != 22) 29 | return false; 30 | 31 | return !Regex.IsMatch(artistId, @"[^0-9a-zA-Z_\-]"); 32 | } 33 | 34 | private static string? TryNormalize(string? artistIdOrUrl) 35 | { 36 | if (string.IsNullOrWhiteSpace(artistIdOrUrl)) 37 | return null; 38 | 39 | // Id 40 | // 1fZAAHNWdSM5gqbi9o5iEA 41 | if (IsValid(artistIdOrUrl!)) 42 | return artistIdOrUrl; 43 | 44 | // Regular URL 45 | // https://open.spotify.com/artist/1fZAAHNWdSM5gqbi9o5iEA 46 | var regularMatch = Regex.Match(artistIdOrUrl, @"spotify\..+?\/artist\/([a-zA-Z0-9]+)").Groups[1].Value; 47 | if (!string.IsNullOrWhiteSpace(regularMatch) && IsValid(regularMatch)) 48 | return regularMatch; 49 | 50 | // Invalid input 51 | return null; 52 | } 53 | 54 | /// 55 | /// Attempts to parse the specified string as a track ID or URL. 56 | /// Returns null in case of failure. 57 | /// 58 | public static ArtistId? TryParse(string? artistIdOrUrl) => 59 | TryNormalize(artistIdOrUrl)?.Pipe(id => new ArtistId(id)); 60 | 61 | /// 62 | /// Parses the specified string as a Spotify track ID or URL. 63 | /// Throws an exception in case of failure. 64 | /// 65 | public static ArtistId Parse(string artistIdOrUrl) => 66 | TryParse(artistIdOrUrl) ?? 67 | throw new ArgumentException($"Invalid Spotify track ID or URL '{artistIdOrUrl}'."); 68 | 69 | /// 70 | /// Converts string to ID. 71 | /// 72 | public static implicit operator ArtistId(string artistIdOrUrl) => Parse(artistIdOrUrl); 73 | 74 | /// 75 | /// Converts ID to string. 76 | /// 77 | public static implicit operator string(ArtistId artistId) => artistId.ToString(); 78 | } 79 | 80 | public partial struct ArtistId : IEquatable 81 | { 82 | /// 83 | public bool Equals(ArtistId other) => StringComparer.Ordinal.Equals(Value, other.Value); 84 | 85 | /// 86 | public override bool Equals(object? obj) => obj is ArtistId other && Equals(other); 87 | 88 | /// 89 | public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Value); 90 | 91 | /// 92 | /// Equality check. 93 | /// 94 | public static bool operator ==(ArtistId left, ArtistId right) => left.Equals(right); 95 | 96 | /// 97 | /// Equality check. 98 | /// 99 | public static bool operator !=(ArtistId left, ArtistId right) => !(left == right); 100 | } -------------------------------------------------------------------------------- /SpotifyExplode/Artists/ArtistIdJsonConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.Json; 3 | using System.Text.Json.Serialization; 4 | 5 | namespace SpotifyExplode.Artists; 6 | 7 | internal class ArtistIdJsonConverter : JsonConverter 8 | { 9 | public override ArtistId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) 10 | { 11 | var val = reader.GetString(); 12 | var result = ArtistId.TryParse(val); 13 | 14 | return result ?? throw new InvalidOperationException($"Invalid JSON for type '{typeToConvert.FullName}'."); 15 | } 16 | 17 | public override void Write(Utf8JsonWriter writer, ArtistId value, JsonSerializerOptions options) 18 | { 19 | writer.WriteStringValue(value); 20 | } 21 | } -------------------------------------------------------------------------------- /SpotifyExplode/Common/ExternalIds.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace SpotifyExplode.Common; 4 | 5 | public class ExternalIds 6 | { 7 | /// 8 | /// International Standard Recording Code 9 | /// 10 | [JsonPropertyName("isrc")] 11 | public string? IsrcCode { get; set; } 12 | 13 | /// 14 | /// International Article Number 15 | /// 16 | [JsonPropertyName("ean")] 17 | public string? EanCode { get; set; } 18 | 19 | /// 20 | /// Universal Product Code 21 | /// 22 | [JsonPropertyName("upc")] 23 | public string? UpcCode { get; set; } 24 | } -------------------------------------------------------------------------------- /SpotifyExplode/Common/Image.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace SpotifyExplode.Common; 4 | 5 | public class Image 6 | { 7 | [JsonPropertyName("url")] 8 | public string Url { get; set; } = default!; 9 | 10 | [JsonPropertyName("height")] 11 | public int? Height { get; set; } 12 | 13 | [JsonPropertyName("width")] 14 | public int? Width { get; set; } 15 | } -------------------------------------------------------------------------------- /SpotifyExplode/Constants.cs: -------------------------------------------------------------------------------- 1 | namespace SpotifyExplode; 2 | 3 | internal static class Constants 4 | { 5 | public const int DefaultOffset = 0; 6 | 7 | public const int DefaultLimit = 50; 8 | 9 | public const int MinLimit = 0; 10 | 11 | public const int MaxLimit = 50; 12 | } -------------------------------------------------------------------------------- /SpotifyExplode/Exceptions/RequestLimitExceededException.cs: -------------------------------------------------------------------------------- 1 | namespace SpotifyExplode.Exceptions; 2 | 3 | /// 4 | /// Exception thrown when Spotify denies a request because the client has exceeded rate limit. 5 | /// 6 | /// 7 | /// Initializes an instance of . 8 | /// 9 | public class RequestLimitExceededException(string message) : SpotifyExplodeException(message) 10 | { 11 | } -------------------------------------------------------------------------------- /SpotifyExplode/Exceptions/SpotifyExplodeException.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace SpotifyExplode.Exceptions; 4 | 5 | /// 6 | /// Exception thrown within . 7 | /// 8 | /// 9 | /// Initializes an instance of . 10 | /// 11 | /// 12 | public class SpotifyExplodeException(string message) : Exception(message) 13 | { 14 | } -------------------------------------------------------------------------------- /SpotifyExplode/Http/Http.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net.Http; 3 | using System.Net.Security; 4 | using SpotifyExplode.Utils; 5 | 6 | namespace SpotifyExplode; 7 | 8 | internal static class Http 9 | { 10 | private static readonly Lazy HttpClientLazy = new(() => 11 | { 12 | var handler = new HttpClientHandler(); 13 | 14 | return new HttpClient(handler, true); 15 | }); 16 | 17 | public static HttpClient Client => HttpClientLazy.Value; 18 | 19 | #region User Agent 20 | 21 | /// 22 | /// Generates a random User-Agent from the IE browser. 23 | /// 24 | /// Random User-Agent from IE browser. 25 | public static string IEUserAgent() 26 | { 27 | var windowsVersion = RandomWindowsVersion(); 28 | 29 | string version; 30 | string mozillaVersion; 31 | string trident; 32 | string otherParams; 33 | 34 | #region Random version generation 35 | 36 | if (windowsVersion.Contains("NT 5.1")) 37 | { 38 | version = "9.0"; 39 | mozillaVersion = "5.0"; 40 | trident = "5.0"; 41 | otherParams = ".NET CLR 2.0.50727; .NET CLR 3.5.30729"; 42 | } 43 | else if (windowsVersion.Contains("NT 6.0")) 44 | { 45 | version = "9.0"; 46 | mozillaVersion = "5.0"; 47 | trident = "5.0"; 48 | otherParams = ".NET CLR 2.0.50727; Media Center PC 5.0; .NET CLR 3.5.30729"; 49 | } 50 | else 51 | { 52 | switch (Randomizer.Instance.Next(3)) 53 | { 54 | case 0: 55 | version = "10.0"; 56 | trident = "6.0"; 57 | mozillaVersion = "5.0"; 58 | break; 59 | 60 | case 1: 61 | version = "10.6"; 62 | trident = "6.0"; 63 | mozillaVersion = "5.0"; 64 | break; 65 | 66 | default: 67 | version = "11.0"; 68 | trident = "7.0"; 69 | mozillaVersion = "5.0"; 70 | break; 71 | } 72 | 73 | otherParams = ".NET CLR 2.0.50727; .NET CLR 3.5.30729; .NET CLR 3.0.30729; Media Center PC 6.0; .NET4.0C; .NET4.0E"; 74 | } 75 | 76 | #endregion 77 | 78 | return 79 | $"Mozilla/{mozillaVersion} (compatible; MSIE {version}; {windowsVersion}; Trident/{trident}; {otherParams})"; 80 | } 81 | 82 | /// 83 | /// Generates a random User-Agent from the Opera browser. 84 | /// 85 | /// A random User-Agent from the Opera browser. 86 | public static string OperaUserAgent() 87 | { 88 | string version; 89 | string presto; 90 | 91 | #region Random version generation 92 | 93 | switch (Randomizer.Instance.Next(4)) 94 | { 95 | case 0: 96 | version = "12.16"; 97 | presto = "2.12.388"; 98 | break; 99 | 100 | case 1: 101 | version = "12.14"; 102 | presto = "2.12.388"; 103 | break; 104 | 105 | case 2: 106 | version = "12.02"; 107 | presto = "2.10.289"; 108 | break; 109 | 110 | default: 111 | version = "12.00"; 112 | presto = "2.10.181"; 113 | break; 114 | } 115 | 116 | #endregion 117 | 118 | return $"Opera/9.80 ({RandomWindowsVersion()}); U) Presto/{presto} Version/{version}"; 119 | } 120 | 121 | /// 122 | /// Generates a random User-Agent from the Chrome browser. 123 | /// 124 | /// Random User-Agent from Chrome browser. 125 | public static string ChromeUserAgent() 126 | { 127 | var major = Randomizer.Instance.Next(62, 70); 128 | var build = Randomizer.Instance.Next(2100, 3538); 129 | var branchBuild = Randomizer.Instance.Next(170); 130 | 131 | return $"Mozilla/5.0 ({RandomWindowsVersion()}) AppleWebKit/537.36 (KHTML, like Gecko) " + 132 | $"Chrome/{major}.0.{build}.{branchBuild} Safari/537.36"; 133 | } 134 | 135 | 136 | private static readonly byte[] FirefoxVersions = { 64, 63, 62, 60, 58, 52, 51, 46, 45 }; 137 | 138 | /// 139 | /// Generates a random User-Agent from the Firefox browser. 140 | /// 141 | /// Random User-Agent from the Firefox browser. 142 | public static string FirefoxUserAgent() 143 | { 144 | var version = FirefoxVersions[Randomizer.Instance.Next(FirefoxVersions.Length - 1)]; 145 | 146 | return $"Mozilla/5.0 ({RandomWindowsVersion()}; rv:{version}.0) Gecko/20100101 Firefox/{version}.0"; 147 | } 148 | 149 | /// 150 | /// Generates a random User-Agent from the Opera mobile browser. 151 | /// 152 | /// Random User-Agent from Opera mobile browser. 153 | public static string OperaMiniUserAgent() 154 | { 155 | string os; 156 | string miniVersion; 157 | string version; 158 | string presto; 159 | 160 | #region Random version generation 161 | 162 | switch (Randomizer.Instance.Next(3)) 163 | { 164 | case 0: 165 | os = "iOS"; 166 | miniVersion = "7.0.73345"; 167 | version = "11.62"; 168 | presto = "2.10.229"; 169 | break; 170 | 171 | case 1: 172 | os = "J2ME/MIDP"; 173 | miniVersion = "7.1.23511"; 174 | version = "12.00"; 175 | presto = "2.10.181"; 176 | break; 177 | 178 | default: 179 | os = "Android"; 180 | miniVersion = "7.5.54678"; 181 | version = "12.02"; 182 | presto = "2.10.289"; 183 | break; 184 | } 185 | 186 | #endregion 187 | 188 | return $"Opera/9.80 ({os}; Opera Mini/{miniVersion}/28.2555; U; ru) Presto/{presto} Version/{version}"; 189 | } 190 | 191 | /// 192 | /// Returns a random Chrome / Firefox / Opera User-Agent based on their popularity. 193 | /// 194 | /// User-Agent header value string 195 | public static string RandomUserAgent() 196 | { 197 | var rand = Randomizer.Instance.Next(99) + 1; 198 | 199 | // TODO: edge, yandex browser, safari 200 | 201 | // Chrome = 70% 202 | if (rand >= 1 && rand <= 70) 203 | return ChromeUserAgent(); 204 | 205 | // Firefox = 15% 206 | if (rand > 70 && rand <= 85) 207 | return FirefoxUserAgent(); 208 | 209 | // IE = 6% 210 | if (rand > 85 && rand <= 91) 211 | return IEUserAgent(); 212 | 213 | // Opera 12 = 5% 214 | if (rand > 91 && rand <= 96) 215 | return OperaUserAgent(); 216 | 217 | // Opera mini = 4% 218 | return OperaMiniUserAgent(); 219 | } 220 | 221 | #endregion 222 | 223 | #region Static methods (private) 224 | 225 | private static bool AcceptAllCertifications(object sender, 226 | System.Security.Cryptography.X509Certificates.X509Certificate certification, 227 | System.Security.Cryptography.X509Certificates.X509Chain chain, 228 | SslPolicyErrors sslPolicyErrors) => true; 229 | 230 | private static string RandomWindowsVersion() 231 | { 232 | var windowsVersion = "Windows NT "; 233 | var random = Randomizer.Instance.Next(99) + 1; 234 | 235 | // Windows 10 = 45% popularity 236 | if (random >= 1 && random <= 45) 237 | windowsVersion += "10.0"; 238 | 239 | // Windows 7 = 35% popularity 240 | else if (random > 45 && random <= 80) 241 | windowsVersion += "6.1"; 242 | 243 | // Windows 8.1 = 15% popularity 244 | else if (random > 80 && random <= 95) 245 | windowsVersion += "6.3"; 246 | 247 | // Windows 8 = 5% popularity 248 | else 249 | windowsVersion += "6.2"; 250 | 251 | // Append WOW64 for X64 system 252 | if (Randomizer.Instance.NextDouble() <= 0.65) 253 | windowsVersion += Randomizer.Instance.NextDouble() <= 0.5 ? "; WOW64" : "; Win64; x64"; 254 | 255 | return windowsVersion; 256 | } 257 | 258 | #endregion 259 | } -------------------------------------------------------------------------------- /SpotifyExplode/Http/SpotifyHttp.cs: -------------------------------------------------------------------------------- 1 | using System.Net.Http; 2 | using System.Net.Http.Headers; 3 | using System.Text.Json.Nodes; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using SpotifyExplode.Utils.Extensions; 7 | 8 | namespace SpotifyExplode; 9 | 10 | internal class SpotifyHttp(HttpClient http) 11 | { 12 | public async ValueTask GetAsync( 13 | string url, 14 | CancellationToken cancellationToken = default) 15 | { 16 | var accessToken = await GetAccessTokenAsync(); 17 | using var request = new HttpRequestMessage(HttpMethod.Get, url); 18 | request.Headers.Authorization = 19 | new AuthenticationHeaderValue("Bearer", accessToken); 20 | return await http.ExecuteAsync(request, cancellationToken); 21 | } 22 | 23 | private async ValueTask GetAccessTokenAsync( 24 | CancellationToken cancellationToken = default) 25 | { 26 | using var request = new HttpRequestMessage( 27 | HttpMethod.Get, 28 | "https://open.spotify.com/get_access_token?reason=transport&productType=web_player" 29 | ); 30 | 31 | var tokenJson = await http.ExecuteAsync(request, cancellationToken); 32 | 33 | var spotifyJsonToken = JsonNode.Parse(tokenJson)!; 34 | 35 | return spotifyJsonToken["accessToken"]!.ToString(); 36 | } 37 | } -------------------------------------------------------------------------------- /SpotifyExplode/Playlists/Follower.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace SpotifyExplode.Playlists; 4 | 5 | public class Follower 6 | { 7 | [JsonPropertyName("href")] 8 | public string? Link { get; set; } 9 | 10 | [JsonPropertyName("total")] 11 | public int Total { get; set; } 12 | } -------------------------------------------------------------------------------- /SpotifyExplode/Playlists/Item.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.Json.Serialization; 3 | using SpotifyExplode.Tracks; 4 | using SpotifyExplode.Users; 5 | 6 | namespace SpotifyExplode.Playlists; 7 | 8 | public class Item 9 | { 10 | [JsonPropertyName("added_at")] 11 | public DateTime AddedAt { get; set; } 12 | 13 | public User? AddedBy { get; set; } 14 | 15 | [JsonPropertyName("track")] 16 | public Track Track { get; set; } = default!; 17 | } -------------------------------------------------------------------------------- /SpotifyExplode/Playlists/Playlist.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Diagnostics.CodeAnalysis; 3 | using System.Text.Json.Serialization; 4 | using SpotifyExplode.Common; 5 | using SpotifyExplode.Tracks; 6 | using SpotifyExplode.Users; 7 | 8 | namespace SpotifyExplode.Playlists; 9 | 10 | public class Playlist 11 | { 12 | [JsonPropertyName("id")] 13 | public PlaylistId Id { get; set; } 14 | 15 | [JsonPropertyName("name")] 16 | public string Name { get; set; } = default!; 17 | 18 | [JsonPropertyName("description")] 19 | public string Description { get; set; } = default!; 20 | 21 | [JsonPropertyName("images")] 22 | public List Images { get; set; } = []; 23 | 24 | [JsonPropertyName("followers")] 25 | public Follower Followers { get; set; } = default!; 26 | 27 | [JsonPropertyName("owner")] 28 | public User Owner { get; set; } = default!; 29 | 30 | [JsonPropertyName("items")] 31 | public List Items { get; set; } = default!; 32 | 33 | /// 34 | /// Maximum number of results to return. 35 | /// Default: 20 36 | /// Minimum: 1 37 | /// Maximum: 50 38 | /// Note: The limit is applied within each type, not on the total response. 39 | /// For example, if the limit value is 3 and the type is artist,album, 40 | /// the response contains 3 artists and 3 albums. 41 | /// 42 | /// 43 | [JsonPropertyName("limit")] 44 | public int Limit { get; set; } 45 | 46 | //[JsonPropertyName("next")] 47 | //public PlaylistId? Next { get; set; } 48 | 49 | [JsonPropertyName("offset")] 50 | public int Offset { get; set; } 51 | 52 | //[JsonPropertyName("previous")] 53 | //public PlaylistId? Previous { get; set; } 54 | 55 | [JsonPropertyName("total")] 56 | public int Total { get; set; } 57 | 58 | [JsonIgnore] 59 | public List Tracks => Items.ConvertAll(x => x.Track); 60 | 61 | /// 62 | [ExcludeFromCodeCoverage] 63 | public override string ToString() => $"Playlist ({Name})"; 64 | } -------------------------------------------------------------------------------- /SpotifyExplode/Playlists/PlaylistClient.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Net.Http; 3 | using System.Text.Json; 4 | using System.Text.Json.Nodes; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using SpotifyExplode.Exceptions; 8 | using SpotifyExplode.Tracks; 9 | using SpotifyExplode.Users; 10 | using SpotifyExplode.Utils; 11 | 12 | namespace SpotifyExplode.Playlists; 13 | 14 | /// 15 | /// Operations related to Spotify playlists. 16 | /// 17 | /// 18 | /// Initializes an instance of . 19 | /// 20 | public class PlaylistClient(HttpClient http) 21 | { 22 | private readonly SpotifyHttp _spotifyHttp = new(http); 23 | 24 | /// 25 | /// Gets the metadata associated with the specified playlist. 26 | /// 27 | public async ValueTask GetAsync( 28 | PlaylistId playlistId, 29 | CancellationToken cancellationToken = default) 30 | { 31 | var response = await _spotifyHttp.GetAsync( 32 | $"https://api.spotify.com/v1/playlists/{playlistId}", 33 | cancellationToken 34 | ); 35 | 36 | var playlistJObj = JsonNode.Parse(response); 37 | var tracksItems = playlistJObj!["tracks"]?["items"]?.ToString() ?? playlistJObj["items"]!.ToString(); 38 | 39 | var playlist = JsonSerializer.Deserialize(response, JsonDefaults.Options)!; 40 | playlist.Items = JsonSerializer.Deserialize>(tracksItems, JsonDefaults.Options)!; 41 | 42 | return playlist; 43 | } 44 | 45 | /// 46 | /// Gets the tracks associated with the specified playlist. 47 | /// 48 | /// 49 | /// 50 | /// Limit should not exceed 100 according to Spotify 51 | /// 52 | /// 53 | public async ValueTask> GetItemsAsync( 54 | PlaylistId playlistId, 55 | int offset = Constants.DefaultOffset, 56 | int limit = Constants.DefaultLimit, 57 | CancellationToken cancellationToken = default) 58 | { 59 | if (limit is < Constants.MinLimit or > Constants.MaxLimit) 60 | throw new SpotifyExplodeException($"Limit must be between {Constants.MinLimit} and {Constants.MaxLimit}"); 61 | 62 | var response = await _spotifyHttp.GetAsync( 63 | $"https://api.spotify.com/v1/playlists/{playlistId}/tracks?offset={offset}&limit={limit}", 64 | cancellationToken 65 | ); 66 | 67 | var playlistJObj = JsonNode.Parse(response); 68 | 69 | var tracksItems = playlistJObj!["tracks"]?["items"]?.ToString() 70 | ?? playlistJObj["items"]?.ToString(); 71 | 72 | var list = new List(); 73 | 74 | if (string.IsNullOrEmpty(tracksItems)) 75 | return list; 76 | 77 | foreach (var token in JsonNode.Parse(tracksItems!)!.AsArray()) 78 | { 79 | var item = JsonSerializer.Deserialize(token!.ToString(), JsonDefaults.Options)!; 80 | 81 | var userId = token["added_by"]?["id"]?.ToString(); 82 | if (!string.IsNullOrEmpty(userId)) 83 | { 84 | item.AddedBy = JsonSerializer.Deserialize( 85 | token["added_by"]!.ToString(), 86 | JsonDefaults.Options 87 | )!; 88 | } 89 | 90 | list.Add(item); 91 | } 92 | 93 | return list; 94 | } 95 | 96 | /// 97 | /// Gets the items associated with the specified playlist. 98 | /// 99 | public async ValueTask> GetAllItemsAsync( 100 | PlaylistId playlistId, 101 | CancellationToken cancellationToken = default) 102 | { 103 | var playlistItems = new List(); 104 | 105 | var offset = 0; 106 | 107 | while (true) 108 | { 109 | var tracks = await GetItemsAsync( 110 | playlistId, 111 | offset, 112 | Constants.MaxLimit, 113 | cancellationToken 114 | ); 115 | 116 | playlistItems.AddRange(tracks); 117 | 118 | if (tracks.Count < 4) 119 | break; 120 | 121 | offset += tracks.Count; 122 | } 123 | 124 | return playlistItems; 125 | } 126 | 127 | /// 128 | /// Gets the tracks associated with the specified playlist. 129 | /// 130 | public async ValueTask> GetTracksAsync( 131 | PlaylistId playlistId, 132 | int offset = Constants.DefaultOffset, 133 | int limit = Constants.DefaultLimit, 134 | CancellationToken cancellationToken = default) => 135 | (await GetItemsAsync(playlistId, offset, limit, cancellationToken)) 136 | .ConvertAll(x => x.Track); 137 | 138 | /// 139 | /// Gets all the tracks associated with the specified playlist. 140 | /// 141 | public async ValueTask> GetAllTracksAsync( 142 | PlaylistId playlistId, 143 | CancellationToken cancellationToken = default) => 144 | (await GetAllItemsAsync(playlistId, cancellationToken)).ConvertAll(x => x.Track); 145 | } -------------------------------------------------------------------------------- /SpotifyExplode/Playlists/PlaylistId.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.RegularExpressions; 3 | using SpotifyExplode.Utils.Extensions; 4 | 5 | namespace SpotifyExplode.Playlists; 6 | 7 | /// 8 | /// Represents a syntactically valid Spotify playlist ID. 9 | /// 10 | public readonly partial struct PlaylistId 11 | { 12 | /// 13 | /// Raw ID value. 14 | /// 15 | public string Value { get; } 16 | 17 | private PlaylistId(string value) => Value = value; 18 | 19 | /// 20 | public override string ToString() => Value; 21 | } 22 | 23 | public partial struct PlaylistId 24 | { 25 | private static bool IsValid(string playlistId) 26 | { 27 | // Track IDs are always 22 characters 28 | if (playlistId.Length != 22) 29 | return false; 30 | 31 | return !Regex.IsMatch(playlistId, @"[^0-9a-zA-Z_\-]"); 32 | } 33 | 34 | private static string? TryNormalize(string? playlistIdOrUrl) 35 | { 36 | if (string.IsNullOrWhiteSpace(playlistIdOrUrl)) 37 | return null; 38 | 39 | // Id 40 | // 0tSYjDUflcozy78WwUFe6y 41 | if (IsValid(playlistIdOrUrl!)) 42 | return playlistIdOrUrl; 43 | 44 | // Regular URL 45 | // https://open.spotify.com/playlist/0tSYjDUflcozy78WwUFe6y 46 | var regularMatch = Regex.Match(playlistIdOrUrl, @"spotify\..+?\/playlist\/([a-zA-Z0-9]+)").Groups[1].Value; 47 | if (!string.IsNullOrWhiteSpace(regularMatch) && IsValid(regularMatch)) 48 | return regularMatch; 49 | 50 | // Track Limit URL 51 | // https://api.spotify.com/v1/playlists/0tSYjDUflcozy78WwUFe6y/tracks?offset=19&limit=19 52 | var limitMatch = Regex.Match(playlistIdOrUrl, @"spotify\..+?\/v1\/playlists\/([a-zA-Z0-9]+)").Groups[1].Value; 53 | if (!string.IsNullOrWhiteSpace(limitMatch) && IsValid(limitMatch)) 54 | return limitMatch; 55 | 56 | // Invalid input 57 | return null; 58 | } 59 | 60 | /// 61 | /// Attempts to parse the specified string as a track ID or URL. 62 | /// Returns null in case of failure. 63 | /// 64 | public static PlaylistId? TryParse(string? playlistIdOrUrl) => 65 | TryNormalize(playlistIdOrUrl)?.Pipe(id => new PlaylistId(id)); 66 | 67 | /// 68 | /// Parses the specified string as a Spotify track ID or URL. 69 | /// Throws an exception in case of failure. 70 | /// 71 | public static PlaylistId Parse(string playlistIdOrUrl) => 72 | TryParse(playlistIdOrUrl) ?? 73 | throw new ArgumentException($"Invalid Spotify track ID or URL '{playlistIdOrUrl}'."); 74 | 75 | /// 76 | /// Converts string to ID. 77 | /// 78 | public static implicit operator PlaylistId(string playlistIdOrUrl) => Parse(playlistIdOrUrl); 79 | 80 | /// 81 | /// Converts ID to string. 82 | /// 83 | public static implicit operator string(PlaylistId playlistId) => playlistId.ToString(); 84 | } 85 | 86 | public partial struct PlaylistId : IEquatable 87 | { 88 | /// 89 | public bool Equals(PlaylistId other) => StringComparer.Ordinal.Equals(Value, other.Value); 90 | 91 | /// 92 | public override bool Equals(object? obj) => obj is PlaylistId other && Equals(other); 93 | 94 | /// 95 | public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Value); 96 | 97 | /// 98 | /// Equality check. 99 | /// 100 | public static bool operator ==(PlaylistId left, PlaylistId right) => left.Equals(right); 101 | 102 | /// 103 | /// Equality check. 104 | /// 105 | public static bool operator !=(PlaylistId left, PlaylistId right) => !(left == right); 106 | } -------------------------------------------------------------------------------- /SpotifyExplode/Playlists/PlaylistIdJsonConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.Json; 3 | using System.Text.Json.Serialization; 4 | 5 | namespace SpotifyExplode.Playlists; 6 | 7 | internal class PlaylistIdJsonConverter : JsonConverter 8 | { 9 | public override PlaylistId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) 10 | { 11 | var val = reader.GetString(); 12 | var result = PlaylistId.TryParse(val); 13 | 14 | return result ?? throw new InvalidOperationException($"Invalid JSON for type '{typeToConvert.FullName}'."); 15 | } 16 | 17 | public override void Write(Utf8JsonWriter writer, PlaylistId value, JsonSerializerOptions options) 18 | { 19 | writer.WriteStringValue(value); 20 | } 21 | } -------------------------------------------------------------------------------- /SpotifyExplode/Search/AlbumSearchResult.cs: -------------------------------------------------------------------------------- 1 | using SpotifyExplode.Albums; 2 | 3 | namespace SpotifyExplode.Search; 4 | 5 | public class AlbumSearchResult : Album, ISearchResult 6 | { 7 | public string? Title => base.Name; 8 | } -------------------------------------------------------------------------------- /SpotifyExplode/Search/ArtistSearchResult.cs: -------------------------------------------------------------------------------- 1 | using SpotifyExplode.Artists; 2 | 3 | namespace SpotifyExplode.Search; 4 | 5 | public class ArtistSearchResult : Artist, ISearchResult 6 | { 7 | public string? Url => Id; 8 | 9 | public string? Title => Name; 10 | } -------------------------------------------------------------------------------- /SpotifyExplode/Search/ISearchResult.cs: -------------------------------------------------------------------------------- 1 | namespace SpotifyExplode.Search; 2 | 3 | /// 4 | ///

5 | /// Abstract result returned by a search query. 6 | /// Use pattern matching to handle specific instances of this type. 7 | ///

8 | ///

9 | /// Can be either one of the following: 10 | /// 11 | /// 12 | /// 13 | /// 14 | /// 15 | /// 16 | ///

17 | ///
18 | 19 | public interface ISearchResult 20 | { 21 | /// 22 | /// Result URL. 23 | /// 24 | string? Url { get; } 25 | 26 | /// 27 | /// Result title. 28 | /// 29 | string? Title { get; } 30 | } -------------------------------------------------------------------------------- /SpotifyExplode/Search/PlaylistSearchResult.cs: -------------------------------------------------------------------------------- 1 | using SpotifyExplode.Playlists; 2 | 3 | namespace SpotifyExplode.Search; 4 | 5 | public class PlaylistSearchResult : Playlist, ISearchResult 6 | { 7 | public string? Url => Id; 8 | 9 | public string? Title => Name; 10 | } -------------------------------------------------------------------------------- /SpotifyExplode/Search/SearchClient.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Net.Http; 5 | using System.Text.Json; 6 | using System.Text.Json.Nodes; 7 | using System.Threading; 8 | using System.Threading.Tasks; 9 | using SpotifyExplode.Exceptions; 10 | using SpotifyExplode.Utils; 11 | 12 | namespace SpotifyExplode.Search; 13 | 14 | /// 15 | /// Operations related to Spotify search. 16 | /// 17 | /// 18 | /// Initializes an instance of . 19 | /// 20 | public class SearchClient(HttpClient http) 21 | { 22 | private readonly SpotifyHttp _spotifyHttp = new(http); 23 | 24 | /// 25 | /// Gets the metadata associated with the specified artist. 26 | /// 27 | public async ValueTask> GetResultsAsync( 28 | string query, 29 | SearchFilter searchFilter = SearchFilter.Track, 30 | int offset = Constants.DefaultOffset, 31 | int limit = Constants.DefaultLimit, 32 | CancellationToken cancellationToken = default) 33 | { 34 | if (limit is < Constants.MinLimit or > Constants.MaxLimit) 35 | throw new SpotifyExplodeException($"Limit must be between {Constants.MinLimit} and {Constants.MaxLimit}"); 36 | 37 | //query = query.Replace(" ", "%"); 38 | query = Uri.EscapeDataString(query); 39 | 40 | var searchFilterStr = searchFilter switch 41 | { 42 | SearchFilter.Track => "&type=track", 43 | SearchFilter.Album => "&type=album", 44 | SearchFilter.Artist => "&type=artist", 45 | SearchFilter.Playlist => "&type=playlist", 46 | 47 | //Implement later 48 | //SearchFilter.Show => "&type=show", 49 | //SearchFilter.Episode => "&type=episode", 50 | //SearchFilter.AudioBook => "&type=audiobook", 51 | 52 | _ => "" 53 | }; 54 | 55 | var results = new List(); 56 | 57 | var response = await _spotifyHttp.GetAsync( 58 | $"https://api.spotify.com/v1/search?q={query}{searchFilterStr}&market=us&limit={limit}&offset={offset}", 59 | cancellationToken 60 | ); 61 | 62 | switch (searchFilter) 63 | { 64 | case SearchFilter.Album: 65 | var albums = JsonNode.Parse(response)!["albums"]!["items"]!.ToString(); 66 | results.AddRange(JsonSerializer.Deserialize>(albums, JsonDefaults.Options)!); 67 | break; 68 | 69 | case SearchFilter.Artist: 70 | var artists = JsonNode.Parse(response)!["artists"]!["items"]!.ToString(); 71 | results.AddRange(JsonSerializer.Deserialize>(artists, JsonDefaults.Options)!); 72 | break; 73 | 74 | case SearchFilter.Playlist: 75 | var playlists = JsonNode.Parse(response)!["playlists"]!["items"]!.ToString(); 76 | results.AddRange(JsonSerializer.Deserialize>(playlists, JsonDefaults.Options)!); 77 | break; 78 | 79 | case SearchFilter.Track: 80 | var tracks = JsonNode.Parse(response)!["tracks"]!["items"]!.ToString(); 81 | results.AddRange(JsonSerializer.Deserialize>(tracks, JsonDefaults.Options)!); 82 | break; 83 | } 84 | 85 | return results; 86 | } 87 | 88 | /// 89 | /// Gets album search results returned by the specified query. 90 | /// 91 | public async ValueTask> GetAlbumsAsync( 92 | string query, 93 | int offset = Constants.DefaultOffset, 94 | int limit = Constants.DefaultLimit, 95 | CancellationToken cancellationToken = default) => 96 | (await GetResultsAsync(query, SearchFilter.Album, offset, limit, cancellationToken)) 97 | .OfType().ToList(); 98 | 99 | /// 100 | /// Gets artist search results returned by the specified query. 101 | /// 102 | public async ValueTask> GetArtistsAsync( 103 | string query, 104 | int offset = Constants.DefaultOffset, 105 | int limit = Constants.DefaultLimit, 106 | CancellationToken cancellationToken = default) => 107 | (await GetResultsAsync(query, SearchFilter.Artist, offset, limit, cancellationToken)) 108 | .OfType().ToList(); 109 | 110 | /// 111 | /// Gets playlist search results returned by the specified query. 112 | /// 113 | public async ValueTask> GetPlaylistsAsync( 114 | string query, 115 | int offset = Constants.DefaultOffset, 116 | int limit = Constants.DefaultLimit, 117 | CancellationToken cancellationToken = default) => 118 | (await GetResultsAsync(query, SearchFilter.Playlist, offset, limit, cancellationToken)) 119 | .OfType().ToList(); 120 | 121 | /// 122 | /// Gets track search results returned by the specified query. 123 | /// 124 | public async ValueTask> GetTracksAsync( 125 | string query, 126 | int offset = Constants.DefaultOffset, 127 | int limit = Constants.DefaultLimit, 128 | CancellationToken cancellationToken = default) => 129 | (await GetResultsAsync(query, SearchFilter.Track, offset, limit, cancellationToken)) 130 | .OfType().ToList(); 131 | } -------------------------------------------------------------------------------- /SpotifyExplode/Search/SearchFilter.cs: -------------------------------------------------------------------------------- 1 | namespace SpotifyExplode.Search; 2 | 3 | /// 4 | /// Filter applied to a Spotify search query. 5 | /// 6 | public enum SearchFilter 7 | { 8 | /// 9 | /// Only search for albums. 10 | /// 11 | Album, 12 | 13 | /// 14 | /// Only search for artists. 15 | /// 16 | Artist, 17 | 18 | /// 19 | /// Only search for playlists. 20 | /// 21 | Playlist, 22 | 23 | /// 24 | /// Only search for tracks. 25 | /// 26 | Track, 27 | 28 | //Implement later 29 | 30 | /*/// 31 | /// Only search for shows. 32 | /// 33 | Show, 34 | 35 | /// 36 | /// Only search for episodes. 37 | /// 38 | Episode, 39 | 40 | /// 41 | /// Only search for audiobooks. 42 | /// 43 | AudioBook*/ 44 | } -------------------------------------------------------------------------------- /SpotifyExplode/Search/TrackSearchResult.cs: -------------------------------------------------------------------------------- 1 | using SpotifyExplode.Tracks; 2 | 3 | namespace SpotifyExplode.Search; 4 | 5 | public class TrackSearchResult : Track, ISearchResult 6 | { 7 | } -------------------------------------------------------------------------------- /SpotifyExplode/SpotifyClient.cs: -------------------------------------------------------------------------------- 1 | using System.Net.Http; 2 | using SpotifyExplode.Albums; 3 | using SpotifyExplode.Artists; 4 | using SpotifyExplode.Playlists; 5 | using SpotifyExplode.Search; 6 | using SpotifyExplode.Tracks; 7 | using SpotifyExplode.Users; 8 | 9 | namespace SpotifyExplode; 10 | 11 | /// 12 | /// Client for interacting with Spotify. 13 | /// 14 | public class SpotifyClient(HttpClient http) 15 | { 16 | /// 17 | /// Operations related to Spotify search. 18 | /// 19 | public SearchClient Search { get; } = new SearchClient(http); 20 | 21 | /// 22 | /// Operations related to Spotify tracks. 23 | /// 24 | public TrackClient Tracks { get; } = new TrackClient(http); 25 | 26 | /// 27 | /// Operations related to Spotify artists. 28 | /// 29 | public ArtistClient Artists { get; } = new ArtistClient(http); 30 | 31 | /// 32 | /// Operations related to Spotify albums. 33 | /// 34 | public AlbumClient Albums { get; } = new AlbumClient(http); 35 | 36 | /// 37 | /// Operations related to Spotify playlists. 38 | /// 39 | public PlaylistClient Playlists { get; } = new PlaylistClient(http); 40 | 41 | /// 42 | /// Operations related to Spotify users. 43 | /// 44 | public UserClient Users { get; } = new UserClient(http); 45 | 46 | /// 47 | /// Initializes an instance of . 48 | /// 49 | public SpotifyClient() : this(Http.Client) 50 | { 51 | } 52 | } -------------------------------------------------------------------------------- /SpotifyExplode/SpotifyExplode.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net6.0;net5.0;netstandard2.1;netstandard2.0;net461;netcoreapp3.1 5 | true 6 | Spotify API Wrapper 7 | true 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /SpotifyExplode/Tracks/Track.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Diagnostics.CodeAnalysis; 3 | using System.Text.Json.Serialization; 4 | using SpotifyExplode.Albums; 5 | using SpotifyExplode.Artists; 6 | using SpotifyExplode.Common; 7 | 8 | namespace SpotifyExplode.Tracks; 9 | 10 | public class Track 11 | { 12 | /// 13 | [JsonPropertyName("id")] 14 | public TrackId Id { get; set; } 15 | 16 | /// 17 | public string Url => $"https://open.spotify.com/track/{Id}"; 18 | 19 | /// 20 | [JsonPropertyName("name")] 21 | public string Title { get; set; } = default!; 22 | 23 | [JsonPropertyName("track_number")] 24 | public int TrackNumber { get; set; } 25 | 26 | [JsonPropertyName("popularity")] 27 | public int Popularity { get; set; } 28 | 29 | [JsonPropertyName("available_markets")] 30 | public List AvailableMarkets { get; set; } = default!; 31 | 32 | [JsonPropertyName("disc_number")] 33 | public int DiscNumber { get; set; } 34 | 35 | [JsonPropertyName("duration_ms")] 36 | public long DurationMs { get; set; } 37 | 38 | [JsonPropertyName("explicit")] 39 | public bool Explicit { get; set; } 40 | 41 | [JsonPropertyName("is_local")] 42 | public bool IsLocal { get; set; } 43 | 44 | [JsonPropertyName("preview_url")] 45 | public string PreviewUrl { get; set; } = default!; 46 | 47 | [JsonPropertyName("artists")] 48 | public List Artists { get; set; } = default!; 49 | 50 | [JsonPropertyName("album")] 51 | public Album Album { get; set; } = default!; 52 | 53 | /// 54 | /// Known external IDs for the track. 55 | /// 56 | [JsonPropertyName("external_ids")] 57 | public ExternalIds? ExternalIds { get; set; } = default!; 58 | 59 | /// 60 | [ExcludeFromCodeCoverage] 61 | public override string ToString() => $"Track ({Title})"; 62 | } -------------------------------------------------------------------------------- /SpotifyExplode/Tracks/TrackClient.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.Net.Http; 5 | using System.Text.Json; 6 | using System.Text.Json.Nodes; 7 | using System.Threading; 8 | using System.Threading.Tasks; 9 | using HtmlAgilityPack; 10 | using SpotifyExplode.Exceptions; 11 | using SpotifyExplode.Utils; 12 | using SpotifyExplode.Utils.Extensions; 13 | 14 | namespace SpotifyExplode.Tracks; 15 | 16 | /// 17 | /// Operations related to Spotify tracks. 18 | /// 19 | /// 20 | /// Initializes an instance of . 21 | /// 22 | public class TrackClient(HttpClient http) 23 | { 24 | private readonly SpotifyHttp _spotifyHttp = new(http); 25 | 26 | /// 27 | /// Gets the metadata associated with the specified track. 28 | /// 29 | public async ValueTask GetAsync( 30 | TrackId trackId, 31 | CancellationToken cancellationToken = default) 32 | { 33 | var response = await _spotifyHttp.GetAsync( 34 | $"https://api.spotify.com/v1/tracks/{trackId}", 35 | cancellationToken 36 | ); 37 | 38 | return JsonSerializer.Deserialize(response, JsonDefaults.Options)!; 39 | } 40 | 41 | /// 42 | /// Gets the best match YouTube ID using spotifydown.com. 43 | /// 44 | [Obsolete("This method doesn't work anymore because api.spotifydown.com has changed")] 45 | public async ValueTask GetYoutubeIdAsync( 46 | TrackId trackId, 47 | CancellationToken cancellationToken = default) 48 | { 49 | var response = await http.ExecuteAsync( 50 | //$"https://api.spotifydown.com/metadata/track/{trackId}", 51 | $"https://api.spotifydown.com/getId/{trackId}", 52 | new Dictionary() 53 | { 54 | { "referer", "https://spotifydown.com/" }, 55 | { "origin", "https://spotifydown.com" } 56 | }, 57 | cancellationToken 58 | ); 59 | 60 | if (string.IsNullOrEmpty(response)) 61 | return null; 62 | 63 | var data = JsonNode.Parse(response)!; 64 | 65 | _ = bool.TryParse(data["success"]?.ToString(), out var success); 66 | 67 | if (!success) 68 | throw new SpotifyExplodeException(data["message"]!.ToString()); 69 | 70 | return JsonNode.Parse(response)?["id"]!.ToString(); 71 | } 72 | 73 | /// 74 | /// Gets the metadata associated with the specified track. 75 | /// 76 | public async ValueTask GetDownloadUrlAsync( 77 | TrackId trackId, 78 | CancellationToken cancellationToken = default) 79 | { 80 | var url = string.Empty; 81 | 82 | try 83 | { 84 | url = await GetSpotifyDownUrlAsync(trackId, cancellationToken); 85 | } 86 | catch (SpotifyExplodeException) 87 | { 88 | if (!Debugger.IsAttached) 89 | throw; 90 | } 91 | catch 92 | { 93 | } 94 | 95 | // Fallback 96 | if (string.IsNullOrEmpty(url)) 97 | url = await GetSpotifymateUrlAsync(trackId, cancellationToken); 98 | 99 | return url; 100 | } 101 | 102 | private async Task> GetSpotifymateToken(CancellationToken cancellationToken) 103 | { 104 | var html = await http.ExecuteAsync( 105 | "https://spotifymate.com/", 106 | cancellationToken 107 | ); 108 | 109 | var document = new HtmlDocument(); 110 | document.LoadHtml(html); 111 | 112 | var hiddenInput = document.GetElementbyId("get_video")?.SelectSingleNode("//input[@type=\"hidden\"]")?.Attributes; 113 | 114 | return new KeyValuePair(hiddenInput?["name"]?.Value, hiddenInput?["value"]?.Value); 115 | } 116 | 117 | /// 118 | /// Gets download link from spotifymate.com 119 | /// 120 | public async ValueTask GetSpotifymateUrlAsync( 121 | TrackId trackId, 122 | CancellationToken cancellationToken = default) 123 | { 124 | var formContent = new FormUrlEncodedContent(new KeyValuePair[] 125 | { 126 | new("url", $"https://open.spotify.com/track/{trackId}"), 127 | await GetSpotifymateToken(cancellationToken) 128 | }); 129 | 130 | var response = await http.PostAsync( 131 | "https://spotifymate.com/action", 132 | null, 133 | formContent, 134 | cancellationToken 135 | ); 136 | 137 | if (string.IsNullOrEmpty(response)) 138 | return null; 139 | 140 | var document = new HtmlDocument(); 141 | document.LoadHtml(response); 142 | 143 | return document.GetElementbyId("download-block")? 144 | .SelectSingleNode(".//a")?.Attributes["href"]?.Value; 145 | } 146 | 147 | /// 148 | /// Gets download link from spotifydown.com 149 | /// 150 | public async ValueTask GetSpotifyDownUrlAsync( 151 | TrackId trackId, 152 | CancellationToken cancellationToken = default) 153 | { 154 | var response = await http.ExecuteAsync( 155 | $"https://api.spotifydown.com/download/{trackId}", 156 | new Dictionary() 157 | { 158 | { "referer", "https://spotifydown.com/" }, 159 | { "origin", "https://spotifydown.com" }, 160 | { "host", "api.spotifydown.com" }, 161 | }, 162 | cancellationToken 163 | ); 164 | 165 | if (string.IsNullOrEmpty(response)) 166 | return null; 167 | 168 | var data = JsonNode.Parse(response)!; 169 | 170 | _ = bool.TryParse(data["success"]?.ToString(), out var success); 171 | 172 | if (!success) 173 | throw new SpotifyExplodeException(data["message"]!.ToString()); 174 | 175 | return JsonNode.Parse(response)?["link"]!.ToString(); 176 | } 177 | } -------------------------------------------------------------------------------- /SpotifyExplode/Tracks/TrackId.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.RegularExpressions; 3 | using SpotifyExplode.Utils.Extensions; 4 | 5 | namespace SpotifyExplode.Tracks; 6 | 7 | /// 8 | /// Represents a syntactically valid Spotify track ID. 9 | /// 10 | public readonly partial struct TrackId 11 | { 12 | /// 13 | /// Raw ID value. 14 | /// 15 | public string Value { get; } 16 | 17 | private TrackId(string value) => Value = value; 18 | 19 | /// 20 | public override string ToString() => Value; 21 | } 22 | 23 | public partial struct TrackId 24 | { 25 | private static bool IsValid(string trackId) 26 | { 27 | // Track IDs are always 22 characters 28 | if (trackId.Length != 22) 29 | return false; 30 | 31 | return !Regex.IsMatch(trackId, @"[^0-9a-zA-Z_\-]"); 32 | } 33 | 34 | private static string? TryNormalize(string? trackIdOrUrl) 35 | { 36 | if (string.IsNullOrWhiteSpace(trackIdOrUrl)) 37 | return null; 38 | 39 | // Id 40 | // 0VjIjW4GlUZAMYd2vXMi3b 41 | if (IsValid(trackIdOrUrl!)) 42 | return trackIdOrUrl; 43 | 44 | // Regular URL 45 | // https://open.spotify.com/track/0VjIjW4GlUZAMYd2vXMi3b 46 | var regularMatch = Regex.Match(trackIdOrUrl, @"spotify\..+?\/track\/([a-zA-Z0-9]+)").Groups[1].Value; 47 | if (!string.IsNullOrWhiteSpace(regularMatch) && IsValid(regularMatch)) 48 | return regularMatch; 49 | 50 | // Invalid input 51 | return null; 52 | } 53 | 54 | /// 55 | /// Attempts to parse the specified string as a track ID or URL. 56 | /// Returns null in case of failure. 57 | /// 58 | public static TrackId? TryParse(string? trackIdOrUrl) => 59 | TryNormalize(trackIdOrUrl)?.Pipe(id => new TrackId(id)); 60 | 61 | /// 62 | /// Parses the specified string as a Spotify track ID or URL. 63 | /// Throws an exception in case of failure. 64 | /// 65 | public static TrackId Parse(string trackIdOrUrl) => 66 | TryParse(trackIdOrUrl) ?? 67 | throw new ArgumentException($"Invalid Spotify track ID or URL '{trackIdOrUrl}'."); 68 | 69 | /// 70 | /// Converts string to ID. 71 | /// 72 | public static implicit operator TrackId(string trackIdOrUrl) => Parse(trackIdOrUrl); 73 | 74 | /// 75 | /// Converts ID to string. 76 | /// 77 | public static implicit operator string(TrackId trackId) => trackId.ToString(); 78 | } 79 | 80 | public partial struct TrackId : IEquatable 81 | { 82 | /// 83 | public bool Equals(TrackId other) => StringComparer.Ordinal.Equals(Value, other.Value); 84 | 85 | /// 86 | public override bool Equals(object? obj) => obj is TrackId other && Equals(other); 87 | 88 | /// 89 | public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Value); 90 | 91 | /// 92 | /// Equality check. 93 | /// 94 | public static bool operator ==(TrackId left, TrackId right) => left.Equals(right); 95 | 96 | /// 97 | /// Equality check. 98 | /// 99 | public static bool operator !=(TrackId left, TrackId right) => !(left == right); 100 | } -------------------------------------------------------------------------------- /SpotifyExplode/Tracks/TrackIdJsonConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.Json; 3 | using System.Text.Json.Serialization; 4 | 5 | namespace SpotifyExplode.Tracks; 6 | 7 | internal class TrackIdJsonConverter : JsonConverter 8 | { 9 | public override TrackId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) 10 | { 11 | var val = reader.GetString(); 12 | var result = TrackId.TryParse(val); 13 | 14 | return result ?? throw new InvalidOperationException($"Invalid JSON for type '{typeToConvert.FullName}'."); 15 | } 16 | 17 | public override void Write(Utf8JsonWriter writer, TrackId value, JsonSerializerOptions options) 18 | { 19 | writer.WriteStringValue(value); 20 | } 21 | } -------------------------------------------------------------------------------- /SpotifyExplode/Users/User.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Diagnostics.CodeAnalysis; 3 | using System.Text.Json.Serialization; 4 | using System.Xml.Linq; 5 | using SpotifyExplode.Common; 6 | using SpotifyExplode.Playlists; 7 | 8 | namespace SpotifyExplode.Users; 9 | 10 | public class User 11 | { 12 | [JsonPropertyName("id")] 13 | public UserId Id { get; set; } 14 | 15 | [JsonPropertyName("display_name")] 16 | public string DisplayName { get; set; } = default!; 17 | 18 | [JsonPropertyName("followers")] 19 | public Follower Followers { get; set; } = default!; 20 | 21 | [JsonPropertyName("images")] 22 | public List Images { get; set; } = []; 23 | 24 | /// 25 | [ExcludeFromCodeCoverage] 26 | public override string ToString() => $"User ({DisplayName})"; 27 | } -------------------------------------------------------------------------------- /SpotifyExplode/Users/UserClient.cs: -------------------------------------------------------------------------------- 1 | using System.Net.Http; 2 | using System.Text.Json; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using SpotifyExplode.Utils; 6 | 7 | namespace SpotifyExplode.Users; 8 | 9 | /// 10 | /// Operations related to Spotify users. 11 | /// 12 | /// 13 | /// Initializes an instance of . 14 | /// 15 | public class UserClient(HttpClient http) 16 | { 17 | private readonly SpotifyHttp _spotifyHttp = new(http); 18 | 19 | /// 20 | /// Gets the metadata associated with the specified user. 21 | /// 22 | public async ValueTask GetAsync( 23 | UserId userId, 24 | CancellationToken cancellationToken = default) 25 | { 26 | var response = await _spotifyHttp.GetAsync( 27 | $"https://api.spotify.com/v1/users/{userId}", 28 | cancellationToken 29 | ); 30 | 31 | return JsonSerializer.Deserialize(response, JsonDefaults.Options)!; 32 | } 33 | } -------------------------------------------------------------------------------- /SpotifyExplode/Users/UserId.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.RegularExpressions; 3 | using SpotifyExplode.Utils.Extensions; 4 | 5 | namespace SpotifyExplode.Users; 6 | 7 | /// 8 | /// Represents a syntactically valid Spotify user ID. 9 | /// 10 | public readonly partial struct UserId 11 | { 12 | /// 13 | /// Raw ID value. 14 | /// 15 | public string Value { get; } 16 | 17 | private UserId(string value) => Value = value; 18 | 19 | /// 20 | public override string ToString() => Value; 21 | } 22 | 23 | public partial struct UserId 24 | { 25 | private static bool IsValid(string userId) 26 | { 27 | return !Regex.IsMatch(userId, @"[^0-9a-zA-Z_\-]"); 28 | } 29 | 30 | private static string? TryNormalize(string? userIdOrUrl) 31 | { 32 | if (string.IsNullOrWhiteSpace(userIdOrUrl)) 33 | return null; 34 | 35 | // Id 36 | // xxu0yww90v07gbh9veqta7ze0 37 | if (IsValid(userIdOrUrl!)) 38 | return userIdOrUrl; 39 | 40 | // Regular URL 41 | // https://open.spotify.com/user/xxu0yww90v07gbh9veqta7ze0 42 | var regularMatch = Regex.Match(userIdOrUrl, @"spotify\..+?\/user\/([a-zA-Z0-9]+)").Groups[1].Value; 43 | if (!string.IsNullOrWhiteSpace(regularMatch) && IsValid(regularMatch)) 44 | return regularMatch; 45 | 46 | // Invalid input 47 | return null; 48 | } 49 | 50 | /// 51 | /// Attempts to parse the specified string as a track ID or URL. 52 | /// Returns null in case of failure. 53 | /// 54 | public static UserId? TryParse(string? userIdOrUrl) => 55 | TryNormalize(userIdOrUrl)?.Pipe(id => new UserId(id)); 56 | 57 | /// 58 | /// Parses the specified string as a Spotify track ID or URL. 59 | /// Throws an exception in case of failure. 60 | /// 61 | public static UserId Parse(string userIdOrUrl) => 62 | TryParse(userIdOrUrl) ?? 63 | throw new ArgumentException($"Invalid Spotify track ID or URL '{userIdOrUrl}'."); 64 | 65 | /// 66 | /// Converts string to ID. 67 | /// 68 | public static implicit operator UserId(string userIdOrUrl) => Parse(userIdOrUrl); 69 | 70 | /// 71 | /// Converts ID to string. 72 | /// 73 | public static implicit operator string(UserId userId) => userId.ToString(); 74 | } 75 | 76 | public partial struct UserId : IEquatable 77 | { 78 | /// 79 | public bool Equals(UserId other) => StringComparer.Ordinal.Equals(Value, other.Value); 80 | 81 | /// 82 | public override bool Equals(object? obj) => obj is UserId other && Equals(other); 83 | 84 | /// 85 | public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Value); 86 | 87 | /// 88 | /// Equality check. 89 | /// 90 | public static bool operator ==(UserId left, UserId right) => left.Equals(right); 91 | 92 | /// 93 | /// Equality check. 94 | /// 95 | public static bool operator !=(UserId left, UserId right) => !(left == right); 96 | } -------------------------------------------------------------------------------- /SpotifyExplode/Users/UserIdJsonConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.Json; 3 | using System.Text.Json.Serialization; 4 | 5 | namespace SpotifyExplode.Users; 6 | 7 | internal class UserIdJsonConverter : JsonConverter 8 | { 9 | public override UserId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) 10 | { 11 | var val = reader.GetString(); 12 | var result = UserId.TryParse(val); 13 | 14 | return result ?? throw new InvalidOperationException($"Invalid JSON for type '{typeToConvert.FullName}'."); 15 | } 16 | 17 | public override void Write(Utf8JsonWriter writer, UserId value, JsonSerializerOptions options) 18 | { 19 | writer.WriteStringValue(value); 20 | } 21 | } -------------------------------------------------------------------------------- /SpotifyExplode/Utils/Extensions/GenericExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace SpotifyExplode.Utils.Extensions; 4 | 5 | internal static class GenericExtensions 6 | { 7 | public static TOut Pipe(this TIn input, Func transform) => transform(input); 8 | } -------------------------------------------------------------------------------- /SpotifyExplode/Utils/Extensions/HttpExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Net; 6 | using System.Net.Http; 7 | using System.Net.Http.Headers; 8 | using System.Threading; 9 | using System.Threading.Tasks; 10 | 11 | namespace SpotifyExplode.Utils.Extensions; 12 | 13 | internal static class HttpExtensions 14 | { 15 | public static async ValueTask HeadAsync( 16 | this HttpClient http, 17 | string requestUri, 18 | CancellationToken cancellationToken = default) 19 | { 20 | using var request = new HttpRequestMessage(HttpMethod.Head, requestUri); 21 | return await http.SendAsync( 22 | request, 23 | HttpCompletionOption.ResponseHeadersRead, 24 | cancellationToken 25 | ); 26 | } 27 | 28 | public static async ValueTask GetStreamAsync( 29 | this HttpClient http, 30 | string requestUri, 31 | long? from = null, 32 | long? to = null, 33 | bool ensureSuccess = true, 34 | CancellationToken cancellationToken = default) 35 | { 36 | using var request = new HttpRequestMessage(HttpMethod.Get, requestUri); 37 | request.Headers.Range = new RangeHeaderValue(from, to); 38 | 39 | var response = await http.SendAsync( 40 | request, 41 | HttpCompletionOption.ResponseHeadersRead, 42 | cancellationToken 43 | ); 44 | 45 | if (ensureSuccess) 46 | response.EnsureSuccessStatusCode(); 47 | 48 | return await response.Content.ReadAsStreamAsync(cancellationToken); 49 | } 50 | 51 | public static async ValueTask TryGetContentLengthAsync( 52 | this HttpClient http, 53 | string requestUri, 54 | bool ensureSuccess = true, 55 | CancellationToken cancellationToken = default) 56 | { 57 | using var response = await http.HeadAsync(requestUri, cancellationToken); 58 | 59 | if (ensureSuccess) 60 | response.EnsureSuccessStatusCode(); 61 | 62 | return response.Content.Headers.ContentLength; 63 | } 64 | 65 | public static async ValueTask GetAsync( 66 | this HttpClient http, 67 | string url, 68 | CancellationToken cancellationToken = default) 69 | { 70 | using var request = new HttpRequestMessage(HttpMethod.Get, url); 71 | return await http.ExecuteAsync(request, cancellationToken); 72 | } 73 | 74 | public static async ValueTask PostAsync( 75 | this HttpClient http, 76 | string url, 77 | CancellationToken cancellationToken = default) 78 | { 79 | using var request = new HttpRequestMessage(HttpMethod.Post, url); 80 | return await http.ExecuteAsync(request, cancellationToken); 81 | } 82 | 83 | public static async ValueTask PostAsync( 84 | this HttpClient http, 85 | string url, 86 | IDictionary headers, 87 | CancellationToken cancellationToken = default) 88 | { 89 | using var request = new HttpRequestMessage(HttpMethod.Post, url); 90 | for (var j = 0; j < headers.Count; j++) 91 | request.Headers.TryAddWithoutValidation(headers.ElementAt(j).Key, headers.ElementAt(j).Value); 92 | 93 | return await http.ExecuteAsync(request, cancellationToken); 94 | } 95 | 96 | public static async ValueTask PostAsync( 97 | this HttpClient http, 98 | string url, 99 | IDictionary? headers, 100 | HttpContent content, 101 | CancellationToken cancellationToken = default) 102 | { 103 | using var request = new HttpRequestMessage(HttpMethod.Post, url); 104 | for (var j = 0; j < headers?.Count; j++) 105 | request.Headers.TryAddWithoutValidation(headers.ElementAt(j).Key, headers.ElementAt(j).Value); 106 | 107 | request.Content = content; 108 | 109 | return await http.ExecuteAsync(request, cancellationToken); 110 | } 111 | 112 | public static async ValueTask GetFileSizeAsync( 113 | this HttpClient http, 114 | string url, 115 | IDictionary headers, 116 | CancellationToken cancellationToken = default) 117 | { 118 | using var request = new HttpRequestMessage(HttpMethod.Head, url); 119 | for (var j = 0; j < headers.Count; j++) 120 | request.Headers.TryAddWithoutValidation(headers.ElementAt(j).Key, headers.ElementAt(j).Value); 121 | 122 | using var response = await http.SendAsync( 123 | request, 124 | HttpCompletionOption.ResponseHeadersRead, 125 | cancellationToken 126 | ); 127 | 128 | if (!response.IsSuccessStatusCode) 129 | { 130 | throw new HttpRequestException( 131 | $"Response status code does not indicate success: {(int)response.StatusCode} ({response.StatusCode})." + 132 | Environment.NewLine + 133 | "Request:" + 134 | Environment.NewLine + 135 | request 136 | ); 137 | } 138 | 139 | return response.Content.Headers.ContentLength ?? 0; 140 | } 141 | 142 | public static async ValueTask ExecuteAsync( 143 | this HttpClient http, 144 | string url, 145 | CancellationToken cancellationToken = default) 146 | { 147 | using var request = new HttpRequestMessage(HttpMethod.Get, url); 148 | return await http.ExecuteAsync(request, cancellationToken); 149 | } 150 | 151 | public static async ValueTask ExecuteAsync( 152 | this HttpClient http, 153 | string url, 154 | IDictionary headers, 155 | CancellationToken cancellationToken = default) 156 | { 157 | using var request = new HttpRequestMessage(HttpMethod.Get, url); 158 | for (var j = 0; j < headers.Count; j++) 159 | request.Headers.TryAddWithoutValidation(headers.ElementAt(j).Key, headers.ElementAt(j).Value); 160 | 161 | return await http.ExecuteAsync(request, cancellationToken); 162 | } 163 | 164 | public static async Task ExecuteAsync( 165 | this HttpClient http, 166 | HttpRequestMessage request, 167 | CancellationToken cancellationToken = default) 168 | { 169 | // User-agent 170 | if (!request.Headers.Contains("User-Agent")) 171 | { 172 | request.Headers.Add( 173 | "User-Agent", 174 | //The generated user-agent will not work on mobile devices in some cases. 175 | //Http.ChromeUserAgent() 176 | "Other" 177 | ); 178 | } 179 | 180 | using var response = await http.SendAsync( 181 | request, 182 | HttpCompletionOption.ResponseHeadersRead, 183 | cancellationToken 184 | ); 185 | 186 | if (response.StatusCode == HttpStatusCode.NotFound) 187 | return string.Empty; 188 | 189 | if (!response.IsSuccessStatusCode) 190 | { 191 | throw new HttpRequestException( 192 | $"Response status code does not indicate success: {(int)response.StatusCode} ({response.StatusCode})." + 193 | Environment.NewLine + 194 | "Request:" + 195 | Environment.NewLine + 196 | request 197 | ); 198 | } 199 | 200 | return await response.Content.ReadAsStringAsync(cancellationToken); 201 | } 202 | } -------------------------------------------------------------------------------- /SpotifyExplode/Utils/JsonDefaults.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using System.Text.Json.Serialization; 3 | using SpotifyExplode.Albums; 4 | using SpotifyExplode.Artists; 5 | using SpotifyExplode.Playlists; 6 | using SpotifyExplode.Tracks; 7 | using SpotifyExplode.Users; 8 | 9 | namespace SpotifyExplode.Utils; 10 | 11 | internal static class JsonDefaults 12 | { 13 | public static JsonSerializerOptions Options => GetOptions(); 14 | 15 | private static JsonSerializerOptions GetOptions() 16 | { 17 | var options = new JsonSerializerOptions 18 | { 19 | PropertyNameCaseInsensitive = true 20 | }; 21 | 22 | options.Converters.Add(new JsonStringEnumConverter()); 23 | options.Converters.Add(new TrackIdJsonConverter()); 24 | options.Converters.Add(new PlaylistIdJsonConverter()); 25 | options.Converters.Add(new AlbumIdJsonConverter()); 26 | options.Converters.Add(new ArtistIdJsonConverter()); 27 | options.Converters.Add(new UserIdJsonConverter()); 28 | 29 | return options; 30 | } 31 | } -------------------------------------------------------------------------------- /SpotifyExplode/Utils/Polyfills.Streams.cs: -------------------------------------------------------------------------------- 1 | // ReSharper disable CheckNamespace 2 | 3 | #if !NET5_0 4 | using System.IO; 5 | using System.Net.Http; 6 | using System.Threading; 7 | using System.Threading.Tasks; 8 | 9 | internal static class StreamPolyfills 10 | { 11 | #if !NETSTANDARD2_1 && !NETCOREAPP3_0 12 | public static async Task ReadAsync(this Stream stream, byte[] buffer, CancellationToken cancellationToken) => 13 | await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken); 14 | #endif 15 | 16 | public static async Task ReadAsStreamAsync( 17 | this HttpContent httpContent, 18 | CancellationToken cancellationToken) 19 | { 20 | cancellationToken.ThrowIfCancellationRequested(); 21 | return await httpContent.ReadAsStreamAsync(); 22 | } 23 | 24 | public static async Task ReadAsStringAsync( 25 | this HttpContent httpContent, 26 | CancellationToken cancellationToken) 27 | { 28 | cancellationToken.ThrowIfCancellationRequested(); 29 | return await httpContent.ReadAsStringAsync(); 30 | } 31 | } 32 | #endif -------------------------------------------------------------------------------- /SpotifyExplode/Utils/Randomizer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Security.Cryptography; 3 | 4 | namespace SpotifyExplode.Utils; 5 | 6 | /// 7 | /// Wrapper class for thread-safe generation of pseudo-random numbers. 8 | /// Lazy-load singleton for ThreadStatic . 9 | /// 10 | public static class Randomizer 11 | { 12 | private static readonly RNGCryptoServiceProvider Generator = new(); 13 | 14 | private static Random Generate() 15 | { 16 | var buffer = new byte[4]; 17 | Generator.GetBytes(buffer); 18 | return new Random(BitConverter.ToInt32(buffer, 0)); 19 | } 20 | 21 | public static Random Instance => _rand ?? (_rand = Generate()); 22 | [ThreadStatic] private static Random _rand = default!; 23 | } 24 | -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jerry08/SpotifyExplode/e0564e66336c89818fa3108a7b9709d7832e4eaa/favicon.ico -------------------------------------------------------------------------------- /favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jerry08/SpotifyExplode/e0564e66336c89818fa3108a7b9709d7832e4eaa/favicon.png -------------------------------------------------------------------------------- /favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | image/svg+xml 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | --------------------------------------------------------------------------------