├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── deploy-docs.yml ├── .gitignore ├── .spi.yml ├── .swiftlint.yml ├── .swiftpm ├── configuration │ └── Package.resolved └── xcode │ ├── package.xcworkspace │ └── contents.xcworkspacedata │ └── xcshareddata │ └── xcschemes │ ├── MusadoraKit.xcscheme │ └── MusadoraKitTests.xcscheme ├── CLAUDE.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── Musadora ├── Musadora.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ └── swiftpm │ │ │ └── Package.resolved │ └── xcshareddata │ │ └── xcschemes │ │ └── Musadora.xcscheme ├── Musadora │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── Contents.json │ │ │ └── Group 121.png │ │ └── Contents.json │ ├── Catalog │ │ ├── CatalogView.swift │ │ └── Station Genres │ │ │ ├── StationGenreDetailedView.swift │ │ │ └── StationGenresView.swift │ ├── Charts │ │ ├── AlbumChartView.swift │ │ ├── ChartView.swift │ │ ├── ChartsView.swift │ │ ├── MusicVideoChartView.swift │ │ ├── PlaylistChartView.swift │ │ └── SongChartView.swift │ ├── History │ │ ├── MusicSummariesView.swift │ │ ├── RecentlyAddedView.swift │ │ └── RecentlyPlayedView.swift │ ├── Info.plist │ ├── Library │ │ ├── LibraryAlbumsView.swift │ │ ├── LibraryPlaylistsView.swift │ │ ├── LibrarySongsView.swift │ │ └── LibraryView.swift │ ├── Main │ │ ├── MTabView.swift │ │ └── MusadoraApp.swift │ ├── Music Items │ │ ├── Album │ │ │ ├── AlbumRow.swift │ │ │ └── AlbumsView.swift │ │ ├── Playlist │ │ │ ├── PlaylistRow.swift │ │ │ └── PlaylistsView.swift │ │ ├── Song │ │ │ ├── SongRow.swift │ │ │ └── SongsView.swift │ │ └── Station │ │ │ └── StationRow.swift │ ├── Onboarding │ │ └── WelcomeView.swift │ ├── Preview Content │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ ├── README.md │ ├── Recommendations │ │ ├── RecommendationView.swift │ │ └── RecommendationsView.swift │ ├── Settings │ │ └── SettingsView.swift │ └── Shared │ │ └── NavigationListStack.swift ├── README.md └── release_notes.json ├── MusadoraKitIcon.png ├── Package.resolved ├── Package.swift ├── README.md ├── Sources └── MusadoraKit │ ├── 100 Best Albums │ ├── HundredBestAlbum.swift │ ├── HundredBestAlbumRequest.swift │ └── HundredBestAlbums.swift │ ├── Add Resources │ ├── CreatePlaylist.swift │ ├── LibraryPlaylistCreationRequest.swift │ └── MAddResourcesRequest.swift │ ├── Catalog │ ├── CatalogAlbum.swift │ ├── CatalogArtist.swift │ ├── CatalogChart.swift │ ├── CatalogChart │ │ ├── ChartItemCollection.swift │ │ ├── MChartItem.swift │ │ ├── MChartRequest.swift │ │ ├── MChartResponse.swift │ │ └── MCharts.swift │ ├── CatalogCurator.swift │ ├── CatalogGenre.swift │ ├── CatalogMusicVideo.swift │ ├── CatalogPlaylist.swift │ ├── CatalogRadioShow.swift │ ├── CatalogRecordLabel.swift │ ├── CatalogSearch.swift │ ├── CatalogSong.swift │ ├── CatalogStation.swift │ ├── CatalogSuggestions │ │ ├── MCatalogSuggestionsRequest.swift │ │ ├── MCatalogSuggestionsResponse.swift │ │ ├── MusicCatalogSearchable.swift │ │ ├── SearchSuggestionItem.swift │ │ ├── SuggestionKind.swift │ │ ├── Suggestions.swift │ │ ├── SuggestionsKind.swift │ │ ├── TermSuggestion.swift │ │ └── TopResultsSuggestion.swift │ ├── MCatalog.swift │ └── MCatalogSearchType.swift │ ├── Documentation.docc │ ├── MusadoraKit.md │ ├── Tutorials │ │ ├── Building-Music-Player.tutorial │ │ ├── Getting-Started.tutorial │ │ ├── Search-and-Discovery.tutorial │ │ ├── Working-with-Library.tutorial │ │ └── table-of-contents.tutorial │ └── icon.png │ ├── Equivalents │ ├── CatalogCleanEquivalent.swift │ ├── CatalogCleanEquivalents.swift │ ├── CatalogEquivalent.swift │ ├── CatalogEquivalents.swift │ ├── EquivalentMusicItemType.swift │ └── EquivalentRequestable.swift │ ├── Extension │ ├── Array.swift │ ├── Artwork.swift │ ├── CommonImageProcessing.swift │ ├── Data.swift │ ├── NSColor.swift │ ├── NSImage.swift │ ├── UIColor.swift │ └── UIImage.swift │ ├── Favorites │ ├── Favorites.swift │ └── MFavoritesRequest.swift │ ├── History │ ├── MHistory.swift │ ├── MHistoryEndpoints.swift │ ├── MHistoryRequest.swift │ ├── MHistoryResponse.swift │ ├── MusicRecentlyAddedRequest.swift │ └── RecentlyPlayed.swift │ ├── Library │ ├── LibraryAlbum.swift │ ├── LibraryArtist.swift │ ├── LibraryCatalog.swift │ ├── LibraryGenre.swift │ ├── LibraryPlaylist.swift │ ├── LibrarySearch.swift │ ├── LibrarySong.swift │ ├── MLibrary.swift │ ├── MLibraryPlaylist.swift │ ├── MusicLibrarySearchable.swift │ ├── Playlist Folder Request │ │ ├── LibraryPlaylistFolder.swift │ │ └── MLibraryPlaylistFolder.swift │ ├── Resource Request │ │ ├── FilterableLibraryItem.swift │ │ ├── LibraryMusicItemType.swift │ │ ├── MLibraryResourceRequest.swift │ │ └── MLibraryResourceResponse.swift │ └── Search Request │ │ ├── MLibrarySearchRequest.swift │ │ ├── MusadoraLibrarySearchResponse.swift │ │ ├── MusicLibrarySearchResponseResults.swift │ │ └── MusicLibrarySearchType.swift │ ├── Models │ ├── AppleMusicURLComponents.swift │ ├── MusadoraKitError.swift │ ├── RelationshipItem.swift │ ├── SongsForAlbums.swift │ ├── SongsForArtists.swift │ ├── SongsForGenres.swift │ ├── SongsForPlaylists.swift │ ├── StationGenre.swift │ └── UserMusicItem.swift │ ├── Multiple Resources │ ├── MusicCatalogResources │ │ ├── MusicCatalogResourcesRequest.swift │ │ ├── MusicCatalogResourcesResponse.swift │ │ └── MusicCatalogResourcesType.swift │ └── MusicLibraryResources │ │ ├── MusicLibraryResourcesRequest.swift │ │ ├── MusicLibraryResourcesResponse.swift │ │ └── MusicLibraryResourcesType.swift │ ├── MusadoraKit.swift │ ├── Music Item Properties │ ├── AlbumProperties.swift │ ├── ArtistProperties.swift │ ├── CuratorProperties.swift │ ├── MusicVideoProperties.swift │ ├── PlaylistProperties.swift │ ├── RadioShowProperties.swift │ ├── RecordLabelProperties.swift │ └── SongProperties.swift │ ├── Music Items │ ├── Albums.swift │ ├── Artists.swift │ ├── Curators.swift │ ├── Genres.swift │ ├── LibraryPlaylists.swift │ ├── MusicVideos.swift │ ├── Playlists.swift │ ├── RadioShows.swift │ ├── RecordLabels.swift │ ├── Songs.swift │ ├── StationGenres.swift │ ├── Stations.swift │ ├── Tracks.swift │ └── UserMusicItems.swift │ ├── Music Player │ ├── APlayer.swift │ └── SPlayer.swift │ ├── Music Summaries │ ├── MSummary.swift │ ├── MSummaryRequest.swift │ └── MSummaryResponse.swift │ ├── MusicRequest.swift │ ├── PrivacyInfo.xcprivacy │ ├── Ratings │ ├── Catalog │ │ ├── AddCatalogRating.swift │ │ ├── CatalogRatingMusicItemType.swift │ │ ├── DeleteCatalogRating.swift │ │ ├── GetCatalogRating.swift │ │ ├── MCatalogRatingAddRequest.swift │ │ ├── MCatalogRatingDeleteRequest.swift │ │ └── MCatalogRatingRequest.swift │ ├── Library │ │ ├── AddLibraryRating.swift │ │ ├── DeleteLibraryRating.swift │ │ ├── GetLibraryRating.swift │ │ ├── LibraryRatingMusicItemType.swift │ │ ├── MLibraryRatingAddRequest.swift │ │ ├── MLibraryRatingDeleteRequest.swift │ │ └── MLibraryRatingRequest.swift │ └── Models │ │ ├── RatingRequest.swift │ │ ├── RatingType.swift │ │ ├── Ratings.swift │ │ └── RatingsResponse.swift │ ├── Recommendations │ ├── MRecommendation+default.swift │ ├── MRecommendation+personal.swift │ ├── MRecommendation.swift │ ├── MRecommendationItem.swift │ ├── MRecommendationMusicItem.swift │ ├── MRecommendationRequest.swift │ ├── MRecommendationResponse.swift │ ├── MRecommendations.swift │ └── PersonalRecommendations.swift │ ├── Requests │ ├── MDataDeleteRequest.swift │ ├── MDataPostRequest.swift │ ├── MDataPutRequest.swift │ ├── MDataRequest.swift │ ├── MDeveloperTokenProvider.swift │ └── MUserRequest.swift │ ├── Storefronts.swift │ └── Views │ └── AnimatedArtworkView.swift ├── Tests └── MusadoraKitTests │ ├── 100 Best Albums │ ├── HundredBestAlbumIntegrationTests.swift │ └── HundredBestAlbumRequestTests.swift │ ├── Add Resources │ └── MusicAddResourcesRequestEndpointTests.swift │ ├── AppleMusicURLComponentsTests.swift │ ├── Catalog │ ├── CatalogChartTests.swift │ ├── CatalogGenreEndpointTests.swift │ ├── CatalogPlaylistEndpointTests.swift │ ├── CatalogSearchTests.swift │ ├── CatalogStationEndpointTests.swift │ ├── CatalogStationGenreTests.swift │ ├── CatalogStorefrontTests.swift │ └── CatalogSuggestionsTests.swift │ ├── Equivalents │ └── EquivalentItemsEndpointTests.swift │ ├── Library │ └── MLibrarySearchRequestEndpointTests.swift │ ├── LibraryPlaylistTests.swift │ ├── MusadoraKitTests.swift │ ├── Music Items │ ├── Album.swift │ ├── Song.swift │ └── Station.swift │ ├── MusicHistoryRequestEndpointTests.swift │ ├── MusicItemPropertiesTests.swift │ ├── Ratings │ ├── MusicCatalogRatingRequestTests.swift │ └── MusicLibraryRatingRequestTests.swift │ ├── Recommendation │ └── MusicRecommendationRequestEndpointTests.swift │ └── Summaries │ └── MSummaryRequestEndpointTests.swift └── codemagic.yaml /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: rryam 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/deploy-docs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy DocC Documentation 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: read 10 | pages: write 11 | id-token: write 12 | 13 | concurrency: 14 | group: "pages" 15 | cancel-in-progress: false 16 | 17 | jobs: 18 | build: 19 | runs-on: macos-latest 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v4 23 | 24 | - name: Setup Xcode 25 | uses: maxim-lobanov/setup-xcode@v1 26 | with: 27 | xcode-version: latest-stable 28 | 29 | - name: Build DocC Documentation 30 | run: | 31 | swift package --allow-writing-to-directory ./docs \ 32 | generate-documentation --target MusadoraKit \ 33 | --output-path ./docs \ 34 | --transform-for-static-hosting \ 35 | --hosting-base-path MusadoraKit 36 | 37 | - name: Setup Pages 38 | uses: actions/configure-pages@v4 39 | 40 | - name: Upload artifact 41 | uses: actions/upload-pages-artifact@v3 42 | with: 43 | path: './docs' 44 | 45 | deploy: 46 | environment: 47 | name: github-pages 48 | url: ${{ steps.deployment.outputs.page_url }} 49 | runs-on: ubuntu-latest 50 | needs: build 51 | steps: 52 | - name: Deploy to GitHub Pages 53 | id: deployment 54 | uses: actions/deploy-pages@v4 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # macOS 86 | .DS_Store 87 | 88 | # Build logs 89 | *.log 90 | 91 | # Code Injection 92 | # 93 | # After new code Injection tools there's a generated folder /iOSInjectionProject 94 | # https://github.com/johnno1962/injectionforxcode 95 | 96 | iOSInjectionProject/ 97 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | external_links: 3 | documentation: "https://rryam.github.io/MusadoraKit/documentation/musadorakit/" 4 | -------------------------------------------------------------------------------- /.swiftpm/configuration/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "swift-docc-plugin", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/apple/swift-docc-plugin", 7 | "state" : { 8 | "revision" : "2eb22993b3dfd0c0d32729b357c8dabb6cd44680", 9 | "version" : "1.4.2" 10 | } 11 | }, 12 | { 13 | "identity" : "swift-docc-symbolkit", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/swiftlang/swift-docc-symbolkit", 16 | "state" : { 17 | "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", 18 | "version" : "1.0.0" 19 | } 20 | } 21 | ], 22 | "version" : 2 23 | } 24 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/MusadoraKitTests.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 17 | 19 | 25 | 26 | 27 | 28 | 29 | 39 | 40 | 46 | 47 | 49 | 50 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Rudrank Riyam 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Musadora/Musadora.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Musadora/Musadora.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Musadora/Musadora.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "cd6af0cf2f8ff347cfff7aa71beb2683ef2df0f08d88311dff4cdc7f35af4d20", 3 | "pins" : [ 4 | { 5 | "identity" : "musadorakit", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/rryam/MusadoraKit", 8 | "state" : { 9 | "branch" : "main", 10 | "revision" : "a2392987b2b888f040682cebfa0fbae82fe76934" 11 | } 12 | } 13 | ], 14 | "version" : 3 15 | } 16 | -------------------------------------------------------------------------------- /Musadora/Musadora/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Musadora/Musadora/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Group 121.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | } 9 | ], 10 | "info" : { 11 | "author" : "xcode", 12 | "version" : 1 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Musadora/Musadora/Assets.xcassets/AppIcon.appiconset/Group 121.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rryam/MusadoraKit/28ae04c064cea01cae18f18d470e230325b8fbe4/Musadora/Musadora/Assets.xcassets/AppIcon.appiconset/Group 121.png -------------------------------------------------------------------------------- /Musadora/Musadora/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Musadora/Musadora/Catalog/CatalogView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CatalogView.swift 3 | // Musadora 4 | // 5 | // Created by Rudrank Riyam on 17/03/23. 6 | // 7 | 8 | import SwiftUI 9 | import MusadoraKit 10 | 11 | struct CatalogView: View { 12 | var body: some View { 13 | NavigationListStack("Catalog") { 14 | NavigationLink("Station Genres", destination: StationGenresView()) 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Musadora/Musadora/Catalog/Station Genres/StationGenreDetailedView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StationGenreDetailedView.swift 3 | // Musadora 4 | // 5 | // Created by Rudrank Riyam on 12/03/23. 6 | // 7 | 8 | import SwiftUI 9 | import MusadoraKit 10 | 11 | 12 | struct StationGenreDetailedView: View { 13 | var stationGenre: StationGenre 14 | 15 | @State private var stations: Stations = [] 16 | 17 | var body: some View { 18 | List { 19 | ForEach(stations) { station in 20 | StationRow(station: station) 21 | } 22 | .navigationTitle(stationGenre.name) 23 | } 24 | .task { 25 | do { 26 | stations = try await MCatalog.stations(for: stationGenre) 27 | } catch { 28 | print(error) 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Musadora/Musadora/Catalog/Station Genres/StationGenresView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StationGenresView.swift 3 | // Musadora 4 | // 5 | // Created by Rudrank Riyam on 12/03/23. 6 | // 7 | 8 | import SwiftUI 9 | import MusadoraKit 10 | 11 | struct StationGenresView: View { 12 | @State private var stationGenres: StationGenres = [] 13 | 14 | var body: some View { 15 | List { 16 | ForEach(stationGenres) { stationGenre in 17 | NavigationLink(stationGenre.name, destination: StationGenreDetailedView(stationGenre: stationGenre)) 18 | } 19 | .navigationTitle("Station Genres") 20 | } 21 | .task { 22 | await fetchStationGenres() 23 | } 24 | } 25 | } 26 | 27 | extension StationGenresView { 28 | private func fetchStationGenres() async { 29 | do { 30 | stationGenres = try await MCatalog.stationGenres() 31 | } catch { 32 | print(error) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Musadora/Musadora/Charts/AlbumChartView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AlbumChartView.swift 3 | // Musadora 4 | // 5 | // Created by Rudrank Riyam on 14/03/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | import MusadoraKit 11 | 12 | struct AlbumChartView: View { 13 | var albumChart: MusicCatalogChart 14 | 15 | var body: some View { 16 | List { 17 | ForEach(albumChart.items) { album in 18 | VStack(alignment: .leading) { 19 | HStack { 20 | if let artwork = album.artwork { 21 | ArtworkImage(artwork, width: 100, height: 100) 22 | .cornerRadius(8) 23 | } 24 | 25 | Spacer() 26 | 27 | Image(systemName: "play.fill") 28 | .foregroundColor(.secondary) 29 | .onTapGesture { 30 | Task { 31 | do { 32 | try await APlayer.shared.play(album: album) 33 | } catch { 34 | print(error) 35 | } 36 | } 37 | } 38 | } 39 | 40 | Text(album.title) 41 | .bold() 42 | .font(.headline) 43 | 44 | Text(album.artistName) 45 | .font(.subheadline) 46 | } 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Musadora/Musadora/Charts/ChartsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChartsView.swift 3 | // Musadora 4 | // 5 | // Created by Rudrank Riyam on 14/03/23. 6 | // 7 | 8 | import SwiftUI 9 | import MusadoraKit 10 | 11 | struct ChartsView: View { 12 | @State private var genres: Genres = [] 13 | 14 | #if os(iOS) 15 | private let columns = [GridItem(.flexible()), GridItem(.flexible())] 16 | #else 17 | private let columns = [GridItem(.adaptive(minimum: 220))] 18 | #endif 19 | 20 | var body: some View { 21 | NavigationStack { 22 | ScrollView { 23 | LazyVGrid(columns: columns, spacing: 12) { 24 | ForEach(genres) { genre in 25 | NavigationLink(destination: { ChartView(genre: genre) }, label: { 26 | Text(genre.name) 27 | .font(.headline) 28 | .multilineTextAlignment(.center) 29 | .frame(maxWidth: .infinity, minHeight: 80) 30 | .padding(12) 31 | .background( 32 | RoundedRectangle(cornerRadius: 12) 33 | .fill(Color.gray.opacity(0.1)) 34 | ) 35 | .overlay( 36 | RoundedRectangle(cornerRadius: 12) 37 | .stroke(Color.secondary.opacity(0.2)) 38 | ) 39 | .contentShape(RoundedRectangle(cornerRadius: 12)) 40 | }) 41 | .buttonStyle(.plain) 42 | } 43 | } 44 | .padding(.horizontal) 45 | .padding(.top) 46 | } 47 | .navigationTitle("Charts") 48 | } 49 | .task { 50 | do { 51 | genres = try await MCatalog.topGenres() 52 | } catch { 53 | print(error) 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Musadora/Musadora/Charts/MusicVideoChartView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MusicVideoChartView.swift 3 | // Musadora 4 | // 5 | // Created by Rudrank Riyam on 14/03/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | import SwiftUI 11 | 12 | import MusadoraKit 13 | 14 | struct MusicVideoChartView: View { 15 | var musicVideoChart: MusicCatalogChart 16 | 17 | var body: some View { 18 | List { 19 | ForEach(musicVideoChart.items) { musicVideo in 20 | VStack(alignment: .leading) { 21 | if let artwork = musicVideo.artwork { 22 | ArtworkImage(artwork, width: 80, height: 80) 23 | .cornerRadius(8) 24 | } 25 | 26 | Text(musicVideo.title) 27 | .bold() 28 | .font(.headline) 29 | 30 | Text(musicVideo.artistName) 31 | .font(.subheadline) 32 | } 33 | } 34 | } 35 | .navigationTitle(musicVideoChart.title) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Musadora/Musadora/Charts/PlaylistChartView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlaylistChartView.swift 3 | // Musadora 4 | // 5 | // Created by Rudrank Riyam on 14/03/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | import MusadoraKit 11 | 12 | struct PlaylistChartView: View { 13 | var playlistChart: MusicCatalogChart 14 | 15 | var body: some View { 16 | List { 17 | ForEach(playlistChart.items) { playlist in 18 | VStack(alignment: .leading) { 19 | HStack { 20 | if let artwork = playlist.artwork { 21 | ArtworkImage(artwork, width: 100, height: 100) 22 | .cornerRadius(8) 23 | } 24 | 25 | Spacer() 26 | 27 | Button(action: { 28 | Task { 29 | do { 30 | try await APlayer.shared.play(playlist: playlist) 31 | } catch { 32 | print(error) 33 | } 34 | } 35 | }, label: { 36 | Image(systemName: "play.fill") 37 | }) 38 | .buttonStyle(.plain) 39 | } 40 | 41 | Text(playlist.name) 42 | .bold() 43 | .font(.headline) 44 | 45 | Text(playlist.curatorName ?? "") 46 | .font(.subheadline) 47 | } 48 | } 49 | } 50 | .navigationTitle(playlistChart.title) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Musadora/Musadora/Charts/SongChartView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SongChartView.swift 3 | // Musadora 4 | // 5 | // Created by Rudrank Riyam on 14/03/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | import MusadoraKit 11 | 12 | struct SongChartView: View { 13 | var songChart: MusicCatalogChart 14 | 15 | var body: some View { 16 | SongsView(with: songChart.items) 17 | .navigationTitle(songChart.title) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Musadora/Musadora/History/MusicSummariesView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MusicSummariesView.swift 3 | // Musadora 4 | // 5 | // Created by Codex on 02/09/25. 6 | // 7 | 8 | import SwiftUI 9 | import MusadoraKit 10 | import MusicKit 11 | 12 | struct MusicSummariesView: View { 13 | @State private var topArtists: Artists = [] 14 | @State private var topAlbums: Albums = [] 15 | @State private var topSongs: Songs = [] 16 | @State private var year: Int? 17 | @State private var errorMessage: String? 18 | @State private var isEligible: Bool = false 19 | 20 | var body: some View { 21 | List { 22 | if let message = errorMessage { 23 | Section { 24 | Text(message) 25 | .foregroundStyle(.secondary) 26 | } 27 | } 28 | 29 | if isEligible { 30 | if let year { 31 | Text("Year: \(year)") 32 | .font(.headline) 33 | } 34 | 35 | NavigationLink("Top Songs", destination: SongsView(with: topSongs)) 36 | NavigationLink("Top Albums", destination: AlbumsView(with: topAlbums)) 37 | 38 | Section("Top Artists") { 39 | ForEach(topArtists) { artist in 40 | Text(artist.name) 41 | } 42 | } 43 | } 44 | } 45 | .navigationTitle("Music Summaries") 46 | .task(id: MusicAuthorization.currentStatus) { 47 | await checkEligibilityAndLoad() 48 | } 49 | } 50 | } 51 | 52 | extension MusicSummariesView { 53 | @MainActor 54 | private func checkEligibilityAndLoad() async { 55 | errorMessage = nil 56 | isEligible = false 57 | 58 | // Do not start request until authorized 59 | let status = MusicAuthorization.currentStatus 60 | guard status == .authorized else { 61 | errorMessage = "Not authorized for Apple Music. Tap Continue on the welcome screen." 62 | return 63 | } 64 | 65 | // Ensure user has an active Apple Music subscription 66 | let subscription: MusicSubscription? = try? await MusicSubscription.current 67 | guard subscription?.canPlayCatalogContent == true else { 68 | errorMessage = "Requires an active Apple Music subscription to load Replay." 69 | return 70 | } 71 | 72 | do { 73 | let summary = try await MSummary.latest() 74 | self.topArtists = summary.topArtists 75 | self.topAlbums = summary.topAlbums 76 | self.topSongs = summary.topSongs 77 | self.year = summary.year 78 | self.isEligible = true 79 | } catch is CancellationError { 80 | // Task was cancelled by SwiftUI lifecycle; ignore 81 | } catch { 82 | print("DEBUG: Full error details: \(error)") 83 | errorMessage = "Could not load Replay (\(error.localizedDescription))." 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Musadora/Musadora/History/RecentlyAddedView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RecentlyAddedView.swift 3 | // Musadora 4 | // 5 | // Created by Rudrank Riyam on 15/03/23. 6 | // 7 | 8 | import SwiftUI 9 | import MusadoraKit 10 | 11 | struct RecentlyAddedView: View { 12 | @State private var recentlyAddedPlaylists: Playlists = [] 13 | @State private var recentlyAddedAlbums: Albums = [] 14 | 15 | var body: some View { 16 | List { 17 | NavigationLink("Playlists", destination: PlaylistsView(with: recentlyAddedPlaylists)) 18 | NavigationLink("Albums", destination: AlbumsView(with: recentlyAddedAlbums)) 19 | } 20 | .navigationTitle("Recently Added") 21 | .task { 22 | do { 23 | recentlyAddedPlaylists = try await MHistory.recentlyAddedPlaylists(limit: 25, offset: 0) 24 | recentlyAddedAlbums = try await MHistory.recentlyAddedAlbums(limit: 25, offset: 0) 25 | } catch { 26 | print(error) 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Musadora/Musadora/History/RecentlyPlayedView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RecentlyPlayedView.swift 3 | // Musadora 4 | // 5 | // Created by Rudrank Riyam on 15/03/23. 6 | // 7 | 8 | import SwiftUI 9 | import MusadoraKit 10 | 11 | struct RecentlyPlayedView: View { 12 | @State private var recentlyPlayedPlaylists: Playlists = [] 13 | @State private var recentlyPlayedAlbums: Albums = [] 14 | 15 | var body: some View { 16 | List { 17 | NavigationLink("Playlists", destination: PlaylistsView(with: recentlyPlayedPlaylists)) 18 | NavigationLink("Albums", destination: AlbumsView(with: recentlyPlayedAlbums)) 19 | } 20 | .navigationTitle("Recently Added") 21 | .task { 22 | do { 23 | recentlyPlayedAlbums = try await MHistory.recentlyPlayedAlbums(limit: 25, offset: 0) 24 | recentlyPlayedPlaylists = try await MHistory.recentlyPlayedPlaylists(limit: 25, offset: 0) 25 | } catch { 26 | print(error) 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Musadora/Musadora/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ITSAppUsesNonExemptEncryption 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Musadora/Musadora/Library/LibraryAlbumsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LibraryAlbumsView.swift 3 | // Musadora 4 | // 5 | // Created by Rudrank Riyam on 15/03/23. 6 | // 7 | 8 | import SwiftUI 9 | import MusadoraKit 10 | 11 | struct LibraryAlbumsView: View { 12 | @State private var albums: Albums = [] 13 | 14 | var body: some View { 15 | AlbumsView(with: albums) 16 | .navigationTitle("Albums") 17 | .task { 18 | do { 19 | albums = try await MLibrary.albums() 20 | } catch { 21 | print(error) 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Musadora/Musadora/Library/LibraryPlaylistsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LibraryPlaylistsView.swift 3 | // Musadora 4 | // 5 | // Created by Rudrank Riyam on 15/03/23. 6 | // 7 | 8 | import SwiftUI 9 | import MusadoraKit 10 | 11 | struct LibraryPlaylistsView: View { 12 | @State private var playlists: Playlists = [] 13 | 14 | var body: some View { 15 | PlaylistsView(with: playlists) 16 | .navigationTitle("Playlists") 17 | .task { 18 | do { 19 | playlists = try await MLibrary.playlists() 20 | } catch { 21 | print(error) 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Musadora/Musadora/Library/LibrarySongsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LibrarySongsView.swift 3 | // Musadora 4 | // 5 | // Created by Rudrank Riyam on 15/03/23. 6 | // 7 | 8 | import SwiftUI 9 | import MusadoraKit 10 | 11 | struct LibrarySongsView: View { 12 | @State private var songs: Songs = [] 13 | 14 | var body: some View { 15 | SongsView(with: songs) 16 | .navigationTitle("Songs") 17 | .task { 18 | do { 19 | songs = try await MLibrary.songs() 20 | } catch { 21 | print(error) 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Musadora/Musadora/Library/LibraryView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LibraryView.swift 3 | // Musadora 4 | // 5 | // Created by Rudrank Riyam on 09/03/23. 6 | // 7 | 8 | import SwiftUI 9 | import MusadoraKit 10 | 11 | struct LibraryView: View { 12 | var body: some View { 13 | NavigationStack { 14 | List { 15 | Section("Library") { 16 | NavigationLink("Songs") { LibrarySongsView() } 17 | NavigationLink("Albums") { LibraryAlbumsView() } 18 | NavigationLink("Playlists") { LibraryPlaylistsView() } 19 | } 20 | 21 | Section("History") { 22 | NavigationLink("Recently Added") { RecentlyAddedView() } 23 | NavigationLink("Recently Played") { RecentlyPlayedView() } 24 | NavigationLink("Music Summaries") { MusicSummariesView() } 25 | } 26 | } 27 | .navigationTitle("Library") 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Musadora/Musadora/Main/MusadoraApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MusadoraApp.swift 3 | // Musadora 4 | // 5 | // Created by Rudrank Riyam on 09/03/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct MusadoraApp: App { 12 | var body: some Scene { 13 | WindowGroup { 14 | MTabView() 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Musadora/Musadora/Music Items/Album/AlbumRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AlbumRow.swift 3 | // Musadora 4 | // 5 | // Created by Rudrank Riyam on 15/03/23. 6 | // 7 | 8 | import SwiftUI 9 | import MusadoraKit 10 | 11 | struct AlbumRow: View { 12 | var album: Album 13 | 14 | var body: some View { 15 | VStack(alignment: .leading) { 16 | HStack { 17 | if let artwork = album.artwork { 18 | ArtworkImage(artwork, width: 100, height: 100) 19 | .cornerRadius(8) 20 | } 21 | 22 | Spacer() 23 | 24 | Image(systemName: "play.fill") 25 | .foregroundColor(.secondary) 26 | .onTapGesture { 27 | Task { 28 | do { 29 | try await APlayer.shared.play(album: album) 30 | } catch { 31 | print(error) 32 | } 33 | } 34 | } 35 | } 36 | 37 | Text(album.title) 38 | .bold() 39 | .font(.headline) 40 | 41 | Text(album.artistName) 42 | .font(.subheadline) 43 | } 44 | .contentShape(Rectangle()) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Musadora/Musadora/Music Items/Album/AlbumsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AlbumsView.swift 3 | // Musadora 4 | // 5 | // Created by Rudrank Riyam on 15/03/23. 6 | // 7 | 8 | import SwiftUI 9 | import MusadoraKit 10 | 11 | struct AlbumsView: View { 12 | private var albums: Albums 13 | 14 | init(with albums: Albums) { 15 | self.albums = albums 16 | } 17 | 18 | var body: some View { 19 | ScrollView { 20 | LazyVStack(spacing: 20) { 21 | if albums.count > 4 { 22 | // Show top 4 in grid 23 | VStack(alignment: .leading, spacing: 16) { 24 | Text("Top Albums") 25 | .font(.title2) 26 | .fontWeight(.bold) 27 | .padding(.horizontal) 28 | 29 | LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 2), spacing: 16) { 30 | ForEach(albums.prefix(4), id: \.id) { album in 31 | AlbumGridCard(album: album) 32 | } 33 | } 34 | .padding(.horizontal) 35 | } 36 | 37 | // Show remaining albums in list 38 | if albums.count > 4 { 39 | VStack(alignment: .leading, spacing: 12) { 40 | Text("All Albums") 41 | .font(.title3) 42 | .fontWeight(.semibold) 43 | .padding(.horizontal) 44 | .padding(.top) 45 | 46 | LazyVStack(spacing: 8) { 47 | ForEach(albums, content: AlbumRow.init) 48 | } 49 | .padding(.horizontal) 50 | } 51 | } 52 | } else { 53 | // Show all albums in grid if 4 or fewer 54 | LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 2), spacing: 16) { 55 | ForEach(albums, id: \.id) { album in 56 | AlbumGridCard(album: album) 57 | } 58 | } 59 | .padding(.horizontal) 60 | } 61 | } 62 | } 63 | .navigationTitle("Top Albums") 64 | .navigationBarTitleDisplayMode(.large) 65 | } 66 | } 67 | 68 | struct AlbumGridCard: View { 69 | let album: Album 70 | 71 | var body: some View { 72 | VStack(alignment: .leading, spacing: 8) { 73 | if let artwork = album.artwork { 74 | ArtworkImage(artwork, width: 150, height: 150) 75 | .cornerRadius(12) 76 | } 77 | 78 | VStack(alignment: .leading, spacing: 4) { 79 | Text(album.title) 80 | .font(.headline) 81 | .lineLimit(2) 82 | 83 | Text(album.artistName) 84 | .font(.subheadline) 85 | .foregroundColor(.secondary) 86 | .lineLimit(1) 87 | } 88 | } 89 | .onTapGesture { 90 | Task { 91 | do { 92 | try await APlayer.shared.play(album: album) 93 | } catch { 94 | print(error) 95 | } 96 | } 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Musadora/Musadora/Music Items/Playlist/PlaylistRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlaylistRow.swift 3 | // Musadora 4 | // 5 | // Created by Rudrank Riyam on 15/03/23. 6 | // 7 | 8 | import SwiftUI 9 | import MusadoraKit 10 | 11 | struct PlaylistRow: View { 12 | var playlist: Playlist 13 | 14 | var body: some View { 15 | VStack(alignment: .leading) { 16 | HStack { 17 | if let artwork = playlist.artwork { 18 | ArtworkImage(artwork, width: 100, height: 100) 19 | .cornerRadius(8) 20 | } 21 | 22 | Spacer() 23 | 24 | Image(systemName: "play.fill") 25 | .foregroundColor(.secondary) 26 | .onTapGesture { 27 | Task { 28 | do { 29 | try await APlayer.shared.play(playlist: playlist) 30 | } catch { 31 | print(error) 32 | } 33 | } 34 | } 35 | } 36 | 37 | Text(playlist.name) 38 | .bold() 39 | .font(.headline) 40 | 41 | Text(playlist.curatorName ?? "") 42 | .font(.subheadline) 43 | } 44 | .contentShape(Rectangle()) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Musadora/Musadora/Music Items/Playlist/PlaylistsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlaylistsView.swift 3 | // Musadora 4 | // 5 | // Created by Rudrank Riyam on 15/03/23. 6 | // 7 | 8 | import SwiftUI 9 | import MusadoraKit 10 | 11 | struct PlaylistsView: View { 12 | private var playlists: Playlists 13 | 14 | init(with playlists: Playlists) { 15 | self.playlists = playlists 16 | } 17 | 18 | var body: some View { 19 | List { 20 | ForEach(playlists, content: PlaylistRow.init) 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Musadora/Musadora/Music Items/Song/SongRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SongRow.swift 3 | // Musadora 4 | // 5 | // Created by Rudrank Riyam on 15/03/23. 6 | // 7 | 8 | import SwiftUI 9 | import MusadoraKit 10 | 11 | struct SongRow: View { 12 | var song: Song 13 | 14 | var body: some View { 15 | VStack(alignment: .leading) { 16 | HStack { 17 | if let artwork = song.artwork { 18 | ArtworkImage(artwork, width: 100, height: 100) 19 | .cornerRadius(8) 20 | } 21 | 22 | Spacer() 23 | 24 | Image(systemName: "play.fill") 25 | .foregroundColor(.secondary) 26 | .onTapGesture { 27 | Task { 28 | do { 29 | try await APlayer.shared.play(song: song) 30 | } catch { 31 | print(error) 32 | } 33 | } 34 | } 35 | } 36 | 37 | Text(song.title) 38 | .bold() 39 | .font(.headline) 40 | 41 | Text(song.artistName) 42 | .font(.subheadline) 43 | } 44 | .contentShape(Rectangle()) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Musadora/Musadora/Music Items/Song/SongsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SongsView.swift 3 | // Musadora 4 | // 5 | // Created by Rudrank Riyam on 15/03/23. 6 | // 7 | 8 | import SwiftUI 9 | import MusadoraKit 10 | 11 | struct SongsView: View { 12 | private var songs: Songs 13 | 14 | init(with songs: Songs) { 15 | self.songs = songs 16 | } 17 | 18 | var body: some View { 19 | List { 20 | ForEach(songs.enumerated().map({ $0 }), id: \.element.id) { index, song in 21 | HStack(spacing: 12) { 22 | // Ranking number 23 | Text("\(index + 1)") 24 | .font(.headline) 25 | .fontWeight(.bold) 26 | .foregroundColor(.primary) 27 | .frame(minWidth: 24) 28 | 29 | // Song artwork 30 | if let artwork = song.artwork { 31 | ArtworkImage(artwork, width: 50, height: 50) 32 | .cornerRadius(8) 33 | } 34 | 35 | // Song details 36 | VStack(alignment: .leading, spacing: 2) { 37 | Text(song.title) 38 | .font(.headline) 39 | .lineLimit(1) 40 | 41 | Text(song.artistName) 42 | .font(.subheadline) 43 | .foregroundColor(.secondary) 44 | .lineLimit(1) 45 | } 46 | 47 | Spacer() 48 | 49 | // Play button 50 | Button(action: { 51 | Task { 52 | do { 53 | try await APlayer.shared.play(song: song) 54 | } catch { 55 | print(error) 56 | } 57 | } 58 | }) { 59 | Image(systemName: "play.fill") 60 | .foregroundColor(.primary) 61 | } 62 | .buttonStyle(PlainButtonStyle()) 63 | } 64 | .contentShape(Rectangle()) 65 | .onTapGesture { 66 | Task { 67 | do { 68 | try await APlayer.shared.play(song: song) 69 | } catch { 70 | print(error) 71 | } 72 | } 73 | } 74 | } 75 | } 76 | .navigationTitle("Top Songs") 77 | .navigationBarTitleDisplayMode(.large) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Musadora/Musadora/Music Items/Station/StationRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StationRow.swift 3 | // Musadora 4 | // 5 | // Created by Rudrank Riyam on 15/03/23. 6 | // 7 | 8 | import SwiftUI 9 | import MusadoraKit 10 | 11 | struct StationRow: View { 12 | var station: Station 13 | 14 | var body: some View { 15 | VStack(alignment: .leading) { 16 | HStack { 17 | if let artwork = station.artwork { 18 | ArtworkImage(artwork, width: 100, height: 100) 19 | .cornerRadius(8) 20 | } 21 | 22 | Spacer() 23 | 24 | Image(systemName: "play.fill") 25 | .foregroundColor(.secondary) 26 | .onTapGesture { 27 | Task { 28 | do { 29 | try await APlayer.shared.play(station: station) 30 | } catch { 31 | print(error) 32 | } 33 | } 34 | } 35 | } 36 | 37 | Text(station.name) 38 | .bold() 39 | .font(.headline) 40 | 41 | Text("\(station.editorialNotes?.short ?? "") • \(station.editorialNotes?.standard ?? "")") 42 | .font(.subheadline) 43 | } 44 | .contentShape(Rectangle()) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Musadora/Musadora/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Musadora/Musadora/README.md: -------------------------------------------------------------------------------- 1 | # Musadora 2 | 3 | Musadora is a sample project to show all the methods and powers of MusadoraKit. It is work in progress right now. 4 | 5 | ## Installation 6 | 7 | You can change the bundle identifier to something of your own that has MusicKit services enabled. Then, build the project on your phone. 8 | 9 | ## Playing Music 10 | 11 | Just long press on music items to start playing it. 12 | -------------------------------------------------------------------------------- /Musadora/Musadora/Recommendations/RecommendationView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RecommendationView.swift 3 | // Musadora 4 | // 5 | // Created by Rudrank Riyam on 14/03/23. 6 | // 7 | 8 | import SwiftUI 9 | import MusadoraKit 10 | 11 | struct RecommendationView: View { 12 | var recommendation: MRecommendationItem 13 | 14 | var body: some View { 15 | ScrollView(.horizontal) { 16 | LazyHStack { 17 | ForEach(recommendation.items, content: RecommendationRow.init) 18 | } 19 | } 20 | } 21 | } 22 | 23 | struct RecommendationRow: View { 24 | var item: UserMusicItem 25 | 26 | var body: some View { 27 | Button(action: { 28 | Task { 29 | do { 30 | APlayer.shared.queue = [item] 31 | try await APlayer.shared.play() 32 | } catch { 33 | print(error) 34 | } 35 | } 36 | }, label: { 37 | VStack(alignment: .leading) { 38 | if let artwork = item.artwork { 39 | ArtworkImage(artwork, width: 250, height: 250) 40 | .cornerRadius(16) 41 | } else { 42 | Color.red 43 | } 44 | 45 | Text(item.title) 46 | .bold() 47 | .font(.headline) 48 | } 49 | .padding(8) 50 | .contentShape(RoundedRectangle(cornerRadius: 24)) 51 | .hoverEffect() 52 | }) 53 | .buttonStyle(.plain) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Musadora/Musadora/Shared/NavigationListStack.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NavigationListStack.swift 3 | // Musadora 4 | // 5 | // Extracted to a shared location to be reused across views. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct NavigationListStack: View where Content: View { 11 | var title: String 12 | var content: () -> Content 13 | 14 | init(_ title: String, @ViewBuilder content: @escaping () -> Content) { 15 | self.title = title 16 | self.content = content 17 | } 18 | 19 | var body: some View { 20 | NavigationStack { 21 | ScrollView { 22 | content() 23 | } 24 | .navigationTitle(title) 25 | } 26 | } 27 | } 28 | 29 | -------------------------------------------------------------------------------- /Musadora/README.md: -------------------------------------------------------------------------------- 1 | # Musadora 2 | 3 | Musadora is a sample project to show all the methods and powers of MusadoraKit. It is work in progress right now. 4 | 5 | ## Installation 6 | 7 | You can change the bundle identifier to something of your own that has MusicKit services enabled. Then, build the project on your phone. 8 | 9 | ## Playing Music 10 | 11 | Just long press on music items to start playing it. 12 | -------------------------------------------------------------------------------- /Musadora/release_notes.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "language": "en-GB", 4 | "text": "Hey there! Welcome to Musadora app. Updated history to include in library tab. Thanks Jay!" 5 | } 6 | ] 7 | -------------------------------------------------------------------------------- /MusadoraKitIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rryam/MusadoraKit/28ae04c064cea01cae18f18d470e230325b8fbe4/MusadoraKitIcon.png -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "18d3baf3f486d1c7e213e87377b79507478b24d548fac6da4bbdb647aa06a07f", 3 | "pins" : [ 4 | { 5 | "identity" : "swift-docc-plugin", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/apple/swift-docc-plugin", 8 | "state" : { 9 | "revision" : "3e4f133a77e644a5812911a0513aeb7288b07d06", 10 | "version" : "1.4.5" 11 | } 12 | }, 13 | { 14 | "identity" : "swift-docc-symbolkit", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/swiftlang/swift-docc-symbolkit", 17 | "state" : { 18 | "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", 19 | "version" : "1.0.0" 20 | } 21 | } 22 | ], 23 | "version" : 3 24 | } 25 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:6.2 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "MusadoraKit", 8 | platforms: [.iOS(.v15), .macOS(.v12), .watchOS(.v8), .tvOS(.v15), .visionOS(.v1)], 9 | products: [ 10 | .library(name: "MusadoraKit", targets: ["MusadoraKit"]) 11 | ], 12 | dependencies: [ 13 | .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.4.0") 14 | ], 15 | targets: [ 16 | .target( 17 | name: "MusadoraKit", 18 | dependencies: [], 19 | resources: [ 20 | .copy("PrivacyInfo.xcprivacy") 21 | ], 22 | swiftSettings: [ 23 | .enableUpcomingFeature("StrictConcurrency") 24 | ] 25 | ), 26 | .testTarget(name: "MusadoraKitTests", dependencies: ["MusadoraKit"]) 27 | ] 28 | ) 29 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Catalog/CatalogChart/ChartItemCollection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChartItemCollection.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 23/04/22. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A collection of chart items. 11 | public struct ChartItemCollection where MusicItemType: MusicItem { 12 | /// The unique name of the chart to use when fetching a specific chart. 13 | public let chart: String 14 | 15 | /// The localized display name for the chart. 16 | public let name: String 17 | 18 | /// A relative cursor to fetch the next paginated results for the chart if more exist. 19 | public let next: String? 20 | 21 | /// The popularity-ordered music item type for the chart. 22 | public let items: [MusicItemType] 23 | 24 | enum CodingKeys: String, CodingKey { 25 | case chart, name 26 | case next 27 | case items = "data" 28 | } 29 | } 30 | 31 | public extension ChartItemCollection { 32 | /// A Boolean value that indicates whether the collection has information 33 | /// that allows it to fetch a subsequent batch of items. 34 | var hasNextBatch: Bool { 35 | next == nil 36 | } 37 | } 38 | 39 | extension ChartItemCollection where MusicItemType: MChartItem {} 40 | 41 | extension ChartItemCollection: Decodable where MusicItemType: Decodable {} 42 | 43 | extension ChartItemCollection: Encodable where MusicItemType: Encodable {} 44 | 45 | extension ChartItemCollection: Equatable, Hashable { 46 | public static func == (lhs: ChartItemCollection, rhs: ChartItemCollection) -> Bool { 47 | lhs.chart == rhs.chart 48 | } 49 | 50 | public func hash(into hasher: inout Hasher) { 51 | hasher.combine(chart) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Catalog/CatalogChart/MChartItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MChartItem.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 23/04/22. 6 | // 7 | 8 | /// A protocol for music items that your app can fetch by 9 | /// using a catalog chart request. 10 | public protocol MChartItem {} 11 | 12 | extension MChartItem { 13 | static var objectIdentifier: ObjectIdentifier { 14 | ObjectIdentifier(Self.self) 15 | } 16 | } 17 | 18 | extension Song: MChartItem {} 19 | 20 | extension Playlist: MChartItem {} 21 | 22 | extension MusicVideo: MChartItem {} 23 | 24 | extension Album: MChartItem {} 25 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Catalog/CatalogChart/MChartRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MChartRequest.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 26/03/22. 6 | // 7 | 8 | import Foundation 9 | 10 | enum MChartType: String { 11 | case cityCharts 12 | case dailyGlobalTopCharts 13 | } 14 | 15 | /// A chart request that your app uses to fetch charts from the Apple Music catalog 16 | /// using the types of charts and for the genre identifier. 17 | struct MChartRequest { 18 | /// The identifier for the genre to use in the chart results. 19 | var genre: MusicItemID? 20 | 21 | /// A limit for the number of items to return 22 | /// in the catalog chart response. 23 | var limit: Int? 24 | 25 | var chartType: [MChartType]? 26 | 27 | /// Creates a request to fetch charts using the list of the 28 | /// types of charts to include in the results. 29 | init(types: [MChartItem.Type]) { 30 | self.types = Set(types.map { $0.objectIdentifier }).compactMap { 31 | switch $0 { 32 | case Song.objectIdentifier: 33 | return "songs" 34 | case Album.objectIdentifier: 35 | return "albums" 36 | case MusicVideo.objectIdentifier: 37 | return "music-videos" 38 | case Playlist.objectIdentifier: 39 | return "playlists" 40 | default: 41 | return nil 42 | } 43 | }.joined(separator: ",") 44 | } 45 | 46 | /// Fetches charts of the requested catalog chart types that match 47 | /// the genre identifier of the request. 48 | func response() async throws -> MChartResponse { 49 | let storefront = try await MusicDataRequest.currentCountryCode 50 | let url = try chartsURL(storefront: storefront) 51 | let request = MusicDataRequest(urlRequest: .init(url: url)) 52 | let response = try await request.response() 53 | 54 | let charts = try JSONDecoder().decode(MCharts.self, from: response.data) 55 | return charts.results 56 | } 57 | 58 | private var types: String 59 | } 60 | 61 | extension MChartRequest { 62 | internal func chartsURL(storefront: String) throws -> URL { 63 | var components = AppleMusicURLComponents() 64 | var queryItems: [URLQueryItem] = [] 65 | components.path = "catalog/\(storefront)/charts" 66 | 67 | queryItems.append(URLQueryItem(name: "types", value: types)) 68 | 69 | if let genre = genre { 70 | queryItems.append(URLQueryItem(name: "genre", value: genre.rawValue)) 71 | } 72 | 73 | if let limit = limit { 74 | queryItems.append(URLQueryItem(name: "limit", value: "\(limit)")) 75 | } 76 | 77 | if let chartType = chartType, !chartType.isEmpty { 78 | let value = chartType.map { $0.rawValue }.joined(separator: ",") 79 | queryItems.append(URLQueryItem(name: "with", value: value)) 80 | } 81 | 82 | components.queryItems = queryItems 83 | 84 | guard let url = components.url else { 85 | throw URLError(.badURL) 86 | } 87 | return url 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Catalog/CatalogChart/MCharts.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MCharts.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 23/04/22. 6 | // 7 | 8 | import Foundation 9 | 10 | /// The response to a request for a chart. 11 | /// 12 | /// This structure represents the response from the Apple Music API when requesting chart data. 13 | /// It contains the results of the chart request, which can include various types of music items like songs, albums, and playlists. 14 | /// 15 | /// Example usage: 16 | /// ```swift 17 | /// let charts = try await MCatalog.charts(kinds: .dailyGlobalTop, types: .songs) 18 | /// print(charts.results.songs) 19 | /// ``` 20 | struct MCharts: Codable { 21 | /// A mapping of a requested type to an array of charts. 22 | let results: MChartResponse 23 | } 24 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Catalog/CatalogRadioShow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CatalogRadioShow.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 10/04/22. 6 | // 7 | 8 | @available(iOS 15.4, macOS 12.3, tvOS 15.4, watchOS 9.0, visionOS 1.0, *) 9 | public extension MCatalog { 10 | /// Fetch a radio show from the Apple Music catalog by using its identifier. 11 | /// - Parameters: 12 | /// - id: The unique identifier for the radio show. 13 | /// - properties: Additional relationships to fetch with the radio show. 14 | /// - Returns: `RadioShow` matching the given identifier. 15 | static func radioShow(id: MusicItemID, fetch properties: RadioShowProperties) async throws -> RadioShow { 16 | var request = MusicCatalogResourceRequest(matching: \.id, equalTo: id) 17 | request.properties = properties 18 | let response = try await request.response() 19 | 20 | guard let radioShow = response.items.first else { 21 | throw MusadoraKitError.notFound(for: id.rawValue) 22 | } 23 | return radioShow 24 | } 25 | 26 | /// Fetch a radio show from the Apple Music catalog by using its identifier with all properties. 27 | /// - Parameters: 28 | /// - id: The unique identifier for the radio show. 29 | /// - Returns: `RadioShow` matching the given identifier. 30 | static func radioShow(id: MusicItemID) async throws -> RadioShow { 31 | var request = MusicCatalogResourceRequest(matching: \.id, equalTo: id) 32 | request.properties = .all 33 | let response = try await request.response() 34 | 35 | guard let radioShow = response.items.first else { 36 | throw MusadoraKitError.notFound(for: id.rawValue) 37 | } 38 | return radioShow 39 | } 40 | 41 | /// Fetch multiple radio shows from the Apple Music catalog by using their identifiers. 42 | /// - Parameters: 43 | /// - ids: The unique identifiers for the radio shows. 44 | /// - properties: Additional relationships to fetch with the radio shows. 45 | /// - Returns: `RadioShows` matching the given identifiers. 46 | static func radioShows(ids: [MusicItemID], fetch properties: RadioShowProperties) async throws -> RadioShows { 47 | var request = MusicCatalogResourceRequest(matching: \.id, memberOf: ids) 48 | request.properties = properties 49 | let response = try await request.response() 50 | return response.items 51 | } 52 | 53 | /// Fetch multiple radio shows from the Apple Music catalog by using their identifiers. 54 | /// - Parameters: 55 | /// - ids: The unique identifiers for the radio shows. 56 | /// - Returns: `RadioShows` matching the given identifiers. 57 | static func radioShows(ids: [MusicItemID]) async throws -> RadioShows { 58 | var request = MusicCatalogResourceRequest(matching: \.id, memberOf: ids) 59 | request.properties = .all 60 | let response = try await request.response() 61 | return response.items 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Catalog/CatalogSuggestions/MCatalogSuggestionsResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MCatalogSuggestionsResponse.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 09/04/22. 6 | // 7 | 8 | import Foundation 9 | 10 | /// An object that contains results for a catalog suggestions request. 11 | /// 12 | /// This structure provides access to search suggestions and top results based on 13 | /// a user's search term, helping users discover content more efficiently. 14 | /// 15 | /// Example usage: 16 | /// ```swift 17 | /// let request = MCatalogSuggestionsRequest(term: "taylor") 18 | /// let response = try await request.response() 19 | /// 20 | /// // Access search term suggestions 21 | /// for term in response.terms { 22 | /// print(term.displayTerm) 23 | /// } 24 | /// 25 | /// // Access top results 26 | /// for result in response.topResults { 27 | /// print(result.title) 28 | /// } 29 | /// ``` 30 | @available(iOS 15.4, macOS 12.3, tvOS 15.4, watchOS 9.0, visionOS 1.0, *) 31 | struct MCatalogSuggestionsResponse { 32 | /// A collection of search and display terms. 33 | var terms: [TermSuggestion] = [] 34 | 35 | /// A collection of different top results. 36 | var topResults: [TopResultsSuggestion] = [] 37 | } 38 | 39 | @available(iOS 15.4, macOS 12.3, tvOS 15.4, watchOS 9.0, visionOS 1.0, *) 40 | extension MCatalogSuggestionsResponse: Codable { 41 | private enum CodingKeys: String, CodingKey { 42 | case suggestions 43 | } 44 | 45 | init(from decoder: Decoder) throws { 46 | let container = try decoder.container(keyedBy: CodingKeys.self) 47 | let suggestions = try container.decode([SuggestionKind].self, forKey: .suggestions) 48 | 49 | for suggestion in suggestions { 50 | switch suggestion { 51 | case let .terms(term): 52 | terms.append(term) 53 | 54 | case let .topResults(topResult): 55 | topResults.append(topResult) 56 | } 57 | } 58 | } 59 | 60 | func encode(to _: Encoder) throws {} 61 | } 62 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Catalog/CatalogSuggestions/MusicCatalogSearchable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MusicCatalogSearchable.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 23/04/22. 6 | // 7 | 8 | @available(iOS 15.4, macOS 12.3, tvOS 15.4, watchOS 9.0, visionOS 1.0, *) 9 | extension MusicCatalogSearchable { 10 | static var identifier: ObjectIdentifier { 11 | ObjectIdentifier(Self.self) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Catalog/CatalogSuggestions/SearchSuggestionItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchSuggestionItem.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 23/04/22. 6 | // 7 | 8 | /// The top search suggestion types. 9 | /// Possible types: Albums, RadioShows, Artists, Curators, MusicVideos, Playlists, RecordLabels, Songs, Stations. 10 | @available(iOS 15.4, macOS 12.3, tvOS 15.4, watchOS 9.0, visionOS 1.0, *) 11 | public enum SearchSuggestionItem: Equatable, Hashable { 12 | case album(Album) 13 | case song(Song) 14 | case musicVideo(MusicVideo) 15 | case artist(Artist) 16 | case playlist(Playlist) 17 | case radioShow(RadioShow) 18 | case curator(Curator) 19 | case station(Station) 20 | case recordLabel(RecordLabel) 21 | } 22 | 23 | @available(iOS 15.4, macOS 12.3, tvOS 15.4, watchOS 9.0, visionOS 1.0, *) 24 | extension SearchSuggestionItem: Codable { 25 | enum CodingKeys: CodingKey { 26 | case type 27 | } 28 | 29 | public init(from decoder: Decoder) throws { 30 | let values = try decoder.container(keyedBy: CodingKeys.self) 31 | let type = try values.decode(String.self, forKey: .type) 32 | 33 | switch type { 34 | case "albums": 35 | let album = try Album(from: decoder) 36 | self = .album(album) 37 | case "songs": 38 | let song = try Song(from: decoder) 39 | self = .song(song) 40 | case "stations": 41 | let station = try Station(from: decoder) 42 | self = .station(station) 43 | case "music-videos": 44 | let musicVideo = try MusicVideo(from: decoder) 45 | self = .musicVideo(musicVideo) 46 | case "playlists": 47 | let playlist = try Playlist(from: decoder) 48 | self = .playlist(playlist) 49 | case "record-labels": 50 | let recordLabel = try RecordLabel(from: decoder) 51 | self = .recordLabel(recordLabel) 52 | case "artists": 53 | let artist = try Artist(from: decoder) 54 | self = .artist(artist) 55 | case "apple-curators": 56 | let radioShow = try RadioShow(from: decoder) 57 | self = .radioShow(radioShow) 58 | case "curators": 59 | let curator = try Curator(from: decoder) 60 | self = .curator(curator) 61 | default: 62 | let decodingErrorContext = DecodingError.Context( 63 | codingPath: decoder.codingPath, 64 | debugDescription: "Unexpected type \"\(type)\" encountered for SearchSuggestionItem." 65 | ) 66 | throw DecodingError.typeMismatch(SearchSuggestionItem.self, decodingErrorContext) 67 | } 68 | } 69 | 70 | public func encode(to _: Encoder) throws {} 71 | } 72 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Catalog/CatalogSuggestions/SuggestionKind.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SuggestionKind.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 23/04/22. 6 | // 7 | 8 | /// The suggestion kinds to include in the results. 9 | @available(iOS 15.4, macOS 12.3, tvOS 15.4, watchOS 9.0, visionOS 1.0, *) 10 | public enum SuggestionKind: Codable { 11 | case terms(TermSuggestion) 12 | case topResults(TopResultsSuggestion) 13 | 14 | private enum CodingKeys: String, CodingKey { 15 | case kind 16 | } 17 | 18 | public init(from decoder: Decoder) throws { 19 | let container = try decoder.container(keyedBy: CodingKeys.self) 20 | let kind = try container.decode(SuggestionsKind.self, forKey: .kind) 21 | 22 | switch kind { 23 | case .terms: 24 | let termSuggestion = try TermSuggestion(from: decoder) 25 | self = .terms(termSuggestion) 26 | case .topResults: 27 | let topResultsSuggestion = try TopResultsSuggestion(from: decoder) 28 | self = .topResults(topResultsSuggestion) 29 | } 30 | } 31 | 32 | public func encode(to _: Encoder) throws {} 33 | } 34 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Catalog/CatalogSuggestions/Suggestions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Suggestions.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 23/04/22. 6 | // 7 | 8 | import Foundation 9 | 10 | /// The response to a request for search suggestions. 11 | @available(iOS 15.4, macOS 12.3, tvOS 15.4, watchOS 9.0, visionOS 1.0, *) 12 | public struct Suggestions: Codable { 13 | /// The results included in the response to a request for search suggestions. 14 | let results: MCatalogSuggestionsResponse 15 | } 16 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Catalog/CatalogSuggestions/SuggestionsKind.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SuggestionsKind.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 23/04/22. 6 | // 7 | 8 | /// The suggestion kinds to include in the results. 9 | @available(iOS 15.4, macOS 12.3, tvOS 15.4, visionOS 1.0, *) 10 | public enum SuggestionsKind: String, Codable { 11 | case terms 12 | case topResults 13 | } 14 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Catalog/CatalogSuggestions/TermSuggestion.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TermSuggestion.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 23/04/22. 6 | // 7 | 8 | /// A suggested search term from a search suggestion response. 9 | @available(iOS 15.4, macOS 12.3, tvOS 15.4, watchOS 9.0, visionOS 1.0, *) 10 | public struct TermSuggestion: Codable, Equatable, Hashable { 11 | /// The kind of suggestion. 12 | /// Value: terms 13 | public let kind: SuggestionsKind 14 | 15 | /// The term to use as a search input when using this suggestion. 16 | public let searchTerm: String 17 | 18 | /// A potentially censored term to display to the user to select from. Use the `searchTerm` value for the actual search. 19 | public let displayTerm: String 20 | } 21 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Catalog/CatalogSuggestions/TopResultsSuggestion.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TopResultsSuggestion.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 23/04/22. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A suggested popular result for similar search prefix terms. 11 | @available(iOS 15.4, macOS 12.3, tvOS 15.4, watchOS 9.0, visionOS 1.0, *) 12 | public struct TopResultsSuggestion: Codable, Equatable, Hashable { 13 | /// The kind of suggestion. 14 | /// Value: topResults 15 | public let kind: SuggestionsKind 16 | 17 | /// The actual resource for the suggested content. 18 | /// Possible types: Albums, RadioShows, Artists, Curators, MusicVideos, Playlists, RecordLabels, Songs, Stations. 19 | public let content: SearchSuggestionItem 20 | } 21 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Catalog/MCatalog.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MCatalog.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 22/12/22. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Structure containing the methods related to the Apple Music catalog. 11 | /// 12 | /// `MCatalog` provides an interface for interacting with the Apple Music catalog. 13 | /// It includes methods for searching, fetching, and managing catalog items like songs, albums, playlists, and more. 14 | /// 15 | /// Example usage: 16 | /// ```swift 17 | /// // Fetch a song by its identifier 18 | /// let song = try await MCatalog.song(id: "1613834314") 19 | /// 20 | /// // Search the catalog 21 | /// let results = try await MCatalog.search(for: "the weeknd", types: [.songs, .albums]) 22 | /// ``` 23 | public struct MCatalog { 24 | } 25 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Documentation.docc/Tutorials/table-of-contents.tutorial: -------------------------------------------------------------------------------- 1 | @Tutorials(name: "MusadoraKit") { 2 | @Intro(title: "Learn MusadoraKit") { 3 | Step-by-step guides to help you integrate MusadoraKit into your apps and unlock the full power of Apple Music. 4 | } 5 | 6 | @Chapter(name: "Getting Started") { 7 | @Image(source: "icon.png", alt: "MusadoraKit Icon") 8 | 9 | @TutorialReference(tutorial: "doc:Getting-Started") 10 | } 11 | 12 | @Chapter(name: "Core Features") { 13 | @Image(source: "icon.png", alt: "MusadoraKit Icon") 14 | 15 | @TutorialReference(tutorial: "doc:Working-with-Library") 16 | @TutorialReference(tutorial: "doc:Search-and-Discovery") 17 | } 18 | 19 | @Chapter(name: "Advanced Topics") { 20 | @Image(source: "icon.png", alt: "MusadoraKit Icon") 21 | 22 | @TutorialReference(tutorial: "doc:Building-Music-Player") 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Documentation.docc/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rryam/MusadoraKit/28ae04c064cea01cae18f18d470e230325b8fbe4/Sources/MusadoraKit/Documentation.docc/icon.png -------------------------------------------------------------------------------- /Sources/MusadoraKit/Equivalents/CatalogCleanEquivalent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CatalogCleanEquivalent.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 18/03/23. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension EquivalentRequestable { 11 | /// Returns a clean version of the music item. 12 | /// 13 | /// Example usage: 14 | /// 15 | /// let album = try await Album(id: "123").clean 16 | /// let song = try await Song(id: "456").clean 17 | /// let musicVideo = try await MusicVideo(id: "789").clean 18 | /// 19 | var clean: Self { 20 | get async throws { 21 | let path = try EquivalentMusicItemType.path(for: Self.self) 22 | let storefront = try await MusicDataRequest.currentCountryCode 23 | 24 | let url = try cleanEquivalentURL(storefront: storefront, path: path) 25 | let request = MusicDataRequest(urlRequest: .init(url: url)) 26 | let response = try await request.response() 27 | let items = try JSONDecoder().decode(MusicItemCollection.self, from: response.data) 28 | 29 | guard let item = items.first else { 30 | throw MusadoraKitError.notFound(for: id.rawValue) 31 | } 32 | return item 33 | } 34 | } 35 | 36 | internal func cleanEquivalentURL(storefront: String, path: EquivalentMusicItemType) throws -> URL { 37 | var components = AppleMusicURLComponents() 38 | 39 | components.path = "catalog/\(storefront)/\(path.rawValue)" 40 | 41 | let filterEquivalentsQuery = URLQueryItem(name: "filter[equivalents]", value: id.rawValue) 42 | let restrictExplicitQuery = URLQueryItem(name: "restrict", value: "explicit") 43 | components.queryItems = [filterEquivalentsQuery, restrictExplicitQuery] 44 | 45 | guard let url = components.url else { 46 | throw URLError(.badURL) 47 | } 48 | 49 | return url 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Equivalents/CatalogCleanEquivalents.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CatalogCleanEquivalents.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 18/03/23. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension MusicItemCollection where MusicItemType: EquivalentRequestable { 11 | 12 | /// Returns a clean version of the music items. 13 | /// 14 | /// Example usage: 15 | /// 16 | /// let albums: MusicItemCollection = ... 17 | /// let cleanAlbums = try await albums.clean 18 | var clean: Self { 19 | get async throws { 20 | let path = try EquivalentMusicItemType.path(for: MusicItemType.self) 21 | let storefront = try await MusicDataRequest.currentCountryCode 22 | 23 | let url = try cleanEquivalentsURL(storefront: storefront, path: path) 24 | let request = MusicDataRequest(urlRequest: .init(url: url)) 25 | let response = try await request.response() 26 | let items = try JSONDecoder().decode(MusicItemCollection.self, from: response.data) 27 | return items 28 | } 29 | } 30 | 31 | internal func cleanEquivalentsURL(storefront: String, path: EquivalentMusicItemType) throws -> URL { 32 | var components = AppleMusicURLComponents() 33 | components.path = "catalog/\(storefront)/\(path.rawValue)" 34 | 35 | let ids = self.map { $0.id.rawValue }.joined(separator: ",") 36 | let filterEquivalentsQuery = URLQueryItem(name: "filter[equivalents]", value: ids) 37 | let restrictExplicitQuery = URLQueryItem(name: "restrict", value: "explicit") 38 | 39 | components.queryItems = [filterEquivalentsQuery, restrictExplicitQuery] 40 | 41 | guard let url = components.url else { 42 | throw URLError(.badURL) 43 | } 44 | return url 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Equivalents/CatalogEquivalents.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CatalogEquivalents.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 18/03/23. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension MusicItemCollection where MusicItemType: EquivalentRequestable { 11 | 12 | /// Fetches the equivalent music items for the given storefront. 13 | /// 14 | /// Example usage: 15 | /// 16 | /// let albums: MusicItemCollection = ... 17 | /// let equivalentAlbums = try await albums.equivalents(for: "us") 18 | /// 19 | /// - Parameters: 20 | /// - targetStorefront: A string representing the storefront for which the equivalent music items should be fetched. 21 | /// 22 | /// - Returns: A collection of equivalent music items for the given storefront. 23 | func equivalents(for targetStorefront: String) async throws -> MusicItemCollection { 24 | let path = try EquivalentMusicItemType.path(for: MusicItemType.self) 25 | 26 | let url = try equivalentsURL(storefront: targetStorefront, path: path) 27 | let request = MusicDataRequest(urlRequest: .init(url: url)) 28 | let response = try await request.response() 29 | let items = try JSONDecoder().decode(MusicItemCollection.self, from: response.data) 30 | return items 31 | } 32 | 33 | internal func equivalentsURL(storefront: String, path: EquivalentMusicItemType) throws -> URL { 34 | var components = AppleMusicURLComponents() 35 | 36 | components.path = "catalog/\(storefront)/\(path.rawValue)" 37 | let ids = self.map { $0.id.rawValue }.joined(separator: ",") 38 | components.queryItems = [URLQueryItem(name: "filter[equivalents]", value: ids)] 39 | 40 | guard let url = components.url else { 41 | throw URLError(.badURL) 42 | } 43 | return url 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Equivalents/EquivalentMusicItemType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EquivalentMusicItemType.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 18/03/23. 6 | // 7 | 8 | import Foundation 9 | import MusicKit 10 | /// An enum representing the different types of equivalent music items. 11 | enum EquivalentMusicItemType: String, Codable { 12 | case songs 13 | case albums 14 | case musicVideos = "music-videos" 15 | } 16 | 17 | extension EquivalentMusicItemType { 18 | 19 | /// Returns the equivalent music item type for the given type. 20 | /// 21 | /// - Parameter item: A type that conforms to `EquivalentRequestable`. 22 | /// 23 | /// - Throws: An error if the item type is not recognized. 24 | /// 25 | /// - Returns: The equivalent music item type for the given type. 26 | static func path(for item: EquivalentRequestable.Type) throws -> Self { 27 | let path: EquivalentMusicItemType 28 | 29 | switch item { 30 | case is Song.Type: path = .songs 31 | case is Album.Type: path = .albums 32 | case is MusicVideo.Type: path = .musicVideos 33 | default: throw NSError(domain: "Wrong equivalent music item type.", code: 0) 34 | } 35 | return path 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Equivalents/EquivalentRequestable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EquivalentRequestable.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 18/03/23. 6 | // 7 | 8 | /// A protocol for music items that your app can fetch by 9 | /// using a equivalent request. 10 | public protocol EquivalentRequestable: MusicItem, Codable { 11 | } 12 | 13 | extension Album: EquivalentRequestable { 14 | } 15 | 16 | extension Song: EquivalentRequestable { 17 | } 18 | 19 | extension MusicVideo: EquivalentRequestable { 20 | } 21 | 22 | /// A protocol indicating that a music item can be fetched from a specific storefront using its identifier. 23 | /// Conforming types should provide the resource path component used in the Apple Music API URL. 24 | public protocol StorefrontRequestable: MusicItem, Decodable { 25 | 26 | /// The resource path component used in the Apple Music API URL. 27 | /// For example, for songs, this would be "songs". 28 | static var resourcePath: String { get } 29 | } 30 | 31 | extension Song: StorefrontRequestable { 32 | public static var resourcePath: String { "songs" } 33 | } 34 | 35 | extension Album: StorefrontRequestable { 36 | public static var resourcePath: String { "albums" } 37 | } 38 | 39 | extension MusicVideo: StorefrontRequestable { 40 | public static var resourcePath: String { "music-videos" } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Extension/Array.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Array.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 01/08/22. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Array where Element: Equatable { 11 | 12 | /// Returns a new array with the duplicate elements removed. 13 | /// 14 | /// - Returns: A new array with the duplicate elements removed. 15 | /// 16 | /// - Note: This method has O(n²) time performance, but is optimized for arrays with no more than 10 elements. 17 | func removeDuplicates() -> Self { 18 | reduce(into: []) { result, element in 19 | if !result.contains(element) { 20 | result.append(element) 21 | } 22 | } 23 | } 24 | } 25 | 26 | extension Array where Element == String { 27 | 28 | /// Returns an array of arrays, each containing at most the specified number of elements. 29 | /// 30 | /// - Parameter size: The maximum number of elements in each chunk. 31 | /// 32 | /// - Returns: An array of arrays, each containing at most the specified number of elements. 33 | func chunked(into size: Int) -> [[Element]] { 34 | return stride(from: 0, to: count, by: size).map { 35 | Array(self[$0 ..< Swift.min($0 + size, count)]) 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Extension/Artwork.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Artwork.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 10/14/24. 6 | // 7 | 8 | #if canImport(UIKit) 9 | import UIKit 10 | #elseif canImport(AppKit) 11 | import AppKit 12 | #endif 13 | 14 | import Foundation 15 | import SwiftUI 16 | 17 | extension Artwork { 18 | 19 | /// Fetches the artwork image and extracts prominent colors from it. 20 | /// 21 | /// This function downloads the artwork image from a specified URL, processes it to extract 22 | /// the most prominent colors, and returns them as an array of SwiftUI `Color` objects. 23 | /// 24 | /// - Parameters: 25 | /// - width: The desired width of the artwork image. 26 | /// - height: The desired height of the artwork image. 27 | /// - numberOfColors: The number of prominent colors to extract. Default is 9. 28 | /// 29 | /// - Returns: An array of SwiftUI `Color` objects representing the prominent colors. 30 | /// 31 | /// - Throws: An error if the image cannot be fetched or processed. 32 | public func fetchColors(width: Int, height: Int, numberOfColors: Int = 9) async throws -> [Color] { 33 | guard let imageURL = self.url(width: width, height: height) else { 34 | throw NSError(domain: "Invalid artwork URL", code: 0, userInfo: nil) 35 | } 36 | 37 | let (data, _) = try await URLSession.shared.data(from: imageURL) 38 | 39 | #if canImport(UIKit) 40 | guard let image = UIImage(data: data) else { 41 | throw NSError(domain: "Invalid image data", code: 0, userInfo: nil) 42 | } 43 | #elseif canImport(AppKit) 44 | guard let image = NSImage(data: data) else { 45 | throw NSError(domain: "Invalid image data", code: 0, userInfo: nil) 46 | } 47 | #endif 48 | 49 | let colors = try image.extractColors(numberOfColors: numberOfColors) 50 | return colors.map { Color($0) } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Extension/Data.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Data.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 14/08/21. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Data { 11 | 12 | /// Returns a formatted JSON string representation of the data. 13 | /// 14 | /// - Throws: An error if the data cannot be decoded. 15 | /// 16 | /// - Returns: A formatted JSON string representation of the data. 17 | func printJSON() throws -> String { 18 | let json = try JSONSerialization.jsonObject(with: self, options: []) 19 | let data = try JSONSerialization.data(withJSONObject: json, options: .prettyPrinted) 20 | 21 | guard let jsonString = String(data: data, encoding: .utf8) else { 22 | throw URLError(.cannotDecodeRawData) 23 | } 24 | return jsonString 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Extension/NSColor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSColor.swift 3 | // MusadoraKit 4 | // 5 | // Created by AI Assistant on 09/02/25. 6 | // 7 | 8 | #if os(macOS) 9 | import AppKit 10 | 11 | extension NSColor { 12 | 13 | /// Converts the NSColor to its hexadecimal string representation. 14 | /// 15 | /// This property provides a convenient way to obtain the hexadecimal string 16 | /// representation of an NSColor. It attempts to convert the color to the sRGB 17 | /// color space before calculating the hex value. If the conversion fails, 18 | /// it returns a default black color hex string. 19 | /// 20 | /// - Returns: A string representing the color in hexadecimal format (#RRGGBB). 21 | /// Returns "#000000" (black) if the color cannot be converted to sRGB. 22 | /// 23 | /// - Note: The returned string is always uppercase and includes the "#" prefix. 24 | var hexString: String { 25 | guard let rgbColor = usingColorSpace(.sRGB) else { 26 | return "#000000" 27 | } 28 | 29 | let r = Int(round(rgbColor.redComponent * 255)) 30 | let g = Int(round(rgbColor.greenComponent * 255)) 31 | let b = Int(round(rgbColor.blueComponent * 255)) 32 | 33 | return String(format: "#%02X%02X%02X", r, g, b) 34 | } 35 | } 36 | #endif 37 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Extension/NSImage.swift: -------------------------------------------------------------------------------- 1 | #if os(macOS) 2 | import AppKit 3 | 4 | extension NSImage { 5 | 6 | /// Extracts the most prominent and unique colors from the image. 7 | /// 8 | /// - Parameter numberOfColors: The number of prominent colors to extract (default is 4). 9 | /// - Returns: An array of NSColors representing the prominent colors. 10 | func extractColors(numberOfColors: Int = 4) throws -> [NSColor] { 11 | guard self.cgImage(forProposedRect: nil, context: nil, hints: nil) != nil else { 12 | throw ImageProcessingError.invalidImage 13 | } 14 | 15 | let size = CGSize(width: 200, height: 200 * self.size.height / self.size.width) 16 | let resizedImage = NSImage(size: size) 17 | resizedImage.lockFocus() 18 | self.draw(in: NSRect(origin: .zero, size: size), from: .zero, operation: .copy, fraction: 1.0) 19 | resizedImage.unlockFocus() 20 | 21 | guard let resizedCGImage = resizedImage.cgImage(forProposedRect: nil, context: nil, hints: nil) else { 22 | throw ImageProcessingError.resizeFailed 23 | } 24 | 25 | let colors = try CommonImageProcessing.extractColors(from: resizedCGImage, numberOfColors: numberOfColors) 26 | return colors.map { NSColor(cgColor: $0)! } 27 | } 28 | } 29 | #endif 30 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Extension/UIColor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIColor.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 10/14/24. 6 | // 7 | 8 | #if os(iOS) || os(tvOS) || os(watchOS) || os(visionOS) 9 | import UIKit 10 | 11 | extension UIColor { 12 | /// Converts the UIColor to its hexadecimal string representation. 13 | /// 14 | /// This property provides a convenient way to obtain the hexadecimal string 15 | /// representation of a UIColor. It uses the color's RGB components to calculate 16 | /// the hex value. 17 | /// 18 | /// - Returns: A string representing the color in hexadecimal format (#RRGGBB). 19 | /// Returns "#000000" (black) if the color components cannot be retrieved. 20 | /// 21 | /// - Note: The returned string is always uppercase and includes the "#" prefix. 22 | var hexString: String { 23 | guard let components = self.cgColor.components, components.count >= 3 else { 24 | return "#000000" 25 | } 26 | 27 | let r = Int(components[0] * 255.0) 28 | let g = Int(components[1] * 255.0) 29 | let b = Int(components[2] * 255.0) 30 | 31 | return String(format: "#%02X%02X%02X", r, g, b) 32 | } 33 | } 34 | #endif 35 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Extension/UIImage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIImage.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 10/14/24. 6 | // 7 | 8 | #if os(iOS) || os(tvOS) || os(visionOS) || os(watchOS) 9 | import UIKit 10 | 11 | extension UIImage { 12 | 13 | /// Extracts the most prominent and unique colors from the image. 14 | /// 15 | /// - Parameter numberOfColors: The number of prominent colors to extract (default is 4). 16 | /// - Returns: An array of UIColors representing the prominent colors. 17 | func extractColors(numberOfColors: Int = 4) throws -> [UIColor] { 18 | let size = CGSize(width: 200, height: 200 * self.size.height / self.size.width) 19 | UIGraphicsBeginImageContext(size) 20 | self.draw(in: CGRect(origin: .zero, size: size)) 21 | guard let resizedImage = UIGraphicsGetImageFromCurrentImageContext() else { 22 | UIGraphicsEndImageContext() 23 | throw ImageProcessingError.resizeFailed 24 | } 25 | UIGraphicsEndImageContext() 26 | 27 | let colors = try CommonImageProcessing.extractColors(from: resizedImage.cgImage!, numberOfColors: numberOfColors) 28 | return colors.map { UIColor(cgColor: $0) } 29 | } 30 | } 31 | #endif 32 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Favorites/Favorites.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Favorites.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 09/02/25. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension MCatalog { 11 | 12 | // MARK: - Public API 13 | 14 | /// Add a song to the user's favorites. 15 | /// 16 | /// - Parameter song: The song to add to favorites. 17 | /// - Returns: A boolean value indicating whether the operation was successful. 18 | @discardableResult static func favorite(song: Song) async throws -> Bool { 19 | try await addToFavorites(id: song.id) 20 | } 21 | 22 | /// Add an album to the user's favorites. 23 | /// 24 | /// - Parameter album: The album to add to favorites. 25 | /// - Returns: A boolean value indicating whether the operation was successful. 26 | @discardableResult static func favorite(album: Album) async throws -> Bool { 27 | try await addToFavorites(id: album.id) 28 | } 29 | 30 | /// Add a playlist to the user's favorites. 31 | /// 32 | /// - Parameter playlist: The playlist to add to favorites. 33 | /// - Returns: A boolean value indicating whether the operation was successful. 34 | @discardableResult static func favorite(playlist: Playlist) async throws -> Bool { 35 | try await addToFavorites(id: playlist.id) 36 | } 37 | 38 | /// Add an artist to the user's favorites. 39 | /// 40 | /// - Parameter artist: The artist to add to favorites. 41 | /// - Returns: A boolean value indicating whether the operation was successful. 42 | @discardableResult static func favorite(artist: Artist) async throws -> Bool { 43 | try await addToFavorites(id: artist.id) 44 | } 45 | 46 | /// Add a music video to the user's favorites. 47 | /// 48 | /// - Parameter musicVideo: The music video to add to favorites. 49 | /// - Returns: A boolean value indicating whether the operation was successful. 50 | @discardableResult static func favorite(musicVideo: MusicVideo) async throws -> Bool { 51 | try await addToFavorites(id: musicVideo.id) 52 | } 53 | 54 | /// Add a station to the user's favorites. 55 | /// 56 | /// - Parameter station: The station to add to favorites. 57 | /// - Returns: A boolean value indicating whether the operation was successful. 58 | @discardableResult static func favorite(station: Station) async throws -> Bool { 59 | try await addToFavorites(id: station.id) 60 | } 61 | 62 | 63 | // MARK: - Internal Implementation 64 | 65 | /// Internal method to add a resource to favorites by ID. 66 | /// 67 | /// - Parameter id: The identifier of the music item to add to favorites. 68 | /// - Returns: A boolean value indicating whether the operation was successful. 69 | @discardableResult static func addToFavorites(id: MusicItemID) async throws -> Bool { 70 | let request = MFavoritesRequest(itemID: id) 71 | return try await request.response() 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Favorites/MFavoritesRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MFavoritesRequest.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 09/02/25. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A request that your app uses to add resources to favorites. 11 | struct MFavoritesRequest { 12 | private var itemIDs: [MusicItemID] 13 | 14 | /// Creates a request to add resources to favorites. 15 | /// 16 | /// - Parameter itemIDs: The IDs of the items to add to favorites. 17 | /// Supports heterogeneous types (songs, albums, playlists, artists, etc.) 18 | init(itemIDs: [MusicItemID]) { 19 | self.itemIDs = itemIDs 20 | } 21 | 22 | /// Creates a request to add a single resource to favorites. 23 | /// 24 | /// - Parameter itemID: The ID of the item to add to favorites. 25 | init(itemID: MusicItemID) { 26 | self.itemIDs = [itemID] 27 | } 28 | 29 | /// Executes the request to add items to favorites and returns 30 | /// a boolean value indicating the success of the operation. 31 | func response() async throws -> Bool { 32 | let url = try favoritesEndpointURL 33 | let request = MDataPostRequest(url: url) 34 | let response = try await request.response() 35 | return response.urlResponse.statusCode == 202 36 | } 37 | } 38 | 39 | extension MFavoritesRequest { 40 | internal var favoritesEndpointURL: URL { 41 | get throws { 42 | var components = AppleMusicURLComponents() 43 | var queryItems: [URLQueryItem] = [] 44 | components.path = "me/favorites" 45 | 46 | // Join all item IDs with commas as per official API spec 47 | let idsString = itemIDs.map { $0.rawValue }.joined(separator: ",") 48 | queryItems.append(URLQueryItem(name: "ids", value: idsString)) 49 | 50 | components.queryItems = queryItems 51 | 52 | guard let url = components.url else { 53 | throw URLError(.badURL) 54 | } 55 | 56 | return url 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/History/MHistory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MHistory.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 15/03/23. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Structure containing the methods for accessing historical data. 11 | /// 12 | /// `MHistory` provides access to a user's music listening history, including 13 | /// recently played items and recently added content. 14 | /// 15 | /// Example usage: 16 | /// ```swift 17 | /// // Fetch recently played items 18 | /// let recentlyPlayed = try await MHistory.recentlyPlayed() 19 | /// print(recentlyPlayed.songs) 20 | /// print(recentlyPlayed.albums) 21 | /// 22 | /// // Fetch recently added items 23 | /// let recentlyAdded = try await MHistory.recentlyAdded() 24 | /// print(recentlyAdded.playlists) 25 | /// ``` 26 | public struct MHistory { 27 | } 28 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/History/MHistoryEndpoints.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MHistoryEndpoints.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 02/04/22. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Different endpoints related to historial data. 11 | /// Possible types: Heavy rotation, recently added, and recently played resources. 12 | enum MHistoryEndpoints { 13 | case heavyRotation 14 | case recentlyAdded 15 | case recentlyPlayed 16 | case recentlyPlayedTracks 17 | case recentlyPlayedStations 18 | 19 | var path: String { 20 | switch self { 21 | case .heavyRotation: 22 | return "history/heavy-rotation" 23 | case .recentlyAdded: 24 | return "library/recently-added" 25 | case .recentlyPlayed: 26 | return "recent/played" 27 | case .recentlyPlayedTracks: 28 | return "recent/played/tracks" 29 | case .recentlyPlayedStations: 30 | return "recent/radio-stations" 31 | } 32 | } 33 | 34 | var maximumLimit: Int { 35 | switch self { 36 | case .heavyRotation, .recentlyPlayed, .recentlyPlayedStations: 37 | return 10 38 | case .recentlyAdded: 39 | return 25 40 | case .recentlyPlayedTracks: 41 | return 30 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/History/MHistoryResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MHistoryResponse.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 02/04/22. 6 | // 7 | 8 | /// An object that contains results for a history request. 9 | /// 10 | /// This structure provides access to a user's historical music activity, 11 | /// including recently played items and recently added content. 12 | /// 13 | /// Example usage: 14 | /// ```swift 15 | /// let request = MHistoryRequest() 16 | /// let response = try await request.response() 17 | /// 18 | /// // Access historical items by type 19 | /// print(response.albums) // Recently played albums 20 | /// print(response.tracks) // Recently played tracks 21 | /// print(response.playlists) // Recently played playlists 22 | /// ``` 23 | struct MHistoryResponse { 24 | /// A collection of historical resources based on the `MusicHistoryRequest`. 25 | let items: UserMusicItems 26 | 27 | /// A collection of historical albums. 28 | var albums: Albums { 29 | MusicItemCollection( 30 | items.compactMap { item in 31 | guard case let .album(album) = item else { return nil } 32 | return album 33 | }) 34 | } 35 | 36 | /// A collection of historical playlists. 37 | var playlists: Playlists { 38 | MusicItemCollection( 39 | items.compactMap { item in 40 | guard case let .playlist(playlist) = item else { return nil } 41 | return playlist 42 | }) 43 | } 44 | 45 | /// A collection of historical stations. 46 | var stations: Stations { 47 | MusicItemCollection( 48 | items.compactMap { item in 49 | guard case let .station(station) = item else { return nil } 50 | return station 51 | }) 52 | } 53 | 54 | /// A collection of historical tracks. 55 | var tracks: Tracks { 56 | MusicItemCollection( 57 | items.compactMap { item in 58 | guard case let .track(track) = item else { return nil } 59 | return track 60 | }) 61 | } 62 | } 63 | 64 | extension MHistoryResponse: Equatable, Hashable, Codable {} 65 | 66 | extension MHistoryResponse: CustomStringConvertible { 67 | var description: String { 68 | "MusicHistoryResponse(\(items.description)" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/History/MusicRecentlyAddedRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MusicRecentlyAddedRequest.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 30/06/22. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A protocol for music items that your app can fetch by 11 | /// using a recently added request. 12 | public protocol MRecentlyAddedRequestable: MusicItem { 13 | } 14 | 15 | /// A request that your app uses to fetch items the user has recently added. 16 | public struct MRecentlyAddedRequest 17 | where MusicItemType: MRecentlyAddedRequestable, MusicItemType: Decodable { 18 | 19 | /// Creates a request for items the user has recently added. 20 | public init() {} 21 | 22 | /// A limit for the number of items to return 23 | /// in the response that contains items the user has recently added. 24 | public var limit: Int? 25 | 26 | /// An offet for the request. 27 | public var offset: Int? 28 | 29 | /// A list of properties which the recently added request 30 | /// will fetch for each music item in the response. 31 | public var properties: [PartialMusicAsyncProperty] = [] 32 | 33 | /// Fetches items the user has recently added. 34 | // public func response() async throws -> MusicRecentlyAddedResponse { 35 | // return MusicRecentlyAddedResponse(items: .init(arrayLiteral: [])) 36 | // } 37 | } 38 | 39 | /// A response that contains items the user has recently added to their library. 40 | /// 41 | /// This structure provides access to a collection of music items that have been recently 42 | /// added to the user's Apple Music library. The items can be of any type that conforms 43 | /// to the `MRecentlyAddedRequestable` protocol. 44 | /// 45 | /// Example usage: 46 | /// ```swift 47 | /// let request = MRecentlyAddedRequest() 48 | /// 49 | /// do { 50 | /// let response = try await request.response() 51 | /// for song in response.items { 52 | /// print("Recently added song: \(song.title)") 53 | /// } 54 | /// } catch { 55 | /// print("Failed to fetch recently added items: \(error)") 56 | /// } 57 | /// ``` 58 | public struct MRecentlyAddedResponse where MusicItemType: MRecentlyAddedRequestable { 59 | 60 | /// A collection of items the user has recently added. 61 | /// 62 | /// This property contains the music items that were recently added to the user's library, 63 | /// ordered by the date they were added (most recent first). 64 | public let items: MusicItemCollection 65 | } 66 | 67 | extension MRecentlyAddedResponse: Sendable { 68 | } 69 | 70 | extension MRecentlyAddedResponse: Decodable where MusicItemType: Decodable { 71 | } 72 | 73 | extension MRecentlyAddedResponse: Encodable where MusicItemType: Encodable { 74 | } 75 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Library/LibraryCatalog.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LibraryCatalog.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 10/05/22. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension FilterableLibraryItem { 11 | 12 | /// The catalog version of the library item. 13 | var catalog: Self { 14 | get async throws { 15 | let path = try LibraryMusicItemType.path(for: Self.self) 16 | let url = try catalogURL(path: path) 17 | let decoder = JSONDecoder() 18 | 19 | if let userToken = MusadoraKit.userToken { 20 | let request = MUserRequest(urlRequest: .init(url: url), userToken: userToken) 21 | let data = try await request.response() 22 | let items = try decoder.decode(MusicItemCollection.self, from: data) 23 | 24 | guard let item = items.first else { 25 | throw MusadoraKitError.notFound(for: id.rawValue) 26 | } 27 | 28 | return item 29 | } else { 30 | let request = MusicDataRequest(urlRequest: URLRequest(url: url)) 31 | let response = try await request.response() 32 | let items = try decoder.decode(MusicItemCollection.self, from: response.data) 33 | 34 | guard let item = items.first else { 35 | throw MusadoraKitError.notFound(for: id.rawValue) 36 | } 37 | 38 | return item 39 | } 40 | } 41 | } 42 | 43 | internal func catalogURL(path: LibraryMusicItemType) throws -> URL { 44 | var components = AppleMusicURLComponents() 45 | components.path = "me/library/\(path.rawValue)/\(id.rawValue)/catalog" 46 | 47 | guard let url = components.url else { 48 | throw URLError(.badURL) 49 | } 50 | 51 | return url 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Library/LibraryGenre.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LibraryGenre.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 29/07/23. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension MLibrary { 11 | 12 | /// Fetch all genres from the user's library. 13 | /// 14 | /// Use this method to retrieve all the genres present in the user's music library. Example of the genres are Alternative, Rock, etc. 15 | /// 16 | /// Example usage: 17 | /// 18 | /// let genres = try await MLibrary.genres() 19 | /// for genre in genres { 20 | /// print("Genre: \(genre.name)") 21 | /// } 22 | /// // ... access other properties 23 | /// 24 | /// - Returns: A `Genres` collection containing all the genres present in the user's library. 25 | /// - Throws: An error if the retrieval fails, such as network connectivity issues or invalid parameters. 26 | /// 27 | /// - Note: This method fetches the genres locally from the device, 28 | /// and is faster because it uses the latest `MusicLibraryRequest` structure. 29 | @available(iOS 16.0, macOS 14.0, macCatalyst 17.0, tvOS 16.0, watchOS 9.0, visionOS 1.0, *) 30 | static func genres() async throws -> Genres { 31 | let request = MusicLibraryRequest() 32 | let response = try await request.response() 33 | return response.items 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Library/MLibrary.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MLibrary.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 24/12/22. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Structure containing the methods related to the user's library. 11 | /// 12 | /// `MLibrary` provides comprehensive access to a user's Apple Music library, 13 | /// including methods to fetch, search, and manage library content. 14 | /// 15 | /// Example usage: 16 | /// ```swift 17 | /// // Fetch all library songs 18 | /// let songs = try await MLibrary.songs() 19 | /// 20 | /// // Search the library 21 | /// let searchResults = try await MLibrary.search(for: "coldplay", types: [Song.self]) 22 | /// 23 | /// // Add a playlist to library 24 | /// try await MLibrary.addPlaylist(name: "My Playlist", description: "My favorite songs") 25 | /// ``` 26 | public struct MLibrary { 27 | } 28 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Library/MusicLibrarySearchable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MLibrarySearchable.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 08/09/21. 6 | // 7 | 8 | /// A protocol representing music items that can be fetched through a library search request. 9 | /// 10 | /// Adhere to this protocol to enable library searching functionality for custom music items. 11 | /// By default, the protocol provides a unique identifier for each type that conforms to it. 12 | /// 13 | /// Example: 14 | /// 15 | /// if let searchableItem = someMusicItem as? MLibrarySearchable { 16 | /// let identifier = searchableItem.searchIdentifier 17 | /// } 18 | /// 19 | public protocol MLibrarySearchable: MusicItem {} 20 | 21 | extension MLibrarySearchable { 22 | 23 | /// A unique identifier associated with the type that conforms to `MLibrarySearchable`. 24 | /// 25 | /// This identifier can be useful for distinguishing different types of music items during search operations or other tasks. 26 | static var searchIdentifier: ObjectIdentifier { 27 | ObjectIdentifier(Self.self) 28 | } 29 | } 30 | 31 | /// `Song` conformance to `MLibrarySearchable`, allowing songs to be fetched using library search requests. 32 | extension Song: MLibrarySearchable {} 33 | 34 | /// `Artist` conformance to `MLibrarySearchable`, allowing artists to be fetched using library search requests. 35 | extension Artist: MLibrarySearchable {} 36 | 37 | /// `Album` conformance to `MLibrarySearchable`, allowing albums to be fetched using library search requests. 38 | extension Album: MLibrarySearchable {} 39 | 40 | /// `MusicVideo` conformance to `MLibrarySearchable`, allowing music videos to be fetched using library search requests. 41 | extension MusicVideo: MLibrarySearchable {} 42 | 43 | /// `Playlist` conformance to `MLibrarySearchable`, allowing playlists to be fetched using library search requests. 44 | extension Playlist: MLibrarySearchable {} 45 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Library/Playlist Folder Request/LibraryPlaylistFolder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LibraryPlaylistFolder.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 26/12/22. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A structure representing a playlist folder in the user's library. 11 | /// 12 | /// `LibraryPlaylistFolder` provides information about a folder that contains playlists 13 | /// in the user's Apple Music library, including its metadata and relationships. 14 | /// 15 | /// Example usage: 16 | /// ```swift 17 | /// let folder = try await MLibrary.playlistFolder(id: "folder_id") 18 | /// print(folder.attributes?.name) 19 | /// print(folder.attributes?.dateAdded) 20 | /// ``` 21 | public struct LibraryPlaylistFolder: Codable, Sendable { 22 | /// The unique identifier of the playlist folder. 23 | public let id: String 24 | 25 | /// The type of the resource, typically "library-playlist-folders". 26 | public let type: String 27 | 28 | /// The attributes of the playlist folder. 29 | public let attributes: Attributes? 30 | 31 | /// The relationships of the playlist folder. 32 | public let relationships: Relationships? 33 | 34 | /// The attributes associated with a playlist folder. 35 | public struct Attributes: Codable, Sendable { 36 | /// The date when the folder was added to the library. 37 | public let dateAdded: Date 38 | 39 | /// The name of the playlist folder. 40 | public let name: String 41 | } 42 | 43 | /// The relationships associated with a playlist folder. 44 | public struct Relationships: Codable, Sendable { 45 | // Reserved for future relationships 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Library/Playlist Folder Request/MLibraryPlaylistFolder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MLibraryPlaylistFolder.swift 3 | // 4 | // 5 | // Created by Rudrank Riyam on 26/12/22. 6 | // 7 | 8 | /// A collection of playlist folders from the user's library. 9 | /// 10 | /// This type alias represents an array of playlist folders that can be retrieved from 11 | /// the user's Apple Music library. Each folder can contain multiple playlists and 12 | /// can be organized hierarchically. 13 | /// 14 | /// Example usage: 15 | /// ```swift 16 | /// do { 17 | /// let folders: LibraryPlaylistFolders = try await MLibrary.playlistFolders() 18 | /// for folder in folders { 19 | /// print("Folder name: \(folder.name)") 20 | /// print("Number of playlists: \(folder.playlists.count)") 21 | /// } 22 | /// } catch { 23 | /// print("Failed to fetch playlist folders: \(error)") 24 | /// } 25 | /// ``` 26 | public typealias LibraryPlaylistFolders = [LibraryPlaylistFolder] 27 | 28 | extension MLibrary { 29 | 30 | /// Fetches all playlist folders from the user's library. 31 | /// 32 | /// This method retrieves the root-level playlist folders from the user's Apple Music library. 33 | /// Each folder may contain playlists and subfolders. 34 | /// 35 | /// Example usage: 36 | /// ```swift 37 | /// do { 38 | /// let folders = try await MLibrary.rootPlaylistsFolder() 39 | /// for folder in folders { 40 | /// print("Found folder: \(folder.name)") 41 | /// } 42 | /// } catch { 43 | /// print("Error fetching playlist folders: \(error)") 44 | /// } 45 | /// ``` 46 | /// 47 | /// - Returns: An array of `LibraryPlaylistFolder` objects representing the user's playlist folders. 48 | /// - Throws: An error if the request fails or if the user's authorization status has changed. 49 | // static func rootPlaylistsFolder() async throws -> LibraryPlaylistFolders { 50 | // Implementation commented out 51 | // } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Library/Resource Request/LibraryMusicItemType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LibraryMusicItemType.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 18/05/22. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Set the `itemType` to indicate the type of library item for which you want to fetch ratings. 11 | /// 12 | /// ### Usage Example: 13 | /// 14 | /// ```swift 15 | /// let itemType: LibraryMusicItemType = .song 16 | /// 17 | /// do { 18 | /// let ratings = try await MLibraryRatingRequest(with: ids, item: itemType).response() 19 | /// // Use the ratings for further processing. 20 | /// } catch { 21 | /// // Handle the error. 22 | /// } 23 | /// ``` 24 | public enum LibraryMusicItemType: String, Codable { 25 | 26 | /// Represents songs in the music library. 27 | case songs 28 | 29 | /// Represents playlists in the music library. 30 | case playlists 31 | 32 | /// Represents albums in the music library. 33 | case albums 34 | 35 | /// Represents artists in the music library. 36 | case artists 37 | 38 | /// Represents music videos in the music library with a raw value of "music-videos". 39 | case musicVideos = "music-videos" 40 | 41 | /// A computed property that generates a type string for the item. 42 | /// 43 | /// The type string is used in requests to identify the specific item type. 44 | public var type: String { 45 | "ids[\(rawValue)]".removingPercentEncoding! 46 | } 47 | } 48 | 49 | /// An extension providing utility methods for `LibraryMusicItemType`. 50 | extension LibraryMusicItemType { 51 | /// Returns the `LibraryMusicItemType` corresponding to a given type conforming to `FilterableLibraryItem`. 52 | /// 53 | /// Use this method to map a type to the appropriate library item type for operations involving music library items. 54 | /// 55 | /// - Parameter item: The type conforming to `FilterableLibraryItem`. 56 | /// - Returns: The `LibraryMusicItemType` corresponding to the provided type. 57 | /// 58 | /// - Throws: An error if the input type does not match any known library item types. 59 | public static func path(for item: any FilterableLibraryItem.Type) throws -> Self { 60 | let path: LibraryMusicItemType 61 | 62 | switch item { 63 | case is Song.Type: 64 | path = .songs 65 | case is Album.Type: 66 | path = .albums 67 | case is Artist.Type: 68 | path = .artists 69 | case is MusicVideo.Type: 70 | path = .musicVideos 71 | case is Playlist.Type: 72 | path = .playlists 73 | default: 74 | throw NSError(domain: "Wrong library music item type.", code: 0) 75 | } 76 | 77 | return path 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Library/Resource Request/MLibraryResourceResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MLibraryResourceResponse.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 02/04/22. 6 | // 7 | 8 | /// An object that contains results for a library resource request. 9 | struct MLibraryResourceResponse where MusicItemType: MusicItem { 10 | /// A collection of items matching the filter used in 11 | /// the originating ``MusicLibraryResourceRequest``. 12 | let items: MusicItemCollection 13 | } 14 | 15 | extension MLibraryResourceResponse: Equatable where MusicItemType: Equatable {} 16 | 17 | extension MLibraryResourceResponse: Hashable where MusicItemType: Hashable {} 18 | 19 | extension MLibraryResourceResponse: Decodable where MusicItemType: Decodable { 20 | /// Creates a new instance by decoding from the given decoder. 21 | /// 22 | /// This initializer throws an error if reading from the decoder fails, or 23 | /// if the data read is corrupted or otherwise invalid. 24 | /// 25 | /// - Parameter decoder: The decoder to read data from. 26 | // init(from decoder: Decoder) throws { 27 | // 28 | // } 29 | } 30 | 31 | extension MLibraryResourceResponse: Encodable where MusicItemType: Encodable { 32 | /// Encodes this value into the given encoder. 33 | /// 34 | /// If the value fails to encode anything, `encoder` will encode an empty 35 | /// keyed container in its place. 36 | /// 37 | /// This function throws an error if any values are invalid for the given 38 | /// encoder's format. 39 | /// 40 | /// - Parameter encoder: The encoder to write data to. 41 | // func encode(to encoder: Encoder) throws { } 42 | } 43 | 44 | extension MLibraryResourceResponse: CustomStringConvertible, CustomDebugStringConvertible { 45 | var description: String { 46 | "" 47 | } 48 | 49 | var debugDescription: String { 50 | "" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Library/Search Request/MusicLibrarySearchResponseResults.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MLibrarySearchResponseResults.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 08/09/21. 6 | // 7 | 8 | /// The results from the library search request using a search term. 9 | /// 10 | /// This structure wraps the search response from the Apple Music API when searching 11 | /// a user's library. It provides access to the search results through its `results` property. 12 | /// 13 | /// Example usage: 14 | /// ```swift 15 | /// let request = MLibrarySearchRequest(term: "coldplay", types: [Song.self]) 16 | /// let response = try await request.response() 17 | /// let results = MLibrarySearchResponseResults(results: response) 18 | /// print(results.results.songs) 19 | /// ``` 20 | struct MLibrarySearchResponseResults: Decodable { 21 | /// The search response containing collections of different music items. 22 | var results: MLibrarySearchResponse 23 | } 24 | 25 | extension MLibrarySearchResponseResults: CustomStringConvertible, CustomDebugStringConvertible { 26 | public var description: String { 27 | var description = "MusicLibrarySearchResponseResults(" 28 | let mirror = Mirror(reflecting: self) 29 | 30 | description += mirror.children.map { "\n\($0.value)," }.joined() 31 | 32 | return description + "\n)" 33 | } 34 | 35 | public var debugDescription: String { 36 | "MusicLibrarySearchResponseResults(\n\(results))" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Library/Search Request/MusicLibrarySearchType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MLibrarySearchType.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 08/09/21. 6 | // 7 | 8 | enum MLibrarySearchType: String, CodingKey { 9 | case songs = "library-songs" 10 | case artists = "library-artists" 11 | case albums = "library-albums" 12 | case musicVideos = "library-music-videos" 13 | case playlists = "library-playlists" 14 | 15 | static func getTypes(_ types: [MLibrarySearchable.Type]) -> String { 16 | types 17 | .map { $0.searchIdentifier } 18 | .removeDuplicates() 19 | .compactMap { 20 | switch $0 { 21 | case Song.searchIdentifier: return songs.rawValue 22 | case Album.searchIdentifier: return albums.rawValue 23 | case MusicVideo.searchIdentifier: return musicVideos.rawValue 24 | case Playlist.searchIdentifier: return playlists.rawValue 25 | case Artist.searchIdentifier: return artists.rawValue 26 | default: return nil 27 | } 28 | } 29 | .joined(separator: ",") 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Models/AppleMusicURLComponents.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppleMusicURLComponents.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 31/07/22. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A protocol for URL components that can be used to construct URLs for API requests. 11 | public protocol MURLComponents { 12 | 13 | /// The query items to include in the URL. 14 | var queryItems: [URLQueryItem]? { get set } 15 | 16 | /// The path for the URL, excluding the base path. 17 | var path: String { get set } 18 | 19 | /// The constructed URL, if valid. 20 | var url: URL? { get } 21 | } 22 | 23 | /// A structure that implements the `MURLComponents` protocol, specifically for Apple Music API requests. 24 | public struct AppleMusicURLComponents: MURLComponents { 25 | 26 | /// The underlying `URLComponents` instance. 27 | private var components: URLComponents 28 | 29 | /// Initializes a new `AppleMusicURLComponents` instance with default values for the scheme and host. 30 | public init() { 31 | self.components = URLComponents() 32 | components.scheme = "https" 33 | components.host = "api.music.apple.com" 34 | } 35 | 36 | /// The query items to include in the URL. 37 | public var queryItems: [URLQueryItem]? { 38 | get { 39 | components.queryItems 40 | } set { 41 | components.queryItems = newValue 42 | } 43 | } 44 | 45 | /// The path for the URL, excluding the base path. 46 | public var path: String { 47 | get { 48 | return components.path 49 | } set { 50 | components.path = "/v1/" + newValue 51 | } 52 | } 53 | 54 | /// The constructed URL, if valid. 55 | public var url: URL? { 56 | components.url 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Models/RelationshipItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RelationshipItem.swift 3 | // RelationshipItem 4 | // 5 | // Created by Rudrank Riyam on 04/08/21. 6 | // 7 | 8 | import Foundation 9 | 10 | /// An enum representing the types of items that can be related to a music item. 11 | public enum RelationshipItem: String { 12 | case albums 13 | case artists 14 | case composers 15 | case genres 16 | case library 17 | case musicVideos = "music-videos" 18 | case station 19 | } 20 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Models/SongsForAlbums.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SongsForAlbums.swift 3 | // 4 | // 5 | // Created by Rudrank Riyam on 02/08/23. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A type alias representing a collection of songs for multiple albums. 11 | /// 12 | /// This alias is used to manage and manipulate a collection of `SongsForAlbums` items. 13 | @available(iOS 16.0, macOS 14.0, macCatalyst 17.0, tvOS 16.0, watchOS 9.0, visionOS 1.0, *) 14 | public typealias SongsForAlbums = [MusicLibrarySection] 15 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Models/SongsForArtists.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SongsForArtists.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 02/08/23. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A type alias representing a collection of songs for multiple artists. 11 | /// 12 | /// This alias is used to manage and manipulate a collection of `SongsForArtists` items. 13 | @available(iOS 16.0, macOS 14.0, macCatalyst 17.0, tvOS 16.0, watchOS 9.0, visionOS 1.0, *) 14 | public typealias SongsForArtists = [MusicLibrarySection] 15 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Models/SongsForGenres.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SongsGenres.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 01/08/23. 6 | // 7 | 8 | /// A type alias representing a collection of songs for multiple genres. 9 | /// 10 | /// This alias is used to manage and manipulate a collection of `SongsForGenre` items. 11 | @available(iOS 16.0, tvOS 16.0, watchOS 9.0, visionOS 1.0, *) 12 | @available(macOS, unavailable) 13 | @available(macCatalyst, unavailable) 14 | public typealias SongsForGenres = [MusicLibrarySection] 15 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Models/SongsForPlaylists.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SongsForPlaylists.swift 3 | // 4 | // 5 | // Created by Rudrank Riyam on 02/08/23. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A type alias representing a collection of songs for multiple playlists. 11 | /// 12 | /// This alias is used to manage and manipulate a collection of `SongsForPlaylists` items. 13 | @available(iOS 16.0, macOS 14.0, macCatalyst 17.0, tvOS 16.0, watchOS 9.0, visionOS 1.0, *) 14 | public typealias SongsForPlaylists = [MusicLibrarySection] 15 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Models/StationGenre.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StationGenre.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 12/03/23. 6 | // 7 | 8 | /// A structure representing a station genre in the Apple Music API. 9 | /// 10 | /// Station genres are used to categorize and organize radio stations in Apple Music. 11 | /// Each genre has a unique identifier, type, and display name. 12 | /// 13 | /// Example usage: 14 | /// ```swift 15 | /// let genre = try await MCatalog.stationGenre(id: "42") 16 | /// print(genre.name) // e.g., "Hip-Hop" 17 | /// print(genre.id) // The unique identifier 18 | /// ``` 19 | public struct StationGenre: Sendable { 20 | 21 | /// The unique identifier of the station genre. 22 | public let id: MusicItemID 23 | 24 | /// The type of the station genre. 25 | public let type: String 26 | 27 | /// The display name of the station genre. 28 | public let name: String 29 | } 30 | 31 | /// An extension that adds `Decodable` conformance to the `StationGenre` structure. 32 | extension StationGenre: Decodable { 33 | 34 | /// An enumeration of the top-level coding keys. 35 | enum CodingKeys: String, CodingKey { 36 | case attributes, id, type 37 | } 38 | 39 | /// An enumeration of the attribute-level coding keys. 40 | enum AttributesKey: String, CodingKey { 41 | case name 42 | } 43 | 44 | /// Initializes a new `StationGenre` instance from the given decoder. 45 | public init(from decoder: Decoder) throws { 46 | let container = try decoder.container(keyedBy: CodingKeys.self) 47 | 48 | id = try container.decode(MusicItemID.self, forKey: .id) 49 | type = try container.decode(String.self, forKey: .type) 50 | 51 | let attributesContainer = try container.nestedContainer( 52 | keyedBy: AttributesKey.self, forKey: .attributes) 53 | name = try attributesContainer.decode(String.self, forKey: .name) 54 | } 55 | } 56 | 57 | /// An extension that adds `MusicItem` conformance to the `StationGenre` structure. 58 | extension StationGenre: MusicItem {} 59 | 60 | /// An extension that adds `Equatable`, `Hashable`, and `Identifiable` conformances to the `StationGenre` structure. 61 | extension StationGenre: Equatable, Hashable, Identifiable { 62 | 63 | /// Determines if two `StationGenre` instances are equal based on their identifiers. 64 | public static func == (lhs: StationGenre, rhs: StationGenre) -> Bool { 65 | lhs.id == rhs.id 66 | } 67 | 68 | /// Computes the hash value for a `StationGenre` instance based on its identifier. 69 | public func hash(into hasher: inout Hasher) { 70 | hasher.combine(id) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Multiple Resources/MusicCatalogResources/MusicCatalogResourcesRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MusicCatalogResourcesRequest.swift 3 | // MusicCatalogResourcesRequest 4 | // 5 | // Created by Rudrank Riyam on 22/04/22. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A request that your app uses to fetch multiple resources from the Apple Music catalog 11 | /// using their identifiers. 12 | @available(iOS 15.4, macOS 12.3, tvOS 15.4, watchOS 9.0, visionOS 1.0, *) 13 | public struct MusicCatalogResourcesRequest { 14 | private var types: [MusicCatalogResourcesType.Key: [MusicItemID]] 15 | 16 | /// Creates a request to fetch multiple resources from the Apple Music catalog using their identifiers. 17 | public init(types: [MusicCatalogResourcesType.Key: [MusicItemID]]) { 18 | self.types = types 19 | } 20 | 21 | /// Fetches different catalog music items based on the types for the given request. 22 | public func response() async throws -> MusicCatalogResourcesResponse { 23 | let url = try await multipleCatalogResourcesEndpointURL 24 | let request = MusicDataRequest(urlRequest: .init(url: url)) 25 | let response = try await request.response() 26 | let items = try JSONDecoder().decode(MusicCatalogResourcesTypes.self, from: response.data) 27 | return MusicCatalogResourcesResponse(items: items) 28 | } 29 | } 30 | 31 | @available(iOS 15.4, macOS 12.3, tvOS 15.4, watchOS 9.0, visionOS 1.0, *) 32 | extension MusicCatalogResourcesRequest { 33 | private var multipleCatalogResourcesEndpointURL: URL { 34 | get async throws { 35 | let storefront = try await MusicDataRequest.currentCountryCode 36 | 37 | var components = AppleMusicURLComponents() 38 | var queryItems: [URLQueryItem] = [] 39 | components.path = "catalog/\(storefront)" 40 | 41 | for (key, value) in types { 42 | let values = value.map { $0.rawValue }.joined(separator: ",") 43 | queryItems.append(URLQueryItem(name: key.type, value: values)) 44 | } 45 | 46 | components.queryItems = queryItems 47 | 48 | guard let url = components.url else { 49 | throw URLError(.badURL) 50 | } 51 | return url 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Multiple Resources/MusicCatalogResources/MusicCatalogResourcesResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MusicCatalogResourcesResponse.swift 3 | // MusicCatalogResourcesResponse 4 | // 5 | // Created by Rudrank Riyam on 23/04/22. 6 | // 7 | 8 | /// An object that contains results for a catalog resources request. 9 | @available(iOS 15.4, macOS 12.3, tvOS 15.4, watchOS 9.0, visionOS 1.0, *) 10 | public struct MusicCatalogResourcesResponse { 11 | 12 | /// A collection of stations. 13 | public var stations: Stations { 14 | MusicItemCollection(items.compactMap { item in 15 | guard case let .station(station) = item else { return nil } 16 | return station 17 | }) 18 | } 19 | 20 | /// A collection of genres. 21 | public var genres: Genres { 22 | MusicItemCollection(items.compactMap { item in 23 | guard case let .genre(genre) = item else { return nil } 24 | return genre 25 | }) 26 | } 27 | 28 | /// A collection of recordLabels. 29 | public var recordLabels: RecordLabels { 30 | MusicItemCollection(items.compactMap { item in 31 | guard case let .recordLabel(recordLabel) = item else { return nil } 32 | return recordLabel 33 | }) 34 | } 35 | 36 | /// A collection of playlists. 37 | public var playlists: Playlists { 38 | MusicItemCollection(items.compactMap { item in 39 | guard case let .playlist(playlist) = item else { return nil } 40 | return playlist 41 | }) 42 | } 43 | 44 | /// A collection of artists. 45 | public var artists: Artists { 46 | MusicItemCollection(items.compactMap { item in 47 | guard case let .artist(artist) = item else { return nil } 48 | return artist 49 | }) 50 | } 51 | 52 | /// A collection of albums. 53 | public var albums: Albums { 54 | MusicItemCollection(items.compactMap { item in 55 | guard case let .album(album) = item else { return nil } 56 | return album 57 | }) 58 | } 59 | 60 | /// A collection of curators. 61 | public var curators: Curators { 62 | MusicItemCollection(items.compactMap { item in 63 | guard case let .curator(curator) = item else { return nil } 64 | return curator 65 | }) 66 | } 67 | 68 | /// A collection of radio shows. 69 | public var radioShows: RadioShows { 70 | MusicItemCollection(items.compactMap { item in 71 | guard case let .radioShow(radioShow) = item else { return nil } 72 | return radioShow 73 | }) 74 | } 75 | 76 | /// A collection of music videos. 77 | public var musicVideos: MusicVideos { 78 | MusicItemCollection(items.compactMap { item in 79 | guard case let .musicVideo(musicVideo) = item else { return nil } 80 | return musicVideo 81 | }) 82 | } 83 | 84 | /// A collection of songs. 85 | public var songs: Songs { 86 | MusicItemCollection(items.compactMap { item in 87 | guard case let .song(song) = item else { return nil } 88 | return song 89 | }) 90 | } 91 | 92 | /// A collection of different music items. 93 | var items: MusicCatalogResourcesTypes 94 | } 95 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Multiple Resources/MusicLibraryResources/MusicLibraryResourcesRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MusicLibraryResourcesRequest.swift 3 | // MusicLibraryResourcesRequest 4 | // 5 | // Created by Rudrank Riyam on 23/04/22. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A request that your app uses to fetch multiple resources from the user's library 11 | /// using their identifiers. 12 | public struct MusicLibraryResourcesRequest { 13 | private var types: [MusicLibraryResourcesType.Key: [MusicItemID]] 14 | 15 | /// Creates a request to fetch multiple resources from the user's library using their identifiers. 16 | public init(types: [MusicLibraryResourcesType.Key: [MusicItemID]]) { 17 | self.types = types 18 | } 19 | 20 | /// Fetches different library music items based on the types for the given request. 21 | public func response() async throws -> MusicLibraryResourcesResponse { 22 | let url = try multipleLibraryResourcesEndpointURL 23 | let request = MusicDataRequest(urlRequest: .init(url: url)) 24 | let response = try await request.response() 25 | let items = try JSONDecoder().decode(MusicLibraryResourcesTypes.self, from: response.data) 26 | return MusicLibraryResourcesResponse(items: items) 27 | } 28 | } 29 | 30 | extension MusicLibraryResourcesRequest { 31 | private var multipleLibraryResourcesEndpointURL: URL { 32 | get throws { 33 | var components = AppleMusicURLComponents() 34 | var queryItems: [URLQueryItem] = [] 35 | components.path = "me/library" 36 | 37 | for (key, value) in types { 38 | let values = value.map { $0.rawValue }.joined(separator: ",") 39 | queryItems.append(URLQueryItem(name: key.type, value: values)) 40 | } 41 | 42 | components.queryItems = queryItems 43 | 44 | guard let url = components.url else { 45 | throw URLError(.badURL) 46 | } 47 | return url 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Multiple Resources/MusicLibraryResources/MusicLibraryResourcesResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MusicLibraryResourcesResponse.swift 3 | // 4 | // 5 | // Created by Rudrank Riyam on 23/04/22. 6 | // 7 | 8 | /// An object that contains results for a catalog resources request. 9 | public struct MusicLibraryResourcesResponse { 10 | /// A collection of playlists. 11 | public var playlists: Playlists { 12 | MusicItemCollection(items.compactMap { item in 13 | guard case let .playlist(playlist) = item else { return nil } 14 | return playlist 15 | }) 16 | } 17 | 18 | /// A collection of artists. 19 | public var artists: Artists { 20 | MusicItemCollection(items.compactMap { item in 21 | guard case let .artist(artist) = item else { return nil } 22 | return artist 23 | }) 24 | } 25 | 26 | /// A collection of albums. 27 | public var albums: Albums { 28 | MusicItemCollection(items.compactMap { item in 29 | guard case let .album(album) = item else { return nil } 30 | return album 31 | }) 32 | } 33 | 34 | /// A collection of music videos. 35 | public var musicVideos: MusicVideos { 36 | MusicItemCollection(items.compactMap { item in 37 | guard case let .musicVideo(musicVideo) = item else { return nil } 38 | return musicVideo 39 | }) 40 | } 41 | 42 | /// A collection of songs. 43 | public var songs: Songs { 44 | MusicItemCollection(items.compactMap { item in 45 | guard case let .song(song) = item else { return nil } 46 | return song 47 | }) 48 | } 49 | 50 | /// A collection of different music items. 51 | var items: MusicLibraryResourcesTypes 52 | } 53 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Multiple Resources/MusicLibraryResources/MusicLibraryResourcesType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MusicLibraryResourcesType.swift 3 | // 4 | // 5 | // Created by Rudrank Riyam on 23/04/22. 6 | // 7 | 8 | /// A collection of music catalog resources types. 9 | typealias MusicLibraryResourcesTypes = MusicItemCollection 10 | 11 | /// A generic music item to represent each of the library music items. 12 | public enum MusicLibraryResourcesType { 13 | case album(Album) 14 | case song(Song) 15 | case playlist(Playlist) 16 | case artist(Artist) 17 | case musicVideo(MusicVideo) 18 | 19 | public enum Key: String, Codable { 20 | case songs = "library-songs" 21 | case albums = "library-albums" 22 | case playlists = "library-playlists" 23 | case artists = "library-artists" 24 | case musicVideos = "library-music-videos" 25 | 26 | public var type: String { 27 | "ids[\(rawValue)]".removingPercentEncoding! 28 | } 29 | } 30 | } 31 | 32 | extension MusicLibraryResourcesType: MusicItem { 33 | public var id: MusicItemID { 34 | let id: MusicItemID 35 | 36 | switch self { 37 | case let .song(song): id = song.id 38 | case let .playlist(playlist): id = playlist.id 39 | case let .artist(artist): id = artist.id 40 | case let .album(album): id = album.id 41 | case let .musicVideo(musicVideo): id = musicVideo.id 42 | } 43 | 44 | return id 45 | } 46 | } 47 | 48 | extension MusicLibraryResourcesType: Decodable { 49 | enum CodingKeys: CodingKey { 50 | case type 51 | } 52 | 53 | public init(from decoder: Decoder) throws { 54 | let values = try decoder.container(keyedBy: CodingKeys.self) 55 | let type = try values.decode(MusicLibraryResourcesType.Key.self, forKey: .type) 56 | 57 | switch type { 58 | case .songs: 59 | let song = try Song(from: decoder) 60 | self = .song(song) 61 | case .playlists: 62 | let playlist = try Playlist(from: decoder) 63 | self = .playlist(playlist) 64 | case .musicVideos: 65 | let musicVideo = try MusicVideo(from: decoder) 66 | self = .musicVideo(musicVideo) 67 | case .albums: 68 | let album = try Album(from: decoder) 69 | self = .album(album) 70 | case .artists: 71 | let artist = try Artist(from: decoder) 72 | self = .artist(artist) 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Music Item Properties/AlbumProperties.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AlbumProperties.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 22/12/22. 6 | // 7 | 8 | /// Additional property/relationship of an album. 9 | public typealias AlbumProperty = PartialMusicAsyncProperty 10 | 11 | /// Additional properties/relationships of an album. 12 | public typealias AlbumProperties = [AlbumProperty] 13 | 14 | extension AlbumProperties { 15 | /// All the album properties like artist URL, genres, artists, appears on, 16 | /// other versions, record labels, related albums, related videos, and tracks. 17 | /// For iOS 16+, adds the audio variants property. 18 | public static var all: Self { 19 | var properties: Self = [.artistURL, .genres, .artists, .appearsOn, .otherVersions, .recordLabels, .relatedAlbums, .relatedVideos, .tracks] 20 | 21 | if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, visionOS 1.0, *) { 22 | properties += [.audioVariants] 23 | return properties 24 | } else { 25 | return properties 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Music Item Properties/ArtistProperties.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ArtistProperties.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 22/12/22. 6 | // 7 | 8 | /// Additional property/relationship of an artist. 9 | public typealias ArtistProperty = PartialMusicAsyncProperty 10 | 11 | /// Additional properties/relationships of an artist. 12 | public typealias ArtistProperties = [ArtistProperty] 13 | 14 | extension ArtistProperties { 15 | public static var all: Self { 16 | [.genres, .station, .musicVideos, .albums, .playlists, .appearsOnAlbums, .fullAlbums, .featuredAlbums, .liveAlbums, .compilationAlbums, .featuredPlaylists, .latestRelease, .topSongs, .topMusicVideos, .similarArtists, .singles] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Music Item Properties/CuratorProperties.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CuratorProperties.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 22/12/22. 6 | // 7 | 8 | /// Additional property/relationship of an artist. 9 | @available(iOS 15.4, macOS 12.3, tvOS 15.4, watchOS 9.0, visionOS 1.0, *) 10 | public typealias CuratorProperty = PartialMusicAsyncProperty 11 | 12 | /// Additional properties/relationships of an artist. 13 | @available(iOS 15.4, macOS 12.3, tvOS 15.4, watchOS 9.0, visionOS 1.0, *) 14 | public typealias CuratorProperties = [CuratorProperty] 15 | 16 | @available(iOS 15.4, macOS 12.3, tvOS 15.4, watchOS 9.0, visionOS 1.0, *) 17 | extension CuratorProperties { 18 | public static var all: Self { 19 | [.playlists] 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Music Item Properties/MusicVideoProperties.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MusicVideoProperties.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 22/12/22. 6 | // 7 | 8 | /// Additional property/relationship of a music video. 9 | public typealias MusicVideoProperty = PartialMusicAsyncProperty 10 | 11 | /// Additional properties/relationships of a music video. 12 | public typealias MusicVideoProperties = [MusicVideoProperty] 13 | 14 | extension MusicVideoProperties { 15 | public static var all: Self { 16 | [.albums, .genres, .artists, .artistURL, .moreInGenre, .songs, .moreByArtist] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Music Item Properties/PlaylistProperties.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlaylistProperties.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 22/12/22. 6 | // 7 | 8 | /// Additional property/relationship of a playlist. 9 | public typealias PlaylistProperty = PartialMusicAsyncProperty 10 | 11 | /// Additional properties/relationships of a playlist. 12 | public typealias PlaylistProperties = [PlaylistProperty] 13 | 14 | extension PlaylistProperties { 15 | public static var all: Self { 16 | var properties: Self = [.tracks, .featuredArtists, .moreByCurator] 17 | 18 | if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, visionOS 1.0, *) { 19 | properties += [.curator, .radioShow] 20 | return properties 21 | } else { 22 | return properties 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Music Item Properties/RadioShowProperties.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RadioShowProperties.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 22/12/22. 6 | // 7 | 8 | /// Additional property/relationship of a radio show. 9 | @available(iOS 15.4, macOS 12.3, tvOS 15.4, watchOS 9.0, visionOS 1.0, *) 10 | public typealias RadioShowProperty = PartialMusicAsyncProperty 11 | 12 | /// Additional properties/relationships of a radio show. 13 | @available(iOS 15.4, macOS 12.3, tvOS 15.4, watchOS 9.0, visionOS 1.0, *) 14 | public typealias RadioShowProperties = [RadioShowProperty] 15 | 16 | @available(iOS 15.4, macOS 12.3, tvOS 15.4, watchOS 9.0, visionOS 1.0, *) 17 | extension RadioShowProperties { 18 | public static var all: Self { 19 | [.playlists] 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Music Item Properties/RecordLabelProperties.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RecordLabelProperties.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 22/12/22. 6 | // 7 | 8 | /// Additional property/relationship of a record label. 9 | public typealias RecordLabelProperty = PartialMusicAsyncProperty 10 | 11 | /// Additional properties/relationships of a record label. 12 | public typealias RecordLabelProperties = [RecordLabelProperty] 13 | 14 | extension RecordLabelProperties { 15 | public static var all: Self { 16 | [.latestReleases, .topReleases] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Music Item Properties/SongProperties.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SongProperties.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 22/12/22. 6 | // 7 | 8 | /// Additional property/relationship of a song. 9 | public typealias SongProperty = PartialMusicAsyncProperty 10 | 11 | /// Additional properties/relationships of a song. 12 | public typealias SongProperties = [SongProperty] 13 | 14 | extension SongProperties { 15 | public static var all: Self { 16 | var properties: Self = [.albums, .artists, .composers, .genres, .musicVideos, .artistURL, .station] 17 | 18 | if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, visionOS 1.0, *) { 19 | properties += [.audioVariants] 20 | return properties 21 | } else { 22 | return properties 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Music Items/Albums.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Albums.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 22/12/22. 6 | // 7 | 8 | /// A collection of albums. 9 | public typealias Albums = MusicItemCollection 10 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Music Items/Artists.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Artists.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 22/12/22. 6 | // 7 | 8 | /// A collection of artists. 9 | public typealias Artists = MusicItemCollection 10 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Music Items/Curators.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Curators.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 22/12/22. 6 | // 7 | 8 | /// A collection of curators. 9 | @available(iOS 15.4, macOS 12.3, tvOS 15.4, watchOS 9.0, visionOS 1.0, *) 10 | public typealias Curators = MusicItemCollection 11 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Music Items/Genres.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Genres.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 22/12/22. 6 | // 7 | 8 | /// A collection of genres. 9 | public typealias Genres = MusicItemCollection 10 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Music Items/LibraryPlaylists.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LibraryPlaylists.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 24/12/22. 6 | // 7 | 8 | /// A collection of library playlists. 9 | public typealias LibraryPlaylists = MusicItemCollection 10 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Music Items/MusicVideos.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MusicVideos.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 22/12/22. 6 | // 7 | 8 | /// A collection of music videos. 9 | public typealias MusicVideos = MusicItemCollection 10 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Music Items/Playlists.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Playlists.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 22/12/22. 6 | // 7 | 8 | /// A collection of playlists. 9 | public typealias Playlists = MusicItemCollection 10 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Music Items/RadioShows.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RadioShows.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 22/12/22. 6 | // 7 | 8 | /// A collection of radio shows. 9 | @available(iOS 15.4, macOS 12.3, tvOS 15.4, watchOS 9.0, visionOS 1.0, *) 10 | public typealias RadioShows = MusicItemCollection 11 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Music Items/RecordLabels.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RecordLabels.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 22/12/22. 6 | // 7 | 8 | /// A collection of record labels. 9 | public typealias RecordLabels = MusicItemCollection 10 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Music Items/Songs.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Songs.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 22/12/22. 6 | // 7 | 8 | /// A collection of songs. 9 | public typealias Songs = MusicItemCollection 10 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Music Items/StationGenres.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StationGenre.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 12/03/23. 6 | // 7 | 8 | /// A collection of station genres. 9 | public typealias StationGenres = MusicItemCollection 10 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Music Items/Stations.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Stations.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 22/12/22. 6 | // 7 | 8 | /// A collection of stations. 9 | public typealias Stations = MusicItemCollection 10 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Music Items/Tracks.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Tracks.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 26/12/22. 6 | // 7 | 8 | /// A typealias representing a collection of `Track` items. 9 | /// 10 | /// This type allows you to work with a group of `Track` objects in the context of a music collection. 11 | /// 12 | /// Example usage: 13 | /// 14 | /// let myTracks: Tracks = fetchTracks() 15 | /// print(myTracks.count) 16 | /// 17 | /// **Note:** This is built on top of `MusicItemCollection`, which provides a generic mechanism to work with music items. 18 | public typealias Tracks = MusicItemCollection 19 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Music Items/UserMusicItems.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserMusicItems.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 26/12/22. 6 | // 7 | 8 | /// A collection of user music items. 9 | public typealias UserMusicItems = MusicItemCollection 10 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Music Summaries/MSummary.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MSummary.swift 3 | // MusadoraKit 4 | // 5 | // Created by Codex on 02/09/25. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Structure containing the methods for accessing Apple Music "Replay" (music summaries) data. 11 | /// 12 | /// `MSummary` provides access to the user's yearly music summary for the latest eligible year. 13 | /// You can request the combined response or fetch top artists, albums, or songs directly. 14 | public struct MSummary {} 15 | 16 | // MARK: - Public API 17 | 18 | public extension MSummary { 19 | /// Fetch the user's latest Replay summary for the specified views. 20 | /// - Parameters: 21 | /// - views: Set of views to activate in the response. Defaults to all (`topArtists`, `topAlbums`, `topSongs`). 22 | /// - languageTag: Optional BCP‑47 language tag. If not provided, storefront default is used. 23 | /// - include: Optional relationship names to include. 24 | /// - extend: Optional attribute extensions to apply. 25 | /// - Returns: A typed `MSummaryResponse` containing top artists, albums, and songs (when requested). 26 | static func latest( 27 | views: Set = [.topArtists, .topAlbums, .topSongs], 28 | languageTag: String? = nil, 29 | include: [String]? = nil, 30 | extend: [String]? = nil 31 | ) async throws -> MSummaryResponse { 32 | var request = MSummaryRequest() 33 | request.views = views 34 | request.languageTag = languageTag 35 | request.include = include 36 | request.extend = extend 37 | return try await request.response() 38 | } 39 | 40 | /// Convenience: Fetch only latest top artists. 41 | static func latestTopArtists(languageTag: String? = nil) async throws -> Artists { 42 | var request = MSummaryRequest() 43 | request.views = [.topArtists] 44 | request.languageTag = languageTag 45 | let response = try await request.response() 46 | return response.topArtists 47 | } 48 | 49 | /// Convenience: Fetch only latest top albums. 50 | static func latestTopAlbums(languageTag: String? = nil) async throws -> Albums { 51 | var request = MSummaryRequest() 52 | request.views = [.topAlbums] 53 | request.languageTag = languageTag 54 | let response = try await request.response() 55 | return response.topAlbums 56 | } 57 | 58 | /// Convenience: Fetch only latest top songs. 59 | static func latestTopSongs(languageTag: String? = nil) async throws -> Songs { 60 | var request = MSummaryRequest() 61 | request.views = [.topSongs] 62 | request.languageTag = languageTag 63 | let response = try await request.response() 64 | return response.topSongs 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/MusicRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MusicRequest.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 20/04/25. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A protocol defining the requirements for making an Apple Music API request. 11 | /// 12 | /// Conforming types specify how to construct the request URL and decode the response data. 13 | protocol MusicRequest { 14 | 15 | /// The expected decodable type for the API response. 16 | associatedtype ResponseType: Decodable 17 | 18 | /// The URL for the API request. 19 | /// This property can throw an error if URL construction fails. 20 | var url: URL { get throws } 21 | 22 | /// Decodes the raw data received from the API into the specified `ResponseType`. 23 | /// - Parameter data: The raw `Data` received from the API response. 24 | /// - Returns: An instance of the `ResponseType`. 25 | /// - Throws: An error if decoding fails. 26 | func decodeResponse(data: Data) throws -> ResponseType 27 | } 28 | 29 | extension MusicRequest { 30 | func perform() async throws -> ResponseType { 31 | let request = MusicDataRequest(urlRequest: URLRequest(url: try url)) 32 | let response = try await request.response() 33 | return try decodeResponse(data: response.data) 34 | } 35 | } -------------------------------------------------------------------------------- /Sources/MusadoraKit/PrivacyInfo.xcprivacy: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSPrivacyTracking 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Ratings/Catalog/CatalogRatingMusicItemType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CatalogRatingMusicItemType.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 18/05/22. 6 | // 7 | 8 | import Foundation 9 | 10 | /// An enumeration of the types of music items that can be rated in the Apple Music Catalog. 11 | public enum CatalogRatingMusicItemType: String, Codable { 12 | case song = "songs" 13 | case playlist = "playlists" 14 | case album = "albums" 15 | case station = "stations" 16 | case musicVideo = "music-videos" 17 | } 18 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Ratings/Catalog/MCatalogRatingDeleteRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MCatalogRatingDeleteRequest.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 18/05/22. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A request that your app uses to delete ratings for albums, songs, 11 | /// playlists, music videos, and stations for content in the Apple Music catalog. 12 | public struct MCatalogRatingDeleteRequest { 13 | 14 | private var type: CatalogRatingMusicItemType 15 | private var id: MusicItemID 16 | 17 | /// Creates a request to delete the rating for the unique identifier of the given catalog item. 18 | /// - Parameters: 19 | /// - id: The unique identifier of the catalog item. 20 | /// - type: The type of the catalog item. Possible values: `song`, `album`, `playlist`, `musicVideo`, `station`. 21 | public init(with id: MusicItemID, item type: CatalogRatingMusicItemType) { 22 | self.id = id 23 | self.type = type 24 | } 25 | 26 | /// Deletes the rating of the given catalog item 27 | /// that matches the unique identifier for the request. 28 | public func response() async throws -> Bool { 29 | let url = try catalogDeleteRatingsEndpointURL 30 | let request = MDataDeleteRequest(url: url) 31 | let response = try await request.response() 32 | // 204 EmptyBodyResponse - The modification was successful, but there’s no content in the response. 33 | return response.urlResponse.statusCode == 204 34 | } 35 | } 36 | 37 | extension MCatalogRatingDeleteRequest { 38 | internal var catalogDeleteRatingsEndpointURL: URL { 39 | get throws { 40 | var components = AppleMusicURLComponents() 41 | components.path = "me/ratings/\(type.rawValue)/\(id.rawValue)" 42 | 43 | guard let url = components.url else { 44 | throw URLError(.badURL) 45 | } 46 | return url 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Ratings/Catalog/MCatalogRatingRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MCatalogRatingRequest.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 18/05/22. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A request that your app uses to get ratings for items in the Apple Music catalog. 11 | /// 12 | /// This structure allows fetching existing ratings for catalog items such as songs, 13 | /// albums, playlists, music videos, and stations. 14 | /// 15 | /// Example usage: 16 | /// ```swift 17 | /// // Fetch ratings for multiple songs 18 | /// let request = MCatalogRatingRequest( 19 | /// with: ["1234567890", "0987654321"], 20 | /// item: .song 21 | /// ) 22 | /// 23 | /// do { 24 | /// let ratings = try await request.response() 25 | /// print("Fetched ratings: \(ratings)") 26 | /// } catch { 27 | /// print("Failed to fetch ratings: \(error)") 28 | /// } 29 | /// ``` 30 | public struct MCatalogRatingRequest { 31 | 32 | /// The type of music item to fetch ratings for. 33 | private var type: CatalogRatingMusicItemType 34 | 35 | /// The unique identifiers of the items to fetch ratings for. 36 | private var ids: [MusicItemID] 37 | 38 | /// Creates a request to get ratings for the unique identifiers of the given catalog item. 39 | /// - Parameters: 40 | /// - ids: The unique identifiers of the catalog item. 41 | /// - type: The type of the catalog item. Possible values: `song`, `album`, `playlist`, `musicVideo`, `station`. 42 | public init(with ids: [MusicItemID], item type: CatalogRatingMusicItemType) { 43 | self.type = type 44 | self.ids = ids 45 | } 46 | 47 | /// Creates a request to get the rating for the unique identifier of the given catalog item. 48 | /// - Parameters: 49 | /// - id: The unique identifier of the catalog item. 50 | /// - type: The type of the catalog item. Possible values: `song`, `album`, `playlist`, `musicVideo`, `station`. 51 | public init(with id: MusicItemID, item type: CatalogRatingMusicItemType) { 52 | self.type = type 53 | self.ids = [id] 54 | } 55 | } 56 | 57 | extension MCatalogRatingRequest: MusicRequest { 58 | typealias ResponseType = RatingsResponse 59 | 60 | var url: URL { 61 | get throws { 62 | var components = AppleMusicURLComponents() 63 | var queryItems: [URLQueryItem]? 64 | components.path = "me/ratings/\(type.rawValue)" 65 | let idsString = ids.map { $0.rawValue }.joined(separator: ",") 66 | queryItems = [URLQueryItem(name: "ids", value: idsString)] 67 | components.queryItems = queryItems 68 | guard let url = components.url else { 69 | throw URLError(.badURL) 70 | } 71 | return url 72 | } 73 | } 74 | 75 | func decodeResponse(data: Data) throws -> RatingsResponse { 76 | try JSONDecoder().decode(RatingsResponse.self, from: data) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Ratings/Library/LibraryRatingMusicItemType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LibraryRatingMusicItemType.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 18/05/22. 6 | // 7 | 8 | public enum LibraryRatingMusicItemType: String, Codable { 9 | case song = "library-songs" 10 | case album = "library-albums" 11 | case musicVideo = "library-music-videos" 12 | case playlist = "library-playlists" 13 | } 14 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Ratings/Library/MLibraryRatingAddRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MLibraryRatingAddRequest.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 18/05/22. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A request that your app uses to add ratings for albums, songs, 11 | /// playlists, music videos, and stations for content in the user's iCloud library. 12 | public struct MLibraryRatingAddRequest { 13 | 14 | private var type: LibraryRatingMusicItemType 15 | private var id: MusicItemID 16 | 17 | /// The rating of the library item. 18 | public var rating: RatingType 19 | 20 | /// Creates a request to add the rating for the unique identifier of the given library item. 21 | /// - Parameters: 22 | /// - id: The unique identifier of the library item. 23 | /// - type: The type of the library item. Possible values: `song`, `album`, `playlist`, `musicVideo`. 24 | /// - rating: The rating to add for the given library item. Possible values: `like`, `dislike`. 25 | public init(with id: MusicItemID, item type: LibraryRatingMusicItemType, rating: RatingType) { 26 | self.id = id 27 | self.type = type 28 | self.rating = rating 29 | } 30 | 31 | /// Adds the given rating for the given library item 32 | /// that matches the unique identifier(s) for the request. 33 | public func response() async throws -> RatingsResponse { 34 | let url = try libraryAddRatingsEndpointURL 35 | 36 | let rating = RatingRequest(value: rating) 37 | let data = try JSONEncoder().encode(rating) 38 | 39 | let request = MDataPutRequest(url: url, data: data) 40 | let response = try await request.response() 41 | return try JSONDecoder().decode(RatingsResponse.self, from: response.data) 42 | } 43 | } 44 | 45 | extension MLibraryRatingAddRequest { 46 | internal var libraryAddRatingsEndpointURL: URL { 47 | get throws { 48 | var components = AppleMusicURLComponents() 49 | components.path = "me/ratings/\(type.rawValue)/\(id.rawValue)" 50 | 51 | guard let url = components.url else { 52 | throw URLError(.badURL) 53 | } 54 | return url 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Ratings/Library/MLibraryRatingDeleteRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MusicLibraryRatingDeleteRequest.swift 3 | // MusicLibraryRatingDeleteRequest 4 | // 5 | // Created by Rudrank Riyam on 18/05/22. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A request that your app uses to delete ratings for albums, songs, 11 | /// playlists, music videos, and stations for content in the user's iCloud library. 12 | public struct MLibraryRatingDeleteRequest { 13 | 14 | private var type: LibraryRatingMusicItemType 15 | private var id: MusicItemID 16 | 17 | /// Creates a request to delete the rating for the unique identifier of the given library item. 18 | /// - Parameters: 19 | /// - id: The unique identifier of the library item. 20 | /// - type: The type of the library item. Possible values: `song`, `album`, `playlist`, `musicVideo`. 21 | public init(with id: MusicItemID, item type: LibraryRatingMusicItemType) { 22 | self.id = id 23 | self.type = type 24 | } 25 | 26 | /// Deletes the rating of the given library item 27 | /// that matches the unique identifier for the request. 28 | public func response() async throws -> Bool { 29 | let url = try libraryDeleteRatingsEndpointURL 30 | let request = MDataDeleteRequest(url: url) 31 | let response = try await request.response() 32 | // 204 EmptyBodyResponse - The modification was successful, but there’s no content in the response. 33 | return response.urlResponse.statusCode == 204 34 | } 35 | } 36 | 37 | extension MLibraryRatingDeleteRequest { 38 | internal var libraryDeleteRatingsEndpointURL: URL { 39 | get throws { 40 | var components = AppleMusicURLComponents() 41 | components.path = "me/ratings/\(type.rawValue)/\(id.rawValue)" 42 | 43 | guard let url = components.url else { 44 | throw URLError(.badURL) 45 | } 46 | return url 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Ratings/Library/MLibraryRatingRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MusicLibraryRatingRequest.swift 3 | // 4 | // 5 | // Created by Rudrank Riyam on 18/05/22. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A collection of ratings for content items in the Apple Music Catalog. 11 | public typealias Ratings = [Rating] 12 | 13 | /// A request that your app uses to get ratings for albums, songs, 14 | /// playlists, and music videos for content in the user's iCloud library. 15 | public struct MLibraryRatingRequest { 16 | 17 | private var type: LibraryRatingMusicItemType 18 | private var ids: [MusicItemID] 19 | 20 | /// Creates a request to get ratings for the unique identifiers of the given library item. 21 | /// - Parameters: 22 | /// - ids: The unique identifiers of the library items to get ratings for. 23 | /// - type: The type of the library item. Possible values: `song`, `album`, `playlist`, `musicVideo`. 24 | public init(with ids: [MusicItemID], item type: LibraryRatingMusicItemType) { 25 | self.ids = ids 26 | self.type = type 27 | } 28 | 29 | /// Fetches the given rating(s) of the given library item 30 | /// that matches the unique identifier(s) for the request. 31 | public func response() async throws -> RatingsResponse { 32 | let url = try libraryRatingsEndpointURL 33 | let request = MusicDataRequest(urlRequest: URLRequest(url: url)) 34 | let response = try await request.response() 35 | return try JSONDecoder().decode(RatingsResponse.self, from: response.data) 36 | } 37 | } 38 | 39 | extension MLibraryRatingRequest { 40 | internal var libraryRatingsEndpointURL: URL { 41 | get throws { 42 | var components = AppleMusicURLComponents() 43 | var queryItems: [URLQueryItem]? 44 | components.path = "me/ratings/\(type.rawValue)" 45 | 46 | let ids = ids.map { $0.rawValue }.joined(separator: ",") 47 | queryItems = [URLQueryItem(name: "ids", value: ids)] 48 | 49 | components.queryItems = queryItems 50 | 51 | guard let url = components.url else { 52 | throw URLError(.badURL) 53 | } 54 | return url 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Ratings/Models/RatingRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RatingRequest.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 18/05/22. 6 | // 7 | 8 | /// A request to create or update a rating for a content item in the Apple Music Catalog. 9 | struct RatingRequest: Encodable { 10 | 11 | /// The type of the request. 12 | private let type: String = "ratings" 13 | 14 | /// The attributes of the request. 15 | private let attributes: Attributes 16 | 17 | /// The attributes of the rating. 18 | private struct Attributes: Encodable { 19 | /// The value of the rating. 20 | let value: RatingType 21 | } 22 | 23 | /// Creates a new rating request with the specified rating value. 24 | /// 25 | /// - Parameter value: The value of the rating. 26 | init(value: RatingType) { 27 | attributes = Attributes(value: value) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Ratings/Models/RatingType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RatingsType.swift 3 | // RatingsType 4 | // 5 | // Created by Rudrank Riyam on 20/05/22. 6 | // 7 | 8 | /// Represents the type of a rating for a content item in the Apple Music Catalog. 9 | public enum RatingType: Int, Codable { 10 | 11 | /// A positive rating. 12 | case like = 1 13 | 14 | /// A negative rating. 15 | case dislike = -1 16 | } 17 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Ratings/Models/Ratings.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Rating.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 18/05/22. 6 | // 7 | 8 | /// Represents a rating for a content item in the Apple Music Catalog. 9 | public struct Rating { 10 | 11 | /// The value of the rating. 12 | public let value: RatingType 13 | 14 | /// The identifier of the rating. 15 | public let id: String 16 | 17 | /// The type of the rating. 18 | public let type: String 19 | } 20 | 21 | extension Rating: Identifiable {} 22 | 23 | extension Rating: Equatable {} 24 | 25 | extension Rating: Hashable {} 26 | 27 | extension Rating: Decodable { 28 | enum CodingKeys: String, CodingKey { 29 | case attributes, id, type 30 | } 31 | 32 | enum AttributesKey: String, CodingKey { 33 | case value 34 | } 35 | 36 | public init(from decoder: Decoder) throws { 37 | let container = try decoder.container(keyedBy: CodingKeys.self) 38 | 39 | id = try container.decode(String.self, forKey: .id) 40 | type = try container.decode(String.self, forKey: .type) 41 | 42 | let attributesContainer = try container.nestedContainer(keyedBy: AttributesKey.self, forKey: .attributes) 43 | value = try attributesContainer.decode(RatingType.self, forKey: .value) 44 | } 45 | } 46 | 47 | extension Rating: Encodable { 48 | public func encode(to encoder: Encoder) throws { 49 | var container = encoder.container(keyedBy: CodingKeys.self) 50 | try container.encode(id, forKey: .id) 51 | try container.encode(type, forKey: .type) 52 | 53 | var attributesContainer = container.nestedContainer(keyedBy: AttributesKey.self, forKey: .attributes) 54 | try attributesContainer.encode(value, forKey: .value) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Ratings/Models/RatingsResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RatingsResponse.swift 3 | // RatingsResponse 4 | // 5 | // Created by Rudrank Riyam on 20/05/22. 6 | // 7 | 8 | import Foundation 9 | 10 | /// An object that contains results for a rating request. 11 | public struct RatingsResponse: Codable { 12 | 13 | /// A collection of ratings 14 | public let data: Ratings 15 | } 16 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Recommendations/MRecommendation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MRecommendation.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 25/12/22. 6 | // 7 | 8 | /// Structure containing the methods related to the Apple Music recommendations. 9 | /// 10 | /// `MRecommendation` provides access to Apple Music's recommendation system, 11 | /// allowing you to fetch personalized music recommendations for users. 12 | /// 13 | /// Example usage: 14 | /// ```swift 15 | /// // Fetch default recommendations 16 | /// let recommendations = try await MRecommendation.default() 17 | /// 18 | /// // Access recommended content 19 | /// print(recommendations.first?.albums) 20 | /// print(recommendations.first?.playlists) 21 | /// ``` 22 | public struct MRecommendation { 23 | } 24 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Recommendations/MRecommendationMusicItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MRecommendationMusicItem.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 25/08/22. 6 | // 7 | 8 | /// A protocol for music items that your app can fetch by 9 | /// using a recommendations request. 10 | public protocol MRecommendationMusicItem: MusicItem { 11 | } 12 | 13 | extension Playlist: MRecommendationMusicItem { 14 | } 15 | 16 | extension Station: MRecommendationMusicItem { 17 | } 18 | 19 | extension Album: MRecommendationMusicItem { 20 | } 21 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Recommendations/MRecommendationRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MusicRecommendationRequest.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 02/04/22. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A request that your app uses to fetch recommendations from 11 | /// the user's library, either default ones or based on identifiers. 12 | struct MRecommendationRequest { 13 | /// A limit for the number of items to return 14 | /// in the recommendation response. 15 | var limit: Int? 16 | 17 | private var ids: [String]? 18 | 19 | /// Creates a request to fetch default recommendations. 20 | init() {} 21 | 22 | /// Creates a request to fetch a recommendation by using its identifier. 23 | init(equalTo id: String) { 24 | ids = [id] 25 | } 26 | 27 | /// Creates a request to fetch one or more recommendations by using their identifiers. 28 | init(memberOf ids: [String]) { 29 | self.ids = ids 30 | } 31 | 32 | /// Fetches recommendations based on the user’s library 33 | /// and purchase history for the given request. 34 | func response() async throws -> MRecommendationResponse { 35 | let items: MRecommendations 36 | let url = try recommendationEndpointURL 37 | let decoder = JSONDecoder() 38 | decoder.dateDecodingStrategy = .iso8601 39 | 40 | if let userToken = MusadoraKit.userToken { 41 | let request = MUserRequest(urlRequest: .init(url: url), userToken: userToken) 42 | let data = try await request.response() 43 | items = try decoder.decode(MRecommendations.self, from: data) 44 | } else { 45 | let request = MusicDataRequest(urlRequest: .init(url: url)) 46 | let response = try await request.response() 47 | items = try decoder.decode(MRecommendations.self, from: response.data) 48 | } 49 | 50 | return MRecommendationResponse(items: items) 51 | } 52 | } 53 | 54 | extension MRecommendationRequest { 55 | var recommendationEndpointURL: URL { 56 | get throws { 57 | var components = AppleMusicURLComponents() 58 | var queryItems: [URLQueryItem]? 59 | components.path = "me/recommendations" 60 | 61 | if let ids = ids { 62 | queryItems = [URLQueryItem(name: "ids", value: ids.joined(separator: ","))] 63 | } 64 | 65 | if let limit = limit { 66 | guard limit <= 30 else { 67 | throw MusadoraKitError.recommendationOverLimit(for: limit) 68 | } 69 | 70 | queryItems = [URLQueryItem(name: "limit", value: "\(limit)")] 71 | } 72 | 73 | components.queryItems = queryItems 74 | 75 | guard let url = components.url else { 76 | throw URLError(.badURL) 77 | } 78 | return url 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Recommendations/MRecommendationResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MusicRecommendationResponse.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 02/04/22. 6 | // 7 | 8 | /// An object that contains results for a recommendation request. 9 | /// 10 | /// This structure wraps the recommendations returned by the Apple Music API, 11 | /// providing access to personalized music recommendations for the user. 12 | /// 13 | /// Example usage: 14 | /// ```swift 15 | /// let request = MRecommendationRequest() 16 | /// let response = try await request.response() 17 | /// 18 | /// // Access recommended items 19 | /// for recommendation in response.items { 20 | /// print(recommendation.title) 21 | /// print(recommendation.albums) 22 | /// print(recommendation.playlists) 23 | /// } 24 | /// ``` 25 | public struct MRecommendationResponse { 26 | /// A collection of recommendations based on the `MusicRecommendationRequest`. 27 | /// Each recommendation may contain different types of content like albums, 28 | /// playlists, or stations. 29 | public let items: MRecommendations 30 | } 31 | 32 | extension MRecommendationResponse: Equatable, Hashable, Codable {} 33 | 34 | extension MRecommendationResponse: CustomStringConvertible { 35 | public var description: String { 36 | "MusicRecommendationResponse(\(items.description))" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Recommendations/MRecommendations.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MRecommendations.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 25/12/22. 6 | // 7 | 8 | /// A collection of recommendations. 9 | public typealias MRecommendations = MusicItemCollection 10 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Recommendations/PersonalRecommendations.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PersonalRecommendations.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 25/12/22. 6 | // 7 | 8 | /// A collection of personal recommendations. 9 | @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, visionOS 1.0, *) 10 | public typealias PersonalRecommendations = MusicItemCollection 11 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Requests/MDataDeleteRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MDataDeleteRequest.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 18/05/22. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A request structure for deleting data from an arbitrary Apple Music API endpoint. 11 | /// 12 | /// This structure provides a way to send DELETE requests to the Apple Music API, 13 | /// such as removing items from playlists or deleting user-created playlists. 14 | /// 15 | /// Example usage: 16 | /// ```swift 17 | /// // Delete a playlist from the user's library 18 | /// let playlistURL = URL(string: "https://api.music.apple.com/v1/me/library/playlists/p.12345")! 19 | /// let deleteRequest = MDataDeleteRequest(url: playlistURL) 20 | /// 21 | /// do { 22 | /// let response = try await deleteRequest.response() 23 | /// print("Successfully deleted playlist") 24 | /// } catch { 25 | /// print("Failed to delete playlist: \(error)") 26 | /// } 27 | /// ``` 28 | /// 29 | public struct MDataDeleteRequest { 30 | 31 | /// The URL associated with the data request. 32 | private var url: URL 33 | 34 | /// Creates a data request for deletion based on the specified URL. 35 | /// 36 | /// - Parameter url: The URL representing the Apple Music API endpoint. 37 | public init(url: URL) { 38 | self.url = url 39 | } 40 | 41 | /// Sends a DELETE request to the Apple Music API endpoint specified by the URL. 42 | /// 43 | /// - Returns: A `MusicDataResponse` object containing details about the outcome of the delete operation. 44 | /// - Throws: An error if there's a problem initiating or receiving the delete request. 45 | public func response() async throws -> MusicDataResponse { 46 | var urlRequest = URLRequest(url: url) 47 | urlRequest.httpMethod = "DELETE" 48 | 49 | let request = MusicDataRequest(urlRequest: urlRequest) 50 | let response = try await request.response() 51 | return response 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Requests/MDataPostRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MDataPostRequest.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 23/04/22. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A request structure for uploading data to an arbitrary Apple Music API endpoint. 11 | /// 12 | /// This struct provides a way to send POST requests to the Apple Music API. You can use it to upload data, 13 | /// such as adding songs to a library, creating new playlists, or any other action that requires a POST request. 14 | /// 15 | /// ### Usage Example: 16 | /// 17 | /// ```swift 18 | /// let playlistURL = URL(string: "https://api.music.apple.com/v1/me/library/playlists")! 19 | /// let playlistData: Data = // ... (your encoded playlist data here) 20 | /// let postRequest = MDataPostRequest(url: playlistURL, data: playlistData) 21 | /// 22 | /// do { 23 | /// let response = try await postRequest.response() 24 | /// print("Playlist added successfully.") 25 | /// } catch { 26 | /// print("Failed to add playlist:", error.localizedDescription) 27 | /// } 28 | /// ``` 29 | /// 30 | public struct MDataPostRequest: Sendable { 31 | 32 | /// The URL associated with the data request. 33 | var url: URL 34 | 35 | /// The data payload to be uploaded using the POST request. 36 | var data: Data? 37 | 38 | /// Creates a POST data request based on the specified URL and data payload. 39 | /// 40 | /// - Parameters: 41 | /// - url: The URL representing the Apple Music API endpoint. 42 | /// - data: The data payload to be uploaded. Defaults to `nil`. 43 | public init(url: URL, data: Data? = nil) { 44 | self.url = url 45 | self.data = data 46 | } 47 | 48 | /// Sends a POST request to the Apple Music API endpoint specified by the URL, using the provided data payload. 49 | /// 50 | /// - Returns: A `MusicDataResponse` object containing details about the outcome of the post operation. 51 | /// - Throws: An error if there's a problem initiating or receiving the post request. 52 | public func response() async throws -> MusicDataResponse { 53 | var urlRequest = URLRequest(url: url) 54 | urlRequest.httpMethod = "POST" 55 | urlRequest.httpBody = data 56 | 57 | let request = MusicDataRequest(urlRequest: urlRequest) 58 | let response = try await request.response() 59 | return response 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Requests/MDataPutRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MDataPutRequest.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 18/05/22. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A request structure for updating data at an arbitrary Apple Music API endpoint. 11 | /// 12 | /// The `MDataPutRequest` struct facilitates sending PUT requests to the Apple Music API, allowing you to update existing resources, 13 | /// such as modifying details of a playlist, changing track metadata, or any other action requiring a PUT request. 14 | /// 15 | /// ### Usage Example: 16 | /// 17 | /// ```swift 18 | /// let playlistURL = URL(string: "https://api.music.apple.com/v1/me/library/playlists/{id}")! 19 | /// let updatedPlaylistData: Data = // ... (your encoded updated playlist data here) 20 | /// let putRequest = MDataPutRequest(url: playlistURL, data: updatedPlaylistData) 21 | /// 22 | /// do { 23 | /// let response = try await putRequest.response() 24 | /// print("Playlist updated successfully.") 25 | /// } catch { 26 | /// print("Failed to update playlist:", error.localizedDescription) 27 | /// } 28 | /// ``` 29 | /// 30 | public struct MDataPutRequest { 31 | 32 | /// The URL associated with the data request. 33 | private var url: URL 34 | 35 | /// The data payload to be uploaded for updating the resource. 36 | private var data: Data 37 | 38 | /// Initializes a new PUT data request using the specified URL and data payload. 39 | /// 40 | /// - Parameters: 41 | /// - url: The URL representing the Apple Music API endpoint. 42 | /// - data: The data payload to be used for updating the resource. 43 | public init(url: URL, data: Data) { 44 | self.url = url 45 | self.data = data 46 | } 47 | 48 | /// Sends a PUT request to the Apple Music API endpoint specified by the URL, using the provided data payload. 49 | /// 50 | /// - Returns: A `MusicDataResponse` object containing details about the outcome of the update operation. 51 | /// - Throws: An error if there's a problem initiating or receiving the PUT request. 52 | public func response() async throws -> MusicDataResponse { 53 | var urlRequest = URLRequest(url: url) 54 | urlRequest.httpMethod = "PUT" 55 | urlRequest.httpBody = data 56 | 57 | let request = MusicDataRequest(urlRequest: urlRequest) 58 | let response = try await request.response() 59 | return response 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Requests/MDataRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MDataRequest.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 15/03/23. 6 | // 7 | 8 | import Foundation 9 | @preconcurrency import MusicKit 10 | 11 | /// A request structure for fetching data from an arbitrary Apple Music API endpoint. 12 | /// 13 | /// The `MDataRequest` struct facilitates sending requests to the Apple Music API for fetching resources, 14 | /// such as retrieving details of a playlist, fetching track metadata, or any other action requiring an API call. 15 | /// 16 | /// Before sending a request, it sets a token provider with the provided developer token. This ensures that the API 17 | /// call is authenticated and adheres to the authorization standards of Apple Music API. 18 | /// 19 | /// ### Usage Example: 20 | /// 21 | /// ```swift 22 | /// let trackURL = URL(string: "https://api.music.apple.com/v1/catalog/us/songs/{id}")! 23 | /// let urlRequest = URLRequest(url: trackURL) 24 | /// let dataRequest = MDataRequest(urlRequest: urlRequest, developerToken: "YOUR_DEVELOPER_TOKEN") 25 | /// 26 | /// do { 27 | /// let response = try await dataRequest.response() 28 | /// print("Track data retrieved successfully:", response) 29 | /// } catch { 30 | /// print("Failed to retrieve track data:", error.localizedDescription) 31 | /// } 32 | /// ``` 33 | /// 34 | public struct MDataRequest { 35 | 36 | /// The developer token used for authentication with the Apple Music API. 37 | private let developerToken: String 38 | 39 | /// The URL request used to specify details for the API call, such as endpoint, method, and headers. 40 | public let urlRequest: URLRequest 41 | 42 | /// Initializes a new data request using the specified URL request and developer token. 43 | /// 44 | /// - Parameters: 45 | /// - urlRequest: The URLRequest representing the Apple Music API call. 46 | /// - developerToken: The developer token used for authentication. 47 | public init(urlRequest: URLRequest, developerToken: String) { 48 | self.urlRequest = urlRequest 49 | self.developerToken = developerToken 50 | } 51 | 52 | /// Sends a request to the Apple Music API endpoint specified by the URL request. 53 | /// 54 | /// This method uses the provided developer token to set a token provider, ensuring the API call is authenticated. 55 | /// 56 | /// - Returns: A `MusicDataResponse` object containing the outcome of the API call. 57 | /// - Throws: An error if there's a problem initiating or receiving the response. 58 | public func response() async throws -> MusicDataResponse { 59 | let token = self.developerToken 60 | MusicDataRequest.tokenProvider = await MDeveloperTokenProvider(developerToken: token) 61 | let request = MusicDataRequest(urlRequest: urlRequest) 62 | let response = try await request.response() 63 | return response 64 | } 65 | } 66 | 67 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Requests/MDeveloperTokenProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MDeveloperTokenProvider.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 15/03/23. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A custom implementation of the `MusicTokenProvider` protocol for Apple Music API authentication. 11 | /// 12 | /// The `MDeveloperTokenProvider` class facilitates generating **custom** developer tokens for Apple Music API authentication. 13 | /// Unlike other potential implementations of `MusicTokenProvider`, this class directly takes and returns a **custom** 14 | /// developer token, which can be useful in scenarios where the developer token doesn't need to be periodically refreshed 15 | /// or generated dynamically. 16 | /// 17 | /// Before making a request to Apple Music API, you can set the token provider of the request object using an instance of this class. 18 | /// Doing so ensures that the API call is authenticated and adheres to the authorization standards of Apple Music API. 19 | /// 20 | /// ### Usage Example: 21 | /// 22 | /// ```swift 23 | /// let developerTokenString = "YOUR_CUSTOM_DEVELOPER_TOKEN" 24 | /// let tokenProvider = MDeveloperTokenProvider(developerToken: developerTokenString) 25 | /// MusicDataRequest.tokenProvider = tokenProvider 26 | /// 27 | /// // Now proceed with sending a MusicDataRequest 28 | /// ``` 29 | /// 30 | @MainActor 31 | final public class MDeveloperTokenProvider: MusicTokenProvider, Sendable { 32 | 33 | /// The **custom** developer token used to authenticate Apple Music API requests. 34 | private var developerToken: String = "" 35 | 36 | /// Creates a new instance of `MDeveloperTokenProvider` using the provided **custom** developer token. 37 | /// 38 | /// - Parameter developerToken: The **custom** developer token for Apple Music API authentication. 39 | public convenience init(developerToken: String) { 40 | self.init() 41 | self.developerToken = developerToken 42 | } 43 | 44 | /// Fetches the **custom** developer token set during initialization. 45 | /// 46 | /// This method adheres to the `MusicTokenProvider` protocol but directly returns the **custom** developer token 47 | /// provided during object initialization without making any asynchronous calls or processing. 48 | /// 49 | /// - Parameter options: Options associated with the token request. This parameter is unused in this implementation 50 | /// as the token is provided directly without any dynamic generation. 51 | /// - Returns: The **custom** developer token as a string. 52 | public func developerToken(options: MusicTokenRequestOptions) async throws -> String { 53 | developerToken 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/MusadoraKit/Requests/MUserRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MUserRequest.swift 3 | // MusadoraKit 4 | // 5 | // Created by Rudrank Riyam on 18/02/24. 6 | // 7 | 8 | import Foundation 9 | @preconcurrency import MusicKit 10 | 11 | /// A request structure for fetching data from an arbitrary Apple Music API endpoint requiring user authentication. 12 | /// 13 | /// The `MUserRequest` struct facilitates sending authenticated requests to the Apple Music API, incorporating user tokens for personalized actions. 14 | /// The developer token is dynamically fetched using the token provider to ensure up-to-date authentication. 15 | /// 16 | /// ### Usage Example: 17 | /// 18 | /// ```swift 19 | /// let playlistURL = URL(string: "https://api.music.apple.com/v1/me/library/playlists")! 20 | /// let urlRequest = URLRequest(url: playlistURL) 21 | /// let userToken = "YOUR_USER_TOKEN" 22 | /// let dataRequest = MUserRequest(urlRequest: urlRequest, userToken: userToken) 23 | /// 24 | /// do { 25 | /// let response = try await dataRequest.response() 26 | /// print("Playlist data retrieved successfully:", String(data: response, encoding: .utf8) ?? "") 27 | /// } catch { 28 | /// print("Failed to retrieve playlist data:", error.localizedDescription) 29 | /// } 30 | /// ``` 31 | public struct MUserRequest { 32 | 33 | /// The user token used for personalized API requests. 34 | private let userToken: String 35 | 36 | /// The URL request configured for the API call. 37 | public var urlRequest: URLRequest 38 | 39 | /// Initializes a new data request with the specified URL request and user token. 40 | /// 41 | /// - Parameters: 42 | /// - urlRequest: The URLRequest for the Apple Music API call. 43 | /// - userToken: The user token for personalized requests. 44 | public init(urlRequest: URLRequest, userToken: String) { 45 | self.urlRequest = urlRequest 46 | self.userToken = userToken 47 | } 48 | 49 | /// Sends an authenticated request to the Apple Music API endpoint specified in the URL request. 50 | /// 51 | /// - Returns: The raw `Data` received in response to the API call. 52 | /// - Throws: An error if the request fails or if there's an issue with fetching the developer token. 53 | public func response() async throws -> Data { 54 | let developerToken = try await MusicDataRequest.tokenProvider.developerToken(options: .ignoreCache) 55 | 56 | var request = self.urlRequest 57 | request.addValue("Bearer \(developerToken)", forHTTPHeaderField: "Authorization") 58 | request.addValue(userToken, forHTTPHeaderField: "media-user-token") 59 | request.addValue("https://music.apple.com", forHTTPHeaderField: "Origin") 60 | request.addValue("https://music.apple.com/", forHTTPHeaderField: "Referer") 61 | 62 | let (data, response) = try await URLSession.shared.data(for: request) 63 | 64 | guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { 65 | throw URLError(.badServerResponse) 66 | } 67 | 68 | return data 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Tests/MusadoraKitTests/100 Best Albums/HundredBestAlbumIntegrationTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HundredBestAlbumIntegrationTests.swift 3 | // MusadoraKitTests 4 | // 5 | // Created by Rudrank Riyam on 20/05/24. 6 | // 7 | 8 | import XCTest 9 | 10 | #if canImport(Testing) 11 | import Testing 12 | #endif 13 | @testable import MusadoraKit 14 | 15 | #if swift(>=6.0) 16 | struct HundredBestAlbumSwiftTests { 17 | @Test 18 | func allHundredBestAlbums() async throws { 19 | let albums = try await MRecommendation.allHundredBestAlbums() 20 | 21 | let eightyThirdAlbum = try #require(albums.first(where: { $0.position == "83" })) 22 | 23 | #expect(eightyThirdAlbum.title == "Horses ") 24 | #expect(eightyThirdAlbum.artistName == "Patti Smith") 25 | #expect(eightyThirdAlbum.position == "83") 26 | #expect(eightyThirdAlbum.id == MusicItemID("1038568061")) 27 | } 28 | } 29 | #endif 30 | 31 | final class HundredBestAlbumIntegrationTests: XCTestCase { 32 | func testHundredBestAlbumAtPosition() async throws { 33 | let position = 100 34 | let album = try await MRecommendation.hundredBestAlbum(at: position) 35 | 36 | XCTAssertEqual(album.title, "Body Talk") 37 | XCTAssertEqual(album.artistName, "Robyn") 38 | XCTAssertEqual(album.position, "100") 39 | XCTAssertEqual(album.id, MusicItemID("1440714879")) 40 | } 41 | 42 | func testAllHundredBestAlbums() async throws { 43 | let albums = try await MRecommendation.allHundredBestAlbums() 44 | 45 | guard let eightyThirdAlbum = albums.first(where: { $0.position == "83" }) else { 46 | XCTFail("Failed to find the album at position 83") 47 | return 48 | } 49 | 50 | XCTAssertEqual(eightyThirdAlbum.title, "Horses ") 51 | XCTAssertEqual(eightyThirdAlbum.artistName, "Patti Smith") 52 | XCTAssertEqual(eightyThirdAlbum.position, "83") 53 | XCTAssertEqual(eightyThirdAlbum.id, MusicItemID("1038568061")) 54 | 55 | guard let fortyNinthAlbum = albums.first(where: { $0.position == "49" }) else { 56 | XCTFail("Failed to find the album at position 49") 57 | return 58 | } 59 | 60 | XCTAssertEqual(fortyNinthAlbum.title, "The Joshua Tree") 61 | XCTAssertEqual(fortyNinthAlbum.artistName, "U2") 62 | XCTAssertEqual(fortyNinthAlbum.position, "49") 63 | XCTAssertEqual(fortyNinthAlbum.id, MusicItemID("1443155637")) 64 | } 65 | } 66 | 67 | -------------------------------------------------------------------------------- /Tests/MusadoraKitTests/100 Best Albums/HundredBestAlbumRequestTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HundredBestAlbumRequestTests.swift 3 | // MusadoraKitTests 4 | // 5 | // Created by Rudrank Riyam on 19/05/24. 6 | // 7 | 8 | import XCTest 9 | @testable import MusadoraKit 10 | 11 | final class HundredBestAlbumRequestTests: XCTestCase { 12 | func testHundredBestAlbumEndpointURL() throws { 13 | let request = HundredBestAlbumRequest(position: 100) 14 | let endpointURL = try request.albumEndpointURL 15 | 16 | let expectedURL = "https://100best.music.apple.com/content/us/en-us/100.json" 17 | XCTAssertEqual(endpointURL.absoluteString, expectedURL) 18 | } 19 | 20 | func testHundredBestAlbumRequest() async throws { 21 | let request = HundredBestAlbumRequest(position: 100) 22 | let album = try await request.response() 23 | 24 | XCTAssertEqual(album.title, "Body Talk") 25 | XCTAssertEqual(album.artistName, "Robyn") 26 | XCTAssertEqual(album.position, "100") 27 | XCTAssertEqual(album.id, MusicItemID("1440714879")) 28 | } 29 | 30 | func testDecodingHundredBestAlbumData() throws { 31 | let album = try HundredBestAlbum.mock 32 | 33 | XCTAssertEqual(album.title, "Body Talk") 34 | XCTAssertEqual(album.artistName, "Robyn") 35 | XCTAssertEqual(album.url.absoluteString, "https://music.apple.com/us/album/body-talk/1440714879") 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Tests/MusadoraKitTests/Add Resources/MusicAddResourcesRequestEndpointTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MusicAddResourcesRequestEndpointTests.swift 3 | // MusicAddResourcesRequestEndpointTests 4 | // 5 | // Created by Rudrank Riyam on 18/05/22. 6 | // 7 | 8 | @testable import MusadoraKit 9 | import MusicKit 10 | import XCTest 11 | 12 | class MusicAddResourcesRequestEndpointTests: XCTestCase { 13 | func testAddAlbumsToLibraryEndpointURL() throws { 14 | let albums: [MusicItemID] = ["1577502911", "1577502912"] 15 | 16 | let request = MAddResourcesRequest([(item: .albums, value: albums)]) 17 | let url = try request.addResourcesEndpointURL 18 | 19 | XCTAssertEqualEndpoint(url, "https://api.music.apple.com/v1/me/library?ids[albums]=1577502911,1577502912") 20 | } 21 | 22 | func testAddPlaylistsToLibraryEndpointURL() throws { 23 | let playlists: [MusicItemID] = ["1577502911"] 24 | 25 | let request = MAddResourcesRequest([(item: .playlists, value: playlists)]) 26 | let url = try request.addResourcesEndpointURL 27 | 28 | XCTAssertEqualEndpoint(url, "https://api.music.apple.com/v1/me/library?ids[playlists]=1577502911") 29 | } 30 | 31 | func testAddResourcesToLibraryEndpointURL() throws { 32 | let albums: [MusicItemID] = ["1577502911"] 33 | let songs: [MusicItemID] = ["1545146511"] 34 | 35 | let request = MAddResourcesRequest([(item: .albums, value: albums), (item: .songs, value: songs)]) 36 | let url = try request.addResourcesEndpointURL 37 | 38 | let endpointURL = "https://api.music.apple.com/v1/me/library?ids[albums]=1577502911&ids[songs]=1545146511" 39 | XCTAssertEqualEndpoint(url, endpointURL) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Tests/MusadoraKitTests/AppleMusicURLComponentsTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppleMusicURLComponentsTests.swift 3 | // MusadoraKitTests 4 | // 5 | // Created by Rudrank Riyam on 22/03/23. 6 | // 7 | 8 | @testable import MusadoraKit 9 | import MusicKit 10 | import XCTest 11 | 12 | final class AppleMusicURLComponentsTests: XCTestCase { 13 | func testInit() { 14 | let appleMusicURLComponents = AppleMusicURLComponents() 15 | let expectedURL = URL(string: "https://api.music.apple.com") 16 | 17 | XCTAssertNotNil(appleMusicURLComponents.url) 18 | XCTAssertEqual(appleMusicURLComponents.url?.scheme, "https") 19 | XCTAssertEqual(appleMusicURLComponents.url?.host, "api.music.apple.com") 20 | XCTAssertEqual(appleMusicURLComponents.url, expectedURL) 21 | } 22 | 23 | func testQueryItems() { 24 | var appleMusicURLComponents = AppleMusicURLComponents() 25 | let chartQuery = URLQueryItem(name: "filter[storefront-chart]", value: "in") 26 | let identityQuery = URLQueryItem(name: "filter[identity]", value: "personal") 27 | let queryItems: [URLQueryItem] = [chartQuery, identityQuery] 28 | appleMusicURLComponents.queryItems = queryItems 29 | 30 | XCTAssertEqual(appleMusicURLComponents.queryItems, queryItems) 31 | } 32 | 33 | func testPath() { 34 | var appleMusicURLComponents = AppleMusicURLComponents() 35 | let path = "catalog/us/songs" 36 | appleMusicURLComponents.path = path 37 | 38 | XCTAssertEqual(appleMusicURLComponents.path, "/v1/" + path) 39 | } 40 | 41 | func testURL() { 42 | var appleMusicURLComponents = AppleMusicURLComponents() 43 | let path = "catalog/us/songs" 44 | let idsQuery = URLQueryItem(name: "ids", value: "1234,5678") 45 | let relationshipQuery = URLQueryItem(name: "extend", value: "artists") 46 | let queryItems: [URLQueryItem] = [idsQuery, relationshipQuery] 47 | appleMusicURLComponents.path = path 48 | appleMusicURLComponents.queryItems = queryItems 49 | 50 | let expectedURL = URL(string: "https://api.music.apple.com/v1/catalog/us/songs?ids=1234,5678&extend=artists") 51 | XCTAssertEqual(appleMusicURLComponents.url, expectedURL) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Tests/MusadoraKitTests/Catalog/CatalogChartTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CatalogChartTests.swift 3 | // MusadoraKitTests 4 | // 5 | // Created by Rudrank Riyam on 25/03/23. 6 | // 7 | 8 | @testable import MusadoraKit 9 | import MusicKit 10 | import XCTest 11 | 12 | final class CatalogChartTests: XCTestCase { 13 | func testChartsWithSongTypeURL() async throws { 14 | let request = MChartRequest(types: [Song.self]) 15 | let endpointURL = try request.chartsURL(storefront: "us") 16 | let url = "https://api.music.apple.com/v1/catalog/us/charts?types=songs" 17 | 18 | XCTAssertEqualEndpoint(endpointURL, url) 19 | } 20 | 21 | func testChartsWithSongWithGenreTypeURL() async throws { 22 | var request = MChartRequest(types: [Song.self]) 23 | request.genre = MusicItemID("21") 24 | let endpointURL = try request.chartsURL(storefront: "us") 25 | let url = "https://api.music.apple.com/v1/catalog/us/charts?types=songs&genre=21" 26 | 27 | XCTAssertEqualEndpoint(endpointURL, url) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Tests/MusadoraKitTests/Catalog/CatalogGenreEndpointTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CatalogGenreEndpointTests.swift 3 | // MusadoraKitTests 4 | // 5 | // Created by Rudrank Riyam on 21/03/23. 6 | // 7 | 8 | import Foundation 9 | 10 | @testable import MusadoraKit 11 | import MusicKit 12 | import XCTest 13 | 14 | final class CatalogGenreEndpointTests: XCTestCase { 15 | func testTopGenresURL() throws { 16 | let storefront = "in" 17 | let url = "https://api.music.apple.com/v1/catalog/in/genres" 18 | let endpointURL = try MCatalog.topGenresURL(storefront: storefront) 19 | 20 | XCTAssertEqualEndpoint(endpointURL, url) 21 | } 22 | 23 | func testStationGenresURL() throws { 24 | let storefront = "in" 25 | let url = "https://api.music.apple.com/v1/catalog/in/station-genres" 26 | let endpointURL = try MCatalog.stationGenresURL(storefront: storefront) 27 | 28 | XCTAssertEqualEndpoint(endpointURL, url) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Tests/MusadoraKitTests/Catalog/CatalogPlaylistEndpointTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CatalogPlaylistEndpointTests.swift 3 | // MusadoraKitTests 4 | // 5 | // Created by Rudrank Riyam on 20/03/23. 6 | // 7 | 8 | @testable import MusadoraKit 9 | import MusicKit 10 | import XCTest 11 | 12 | struct BadURLAppleMusicURLComponents: MURLComponents { 13 | var path: String = "" 14 | var queryItems: [URLQueryItem]? 15 | 16 | var url: URL? { 17 | return nil 18 | } 19 | } 20 | 21 | final class CatalogPlaylistEndpointTests: XCTestCase { 22 | func testChartPlaylistsURLWithoutStorefront() throws { 23 | let currentStorefront = "in" 24 | let url = "https://api.music.apple.com/v1/catalog/in/playlists?filter%5Bstorefront-chart%5D=in" 25 | let endpointURL = try MCatalog.chartPlaylistsURL(currentStorefront: currentStorefront) 26 | 27 | XCTAssertEqualEndpoint(endpointURL, url) 28 | } 29 | 30 | func testChartPlaylistsURLWithStorefront() throws { 31 | let currentStorefront = "in" 32 | let targetStorefront = "ca" 33 | let url = "https://api.music.apple.com/v1/catalog/in/playlists?filter%5Bstorefront-chart%5D=ca" 34 | let endpointURL = try MCatalog.chartPlaylistsURL(currentStorefront: currentStorefront, targetStorefront: targetStorefront) 35 | 36 | XCTAssertEqualEndpoint(endpointURL, url) 37 | } 38 | 39 | func testChartPlaylistsURLWithBadURL() throws { 40 | let currentStorefront = "in" 41 | let components = BadURLAppleMusicURLComponents() 42 | 43 | XCTAssertThrowsError(try MCatalog.chartPlaylistsURL(currentStorefront: currentStorefront, components: components)) { error in 44 | XCTAssertEqual(error as? URLError, URLError(.badURL)) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Tests/MusadoraKitTests/Catalog/CatalogSearchTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CatalogSearchTests.swift 3 | // MusadoraKitTests 4 | // 5 | // Created by Rudrank Riyam on 23/03/23. 6 | // 7 | 8 | import Foundation 9 | @testable import MusadoraKit 10 | import MusicKit 11 | import XCTest 12 | 13 | @available(iOS 15.4, macOS 12.3, tvOS 15.4, watchOS 9.0, *) 14 | class CatalogSearchTests: XCTestCase { 15 | func testSearchTypesAll() { 16 | let allTypes = MCatalogSearchTypes.all 17 | 18 | XCTAssertTrue(allTypes.contains(.songs)) 19 | XCTAssertTrue(allTypes.contains(.albums)) 20 | XCTAssertTrue(allTypes.contains(.playlists)) 21 | XCTAssertTrue(allTypes.contains(.artists)) 22 | XCTAssertTrue(allTypes.contains(.stations)) 23 | XCTAssertTrue(allTypes.contains(.recordLabels)) 24 | 25 | XCTAssertTrue(allTypes.contains(.musicVideos)) 26 | XCTAssertTrue(allTypes.contains(.curators)) 27 | XCTAssertTrue(allTypes.contains(.radioShows)) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Tests/MusadoraKitTests/Catalog/CatalogStationEndpointTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CatalogStationEndpointTests.swift 3 | // MusadoraKitTests 4 | // 5 | // Created by Rudrank Riyam on 20/03/23. 6 | // 7 | 8 | @testable import MusadoraKit 9 | import MusicKit 10 | import XCTest 11 | 12 | final class CatalogStationEndpointTests: XCTestCase { 13 | func testPersonalStationEndpointURL() throws { 14 | let storefront = "in" 15 | let endpointURL = try MCatalog.personalStationURL(for: storefront) 16 | print(endpointURL) 17 | 18 | let url = "https://api.music.apple.com/v1/catalog/in/stations?filter%5Bidentity%5D=personal" 19 | 20 | XCTAssertEqualEndpoint(endpointURL, url) 21 | } 22 | 23 | func testLiveStationsURL() throws { 24 | let storefront = "in" 25 | let endpointURL = try MCatalog.liveStationsURL(for: storefront) 26 | let url = "https://api.music.apple.com/v1/catalog/in/stations?filter%5Bfeatured%5D=apple-music-live-radio" 27 | 28 | XCTAssertEqualEndpoint(endpointURL, url) 29 | } 30 | 31 | func testGenreStationsURL() throws { 32 | let storefront = "in" 33 | let endpointURL = try MCatalog.stationsURL(for: "12345", storefront: storefront) 34 | let url = "https://api.music.apple.com/v1/catalog/in/station-genres/12345/stations" 35 | 36 | XCTAssertEqualEndpoint(endpointURL, url) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Tests/MusadoraKitTests/Catalog/CatalogStationGenreTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CatalogStationGenreTests.swift 3 | // MusadoraKitTests 4 | // 5 | // Created by Rudrank Riyam on 24/03/23. 6 | // 7 | 8 | @testable import MusadoraKit 9 | import MusicKit 10 | import XCTest 11 | 12 | final class CatalogStationGenreTests: XCTestCase { 13 | func testDecoding() throws { 14 | let jsonData = """ 15 | { 16 | "id": "1", 17 | "type": "station", 18 | "attributes": { 19 | "name": "Pop" 20 | } 21 | } 22 | """.data(using: .utf8)! 23 | 24 | let decoder = JSONDecoder() 25 | let stationGenre = try decoder.decode(StationGenre.self, from: jsonData) 26 | 27 | XCTAssertEqual(stationGenre.id, "1") 28 | XCTAssertEqual(stationGenre.type, "station") 29 | XCTAssertEqual(stationGenre.name, "Pop") 30 | } 31 | 32 | func testEquatable() { 33 | let stationGenre1 = StationGenre(id: "1", type: "station", name: "Pop") 34 | let stationGenre2 = StationGenre(id: "1", type: "station", name: "Pop") 35 | let stationGenre3 = StationGenre(id: "2", type: "station", name: "Rock") 36 | 37 | XCTAssertTrue(stationGenre1 == stationGenre2) 38 | XCTAssertFalse(stationGenre1 == stationGenre3) 39 | } 40 | 41 | func testHashable() { 42 | let stationGenre1 = StationGenre(id: "1", type: "station", name: "Pop") 43 | let stationGenre2 = StationGenre(id: "1", type: "station", name: "Pop") 44 | let stationGenre3 = StationGenre(id: "2", type: "station", name: "Rock") 45 | 46 | XCTAssertEqual(stationGenre1.hashValue, stationGenre2.hashValue) 47 | XCTAssertNotEqual(stationGenre1.hashValue, stationGenre3.hashValue) 48 | } 49 | 50 | static let allTests = [ 51 | ("testDecoding", testDecoding), 52 | ("testEquatable", testEquatable), 53 | ("testHashable", testHashable) 54 | ] 55 | } 56 | -------------------------------------------------------------------------------- /Tests/MusadoraKitTests/Catalog/CatalogSuggestionsTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CatalogSuggestionsTests.swift 3 | // MusadoraKitTests 4 | // 5 | // Created by Rudrank Riyam on 22/03/23. 6 | // 7 | 8 | @testable import MusadoraKit 9 | import MusicKit 10 | import XCTest 11 | 12 | @available(iOS 15.4, macOS 12.3, tvOS 15.4, watchOS 9.0, *) 13 | class CatalogSuggestionsTests: XCTestCase { 14 | } 15 | -------------------------------------------------------------------------------- /Tests/MusadoraKitTests/Equivalents/EquivalentItemsEndpointTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EquivalentItemsEndpointTests.swift 3 | // MusadoraKitTest 4 | // 5 | // Created by Rudrank Riyam on 20/03/23. 6 | // 7 | 8 | import Foundation 9 | 10 | @testable import MusadoraKit 11 | import MusicKit 12 | import XCTest 13 | 14 | final class EquivalentItemsEndpointTests: XCTestCase { 15 | func testCleanEquivalentEndpointURL() throws { 16 | let storefront = "in" 17 | let song = try Song.mock 18 | let endpointURL = try song.cleanEquivalentURL(storefront: storefront, path: .songs) 19 | let url = "https://api.music.apple.com/v1/catalog/in/songs?filter%5Bequivalents%5D=1640832991&restrict=explicit" 20 | 21 | XCTAssertEqualEndpoint(endpointURL, url) 22 | } 23 | 24 | func testCleanEquivalentsEndpointURL() throws { 25 | let storefront = "in" 26 | let songs = try Song.mocks 27 | let endpointURL = try songs.cleanEquivalentsURL(storefront: storefront, path: .songs) 28 | let url = "https://api.music.apple.com/v1/catalog/in/songs?filter%5Bequivalents%5D=1640832991,1492318640&restrict=explicit" 29 | 30 | XCTAssertEqualEndpoint(endpointURL, url) 31 | } 32 | 33 | func testEquivalentEndpointURL() throws { 34 | let storefront = "tw" 35 | let song = try Song.mock 36 | let endpointURL = try song.equivalentURL(storefront: storefront, path: .songs) 37 | let url = "https://api.music.apple.com/v1/catalog/tw/songs?filter%5Bequivalents%5D=1640832991" 38 | 39 | XCTAssertEqualEndpoint(endpointURL, url) 40 | } 41 | 42 | func testEquivalentsEndpointURL() throws { 43 | let storefront = "tw" 44 | let songs = try Song.mocks 45 | let endpointURL = try songs.equivalentsURL(storefront: storefront, path: .songs) 46 | let url = "https://api.music.apple.com/v1/catalog/tw/songs?filter%5Bequivalents%5D=1640832991,1492318640" 47 | 48 | XCTAssertEqualEndpoint(endpointURL, url) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Tests/MusadoraKitTests/Library/MLibrarySearchRequestEndpointTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MLibrarySearchRequestEndpointTests.swift 3 | // MusadoraKitTests 4 | // 5 | // Created by Rudrank Riyam on 01/08/22. 6 | // 7 | 8 | @testable import MusadoraKit 9 | import MusicKit 10 | import XCTest 11 | 12 | final class MLibrarySearchRequestEndpointTests: XCTestCase { 13 | func testLibrarySearchWithSingleTermAndTypeAsSong() throws { 14 | let term = "ed" 15 | let request = MLibrarySearchRequest(term: term, types: [Song.self]) 16 | let url = try request.librarySearchEndpointURL 17 | 18 | XCTAssertEqualEndpoint(url, "https://api.music.apple.com/v1/me/library/search?term=ed&types=library-songs") 19 | } 20 | 21 | func testLibrarySearchWithSingleTermAndTypeAsMusicVideo() throws { 22 | let term = "MAX" 23 | let request = MLibrarySearchRequest(term: term, types: [MusicVideo.self]) 24 | let url = try request.librarySearchEndpointURL 25 | 26 | XCTAssertEqualEndpoint(url, "https://api.music.apple.com/v1/me/library/search?term=max&types=library-music-videos") 27 | } 28 | 29 | func testLibrarySearchWithSingleTermAndTypeAsSongAndArtist() throws { 30 | let term = "ed" 31 | let request = MLibrarySearchRequest(term: term, types: [Song.self, Artist.self]) 32 | let endpointURL = try request.librarySearchEndpointURL 33 | let url = "https://api.music.apple.com/v1/me/library/search?term=ed&types=library-songs,library-artists" 34 | XCTAssertEqualEndpoint(endpointURL, url) 35 | } 36 | 37 | func testLibrarySearchWithMultipleTermsAndTypeAsSongAndArtist() throws { 38 | let term = "ed sheeran" 39 | let request = MLibrarySearchRequest(term: term, types: [Song.self, Artist.self]) 40 | let endpointURL = try request.librarySearchEndpointURL 41 | let url = "https://api.music.apple.com/v1/me/library/search?term=ed+sheeran&types=library-songs,library-artists" 42 | 43 | XCTAssertEqualEndpoint(endpointURL, url) 44 | } 45 | 46 | func testLibrarySearchWithMultipleTermsAndTypeAsSongAndArtistWithLimit() throws { 47 | let term = "ed sh" 48 | var request = MLibrarySearchRequest(term: term, types: [Song.self, Artist.self]) 49 | request.limit = 2 50 | let endpointURL = try request.librarySearchEndpointURL 51 | let url = "https://api.music.apple.com/v1/me/library/search?term=ed+sh&types=library-songs,library-artists&limit=2" 52 | 53 | XCTAssertEqualEndpoint(endpointURL, url) 54 | } 55 | 56 | func testLibrarySearchWithMultipleTermsAndTypeAsSongAndArtistWithOffset() throws { 57 | let term = "ed sh" 58 | var request = MLibrarySearchRequest(term: term, types: [Song.self, Artist.self]) 59 | request.offset = 2 60 | let endpointURL = try request.librarySearchEndpointURL 61 | let url = "https://api.music.apple.com/v1/me/library/search?term=ed+sh&types=library-songs,library-artists&offset=2" 62 | 63 | XCTAssertEqualEndpoint(endpointURL, url) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Tests/MusadoraKitTests/LibraryPlaylistTests.swift: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Tests/MusadoraKitTests/MusadoraKitTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MusadoraKitTests.swift 3 | // MusadoraKitTests 4 | // 5 | // Created by Rudrank Riyam on 19/04/22. 6 | // 7 | 8 | @testable import MusadoraKit 9 | import XCTest 10 | 11 | final class MusadoraKitTests: XCTestCase {} 12 | 13 | public func XCTAssertEqualEndpoint(_ endpoint: URL, _ url: String) { 14 | let url = URL(string: url)! 15 | XCTAssertEqual(endpoint, url) 16 | } 17 | -------------------------------------------------------------------------------- /Tests/MusadoraKitTests/Music Items/Song.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Song.swift 3 | // MusadoraKitTests 4 | // 5 | // Created by Rudrank Riyam on 08/03/23. 6 | // 7 | 8 | import Foundation 9 | import MusadoraKit 10 | 11 | extension Song { 12 | static var mock: Song { 13 | get throws { 14 | let songData = """ 15 | { 16 | "id":"1640832991", 17 | "type":"songs", 18 | "attributes":{ 19 | "name":"Glimpse of Us", 20 | "artistName":"Joji" 21 | } 22 | } 23 | """.data(using: .utf8) 24 | 25 | guard let songData else { 26 | throw URLError(.cannotDecodeRawData) 27 | } 28 | 29 | let song = try JSONDecoder().decode(Song.self, from: songData) 30 | return song 31 | } 32 | } 33 | 34 | static var mocks: Songs { 35 | get throws { 36 | let songsData = """ 37 | { 38 | "data":[ 39 | { 40 | "id":"1640832991", 41 | "type":"songs", 42 | "attributes":{ 43 | "name":"Glimpse of Us", 44 | "artistName":"Joji" 45 | } 46 | }, 47 | { 48 | "id":"1492318640", 49 | "type":"songs", 50 | "attributes":{ 51 | "name":"Guilty Conscience", 52 | "artistName":"070 Shake" 53 | } 54 | } 55 | ] 56 | } 57 | """.data(using: .utf8) 58 | 59 | guard let songsData else { 60 | throw URLError(.cannotDecodeRawData) 61 | } 62 | 63 | let songs = try JSONDecoder().decode(Songs.self, from: songsData) 64 | return songs 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Tests/MusadoraKitTests/Music Items/Station.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Station.swift 3 | // MusadoraKitTests 4 | // 5 | // Created by Rudrank Riyam on 08/03/23. 6 | // 7 | 8 | import Foundation 9 | import MusicKit 10 | 11 | extension Station { 12 | static var mock: Station { 13 | get throws { 14 | let stationData = """ 15 | { 16 | "id": "ra.1440541046", 17 | "type": "stations", 18 | "attributes": { 19 | "name": "Apple Music Presents: Little Mix - Live from London", 20 | "isLive": true 21 | } 22 | } 23 | """.data(using: .utf8) 24 | 25 | guard let stationData else { 26 | throw URLError(.cannotDecodeRawData) 27 | } 28 | 29 | let station = try JSONDecoder().decode(Station.self, from: stationData) 30 | return station 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Tests/MusadoraKitTests/Ratings/MusicCatalogRatingRequestTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MusicCatalogRatingRequestTests.swift 3 | // MusicCatalogRatingRequestTests 4 | // 5 | // Created by Rudrank Riyam on 17/07/22. 6 | // 7 | 8 | @testable import MusadoraKit 9 | import MusicKit 10 | import XCTest 11 | 12 | final class MusicCatalogRatingRequestTests: XCTestCase { 13 | func testAddPersonalAlbumRatingEndpointURL() throws { 14 | let id: MusicItemID = "1138988512" 15 | let request = MCatalogRatingRequest(with: id, item: .album) 16 | let url = try request.url 17 | XCTAssertEqualEndpoint(url, "https://api.music.apple.com/v1/me/ratings/albums?ids=1138988512") 18 | } 19 | 20 | func testAddPersonalSongRatingEndpointURL() throws { 21 | let id: MusicItemID = "907242702" 22 | let request = MCatalogRatingRequest(with: id, item: .song) 23 | let url = try request.url 24 | XCTAssertEqualEndpoint(url, "https://api.music.apple.com/v1/me/ratings/songs?ids=907242702") 25 | } 26 | 27 | func testAddPersonalPlaylistRatingEndpointURL() throws { 28 | let id: MusicItemID = "907242702" 29 | let request = MCatalogRatingRequest(with: id, item: .playlist) 30 | let url = try request.url 31 | XCTAssertEqualEndpoint(url, "https://api.music.apple.com/v1/me/ratings/playlists?ids=907242702") 32 | } 33 | 34 | func testAddPersonalMusicVideoRatingEndpointURL() throws { 35 | let id: MusicItemID = "907242702" 36 | let request = MCatalogRatingRequest(with: id, item: .musicVideo) 37 | let url = try request.url 38 | XCTAssertEqualEndpoint(url, "https://api.music.apple.com/v1/me/ratings/music-videos?ids=907242702") 39 | } 40 | 41 | func testAddPersonalStationRatingEndpointURL() throws { 42 | let id: MusicItemID = "907242702" 43 | let request = MCatalogRatingRequest(with: id, item: .station) 44 | let url = try request.url 45 | XCTAssertEqualEndpoint(url, "https://api.music.apple.com/v1/me/ratings/stations?ids=907242702") 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Tests/MusadoraKitTests/Ratings/MusicLibraryRatingRequestTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MusicLibraryRatingRequestTests.swift 3 | // MusicLibraryRatingRequestTests 4 | // 5 | // Created by Rudrank Riyam on 17/11/22. 6 | // 7 | 8 | @testable import MusadoraKit 9 | import MusicKit 10 | import XCTest 11 | 12 | final class MusicLibraryRatingRequestTests: XCTestCase { 13 | func testAddPersonalLibraryAlbumRatingEndpointURL() throws { 14 | let id: MusicItemID = "1138988512" 15 | let request = MLibraryRatingRequest(with: [id], item: .album) 16 | let url = try request.libraryRatingsEndpointURL 17 | XCTAssertEqualEndpoint(url, "https://api.music.apple.com/v1/me/ratings/library-albums?ids=1138988512") 18 | } 19 | 20 | func testAddPersonalLibraryPlaylistRatingEndpointURL() throws { 21 | let id: MusicItemID = "1138988512" 22 | let request = MLibraryRatingRequest(with: [id], item: .playlist) 23 | let url = try request.libraryRatingsEndpointURL 24 | XCTAssertEqualEndpoint(url, "https://api.music.apple.com/v1/me/ratings/library-playlists?ids=1138988512") 25 | } 26 | 27 | func testAddPersonalLibrarySongRatingEndpointURL() throws { 28 | let id: MusicItemID = "1138988512" 29 | let request = MLibraryRatingRequest(with: [id], item: .song) 30 | let url = try request.libraryRatingsEndpointURL 31 | XCTAssertEqualEndpoint(url, "https://api.music.apple.com/v1/me/ratings/library-songs?ids=1138988512") 32 | } 33 | 34 | func testAddPersonalLibraryMusicVideoRatingEndpointURL() throws { 35 | let id: MusicItemID = "1138988512" 36 | let request = MLibraryRatingRequest(with: [id], item: .musicVideo) 37 | let url = try request.libraryRatingsEndpointURL 38 | XCTAssertEqualEndpoint(url, "https://api.music.apple.com/v1/me/ratings/library-music-videos?ids=1138988512") 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Tests/MusadoraKitTests/Recommendation/MusicRecommendationRequestEndpointTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MusicRecommendationRequestEndpointTests.swift 3 | // MusicRecommendationRequestEndpointTests 4 | // 5 | // Created by Rudrank Riyam on 27/04/22. 6 | // 7 | 8 | @testable import MusadoraKit 9 | import XCTest 10 | 11 | class MusicRecommendationRequestEndpointTests: XCTestCase { 12 | func testDefaultRecommendationEndpointURL() throws { 13 | let request = MRecommendationRequest() 14 | let url = try request.recommendationEndpointURL 15 | 16 | XCTAssertEqualEndpoint(url, "https://api.music.apple.com/v1/me/recommendations") 17 | } 18 | 19 | func testDefaultRecommendationWithLimitEndpointURL() throws { 20 | var request = MRecommendationRequest() 21 | request.limit = 5 22 | let url = try request.recommendationEndpointURL 23 | 24 | XCTAssertEqualEndpoint(url, "https://api.music.apple.com/v1/me/recommendations?limit=5") 25 | } 26 | 27 | func testRecommendationByIDEndpointURL() throws { 28 | let request = MRecommendationRequest(equalTo: "6-27s5hU6azhJY") 29 | let url = try request.recommendationEndpointURL 30 | 31 | XCTAssertEqualEndpoint(url, "https://api.music.apple.com/v1/me/recommendations?ids=6-27s5hU6azhJY") 32 | } 33 | 34 | func testDefaultRecommendationEndpointURLWithOverLimit() throws { 35 | let limit = 31 36 | var request = MRecommendationRequest() 37 | request.limit = limit 38 | 39 | XCTAssertThrowsError(try request.recommendationEndpointURL) { recommendationOverLimitError in 40 | let error = MusadoraKitError.recommendationOverLimit(for: limit) 41 | XCTAssertEqual(recommendationOverLimitError as? MusadoraKitError, error) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Tests/MusadoraKitTests/Summaries/MSummaryRequestEndpointTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MSummaryRequestEndpointTests.swift 3 | // MusadoraKitTests 4 | // 5 | // Created by Codex on 02/09/25. 6 | // 7 | 8 | @testable import MusadoraKit 9 | import XCTest 10 | 11 | final class MSummaryRequestEndpointTests: XCTestCase { 12 | 13 | func testDefaultEndpointURL_AllViews() throws { 14 | let request = MSummaryRequest() 15 | let url = try request.endpointURL 16 | // Note: views are sorted alphabetically in implementation 17 | // Default include and extend parameters are added automatically 18 | XCTAssertEqualEndpoint( 19 | url, 20 | "https://api.music.apple.com/v1/me/music-summaries?filter[year]=latest&views=top-albums,top-artists,top-songs&include=artist,album,song&extend=artistBio,editorialVideo" 21 | ) 22 | } 23 | 24 | func testEndpointURL_TopSongsOnly() throws { 25 | var request = MSummaryRequest() 26 | request.views = [.topSongs] 27 | let url = try request.endpointURL 28 | // Default include and extend parameters are added automatically 29 | XCTAssertEqualEndpoint( 30 | url, 31 | "https://api.music.apple.com/v1/me/music-summaries?filter[year]=latest&views=top-songs&include=artist,album,song&extend=artistBio,editorialVideo" 32 | ) 33 | } 34 | 35 | func testEndpointURL_LanguageIncludeExtend() throws { 36 | var request = MSummaryRequest() 37 | request.views = [.topArtists] 38 | request.languageTag = "en-US" 39 | request.include = ["relationships"] 40 | request.extend = ["extended-attributes"] 41 | 42 | let url = try request.endpointURL 43 | 44 | // Order of query items is deterministic in our builder (filter -> views -> include -> extend -> l) 45 | XCTAssertEqualEndpoint( 46 | url, 47 | "https://api.music.apple.com/v1/me/music-summaries?filter[year]=latest&views=top-artists&include=relationships&extend=extended-attributes&l=en-US" 48 | ) 49 | } 50 | } 51 | 52 | -------------------------------------------------------------------------------- /codemagic.yaml: -------------------------------------------------------------------------------- 1 | definitions: 2 | triggering: 3 | push: &events 4 | events: 5 | - push 6 | - pull_request 7 | notify: 8 | success: true 9 | failure: true 10 | workflows: 11 | musadorakit: 12 | name: MusadoraKit Workflow 13 | environment: 14 | xcode: 26.0 15 | vars: 16 | XCODE_SCHEME: "MusadoraKit" 17 | APP_ID: "Musadora" 18 | when: 19 | changeset: 20 | includes: 21 | - 'Sources' 22 | - 'Tests' 23 | triggering: 24 | <<: *events 25 | scripts: 26 | - name: Build Framework 27 | script: | 28 | #!/bin/zsh 29 | 30 | declare -a DESTINATIONS=("platform=iOS Simulator,name=iPhone 15" "platform=watchOS Simulator,name=Apple Watch Ultra 2 (49mm)" "platform=tvOS Simulator,name=Apple TV 4K (3rd generation)" "platform=macOS" "platform=visionOS Simulator,name=Apple Vision Pro") 31 | for DESTINATION in "${DESTINATIONS[@]}" 32 | do 33 | xcodebuild clean build \ 34 | -scheme "$XCODE_SCHEME" \ 35 | -destination "$DESTINATION" \ 36 | -skipPackagePluginValidation 37 | done 38 | - name: Test Framework 39 | script: | 40 | #!/bin/zsh 41 | 42 | declare -a DESTINATIONS=("platform=iOS Simulator,name=iPhone 15" "platform=watchOS Simulator,name=Apple Watch Ultra 2 (49mm)" "platform=tvOS Simulator,name=Apple TV 4K (3rd generation)" "platform=macOS" "platform=visionOS Simulator,name=Apple Vision Pro") 43 | for DESTINATION in "${DESTINATIONS[@]}" 44 | do 45 | set -o pipefail 46 | xcodebuild clean test \ 47 | -scheme "$XCODE_SCHEME" \ 48 | -destination "$DESTINATION" \ 49 | -skipPackagePluginValidation | xcpretty --report junit 50 | done 51 | test_report: build/reports/junit.xml 52 | --------------------------------------------------------------------------------