diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 4295767..b2129ac 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -1,73 +1,84 @@ -name: "Build and Release" +name: build and release on: - push: - branches: [ main, master ] - paths-ignore: - - "**/*.md" - - ".vscode/**" - workflow_dispatch: + workflow_call: + inputs: + release_type: + required: true + type: string + tag_name: + required: true + type: string + prerelease: + required: true + type: boolean + branch: + required: true + type: string + release_title: + required: true + type: string + release_body: + required: true + type: string + latest_tag: + required: false + type: string jobs: - Build: - name: Build + build: + name: build runs-on: ubuntu-latest - + steps: - - uses: actions/checkout@v4 - - - name: Install pnpm - uses: pnpm/action-setup@v4 - with: - version: latest - - - name: Install Node.js - uses: actions/setup-node@v4 - with: - cache: pnpm - node-version: latest - - - name: Install dependencies - run: pnpm install - - - name: Build - run: pnpm run build - - - name: Upload Build Artifacts - uses: actions/upload-artifact@v4 - with: - name: luna-artifacts - path: ./dist - - Release: - name: Release latest on GitHub - needs: Build + - name: checkout code + uses: actions/checkout@v4 + + - name: install pnpm + uses: pnpm/action-setup@v4 + with: + version: latest + + - name: install Node.js + uses: actions/setup-node@v4 + with: + cache: pnpm + node-version: latest + + - name: install dependencies + run: pnpm install + + - name: build plugin + run: pnpm run build + + - name: upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: luna-artifacts + path: ./dist + + release: + name: release + needs: build runs-on: ubuntu-latest - if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' + if: github.ref == inputs.branch permissions: contents: write - + steps: - - name: Download All Artifacts - uses: actions/download-artifact@v4 - with: - name: luna-artifacts - path: ./dist/ - - - name: Publish latest release on GitHub - uses: softprops/action-gh-release@v2 - with: - tag_name: latest - name: "Release v${{ github.run_number }}" - body: | - ## Tidarr Integration Plugin + - name: download artifacts + uses: actions/download-artifact@v4 + with: + name: luna-artifacts + path: ./dist/ - Send tracks and albums from Tidal directly to Tidarr for download. + - name: publish release on github + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ inputs.latest_tag || inputs.tag_name }} + name: ${{ inputs.release_title }} + body: ${{ inputs.release_body }} + prerelease: ${{ inputs.prerelease }} + files: ./dist/** + fail_on_unmatched_files: false - ### Installation - 1. Open TidalLuna - 2. Go to Settings → Plugins - 3. Click "Install from URL" - 4. Paste: `https://github.com/DevonCasey/tidal-luna-plugins/releases/download/latest/store.json` - files: ./dist/** - fail_on_unmatched_files: false \ No newline at end of file diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml new file mode 100644 index 0000000..ddeeffb --- /dev/null +++ b/.github/workflows/dev-release.yml @@ -0,0 +1,33 @@ +name: dev release + +on: + push: + branches: + - dev + paths: + - 'plugins/tidarr-integration/**' + - 'plugins/**' + +jobs: + call-shared-workflow: + uses: ./.github/workflows/build-and-release.yml + with: + release_type: dev + tag_name: dev-${{ github.run_number }} + prerelease: true + branch: refs/heads/dev + release_title: "Dev Release #${{ github.run_number }}" + release_body: | + Tidarr Integration Plugin + + Send tracks and albums from Tidal directly to Tidarr for download. + + ### Installation + 1. Open **TidalLuna** + 2. Go to **Settings → Plugins** + 3. Click **"Install from URL"** + 4. Paste this URL: + ``` + https://github.com/DevonCasey/tidal-luna-plugins/releases/download/latest-dev/store.json + ``` + latest_tag: latest-dev diff --git a/.github/workflows/prod-release.yml b/.github/workflows/prod-release.yml new file mode 100644 index 0000000..8e3b2aa --- /dev/null +++ b/.github/workflows/prod-release.yml @@ -0,0 +1,34 @@ +name: main release + +on: + push: + branches: + - main + paths: + - 'plugins/tidarr-integration/**' + - 'plugins/**' + +jobs: + call-shared-workflow: + uses: ./.github/workflows/build-and-release.yml + with: + release_type: stable + tag_name: v${{ github.run_number }} + prerelease: false + branch: refs/heads/main + release_title: "Release v${{ github.run_number }}" + release_body: | + Tidarr Integration Plugin + + Send tracks and albums from Tidal directly to Tidarr for download. + + ### Installation + 1. Open **TidalLuna** + 2. Go to **Settings → Plugins** + 3. Click **"Install from URL"** + 4. Paste this URL: + ``` + https://github.com/DevonCasey/tidal-luna-plugins/releases/download/latest/store.json + ``` + latest_tag: latest + diff --git a/.gitignore b/.gitignore index 849b86b..57ea415 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ temp/ # Example (development reference) example + +# Backup stuff +*.bak diff --git a/package.json b/package.json index 24946cb..4dfd9e9 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,12 @@ { "name": "@devoncasey/tidalluna-plugins", "description": "A collection of plugins for TidalLuna.", - "author": "Devon Casey", - "repository": { + "author": { + "name": "Devon Casey", + "url": "https://github.com/DevonCasey", + "avatarUrl": "https://0.gravatar.com/avatar/4f20153247cc8c6c0868d779e8a8ae0593bb9641880886280281f7de135edc9f" + }, + "repository": { "type": "git", "url": "https://github.com/DevonCasey/TidalLuna-Plugins.git" }, @@ -24,4 +28,4 @@ "tsx": "^4.19.4", "typescript": "^5.8.3" } -} \ No newline at end of file +} diff --git a/plugins/tidarr-integration/package.json b/plugins/tidarr-integration/package.json index 12ea83d..f781b1a 100644 --- a/plugins/tidarr-integration/package.json +++ b/plugins/tidarr-integration/package.json @@ -1,9 +1,13 @@ { "name": "tidarr-integration", - "description": "Send tracks and albums to Tidarr for download right from TidalLuna.", + "description": "Sends tracks and albums to Tidarr for download right from TidalLuna.", + "version": "1.0.0", + "license": "MIT", + "homepage": "https://github.com/DevonCasey/tidal-luna-plugins#tidarr-integration", "author": { "name": "Devon Casey", - "url": "https://github.com/DevonCasey" + "url": "https://github.com/DevonCasey", + "avatarUrl": "https://0.gravatar.com/avatar/4f20153247cc8c6c0868d779e8a8ae0593bb9641880886280281f7de135edc9f" }, "main": "./src/index.ts" -} \ No newline at end of file +} diff --git a/plugins/tidarr-integration/src/Settings.tsx b/plugins/tidarr-integration/src/Settings.tsx index 7d72b02..1464b21 100644 --- a/plugins/tidarr-integration/src/Settings.tsx +++ b/plugins/tidarr-integration/src/Settings.tsx @@ -1,124 +1,156 @@ import { ReactiveStore, ftch } from "@luna/core"; -import { LunaSelectItem, LunaSelectSetting, LunaSettings, LunaSwitchSetting, LunaTextSetting, LunaButton } from "@luna/ui"; +import { + LunaSelectItem, + LunaSelectSetting, + LunaSettings, + LunaTextSetting, + LunaSwitchSetting, +} from "@luna/ui"; import React from "react"; -const defaultTidarrUrl = "http://tidarr.example.com"; - -type Settings = { - tidarrUrl: string; - downloadQuality: string; - adminPassword: string; +type PluginSettings = { + tidarrUrl: string; + adminPassword: string; + downloadQuality: string; + debugMode: boolean; }; -export const settings = await ReactiveStore.getPluginStorage("tidarr-integration", { - tidarrUrl: defaultTidarrUrl, - downloadQuality: "high", - adminPassword: "", -}); +// load settings once and keep a single reactive object +export const settings = await ReactiveStore.getPluginStorage( + "tidarr-integration", + { + tidarrUrl: "", + adminPassword: "", + downloadQuality: "high", + debugMode: false, + } +); export const Settings = () => { - const [tidarrUrl, setTidarrUrl] = React.useState(settings.tidarrUrl); - const [downloadQuality, setDownloadQuality] = React.useState(settings.downloadQuality); - const [adminPassword, setAdminPassword] = React.useState(settings.adminPassword); - const [testResult, setTestResult] = React.useState(""); - const [testing, setTesting] = React.useState(false); + const [tidarrUrl, setTidarrUrl] = React.useState(settings.tidarrUrl); + const [adminPassword, setAdminPassword] = React.useState(settings.adminPassword); + const [downloadQuality, setDownloadQuality] = React.useState(settings.downloadQuality); + const [debugMode, setDebugMode] = React.useState(settings.debugMode); + const [testing, setTesting] = React.useState(false); + const [testResult, setTestResult] = React.useState(null); + + // test tidarr connection logic + const testTidarrConnection = async () => { + setTesting(true); + setTestResult(null); + + try { + const isAuthActiveRes = await ftch.json(`${tidarrUrl}/api/is_auth_active`); + const isAuthActive = isAuthActiveRes?.isAuthActive ?? false; + + if (isAuthActive) { + const authResponse = await ftch.json(`${tidarrUrl}/api/auth`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password: adminPassword || "" }), + }); + + if (authResponse?.token) { + setTestResult("success"); + } else { + setTestResult("failure"); + } + } else { + setTestResult("success"); + } + } catch (error) { + setTestResult("failure"); + } finally { + setTesting(false); + setTimeout(() => setTestResult(null), 3000); + } + }; + + return ( + + { + const newValue = e.target.value; + setTidarrUrl(newValue); + settings.tidarrUrl = newValue; + }} + /> + + { + const newValue = e.target.value; + setAdminPassword(newValue); + settings.adminPassword = newValue; + }} + /> + + { + const newValue = e.target.value; + setDownloadQuality(newValue); + settings.downloadQuality = newValue; + }} + > + Low + Normal + High + Master + + + { + setDebugMode(checked); + settings.debugMode = checked; + }} + /> + +
+ +
+
+ ); +}; - const testTidarrConnection = async () => { - setTesting(true); - setTestResult("Testing connection..."); - - try { - if (adminPassword && adminPassword.trim() !== "") { - // test with auth - setTestResult("Testing authentication..."); - - const authResponse = await ftch.json(`${tidarrUrl}/api/auth`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Origin": tidarrUrl, - "Referer": `${tidarrUrl}/`, - }, - body: JSON.stringify({ password: adminPassword }), - }); - - if (authResponse && (authResponse as any).token) { - setTestResult("Connected! Authentication successful."); - } else { - setTestResult("Authentication failed. Check your password."); - } - } else { - // test without auth - setTestResult("Testing connection..."); - - const healthCheck = await ftch.text(`${tidarrUrl}/api/is_auth_active`); - setTestResult("Connected! No authentication required."); - } - - } catch (error: any) { - setTestResult(`Connection failed: ${error.message || 'Unknown error'}`); - } - - setTesting(false); - }; return ( - - { - const url = e.target.value; - setTidarrUrl((settings.tidarrUrl = url)); - }} - /> - { - const password = e.target.value; - setAdminPassword((settings.adminPassword = password)); - }} - /> - setDownloadQuality((settings.downloadQuality = e.target.value))} - > - - - - - -
- - {testResult && ( -
- {testResult} -
- )} -
-
- ); -}; \ No newline at end of file diff --git a/plugins/tidarr-integration/src/index.ts b/plugins/tidarr-integration/src/index.ts index 1a2685f..760f371 100644 --- a/plugins/tidarr-integration/src/index.ts +++ b/plugins/tidarr-integration/src/index.ts @@ -1,137 +1,168 @@ import { Tracer, type LunaUnload, ReactiveStore, ftch } from "@luna/core"; -import { ContextMenu, safeInterval, StyleTag } from "@luna/lib"; -import { settings } from "./Settings"; +import { ContextMenu } from "@luna/lib"; export const { errSignal, trace } = Tracer("[tidarr-integration]"); export const unloads = new Set(); export { Settings } from "./Settings"; +// loads from saved settings from plugin storage when you call it +async function getSettings() { + return await ReactiveStore.getPluginStorage("tidarr-integration", { + tidarrUrl: "", + adminPassword: "", + downloadQuality: "high", + debugMode: false }); +} + async function sendToTidarr(mediaItem: any) { - const settings = await ReactiveStore.getPluginStorage("tidarr-integration", {}); - const tidarrUrl = settings.tidarrUrl; - const adminPassword = settings.adminPassword; - const quality = settings.downloadQuality || "high"; - - if (!tidarrUrl || !adminPassword) { - trace.msg.err("Tidarr URL or admin password not configured in settings"); - return; - } - - try { - const authResponse = await ftch.json(`${tidarrUrl}/api/auth`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ password: adminPassword }), - }); - - if (!(authResponse as any).accessGranted || !(authResponse as any).token) { - trace.msg.err("Failed to authenticate with Tidarr"); - return; - } - - const token = (authResponse as any).token; - const { tags } = await mediaItem.flacTags(); - - let itemType: string; - let tidalItemUrl: string; - let itemId: string; - let title: string; - let artist: string; - - if (mediaItem.type === "album" || mediaItem.album) { - itemType = "album"; - const rawId = mediaItem.album?.id || mediaItem.id; - // keep getting an ID off by 1... let's adjust for that in the dirtiest way possible - itemId = String(parseInt(rawId) - 1); - title = tags.album || mediaItem.album?.name || mediaItem.name || "Unknown Album"; - artist = tags.albumartist || tags.artist || mediaItem.artists?.[0]?.name || "Unknown Artist"; - tidalItemUrl = `http://www.tidal.com/album/${itemId}`; - } else { - itemType = "track"; - itemId = mediaItem.id; - title = tags.title || mediaItem.name || "Unknown Title"; - artist = tags.artist || mediaItem.artists?.[0]?.name || "Unknown Artist"; - tidalItemUrl = `http://www.tidal.com/track/${itemId}`; - } - const tidarrItem = { - id: itemId, - title: title, - artist: artist, - artists: [{ name: artist }], - url: tidalItemUrl, - type: itemType, - quality: quality, - status: "queue", - loading: true, - error: false - }; - - const response = await ftch.text(`${tidarrUrl}/api/save`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${token}`, - "Origin": tidarrUrl, - "Referer": `${tidarrUrl}/`, - }, - body: JSON.stringify({ item: tidarrItem }), - }); - - if (response === "Created" || response.includes("201")) { - trace.msg.log(`Successfully added to Tidarr: "${title}" by ${artist}`); - } else { - trace.msg.err("Unexpected response from Tidarr:", response); - } - } catch (error: any) { - trace.msg.err("Failed to send to Tidarr:", error.message || error); - } + const settings = await getSettings(); + const tidarrUrl = settings.tidarrUrl; + const adminPassword = settings.adminPassword; + const quality = settings.downloadQuality || "high"; + + if (!tidarrUrl) { + trace.msg.err("Tidarr URL not configured in settings"); + return; + } + + try { + const authResponse = await ftch.json(`${tidarrUrl}/api/auth`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password: adminPassword }), + }); + + if (!(authResponse as any).accessGranted || !(authResponse as any).token) { + trace.msg.err("Failed to authenticate with Tidarr"); + return; + } + + const token = (authResponse as any).token; + + const tidalItem = mediaItem.tidalItem || mediaItem; // fallback + const isAlbumContext = mediaItem.type !== "track" && tidalItem.album !== undefined && (mediaItem.trackCount || 0) > 1; + + let tidarrItem: any; + + if (isAlbumContext && tidalItem.album) { + tidarrItem = { + id: String(tidalItem.album.id), + title: tidalItem.album.title, + artist: tidalItem.artists?.[0]?.name || "Unknown Artist", + artists: [{ name: tidalItem.artists?.[0]?.name || "Unknown Artist" }], + url: tidalItem.album.url || `https://tidal.com/browse/album/${tidalItem.album.id}`, + type: "album", + quality, + status: "queue", + loading: true, + error: false, + }; + } else { + tidarrItem = { + id: String(tidalItem.id), + title: tidalItem.title || "Unknown Title", + artist: tidalItem.artists?.[0]?.name || "Unknown Artist", + artists: + tidalItem.artists?.map((a: any) => ({ name: a.name })) || [{ name: "Unknown Artist" }], + url: + tidalItem.url || + (tidalItem.album + ? `https://tidal.com/browse/album/${tidalItem.album.id}/track/${tidalItem.id}` + : `https://tidal.com/browse/track/${tidalItem.id}`), + type: "track", + quality, + status: "queue", + loading: true, + error: false, + }; + } + + const response = await ftch.text(`${tidarrUrl}/api/save`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}`, + "Origin": tidarrUrl, + "Referer": `${tidarrUrl}/`, + }, + body: JSON.stringify({ item: tidarrItem }), + }); + + if (response === "Created" || response.includes("201")) { + trace.msg.log(`Successfully added to Tidarr: "${tidarrItem.title}" by ${tidarrItem.artist}`); + } else { + trace.msg.err("Unexpected response from Tidarr:", response); + } + } catch (error: any) { + trace.msg.err("Failed to send to Tidarr:", error.message || error); + } } ContextMenu.onMediaItem(unloads, async ({ mediaCollection, contextMenu }) => { - const trackCount = await mediaCollection.count(); - if (trackCount === 0) return; - - const mediaItems = await mediaCollection.mediaItems(); - const firstItem = await mediaItems[Symbol.asyncIterator]().next(); - if (!firstItem.value) return; - - const firstTrack = firstItem.value; - const isAlbumContext = firstTrack.album !== undefined && trackCount > 1; - - const tidarrDownloadButton = (ContextMenu as any).addButton(unloads); - const defaultText = (tidarrDownloadButton.text = isAlbumContext - ? `Send album to Tidarr` - : `Send ${trackCount} track(s) to Tidarr`); - - tidarrDownloadButton.onClick(async () => { - if (tidarrDownloadButton.elem === undefined) return; - - tidarrDownloadButton.text = "Sending to Tidarr..."; - - try { - if (isAlbumContext) { - // send one album request instead of multiple track requests - await sendToTidarr(firstTrack); - tidarrDownloadButton.text = `Sent album to Tidarr!`; - } else { - let successCount = 0; - for await (const mediaItem of await mediaCollection.mediaItems()) { - await sendToTidarr(mediaItem); - successCount++; - } - tidarrDownloadButton.text = `Sent ${successCount} item(s) to Tidarr!`; - } - } catch (error) { - trace.msg.err("Error sending to Tidarr:", error); - tidarrDownloadButton.text = "Failed to send to Tidarr"; - } - - setTimeout(() => { - tidarrDownloadButton.text = defaultText; - }, 3000); - }); - - await tidarrDownloadButton.show(contextMenu); -}); \ No newline at end of file + const settings = await getSettings(); // remember to load settings when context menu starts + const debugMode = settings.debugMode; + + const trackCount = await mediaCollection.count(); + if (trackCount === 0) return; + + const mediaItems = await mediaCollection.mediaItems(); + const firstItem = await mediaItems[Symbol.asyncIterator]().next(); + if (!firstItem.value) return; + + const firstTrack = firstItem.value; + const isAlbumContext = firstTrack.album !== undefined && trackCount > 1; + + const tidarrDownloadButton = (ContextMenu as any).addButton(unloads); + const defaultText = (tidarrDownloadButton.text = isAlbumContext + ? `Send album to Tidarr` + : `Send ${trackCount} track(s) to Tidarr`); + + tidarrDownloadButton.onClick(async () => { + if (!tidarrDownloadButton.elem) return; + + tidarrDownloadButton.text = "Sending to Tidarr..."; + + try { + if (isAlbumContext) { + await sendToTidarr(firstTrack); // send the first track which contains album info + tidarrDownloadButton.text = `Sent album to Tidarr!`; + } else { + let successCount = 0; + for await (const mediaItem of await mediaCollection.mediaItems()) { + await sendToTidarr(mediaItem); + successCount++; + } + tidarrDownloadButton.text = `Sent ${successCount} item(s) to Tidarr!`; + } + } catch (error) { + trace.msg.err("Error sending to Tidarr:", error); + tidarrDownloadButton.text = "Failed to send to Tidarr"; + } + + setTimeout(() => { + tidarrDownloadButton.text = defaultText; + }, 3000); + }); + + await tidarrDownloadButton.show(contextMenu); + + // only show debug button if debugMode is true + if (debugMode) { + const debugButton = (ContextMenu as any).addButton(unloads); + debugButton.text = "[DEBUG] Show Media Info"; + + debugButton.onClick(async () => { + const win = window.open("", "Tidarr Item Info", "width=500,height=400,resizable"); + if (win) { + const info = firstTrack; + win.document.title = "Tidarr Item Info"; + const pre = win.document.createElement("pre"); + pre.textContent = JSON.stringify(info, null, 2); + win.document.body.innerHTML = ""; + win.document.body.appendChild(pre); + } + }); + + await debugButton.show(contextMenu); + } +});