-
-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit 0845bb6
Showing
11 changed files
with
2,914 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
[*.{ts,js,json}] | ||
indent_size = 4 | ||
indent_style = space | ||
insert_final_newline = true | ||
end_of_line = lf |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
dist/ | ||
bin/ | ||
|
||
.yarn/cache | ||
.yarn/install-state.gz | ||
|
||
node_modules | ||
*.tsbuildinfo |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
# iTunes-Presence | ||
|
||
Discord Rich Presence for iTunes on Windows. | ||
|
||
## Acknowledgements | ||
|
||
1. [kkevinm/iTunes-Discord-RP](https://github.com/kkevinm/iTunes-Discord-RP) - Script used for interfacing with the iTunes Scripting Interface. | ||
2. [NextFire/apple-music-discord-rpc](https://github.com/NextFire/apple-music-discord-rpc) - Search album methods for Track artwork. | ||
|
||
## TODO | ||
|
||
- [ ] possibly move to Deno so that binaries are easier to create. | ||
- [ ] add buttons to activity |
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
{ | ||
"name": "itunes-presence", | ||
"main": "dist/index.js", | ||
"bin": "dist/index.js", | ||
"packageManager": "yarn@3.3.0", | ||
"scripts": { | ||
"build:bundle": "esbuild src/index.ts --bundle --minify --target=node18 --platform=node --outfile=dist/index.js", | ||
"build:binary": "pkg ." | ||
}, | ||
"devDependencies": { | ||
"@types/discord-rpc": "^4.0.3", | ||
"@types/node": "^18.11.17", | ||
"esbuild": "^0.16.9", | ||
"pkg": "^5.8.0", | ||
"ts-node": "^10.9.1", | ||
"typescript": "^4.9.4" | ||
}, | ||
"dependencies": { | ||
"discord-rpc": "^4.0.1" | ||
}, | ||
"pkg": { | ||
"outputPath": "bin", | ||
"targets": ["win"] | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,64 @@ | ||
import { Client } from "discord-rpc"; | ||
import { existsSync } from "fs"; | ||
import { rm } from "fs/promises"; | ||
import { startITunesReader, Track, TrackInfo } from "./itunes"; | ||
import { getPresence, getStartTimestamp, getTrack } from "./rpc"; | ||
import { createScriptFile } from "./script"; | ||
|
||
async function main() { | ||
const script = await createScriptFile(); | ||
process.on("SIGINT", async () => { | ||
if (existsSync(script)) await rm(script); | ||
process.exit(0); | ||
}) | ||
|
||
const rpc = new Client({ | ||
transport: "ipc" | ||
}); | ||
|
||
rpc.on("ready", async () => { | ||
while (true) { | ||
let track: Track | null = null, paused = false; | ||
for await (const line of startITunesReader(script)) { | ||
const json = decodeURIComponent(line.trim()); | ||
if (!json.length) { | ||
continue; | ||
} | ||
|
||
console.log("<<<", json); | ||
const info: TrackInfo = JSON.parse(json); | ||
|
||
/* if stopped break out */ | ||
if (info.state === "STOPPED") { | ||
break; | ||
} | ||
|
||
/* ensure that a track is available */ | ||
if (info.position == 0 || track == null) { | ||
track = await getTrack(info); | ||
} | ||
|
||
/* if paused clear activity */ | ||
if (info.state === "PAUSED") { | ||
paused = true; | ||
await rpc.clearActivity(); | ||
continue; | ||
} | ||
|
||
if (paused) { | ||
paused = false; | ||
track.startedAt = getStartTimestamp(info); | ||
} | ||
|
||
rpc.setActivity(getPresence(track)); | ||
}; | ||
|
||
console.log(`Exited iTunes read loop, trying again in 5 seconds.`); | ||
await new Promise(res => setTimeout(res, 5_000)); | ||
} | ||
}); | ||
|
||
await rpc.login({ clientId: "1054013362230530170" }); | ||
} | ||
|
||
void main(); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,81 @@ | ||
import { spawn } from "node:child_process"; | ||
import { Readable } from "node:stream"; | ||
import { ReadableStream, TextDecoderStream } from "node:stream/web"; | ||
|
||
export function startITunesReader(script: string): ReadableStream<string> { | ||
const proc = spawn( | ||
"Cscript.exe", | ||
[script], | ||
{ stdio: "pipe", shell: false } | ||
); | ||
|
||
return Readable.toWeb(proc.stderr).pipeThrough(new TextDecoderStream()) | ||
} | ||
|
||
export async function searchAlbum( | ||
artist: string, | ||
album: string | ||
): Promise<iTunesInfos> { | ||
const params = new URLSearchParams({ | ||
media: "music", | ||
entity: "album", | ||
term: album.includes(artist) ? artist : `${artist} ${album}`, | ||
limit: album.includes(artist) ? "" : "1", | ||
}); | ||
|
||
const resp = await fetch(`https://itunes.apple.com/search?${params}`) | ||
, json: iTunesSearchResponse = await resp.json(); | ||
|
||
let result: iTunesSearchResult | undefined; | ||
if (json.resultCount === 1) { | ||
result = json.results[0]; | ||
} else if (json.resultCount > 1) { | ||
result = json.results.find((r) => r.collectionName === album); | ||
} else if (album.match(/\(.*\)$/)) { | ||
return await searchAlbum(artist, album.replace(/\(.*\)$/, "").trim()); | ||
} | ||
|
||
const artwork = result?.artworkUrl100.replace("100x100bb", "512x512bb") ?? null; | ||
const url = result?.collectionViewUrl ?? null; | ||
return { artwork, url }; | ||
} | ||
|
||
export interface iTunesInfos { | ||
artwork: string | null; | ||
url: string | null; | ||
} | ||
|
||
interface iTunesSearchResponse { | ||
resultCount: number; | ||
results: iTunesSearchResult[]; | ||
} | ||
|
||
interface iTunesSearchResult { | ||
artworkUrl100: string; | ||
collectionViewUrl: string; | ||
collectionName: string; | ||
} | ||
|
||
export type TrackInfo = | ||
| { state: "STOPPED" } | ||
| HydratedTrackInfo | ||
|
||
export interface HydratedTrackInfo { | ||
state: "PLAYING" | "PAUSED"; | ||
name: string; | ||
artist: string; | ||
album: string; | ||
position: number; | ||
duration: number; | ||
trackNumber: number; | ||
trackCount: number; | ||
} | ||
|
||
export interface Track { | ||
info: iTunesInfos; | ||
name: string; | ||
album: string; | ||
duration: number; | ||
artist: string; | ||
startedAt: number; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,55 @@ | ||
import type { Presence } from "discord-rpc"; | ||
import { HydratedTrackInfo, searchAlbum, Track } from "./itunes"; | ||
|
||
export function getStartTimestamp(info: HydratedTrackInfo): number { | ||
let start = Date.now(); | ||
if (info.position > 0) { | ||
/* offset the start timestamp by the current position */ | ||
start -= info.position * 1000; | ||
} | ||
|
||
return start; | ||
} | ||
|
||
export async function getTrack(info: HydratedTrackInfo): Promise<Track> { | ||
const results = await searchAlbum(info.artist, info.album); | ||
return { | ||
startedAt: getStartTimestamp(info), | ||
info: results, | ||
album: info.album, | ||
artist: info.artist, | ||
name: info.name, | ||
duration: info.duration | ||
} | ||
} | ||
|
||
function formatStr(s: string, minLength = 2, maxLength = 128) { | ||
return s.length <= maxLength | ||
? s.padEnd(minLength) | ||
: `${s.slice(0, maxLength - 3)}...`; | ||
} | ||
|
||
export function getPresence(track: Track): Presence { | ||
const presence: Presence = { | ||
details: formatStr(track.name), | ||
endTimestamp: track.startedAt + track.duration * 1000, | ||
largeImageKey: "ico", | ||
largeImageText: track.album, | ||
}; | ||
|
||
if (track.artist.length > 0) { | ||
presence.state = formatStr(`by ${track.artist}`); | ||
} | ||
|
||
if (track.info.url) { | ||
presence.buttons = [ | ||
{ label: "Listen on Apple music", url: track.info.url } | ||
] | ||
} | ||
|
||
if (track.info.artwork) { | ||
presence.largeImageKey = track.info.artwork; | ||
} | ||
|
||
return presence; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,132 @@ | ||
import { writeFile } from "node:fs/promises"; | ||
import { join } from "node:path"; | ||
|
||
const SCRIPT_CONTENT = ` | ||
var sQuery = "SELECT * FROM Win32_Process WHERE Name = 'iTunes.exe'"; | ||
var stderr = WScript.CreateObject("Scripting.FileSystemObject").GetStandardStream(2); | ||
var iTunesApp = null; | ||
var state = ""; | ||
function CreateJsonElement(element) { | ||
if(typeof element == "string") { | ||
return "\\"" + element + "\\""; | ||
} else if (typeof element == "object") { | ||
return CreateJsonObject(element); | ||
} else { | ||
return element; | ||
} | ||
} | ||
function CreateJsonArray(array) { | ||
var output = "["; | ||
for(var i = 0; i < array.length; i++) { | ||
if(output != "[") { | ||
output += ","; | ||
} | ||
var value = array[i]; | ||
output += CreateJsonElement(value); | ||
} | ||
output += "]"; | ||
return output; | ||
} | ||
function CreateJsonObject(object) { | ||
var output = "{"; | ||
for(var key in object) { | ||
if(output != "{") { | ||
output += ","; | ||
} | ||
var value = object[key]; | ||
output += "\\"" + key + "\\":" + CreateJsonElement(value); | ||
} | ||
output += "}"; | ||
return output; | ||
} | ||
function WaitForITunes() { | ||
var service = WScript.CreateObject("WbemScripting.SWbemLocator").ConnectServer(); | ||
while(true) { | ||
// Search for the "iTunes.exe" process | ||
var items = service.ExecQuery(sQuery); | ||
if(items.Count != 0) { | ||
// If there is, create the object used for retrieving the tracks and return | ||
iTunesApp = WScript.CreateObject("iTunes.Application"); | ||
return; | ||
} | ||
WScript.sleep(1000); | ||
} | ||
} | ||
function WriteInfoLine(object) { | ||
var json = CreateJsonObject(object); | ||
stderr.WriteLine(encodeURIComponent(json)); | ||
} | ||
function MainLoop() { | ||
while(true) { | ||
if(iTunesApp == null) { | ||
LogStopped(); | ||
WaitForITunes(); | ||
} else { | ||
try { | ||
var currentTrack = iTunesApp.CurrentTrack; | ||
var playerState = iTunesApp.PlayerState; | ||
if(currentTrack == null || playerState == null) { | ||
LogStopped(); | ||
} else { | ||
if(playerState == 0) { | ||
state = "PAUSED"; | ||
} else { | ||
state = "PLAYING"; | ||
} | ||
// Create a JSON object with the track information | ||
try { | ||
WriteInfoLine({ | ||
name: currentTrack.Name, | ||
artist: currentTrack.Artist, | ||
album: currentTrack.Album, | ||
state: state, | ||
position: iTunesApp.PlayerPosition, | ||
duration: currentTrack.Duration, | ||
trackNumber: currentTrack.TrackNumber, | ||
trackCount: currentTrack.TrackCount | ||
}); | ||
} catch (e) { | ||
stderr.WriteLine(e.message); | ||
} | ||
} | ||
WScript.sleep(1000); | ||
} catch(err) { | ||
// If iTunes stops, reset the variables and do nothing | ||
iTunesApp = null; | ||
} | ||
} | ||
} | ||
} | ||
function LogStopped() { | ||
if(state != "STOPPED") { | ||
state = "STOPPED"; | ||
WriteInfoLine({ state: state }); | ||
} | ||
} | ||
WaitForITunes(); | ||
MainLoop(); | ||
`; | ||
|
||
export async function createScriptFile() { | ||
const path = join(process.cwd(), "script-file.js"); | ||
await writeFile( | ||
path, | ||
SCRIPT_CONTENT | ||
); | ||
|
||
return path; | ||
} |
Oops, something went wrong.