diff --git a/.github/workflows/spotify-advanced.yml b/.github/workflows/spotify-seasonal.yml similarity index 83% rename from .github/workflows/spotify-advanced.yml rename to .github/workflows/spotify-seasonal.yml index a0c5ddb2..5b013d1e 100644 --- a/.github/workflows/spotify-advanced.yml +++ b/.github/workflows/spotify-seasonal.yml @@ -1,10 +1,8 @@ -name: Manually trigger the action +name: Save seasonal playlist + on: - workflow_dispatch: - inputs: - playlistName: - type: string - description: The name of the Spotify playlist. + schedule: + - cron: "00 01 20 Mar,Jun,Sep,Dec *" jobs: spotify-to-yaml: diff --git a/.github/workflows/spotify.yml b/.github/workflows/spotify.yml index 73560d67..d34b654b 100644 --- a/.github/workflows/spotify.yml +++ b/.github/workflows/spotify.yml @@ -1,7 +1,12 @@ name: Save Spotify playlist + on: - schedule: - - cron: "00 01 20 Mar,Jun,Sep,Dec *" + workflow_dispatch: + inputs: + playlist-name: + description: Your Spotify playlist name that you want to export. Required for non-seasonal playlist export. + required: true + type: string jobs: spotify-to-yaml: diff --git a/README.md b/README.md index 994e03a5..63e53d5c 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,28 @@ # spotify-to-yaml-action -Export a seasonal Spotify playlist to YAML. +Export a Spotify playlist to YAML. -At the end of each season, the workflow will fetch last season's playlists, add the the contents to `_data/playlist.yml` and save the playlist thumbnail image to the repository. +This workflow can: + +- Export your Spotify playlist to yaml. +- Fetch last season's playlists, add the the contents to `_data/playlist.yml` and save the playlist thumbnail image to the repository. ## Set up -This workflow requires that you name your Spotify playlists using the following format: `YYYY {season}`. If you use different names for the seasons, you can use the `season-names` [action input](#action-options) to reflect that. Examples: +To connect your Spotify account to this workflow, set the following [secrets to your repository](https://docs.github.com/en/actions/security-guides/encrypted-secrets#creating-encrypted-secrets-for-a-repository). You can find these values from the [Spotify API dashboard](https://developer.spotify.com/dashboard): + +- `SpotifyClientID` +- `SpotifyClientSecret` + +## Seasonal set up + +To take part in season playlist export, you will need to name your Spotify playlists with the following pattern: `YYYY {season}`. If you use different names for the seasons, you can use the `season-names` [action input](#action-options) to reflect that. Examples: - `2021 Fall` - `2021/2022 Winter` - `2022 Spring` - `2022 Summer` -You must also set the following [secrets to your repository](https://docs.github.com/en/actions/security-guides/encrypted-secrets#creating-encrypted-secrets-for-a-repository) to connect to Spotify. You can find these values from the [Spotify API dashboard](https://developer.spotify.com/dashboard): - -- `SpotifyClientID` -- `SpotifyClientSecret` - ## Set up the workflow @@ -26,9 +31,14 @@ To use this action, create a new workflow in `.github/workflows` and modify it a ```yml name: Save Spotify playlist + on: - schedule: - - cron: "00 01 20 Mar,Jun,Sep,Dec *" + workflow_dispatch: + inputs: + playlist-name: + description: Your Spotify playlist name that you want to export. Required for non-seasonal playlist export. + required: true + type: string jobs: spotify-to-yaml: @@ -59,16 +69,14 @@ jobs: ### Additional example workflows
-Manually trigger the action +Save seasonal playlist ```yml -name: Manually trigger the action +name: Save seasonal playlist + on: - workflow_dispatch: - inputs: - playlistName: - type: string - description: The name of the Spotify playlist. + schedule: + - cron: "00 01 20 Mar,Jun,Sep,Dec *" jobs: spotify-to-yaml: @@ -105,4 +113,18 @@ jobs: - `filename`: The YAML file to write your playlists. Default: `_data/playlists.yml`. - `season-names`: The season names in order by the season that ends in March, June, September, and then December. Default: `Winter,Spring,Summer,Fall`. + +## Trigger the action + +To trigger the action, [create a workflow dispatch event](https://docs.github.com/en/rest/actions/workflows#create-a-workflow-dispatch-event) with the following body parameters: + +```js +{ + "ref": "main", // Required. The git reference for the workflow, a branch or tag name. + "inputs": { + "playlist-name": "", // Required. Your Spotify playlist name that you want to export. Required for non-seasonal playlist export. + } +} +``` + diff --git a/dist/index.js b/dist/index.js index f585e620..63882eee 100644 --- a/dist/index.js +++ b/dist/index.js @@ -49298,49 +49298,36 @@ var jsYaml = { ;// CONCATENATED MODULE: ./src/write-file.ts -var __awaiter = (undefined && undefined.__awaiter) || function (thisArg, _arguments, P, generator) { - function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } - return new (P || (P = Promise))(function (resolve, reject) { - function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } - function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } - function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } - step((generator = generator.apply(thisArg, _arguments || [])).next()); - }); -}; -function updateMain(data, filename) { - return __awaiter(this, void 0, void 0, function* () { - try { - const newContents = yield buildNewMain(data, filename); - return yield (0,promises_namespaceObject.writeFile)(filename, newContents); - } - catch (error) { - throw new Error(error); - } - }); +async function updateMain(data, filename) { + try { + const newContents = await buildNewMain(data, filename); + return await (0,promises_namespaceObject.writeFile)(filename, newContents); + } + catch (error) { + throw new Error(error); + } } -function buildNewMain(data, filename) { - return __awaiter(this, void 0, void 0, function* () { - try { - const currentPlaylists = (yield (0,promises_namespaceObject.readFile)(filename, "utf-8")) || ""; - const currentJson = load(currentPlaylists); - const newPlaylist = { - playlist: data.name, - spotify: data.url, - tracks: data.tracks.map(({ name, artist, album }) => ({ - track: name, - artist, - album, - })), - }; - const json = [...(currentPlaylists && [...currentJson]), newPlaylist]; - return dump(json); - } - catch (error) { - throw new Error(error); - } - }); +async function buildNewMain(data, filename) { + try { + const currentPlaylists = (await (0,promises_namespaceObject.readFile)(filename, "utf-8")) || ""; + const currentJson = load(currentPlaylists); + const newPlaylist = { + playlist: data.name, + spotify: data.url, + tracks: data.tracks.map(({ name, artist, album }) => ({ + track: name, + artist, + album, + })), + }; + const json = [...(currentPlaylists && [...currentJson]), newPlaylist]; + return dump(json); + } + catch (error) { + throw new Error(error); + } } // EXTERNAL MODULE: ./node_modules/@actions/core/lib/core.js @@ -49351,33 +49338,28 @@ var github = __nccwpck_require__(5438); function learnPlaylistName() { - let playlistName; const payload = github.context.payload.inputs; - if (payload && payload.playlistName) { - playlistName = payload.playlistName; - } - if (!playlistName) { - const today = new Date(); - const month = process.env.MONTH - ? parseInt(process.env.MONTH) - : today.getMonth(); - const year = process.env.YEAR - ? parseInt(process.env.YEAR) - : today.getFullYear(); - const [marchEnd, juneEnd, septemberEnd, decemberEnd] = validateSeasonNames(); - const seasons = { - 2: marchEnd, - 5: juneEnd, - 8: septemberEnd, - 11: decemberEnd, - }; - const season = seasons[month]; - if (!season) - throw new Error(`The current month does not match an end of season month.`); - playlistName = `${month === 2 ? `${year - 1}/${year}` : year} ${season}`; - } - (0,lib_core.exportVariable)("playlist", playlistName); - return playlistName; + if (payload && payload["playlist-name"]) { + return payload["playlist-name"]; + } + const today = new Date(); + const month = process.env.MONTH + ? parseInt(process.env.MONTH) + : today.getMonth(); + const year = process.env.YEAR + ? parseInt(process.env.YEAR) + : today.getFullYear(); + const [marchEnd, juneEnd, septemberEnd, decemberEnd] = validateSeasonNames(); + const seasons = { + 2: marchEnd, + 5: juneEnd, + 8: septemberEnd, + 11: decemberEnd, + }; + const season = seasons[month]; + if (!season) + throw new Error(`The current month does not match an end of season month.`); + return `${month === 2 ? `${year - 1}/${year}` : year} ${season}`; } function validateSeasonNames() { const seasonNames = (0,lib_core.getInput)("season-names") @@ -49392,88 +49374,80 @@ function validateSeasonNames() { var server = __nccwpck_require__(5337); var server_default = /*#__PURE__*/__nccwpck_require__.n(server); ;// CONCATENATED MODULE: ./src/list-playlists.ts -var list_playlists_awaiter = (undefined && undefined.__awaiter) || function (thisArg, _arguments, P, generator) { - function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } - return new (P || (P = Promise))(function (resolve, reject) { - function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } - function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } - function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } - step((generator = generator.apply(thisArg, _arguments || [])).next()); - }); -}; -function listPlaylists(listName) { - return list_playlists_awaiter(this, void 0, void 0, function* () { +async function listPlaylists(listName) { + try { const spotifyApi = new (server_default())({ clientId: process.env.SpotifyClientID, clientSecret: process.env.SpotifyClientSecret, }); const username = (0,lib_core.getInput)("spotify-username"); - const { body: { access_token }, } = yield spotifyApi.clientCredentialsGrant(); + const { body: { access_token }, } = await spotifyApi.clientCredentialsGrant(); spotifyApi.setAccessToken(access_token); - const { body } = yield spotifyApi.getUserPlaylists(username); + const { body } = await spotifyApi.getUserPlaylists(username); const findPlaylist = body.items.find(({ name }) => name === listName); if (!findPlaylist) { - (0,lib_core.setFailed)(`Could not find playlist "${listName}". Is it private?`); - return; + throw new Error(`Could not find playlist "${listName}". Is it private?`); } - const { body: { items }, } = yield spotifyApi.getPlaylistTracks(findPlaylist.id); + const { body: { items }, } = await spotifyApi.getPlaylistTracks(findPlaylist.id); + if (!items.length) + throw new Error("Playlist has no tracks."); return formatTracks({ name: findPlaylist.name, external_urls: findPlaylist.external_urls, images: findPlaylist.images, tracks: items, }); - }); + } + catch (error) { + throw new Error(error); + } } function formatTracks({ name, external_urls, images, tracks, }) { - const largestImage = images.sort((a, b) => b.width - a.width)[0]; + const largestImage = images.sort((a, b) => (b.width || 0) - (a.width || 0))[0]; return { name, - formatted_name: name.replace("/", "-").toLowerCase().replace(" ", "-"), + formatted_name: formatName(name), url: external_urls.spotify, tracks: tracks.map(({ track }) => ({ - name: track.name, - artist: track.artists.map(({ name }) => name).join(", "), - album: track.album.name, + name: track?.name, + artist: track?.artists.map(({ name }) => name).join(", "), + album: track?.album.name, })), image: largestImage.url, }; } +function formatName(name) { + return name + .replace(/\s/g, "-") + .replace(/[^a-zA-Z0-9-]/g, "") + .replace(/-+/g, "-") + .toLowerCase(); +} ;// CONCATENATED MODULE: ./src/index.ts -var src_awaiter = (undefined && undefined.__awaiter) || function (thisArg, _arguments, P, generator) { - function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } - return new (P || (P = Promise))(function (resolve, reject) { - function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } - function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } - function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } - step((generator = generator.apply(thisArg, _arguments || [])).next()); - }); -}; -function action() { - return src_awaiter(this, void 0, void 0, function* () { - try { - const filename = (0,lib_core.getInput)("filename"); - const playlistName = learnPlaylistName(); - const playlist = (yield listPlaylists(playlistName)); - // export image variable to be downloaded latter - (0,lib_core.exportVariable)("PlaylistImageOutput", `${playlist.formatted_name}.png`); - (0,lib_core.exportVariable)("PlaylistImage", playlist.image); - // replace Spotify image url with local version - playlist.image = `${playlist.formatted_name}.png`; - // save tracks to playlists.yml - yield updateMain(playlist, filename); - } - catch (error) { - (0,lib_core.setFailed)(error.message); - } - }); +async function action() { + try { + const filename = (0,lib_core.getInput)("filename"); + const playlistName = learnPlaylistName(); + const playlist = await listPlaylists(playlistName); + // export image variable to be downloaded latter + (0,lib_core.exportVariable)("playlist", playlistName); + (0,lib_core.exportVariable)("PlaylistImageOutput", `${playlist.formatted_name}.png`); + (0,lib_core.exportVariable)("PlaylistImage", playlist.image); + // replace Spotify image url with local version + playlist.image = `${playlist.formatted_name}.png`; + // save tracks to playlists.yml + await updateMain(playlist, filename); + } + catch (error) { + (0,lib_core.setFailed)(error.message); + } } /* harmony default export */ const src = (action()); diff --git a/package-lock.json b/package-lock.json index 6415a27b..99a25ea9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@types/jest": "^29.5.12", "@types/js-yaml": "^4.0.9", "@types/node": "^20.12.12", + "@types/spotify-web-api-node": "^5.0.11", "@vercel/ncc": "^0.38.1", "eslint": "^8.57.0", "eslint-plugin-jest": "^28.5.0", @@ -3021,6 +3022,21 @@ "undici-types": "~5.26.4" } }, + "node_modules/@types/spotify-api": { + "version": "0.0.25", + "resolved": "https://registry.npmjs.org/@types/spotify-api/-/spotify-api-0.0.25.tgz", + "integrity": "sha512-okhoy0U9fPWtwqCfbDyW8VxamhqvXE0gXIVeMOh5HcvEFQvWW2X0VsvdiX/OyiGQpZbZiOJXIGrbnIPfK0AIpA==", + "dev": true + }, + "node_modules/@types/spotify-web-api-node": { + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/@types/spotify-web-api-node/-/spotify-web-api-node-5.0.11.tgz", + "integrity": "sha512-RS3IkSqH9geC61e8qd+Oy7giOTtiY7ywm0Z4bu5uYuc7XuOcLfDwKjmle85IbpTEdazeCgmIbo8nMLg7WDVvgw==", + "dev": true, + "dependencies": { + "@types/spotify-api": "*" + } + }, "node_modules/@types/stack-utils": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", diff --git a/package.json b/package.json index 969388d8..fe03090d 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@types/jest": "^29.5.12", "@types/js-yaml": "^4.0.9", "@types/node": "^20.12.12", + "@types/spotify-web-api-node": "^5.0.11", "@vercel/ncc": "^0.38.1", "eslint": "^8.57.0", "eslint-plugin-jest": "^28.5.0", diff --git a/src/__test__/learn-playlist-name.test.ts b/src/__test__/learn-playlist-name.test.ts index 524b061d..89978c10 100644 --- a/src/__test__/learn-playlist-name.test.ts +++ b/src/__test__/learn-playlist-name.test.ts @@ -1,4 +1,3 @@ -import { exportVariable } from "@actions/core"; import learnPlaylistName from "../learn-playlist-name"; import * as core from "@actions/core"; import * as github from "@actions/github"; @@ -21,78 +20,58 @@ describe("learnPlaylistName", () => { test("Summer", () => { process.env.MONTH = "8"; expect(learnPlaylistName()).toEqual(`${process.env.YEAR} Summer`); - expect(exportVariable).toHaveBeenCalledWith( - "playlist", - `${process.env.YEAR} Summer` - ); }); test("Fall", () => { process.env.MONTH = "11"; expect(learnPlaylistName()).toEqual(`${process.env.YEAR} Fall`); - expect(exportVariable).toHaveBeenCalledWith( - "playlist", - `${process.env.YEAR} Fall` - ); }); test("Winter", () => { process.env.MONTH = "2"; + const year = process.env.YEAR ? parseInt(process.env.YEAR) - 1 : ''; expect(learnPlaylistName()).toEqual( - `${parseInt(process.env.YEAR) - 1}/${process.env.YEAR} Winter` - ); - expect(exportVariable).toHaveBeenCalledWith( - "playlist", - `${parseInt(process.env.YEAR) - 1}/${process.env.YEAR} Winter` + `${year}/${process.env.YEAR} Winter` ); }); test("Spring", () => { - process.env.MONTH = 5; + process.env.MONTH = "5"; expect(learnPlaylistName()).toEqual(`${process.env.YEAR} Spring`); - expect(exportVariable).toHaveBeenCalledWith( - "playlist", - `${process.env.YEAR} Spring` - ); }); test("Change season order", () => { - process.env.MONTH = 5; + process.env.MONTH = "5"; defaultInputs["season-names"] = "Summer,Fall,Winter,Spring"; expect(learnPlaylistName()).toEqual(`${process.env.YEAR} Fall`); - expect(exportVariable).toHaveBeenCalledWith( - "playlist", - `${process.env.YEAR} Fall` - ); }); test("Month does not match end of season month", () => { - process.env.MONTH = 1; + process.env.MONTH = "1"; expect(() => learnPlaylistName()).toThrow( "The current month does not match an end of season month." ); }); test("Invalid season-names", () => { - process.env.MONTH = 5; + process.env.MONTH = "5"; defaultInputs["season-names"] = "Summer"; expect(() => learnPlaylistName()).toThrow( "There must be 4 seasons listed in `season-names` only found 1 (`Summer`)." ); }); - test("Set workflow input `playlistName`", () => { - process.env.MONTH = 1; + test("Set workflow input `playlist-name`", () => { + process.env.MONTH = "1"; Object.defineProperty(github, "context", { value: { payload: { inputs: { - playlistName: "2020 Fall", + "playlist-name": "2020 Fall", }, }, }, }); expect(learnPlaylistName()).toEqual("2020 Fall"); - expect(exportVariable).toHaveBeenCalledWith("playlist", "2020 Fall"); }); }); diff --git a/src/__test__/list-playlists.test.ts b/src/__test__/list-playlists.test.ts index 08c61c8e..882bc648 100644 --- a/src/__test__/list-playlists.test.ts +++ b/src/__test__/list-playlists.test.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-var-requires */ -import { setFailed } from "@actions/core"; -import listPlaylists from "../list-playlists"; +import listPlaylists, { formatTracks } from "../list-playlists"; +import { getInput } from "@actions/core"; +import SpotifyWebApi from "spotify-web-api-node"; jest.mock("spotify-web-api-node", () => { return jest.fn().mockImplementation(() => { @@ -30,116 +31,191 @@ jest.mock("@actions/core"); describe("listPlaylists", () => { test("returns", async () => { expect(await listPlaylists("2021 Fall")).toMatchInlineSnapshot(` - { - "formatted_name": "2021-fall", - "image": "https://mosaic.scdn.co/640/ab67616d0000b27304a205b54b5c1fbae8543efaab67616d0000b27364fa86e376b0279aa49a1832ab67616d0000b273acddc5a09b8cb4730995b60aab67616d0000b273ba738766498bddccae3d319a", - "name": "2021 Fall", - "tracks": [ - { - "album": "one hand on the steering wheel the other sewing a garden", - "artist": "Ada Lea", - "name": "can't stop me from dying", - }, - { - "album": "No Shadow", - "artist": "Hyd", - "name": "No Shadow", - }, - { - "album": "Trip To Japan", - "artist": "The Shacks", - "name": "Trip To Japan", - }, - { - "album": "Keeper", - "artist": "Hana Vu", - "name": "Maker", - }, - { - "album": "Doomin' Sun", - "artist": "Bachelor, Jay Som, Palehound", - "name": "Anything at All", - }, - { - "album": "Would You Mind Please Pulling Me Close?", - "artist": "Tasha", - "name": "Would You Mind Please Pulling Me Close?", - }, - { - "album": "Genesis", - "artist": "Spencer.", - "name": "Genesis", - }, - { - "album": "Old Peel", - "artist": "Aldous Harding", - "name": "Old Peel", - }, - { - "album": "Everybody's Birthday", - "artist": "Hana Vu", - "name": "Everybody's Birthday", - }, - { - "album": "The Baby", - "artist": "Samia", - "name": "Big Wheel", - }, - { - "album": "-io", - "artist": "Circuit des Yeux", - "name": "Dogma", - }, - { - "album": "private LIFE", - "artist": "Virginia Wing", - "name": "I'm Holding Out For Something", - }, - { - "album": "Blue Weekend", - "artist": "Wolf Alice", - "name": "Delicious Things", - }, - { - "album": "Bottle Episode", - "artist": "Mandy, Indiana", - "name": "Bottle Episode", - }, - { - "album": "The Gaping Mouth", - "artist": "Lowertown", - "name": "The Gaping Mouth", - }, - { - "album": "Fantasize Your Ghost", - "artist": "Ohmme", - "name": "3 2 4 3", - }, - { - "album": "You Think It's Like This But Really It's Like This", - "artist": "Mirah", - "name": "Of Pressure", - }, - { - "album": "When the Sun Comes Up", - "artist": "Greta Morgan", - "name": "When the Sun Comes Up", - }, +{ + "formatted_name": "2021-fall", + "image": "https://mosaic.scdn.co/640/ab67616d0000b27304a205b54b5c1fbae8543efaab67616d0000b27364fa86e376b0279aa49a1832ab67616d0000b273acddc5a09b8cb4730995b60aab67616d0000b273ba738766498bddccae3d319a", + "name": "2021 Fall", + "tracks": [ + { + "album": "one hand on the steering wheel the other sewing a garden", + "artist": "Ada Lea", + "name": "can't stop me from dying", + }, + { + "album": "No Shadow", + "artist": "Hyd", + "name": "No Shadow", + }, + { + "album": "Trip To Japan", + "artist": "The Shacks", + "name": "Trip To Japan", + }, + { + "album": "Keeper", + "artist": "Hana Vu", + "name": "Maker", + }, + { + "album": "Doomin' Sun", + "artist": "Bachelor, Jay Som, Palehound", + "name": "Anything at All", + }, + { + "album": "Would You Mind Please Pulling Me Close?", + "artist": "Tasha", + "name": "Would You Mind Please Pulling Me Close?", + }, + { + "album": "Genesis", + "artist": "Spencer.", + "name": "Genesis", + }, + { + "album": "Old Peel", + "artist": "Aldous Harding", + "name": "Old Peel", + }, + { + "album": "Everybody's Birthday", + "artist": "Hana Vu", + "name": "Everybody's Birthday", + }, + { + "album": "The Baby", + "artist": "Samia", + "name": "Big Wheel", + }, + { + "album": "-io", + "artist": "Circuit des Yeux", + "name": "Dogma", + }, + { + "album": "private LIFE", + "artist": "Virginia Wing", + "name": "I'm Holding Out For Something", + }, + { + "album": "Blue Weekend", + "artist": "Wolf Alice", + "name": "Delicious Things", + }, + { + "album": "Bottle Episode", + "artist": "Mandy, Indiana", + "name": "Bottle Episode", + }, + { + "album": "The Gaping Mouth", + "artist": "Lowertown", + "name": "The Gaping Mouth", + }, + { + "album": "Fantasize Your Ghost", + "artist": "Ohmme", + "name": "3 2 4 3", + }, + { + "album": "You Think It's Like This But Really It's Like This", + "artist": "Mirah", + "name": "Of Pressure", + }, + { + "album": "When the Sun Comes Up", + "artist": "Greta Morgan", + "name": "When the Sun Comes Up", + }, + { + "album": "Ceremony", + "artist": "Anna von Hausswolff", + "name": "Mountains Crave", + }, + ], + "url": "https://open.spotify.com/playlist/2YnPs9UNBkJpswmsRNwQ1o", +} +`); + }); + + it("throws an error when the playlist has no tracks", async () => { + // Mock the getInput function to return a valid username + getInput.mockImplementation(() => "test-username"); + + // Mock the SpotifyWebApi methods + const mockClientCredentialsGrant = jest.fn().mockResolvedValue({ + body: { access_token: "test-access-token" }, + }); + const mockGetUserPlaylists = jest.fn().mockResolvedValue({ + body: { + items: [ { - "album": "Ceremony", - "artist": "Anna von Hausswolff", - "name": "Mountains Crave", + name: "test-playlist", + id: "test-playlist-id", }, ], - "url": "https://open.spotify.com/playlist/2YnPs9UNBkJpswmsRNwQ1o", - } - `); + }, + }); + const mockGetPlaylistTracks = jest.fn().mockResolvedValue({ + body: { items: [] }, + }); + + SpotifyWebApi.mockImplementation(() => ({ + clientCredentialsGrant: mockClientCredentialsGrant, + getUserPlaylists: mockGetUserPlaylists, + getPlaylistTracks: mockGetPlaylistTracks, + setAccessToken: jest.fn(), + })); + + // Call the function and expect an error to be thrown + await expect(listPlaylists("test-playlist")).rejects.toThrow( + "Playlist has no tracks." + ); }); test("cannot find", async () => { - await listPlaylists("2022 Fall"); - expect(setFailed).toHaveBeenCalledWith( + await expect(listPlaylists("2022 Fall")).rejects.toThrow( 'Could not find playlist "2022 Fall". Is it private?' ); }); }); + +describe("formatTracks", () => { + it("formats the playlist name correctly", () => { + const playlist = { + name: "Test / Playlist!", + external_urls: { spotify: "https://spotify.com" }, + images: [ + { url: "https://image1.com", width: 500 }, + { url: "https://image2.com" }, + { url: "https://image3.com" }, + ], + tracks: [ + { + track: { + name: "Test Track", + artists: [{ name: "Test Artist" }], + album: { name: "Test Album" }, + }, + }, + ], + }; + + const result = formatTracks(playlist); + + expect(result).toMatchInlineSnapshot(` +{ + "formatted_name": "test-playlist", + "image": "https://image1.com", + "name": "Test / Playlist!", + "tracks": [ + { + "album": "Test Album", + "artist": "Test Artist", + "name": "Test Track", + }, + ], + "url": "https://spotify.com", +} +`); + }); +}); diff --git a/src/index.ts b/src/index.ts index 5895f625..12f403a3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,20 +8,25 @@ export type Playlist = { formatted_name: string; url: string; tracks: { - name: string; - artist: string; - album: string; + name?: string; + artist?: string; + album?: string; }[]; image: string; }; +export type WorkflowPayload = { + "playlist-name"?: string; +}; + export async function action() { try { const filename = getInput("filename"); + const playlistName = learnPlaylistName(); + const playlist = await listPlaylists(playlistName); - const playlistName: string = learnPlaylistName(); - const playlist = (await listPlaylists(playlistName)) as Playlist; // export image variable to be downloaded latter + exportVariable("playlist", playlistName); exportVariable("PlaylistImageOutput", `${playlist.formatted_name}.png`); exportVariable("PlaylistImage", playlist.image); // replace Spotify image url with local version diff --git a/src/learn-playlist-name.ts b/src/learn-playlist-name.ts index a69e8afa..84413f79 100644 --- a/src/learn-playlist-name.ts +++ b/src/learn-playlist-name.ts @@ -1,38 +1,30 @@ -import { exportVariable, getInput } from "@actions/core"; +import { getInput } from "@actions/core"; import * as github from "@actions/github"; export default function learnPlaylistName(): string { - let playlistName; const payload = github.context.payload.inputs; - if (payload && payload.playlistName) { - playlistName = payload.playlistName; + if (payload && payload["playlist-name"]) { + return payload["playlist-name"]; } - if (!playlistName) { - const today = new Date(); - const month = process.env.MONTH - ? parseInt(process.env.MONTH) - : today.getMonth(); - const year = process.env.YEAR - ? parseInt(process.env.YEAR) - : today.getFullYear(); - const [marchEnd, juneEnd, septemberEnd, decemberEnd] = - validateSeasonNames(); - const seasons = { - 2: marchEnd, - 5: juneEnd, - 8: septemberEnd, - 11: decemberEnd, - }; - const season = seasons[month]; - if (!season) - throw new Error( - `The current month does not match an end of season month.` - ); - playlistName = `${month === 2 ? `${year - 1}/${year}` : year} ${season}`; - } - exportVariable("playlist", playlistName); - return playlistName; + const today = new Date(); + const month = process.env.MONTH + ? parseInt(process.env.MONTH) + : today.getMonth(); + const year = process.env.YEAR + ? parseInt(process.env.YEAR) + : today.getFullYear(); + const [marchEnd, juneEnd, septemberEnd, decemberEnd] = validateSeasonNames(); + const seasons = { + 2: marchEnd, + 5: juneEnd, + 8: septemberEnd, + 11: decemberEnd, + }; + const season = seasons[month]; + if (!season) + throw new Error(`The current month does not match an end of season month.`); + return `${month === 2 ? `${year - 1}/${year}` : year} ${season}`; } function validateSeasonNames() { diff --git a/src/list-playlists.ts b/src/list-playlists.ts index ad5a9fd7..0e741194 100644 --- a/src/list-playlists.ts +++ b/src/list-playlists.ts @@ -1,38 +1,41 @@ -import { setFailed, getInput } from "@actions/core"; +import { getInput } from "@actions/core"; import SpotifyWebApi from "spotify-web-api-node"; import { Playlist } from "./index.js"; export default async function listPlaylists( listName: string -): Promise { - const spotifyApi = new SpotifyWebApi({ - clientId: process.env.SpotifyClientID, - clientSecret: process.env.SpotifyClientSecret, - }); - const username = getInput("spotify-username"); - const { - body: { access_token }, - } = await spotifyApi.clientCredentialsGrant(); +): Promise { + try { + const spotifyApi = new SpotifyWebApi({ + clientId: process.env.SpotifyClientID, + clientSecret: process.env.SpotifyClientSecret, + }); + const username = getInput("spotify-username"); + const { + body: { access_token }, + } = await spotifyApi.clientCredentialsGrant(); - spotifyApi.setAccessToken(access_token); + spotifyApi.setAccessToken(access_token); - const { body } = await spotifyApi.getUserPlaylists(username); - const findPlaylist: SpotifyPlaylist = body.items.find( - ({ name }) => name === listName - ); - if (!findPlaylist) { - setFailed(`Could not find playlist "${listName}". Is it private?`); - return; + const { body } = await spotifyApi.getUserPlaylists(username); + const findPlaylist = body.items.find(({ name }) => name === listName); + if (!findPlaylist) { + throw new Error(`Could not find playlist "${listName}". Is it private?`); + } + const { + body: { items }, + } = await spotifyApi.getPlaylistTracks(findPlaylist.id); + if (!items.length) throw new Error("Playlist has no tracks."); + + return formatTracks({ + name: findPlaylist.name, + external_urls: findPlaylist.external_urls, + images: findPlaylist.images, + tracks: items, + }); + } catch (error) { + throw new Error(error); } - const { - body: { items }, - } = await spotifyApi.getPlaylistTracks(findPlaylist.id); - return formatTracks({ - name: findPlaylist.name, - external_urls: findPlaylist.external_urls, - images: findPlaylist.images, - tracks: items, - }); } export function formatTracks({ @@ -42,139 +45,30 @@ export function formatTracks({ tracks, }: { name: string; - external_urls: SpotifyPlaylist["external_urls"]; - images: SpotifyPlaylist["images"]; - tracks: SpotifyTrack[]; + external_urls: SpotifyApi.ExternalUrlObject; + images: SpotifyApi.ImageObject[]; + tracks: SpotifyApi.PlaylistTrackObject[]; }): Playlist { - const largestImage = images.sort((a, b) => b.width - a.width)[0]; + const largestImage = images.sort( + (a, b) => (b.width || 0) - (a.width || 0) + )[0]; return { name, - formatted_name: name.replace("/", "-").toLowerCase().replace(" ", "-"), + formatted_name: formatName(name), url: external_urls.spotify, tracks: tracks.map(({ track }) => ({ - name: track.name, - artist: track.artists.map(({ name }) => name).join(", "), - album: track.album.name, + name: track?.name, + artist: track?.artists.map(({ name }) => name).join(", "), + album: track?.album.name, })), image: largestImage.url, }; } -export type SpotifyTrack = { - added_at: string; - added_by: { - external_urls: { - spotify: string; - }; - href: string; - id: string; - type: string; - uri: string; - }; - is_local: boolean; - primary_color: string | null; - track: { - album: { - album_type: string; - artists: [ - { - external_urls: { - spotify: string; - }; - href: string; - id: string; - name: string; - type: string; - uri: string; - } - ]; - available_markets: string[]; - external_urls: { - spotify: string; - }; - href: string; - id: string; - images: { - height: number; - url: string; - width: number; - }[]; - - name: string; - release_date: string; - release_date_precision: string; - total_tracks: number; - type: string; - uri: string; - }; - artists: { - external_urls: { - spotify: string; - }; - href: string; - id: string; - name: string; - type: string; - uri: string; - }[]; - available_markets: string[]; - disc_number: number; - duration_ms: number; - episode: boolean; - explicit: boolean; - external_ids: { - isrc: string; - }; - external_urls: { - spotify: string; - }; - href: string; - id: string; - is_local: boolean; - name: string; - popularity: number; - preview_url: string; - track: true; - track_number: number; - type: string; - uri: string; - }; - video_thumbnail: { - url: string | null; - }; -}; - -export type SpotifyPlaylist = { - collaborative: boolean; - description: string; - external_urls: { - spotify: string; - }; - href: string; - id: string; - images: { - height: number; - url: string; - width: number; - }[]; - name: string; - owner: { - display_name: string; - external_urls: { - spotify: string; - }; - href: string; - id: string; - type: string; - uri: string; - }; - primary_color: string | null; - public: boolean; - snapshot_id: string; - tracks: { - href: string; - total: number; - }; - type: string; - uri: string; -}; +function formatName(name: string): string { + return name + .replace(/\s/g, "-") + .replace(/[^a-zA-Z0-9-]/g, "") + .replace(/-+/g, "-") + .toLowerCase(); +} diff --git a/tsconfig.json b/tsconfig.json index 9b8e475a..d9394886 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,9 @@ { "compilerOptions": { - "target": "es2015", + "target": "es2021", "moduleResolution": "node", - "strictNullChecks": true + "strictNullChecks": true, + "allowSyntheticDefaultImports": true }, "include": ["src/*.ts"] }