diff --git a/.github/workflows/tf-apply.yaml b/.github/workflows/tf-apply.yaml index 4c47c94..bcab815 100644 --- a/.github/workflows/tf-apply.yaml +++ b/.github/workflows/tf-apply.yaml @@ -1,4 +1,4 @@ -name: Update playlist +name: Update playlist with Spotify Auth on: push: @@ -6,16 +6,233 @@ on: - main jobs: - apply: + spotify-auth-and-apply: runs-on: ubuntu-latest - name: Apply terraform - env: - TF_VAR_SPOTIFY_API_KEY: ${{ secrets.SPOTIFY_API_KEY }} + name: Authenticate and Apply + timeout-minutes: 15 + steps: - - name: Checkout - uses: actions/checkout@v2 + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Install patched spotify_auth_proxy from fork + run: | + echo "$HOME/go/bin" >> $GITHUB_PATH + git clone -b fix/base-url-127 https://github.com/gouravslnk/terraform-provider-spotify + cd terraform-provider-spotify/spotify_auth_proxy + go build -o $HOME/go/bin/spotify_auth_proxy . + if ! command -v spotify_auth_proxy &> /dev/null; then + echo "::error::Installation failed" + exit 1 + fi + + - name: Run spotify_auth_proxy + env: + SPOTIFY_CLIENT_ID: ${{ secrets.SPOTIFY_CLIENT_ID }} + SPOTIFY_CLIENT_SECRET: ${{ secrets.SPOTIFY_CLIENT_SECRET }} + SPOTIFY_REDIRECT_URI: ${{ secrets.SPOTIFY_REDIRECT_URI }} + SPOTIFY_PROXY_BASE_URI: http://127.0.0.1:8080 + run: | + spotify_auth_proxy --port 8080 > proxy.log 2>&1 & + echo "Waiting for auth URL..." + for i in {1..30}; do + if grep -q "Auth URL:" proxy.log; then break; fi + sleep 1 + done + auth_url=$(grep "Auth URL:" proxy.log | awk '{print $3}') + api_key=$(grep "APIKey:" proxy.log | awk '{print $2}') + echo "AUTH_URL=$auth_url" >> $GITHUB_ENV + echo "SPOTIFY_API_KEY=$api_key" >> $GITHUB_ENV + if [ -z "$auth_url" ] || [ -z "$api_key" ]; then + echo "::error::Failed to get auth credentials" + cat proxy.log + exit 1 + fi + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: '20' + + - name: Install Node dependencies + run: npm install puppeteer@22.8.2 fs-extra@11 + + - name: Perform Spotify login and authorization + env: + AUTH_URL: ${{ env.AUTH_URL }} + SPOTIFY_USERNAME: ${{ secrets.SPOTIFY_USERNAME }} + SPOTIFY_PASSWORD: ${{ secrets.SPOTIFY_PASSWORD }} + run: | + node - <<'EOF' + const puppeteer = require('puppeteer'); + const fs = require('fs-extra'); + const delay = ms => new Promise(res => setTimeout(res, ms)); + const randomDelay = (min, max) => Math.floor(Math.random() * (max - min + 1) + min); + async function typeWithHumanDelay(page, selector, text) { + for (const char of text) { + await page.type(selector, char, { delay: randomDelay(30, 100) }); + if (Math.random() > 0.8) await delay(randomDelay(50, 150)); + } + } + async function clickByText(page, text, tag = '*') { + const elements = await page.$$(tag); + for (const el of elements) { + const content = await page.evaluate(el => el.textContent?.trim(), el); + if (content?.toLowerCase() === text.toLowerCase()) { + await el.click(); + return true; + } + } + return false; + } + async function safeClick(page, selector, timeout = 10000) { + try { + await page.waitForSelector(selector, { visible: true, timeout }); + await delay(500); + const element = await page.$(selector); + if (element) { + await element.click(); + return true; + } + } catch {} + return false; + } + + (async () => { + const browser = await puppeteer.launch({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox'], + defaultViewport: { width: 1280, height: 800 } + }); + const page = await browser.newPage(); + await page.setUserAgent('Mozilla/5.0'); + + try { + console.log("🌐 Navigating to Spotify Auth URL..."); + await page.goto(process.env.AUTH_URL, { waitUntil: 'networkidle2', timeout: 60000 }); + console.log("πŸ“ Current page:", page.url()); + + if (page.url().includes('accounts.google.com')) { + throw new Error("❌ Redirected to Google login instead of Spotify login."); + } + + console.log("πŸ“§ Entering email..."); + await page.waitForSelector('input[type="email"], #login-username', { visible: true, timeout: 15000 }); + await typeWithHumanDelay(page, 'input[type="email"], #login-username', process.env.SPOTIFY_USERNAME); + await delay(1000); + + console.log("➑️ Clicking Continue..."); + const continued = await clickByText(page, 'Continue') || + await safeClick(page, 'button[data-testid="login-button"]') || + await safeClick(page, 'button[type="submit"]'); + if (!continued) throw new Error("❌ Continue button not found."); + + await delay(3000); + + const otpDetected = await page.evaluate(() => + Array.from(document.querySelectorAll('*')).some(el => + el.textContent?.toLowerCase().includes('6-digit code') + ) + ); + + if (otpDetected) { + console.log("πŸ” Switching to password login..."); + const clicked = await clickByText(page, 'Log in with a password') || + await safeClick(page, 'button[data-encore-id="buttonTertiary"]'); + if (!clicked) throw new Error("❌ Could not find 'Log in with a password'."); + await delay(3000); + } + + console.log("πŸ”‘ Entering password..."); + await page.waitForSelector('input[type="password"]', { visible: true, timeout: 10000 }); + await typeWithHumanDelay(page, 'input[type="password"]', process.env.SPOTIFY_PASSWORD); + await delay(1000); + + console.log("πŸš€ Logging in..."); + const loggedIn = await safeClick(page, '#login-button') || + await safeClick(page, 'button[data-testid="login-button"]') || + await safeClick(page, 'button[data-encore-id="buttonPrimary"]') || + await clickByText(page, 'Log In'); + if (!loggedIn) throw new Error("❌ Login button not found."); + + await delay(5000); + + const authorized = await safeClick(page, '#auth-accept') || await clickByText(page, 'Agree'); + if (authorized) { + console.log("βœ… Accepted authorization."); + await page.waitForNavigation({ waitUntil: 'networkidle0', timeout: 15000 }); + } + + const finalURL = page.url(); + if (!finalURL.includes('callback?code=')) { + throw new Error(`❌ Authorization failed. Final URL: ${finalURL}`); + } + + console.log("πŸŽ‰ Spotify authorization successful!"); + } catch (error) { + console.error("πŸ’₯ Error during login:", error.message); + await page.screenshot({ path: 'spotify_error.png', fullPage: true }); + await fs.writeFile('error_debug.html', await page.content()); + process.exit(1); + } finally { + await browser.close(); + } + })(); + EOF + + - name: Install jq + run: sudo apt-get update && sudo apt-get install -y jq + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: 1.12.2 + + - name: Terraform Init + run: terraform init + + - name: Clean Terraform State if Playlist Invalid + env: + SPOTIFY_API_KEY: ${{ env.SPOTIFY_API_KEY }} + run: | + if grep -q "spotify_playlist" terraform.tfstate; then + echo "Checking playlist ownership..." + PLAYLIST_ID=$(jq -r '.resources[] | select(.type=="spotify_playlist") | .instances[0].attributes.id' terraform.tfstate) + RESPONSE=$(curl -s -H "Authorization: Bearer $SPOTIFY_API_KEY" https://api.spotify.com/v1/playlists/$PLAYLIST_ID) + if echo "$RESPONSE" | grep -q "\"error\""; then + echo "❌ Playlist does not exist or does not belong to current user. Resetting state..." + rm -f terraform.tfstate terraform.tfstate.backup + else + echo "βœ… Playlist exists and belongs to user. Continuing..." + fi + else + echo "ℹ️ No playlist in state. Continuing..." + fi + + - name: Terraform Validate + run: terraform validate + + - name: Terraform Plan + env: + SPOTIFY_API_KEY: ${{ env.SPOTIFY_API_KEY }} + run: terraform plan -var "SPOTIFY_API_KEY=${SPOTIFY_API_KEY}" + + - name: Terraform Apply + env: + SPOTIFY_API_KEY: ${{ env.SPOTIFY_API_KEY }} + run: terraform apply -auto-approve -var "SPOTIFY_API_KEY=${SPOTIFY_API_KEY}" - - name: terraform apply - uses: dflook/terraform-apply@v1 + - name: Upload debug artifacts + if: failure() + uses: actions/upload-artifact@v4 with: - auto_approve: true + name: spotify-debug + path: | + spotify_error.png + error_debug.html + proxy.log diff --git a/.gitignore b/.gitignore index 9b8a46e..6304eb3 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,8 @@ crash.log crash.*.log # Exclude all .tfvars files, which are likely to contain sensitive data, such as -# password, private keys, and other secrets. These should not be part of version -# control as they are data points which are potentially sensitive and subject +# password, private keys, and other secrets. These should not be part of version +# control as they are data points which are potentially sensitive and subject # to change depending on the environment. *.tfvars *.tfvars.json diff --git a/.terraform.lock.hcl b/.terraform.lock.hcl new file mode 100644 index 0000000..c6de87a --- /dev/null +++ b/.terraform.lock.hcl @@ -0,0 +1,23 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/conradludgate/spotify" { + version = "0.2.7" + constraints = "~> 0.2.6" + hashes = [ + "h1:wdJjcGLiJdgUg+Nkq45kn/Z2ZG63W/QCsj6M27Z7Pso=", + "zh:0babfe7700c4067f1d012fcb4ad6400e3e4e7a5cb13a4fd4fe471b72daa2fcc6", + "zh:0d6c5fabdf78ac72383d05bba63f042ee9babc92bec8cc05d2a3e2f8d61567fe", + "zh:18e55fb5eb0287a13b0fe37d57ee07be82e8e74e88a49d5b867d0f2c705db562", + "zh:2767ca1a7e1ed432147ff88b7447c32d96e1b2ca3f7a115120bb8abceb7db59a", + "zh:28d67a374290dacf84f021d052c1fdd8b612241dec9d7d955c31aff631e40aeb", + "zh:52a82a2e57cb2cfa9f30efa744c8395d95d571028a44a2c7b2f461d95095bb9e", + "zh:607e86d079789d4a21120106b2aa6af5eedbbb2c31b7d24c58d3cb32c96f7885", + "zh:6937bbe036e1b5b601a788b81c611b97ce31e73e6786b964212c87d5a7fdf0bc", + "zh:850722c1c3d601363e6c613fd1d11d6f79a91672a0a716580afe2e51d521c1c0", + "zh:e18b8e1db6ea08abc33f497a27aa3ead1f9635554cdeeb6bfd72e988ab1b939a", + "zh:ea9fb74a922ef451dd0050e11781855485bbde7629b3ab8b68e239d8967afedf", + "zh:f2f8e6317f0eaf6ca38bdccfc5eb909ddaf609ab2a6cad47c4bdfc5ca86c602b", + "zh:f73abc00276e414c0503dbac6786fa16e2d8d245f779c56a3b4e0f55562be072", + ] +} diff --git a/README.md b/README.md index 4cd3a0d..b73a139 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,138 @@ -# [OSDPlaylist](https://open.spotify.com/playlist/20lFoFnoKTEcKoy6sMoP0e) +# [🎡 OSDPlaylist](https://open.spotify.com/playlist/20lFoFnoKTEcKoy6sMoP0e) + > *One should be able to fork playlists.* + + +## πŸ—οΈ **How It Works** + +1. **Terraform Configuration**: Defines playlist structure and tracks +2. **GitHub Actions**: Automatically authenticates with Spotify and applies changes +3. **Patched Provider**: Uses a forked Spotify provider that fixes authentication issues +4. **Smart State Management**: Prevents duplicate playlists and manages existing ones + + +## 🚦 **Getting Started** + +### Prerequisites + +- **Spotify Developer Account** ([Create one here](https://developer.spotify.com)) +- **GitHub Account** with repository access +- **Basic knowledge** of Terraform and GitHub Actions + +### 1. Fork This Repository + +```bash +# Clone your fork +git clone https://github.com/YOUR_USERNAME/OSDPlaylist.git +cd OSDPlaylist +``` + +### 2. Set Up Spotify App + +1. Go to [Spotify Developer Dashboard](https://developer.spotify.com/dashboard) +2. Create a new app +3. Note your `Client ID` and `Client Secret` +4. Add redirect URI: `http://127.0.0.1:8080/spotify_callback` + +### 3. Configure GitHub Secrets + +Add these secrets in your repository settings: + +| Secret Name | Description | Example | +|-------------|-------------|---------| +| `SPOTIFY_CLIENT_ID` | Your Spotify app client ID | `abc123def456` | +| `SPOTIFY_CLIENT_SECRET` | Your Spotify app client secret | `xyz789uvw012` | +| `SPOTIFY_REDIRECT_URI` | Redirect URI from your app | `http://127.0.0.1:8080/spotify_callback` | +| `SPOTIFY_USERNAME` | Your Spotify email/username | `your@email.com` | +| `SPOTIFY_PASSWORD` | Your Spotify password | `your_password` | + +### 4. Customize Your Playlist + +Edit `main.tf` to add your favorite tracks: + +```terraform +data "spotify_track" "your_favorite_song" { + url = "https://open.spotify.com/track/TRACK_ID" +} + +# Add to the tracks array in spotify_playlist resource +data.spotify_track.your_favorite_song.id, +``` + +### 5. Push and Watch Magic Happen! ✨ + +```bash +git add . +git commit -m "feat: customize playlist with my tracks" +git push origin main +``` + +The GitHub Action will automatically: + +- πŸ” Authenticate with Spotify +- 🎡 Create/update your playlist +- βœ… Verify everything works + +## πŸ“ **Project Structure** + +```text +OSDPlaylist/ +β”œβ”€β”€ .github/workflows/ +β”‚ └── tf-apply.yaml # Automated deployment pipeline +β”œβ”€β”€ main.tf # Main Terraform configuration +β”œβ”€β”€ variables.tf # Input variables +β”œβ”€β”€ update_playlist.md # Local development guide +β”œβ”€β”€ guide.md # Future improvements +└── README.md # This file +``` + +## πŸ”§ **Local Development** + +Want to test changes locally? See our detailed [Local Setup Guide](update_playlist.md). + +### Quick Local Setup + +1. **Install Terraform** ([Download here](https://terraform.io/downloads)) +2. **Clone the patched provider**: + + ```bash + git clone -b fix/base-url-127 https://github.com/gouravslnk/terraform-provider-spotify + cd terraform-provider-spotify/spotify_auth_proxy + go build -o spotify_auth_proxy.exe + ``` + +3. **Set environment variables** and run the auth proxy +4. **Initialize and apply**: + + ```bash + terraform init + terraform plan -var "SPOTIFY_API_KEY=your_api_key" + terraform apply -var "SPOTIFY_API_KEY=your_api_key" + ``` + +## 🀝 **Contributing** + +Want to add tracks or improve the project? + +1. **Fork** the repository +2. **Create** a feature branch (`git checkout -b feature/amazing-tracks`) +3. **Add** your tracks to `main.tf` +4. **Commit** your changes (`git commit -m 'feat: add amazing tracks'`) +5. **Push** to the branch (`git push origin feature/amazing-tracks`) +6. **Open** a Pull Request + +## πŸ›‘οΈ **Security Notes** + +- Terraform state files are excluded from git +- Sensitive data stored in GitHub Secrets +- Authentication handled via secure proxy +- Never commit credentials to the repository + +## 🎯 **Roadmap** + +- [ ] **Cloud State Storage** - Move state to AWS S3/Terraform Cloud +- [ ] **Multiple User Support** - State locking for collaboration +- [ ] **Performance Optimization** - Reduce workflow execution time +- [ ] **Track Analytics** - Monitor playlist engagement +- [ ] **Dynamic Track Discovery** - Auto-add trending tracks +- [ ] **Enhanced UI** - Web interface for playlist management diff --git a/guide.md b/guide.md new file mode 100644 index 0000000..aeb2bb1 --- /dev/null +++ b/guide.md @@ -0,0 +1,4 @@ +# Next Steps +- Use a cloud storage to store and access the tfstate file. +- Manage multiple users working on the same repo at the same time. Using locks? +- Decrease the time taken by Workflow. Current: ~60s diff --git a/main.tf b/main.tf index c5dc76d..17dd1e1 100644 --- a/main.tf +++ b/main.tf @@ -7,24 +7,19 @@ terraform { } } -variable "SPOTIFY_API_KEY" { - type = string - description = "The spotify API key, post authentication" -} - provider "spotify" { - api_key = var.SPOTIFY_API_KEY + api_key = var.SPOTIFY_API_KEY + auth_server = "http://127.0.0.1:8080" //change the port if you want } data "spotify_search_track" "RAM" { - # artist = "Daft Punk" album = "Random Access Memories (10th Anniversary Edition)" - limit = 8 + limit = 7 } data "spotify_search_track" "LImperatrice" { artist = "L'ImpΓ©ratrice" - limit = 10 + limit = 7 } data "spotify_search_track" "Odyssee" { @@ -33,13 +28,54 @@ data "spotify_search_track" "Odyssee" { data "spotify_search_track" "Parcels" { artist = "Parcels" - limit = 8 + limit = 7 } + data "spotify_track" "Zenith" { url = "https://open.spotify.com/track/0qKX14YZHptDWiEN0CgxGz?si=174ddb3f25414e2c" } +data "spotify_track" "A_View_To_Kill" { + url = "https://open.spotify.com/track/7fN3QQtmCMkiczQ41IuhwK?si=d0e32a11da1f4c72" +} + +data "spotify_track" "Veridis_Quo" { + url = "https://open.spotify.com/track/2LD2gT7gwAurzdQDQtILds?si=392db9f1e95849e6" +} + +data "spotify_track" "instant_crush" { + url = "https://open.spotify.com/track/2cGxRwrMyEAp8dEbuZaVv6?si=0e20066520e04bef" +} + +data "spotify_track" "nightcall" { + url = "https://open.spotify.com/track/0U0ldCRmgCqhVvD6ksG63j?si=ca6db3e2a5584f04" +} + +data "spotify_track" "lady_hear_me_tonight" { + url = "https://open.spotify.com/track/49X0LAl6faAusYq02PRAY6?si=9059c63e0984402b" +} + +data "spotify_track" "supermassive_black_hole" { + url = "https://open.spotify.com/track/3lPr8ghNDBLc2uZovNyLs9?si=6dac397517d3427f" +} + +data "spotify_track" "Get_Lucky" { + url = "https://open.spotify.com/track/69kOkLUCkxIZYexIgSG8rq?si=945ac1a66e3e4033" +} + +data "spotify_track" "starlight" { + url = "https://open.spotify.com/track/5luWJxS799LLp2e88RffUx?si=ac548be34f354912" +} + +data "spotify_track" "vigilante" { + url = "https://open.spotify.com/track/2U7aXicPJAjJBYEIWIXsVI?si=dc24ab05cac54a43" +} + +data "spotify_track" "horizon" { + url = "https://open.spotify.com/track/69fx4BG9cZFOrBJk5aXDeL?si=8f99804af0b842b8" +} + resource "spotify_playlist" "playlist" { name = "The CodeJam Playlist" description = "Wishing you make the nicest, most Randomly Accessible Memories this CodeRΜΆAΜΆMΜΆJam :)" @@ -51,5 +87,16 @@ resource "spotify_playlist" "playlist" { data.spotify_search_track.Odyssee.tracks[*].id, data.spotify_search_track.Parcels.tracks[*].id, data.spotify_track.Zenith.id, + data.spotify_track.A_View_To_Kill.id, + data.spotify_track.Veridis_Quo.id, + // added tracks + data.spotify_track.instant_crush.id, + data.spotify_track.nightcall.id, + data.spotify_track.lady_hear_me_tonight.id, + data.spotify_track.supermassive_black_hole.id, + data.spotify_track.Get_Lucky.id, + data.spotify_track.starlight.id, + data.spotify_track.vigilante.id, + data.spotify_track.horizon.id ]) } diff --git a/terraform.tfvars.example b/terraform.tfvars.example new file mode 100644 index 0000000..9e7baef --- /dev/null +++ b/terraform.tfvars.example @@ -0,0 +1,6 @@ +# Example Terraform variables file +# Copy this file to terraform.tfvars and fill in your actual values +# DO NOT commit terraform.tfvars to git - it contains sensitive data + +# Get this from your Spotify auth proxy after running authentication +SPOTIFY_API_KEY = "your_spotify_api_key_here" diff --git a/update_playlist.md b/update_playlist.md new file mode 100644 index 0000000..b814930 --- /dev/null +++ b/update_playlist.md @@ -0,0 +1,104 @@ +## 🎢 Import Your Spotify Playlist into Terraform State + +To make Terraform manage your **own Spotify playlist** (instead of creating a new one every time), follow these steps carefully: + +--- + +### 🧱 1. Initialize Terraform + +Open your terminal in the project root and run: + +```bash +terraform init +``` + +--- + +### πŸ› οΈ 2. Clone and Build Patched Spotify Auth Proxy + +Clone the patched Terraform Spotify provider and build the `spotify_auth_proxy` binary: + +```bash +git clone -b fix/base-url-127 https://github.com/gauravslnk/terraform-provider-spotify +cd terraform-provider-spotify/spotify_auth_proxy +go build -o spotify_auth_proxy.exe +``` + +> βœ… This builds the proxy binary required for authorization and token generation. + +--- + +### πŸ” 3. Set Required Environment Variables + +Replace placeholders with your actual Spotify app credentials: + +```powershell +$env:SPOTIFY_CLIENT_ID = "" +$env:SPOTIFY_CLIENT_SECRET = "" +$env:SPOTIFY_REDIRECT_URI = "" # add your redirect uri which you set in spotify developer app +$env:SPOTIFY_PROXY_BASE_URI = "http://127.0.0.1:8080" +``` + +--- + +### πŸšͺ 4. Start the Spotify Auth Proxy + +```powershell +.\spotify_auth_proxy --port 8080 +``` + +It will output something like this: + +``` +APIKey: gS4pTULOzrmczFBC3d4olxxtWBss-j4zt-gvQIaiPYaymedCSggxigu7Ksjgq4gS +Auth URL: http://127.0.0.1:8080/authorize?token=... +Listening on: 127.0.0.1:8080 +``` + +> ⚠️ **Do not close this terminal** β€” keep it running. + +--- + +### βœ… 5. Authorize Access + +Open the **Auth URL** in your browser, log in, and authorize the app. + +You should see: + +``` +Authorization successful +Token Retrieved +``` + +--- + +### ⛓️ 6. Import Your Playlist into Terraform State + +1. Open a **new terminal window**. +2. In the new terminal, run: + +```bash +terraform import spotify_playlist.playlist +``` + +Example: + +```bash +terraform import spotify_playlist.playlist 5GkQIP5IetMlF4E7IoUayV +``` + +3. When prompted for `SPOTIFY_API_KEY`, **paste the API key** shown in the other terminal. +4. Wait for a success message like: + +``` +spotify_playlist.playlist: Import prepared! +``` + +--- + +### 🧾 Result + +Your playlist is now fully tracked by Terraform! βœ… + +* It will **no longer create new playlists** on each run. +* Terraform will **apply updates** only to this existing playlist. diff --git a/variables.tf b/variables.tf new file mode 100644 index 0000000..dbfea00 --- /dev/null +++ b/variables.tf @@ -0,0 +1,7 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + +variable "SPOTIFY_API_KEY" { + type = string + description = "Set this as the APIKey that the authorization proxy server outputs" +} \ No newline at end of file