Skip to content

Commit

Permalink
project: init
Browse files Browse the repository at this point in the history
  • Loading branch information
viztea committed Dec 18, 2022
0 parents commit 0845bb6
Show file tree
Hide file tree
Showing 11 changed files with 2,914 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .editorconfig
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
8 changes: 8 additions & 0 deletions .gitignore
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
13 changes: 13 additions & 0 deletions README.md
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
Binary file added assets/ico.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
25 changes: 25 additions & 0 deletions package.json
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"]
}
}
64 changes: 64 additions & 0 deletions src/index.ts
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();
81 changes: 81 additions & 0 deletions src/itunes.ts
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;
}
55 changes: 55 additions & 0 deletions src/rpc.ts
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;
}
132 changes: 132 additions & 0 deletions src/script.ts
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;
}
Loading

0 comments on commit 0845bb6

Please sign in to comment.