Skip to content

Commit

Permalink
lib - Added ContextMenu & Playlist/Album ItemCaches
Browse files Browse the repository at this point in the history
  • Loading branch information
Inrixia committed Jun 24, 2024
1 parent f97b995 commit 67b0016
Show file tree
Hide file tree
Showing 9 changed files with 242 additions and 124 deletions.
41 changes: 41 additions & 0 deletions plugins/RealMAX/src/MaxTrack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { fetchIsrcIterable } from "@inrixia/lib/api/tidal/isrc";
import { Resource } from "@inrixia/lib/api/tidal/types/ISRC";
import { ExtendedTrackItem } from "@inrixia/lib/Caches/ExtendedTrackItem";
import { TrackItemCache } from "@inrixia/lib/Caches/TrackItemCache";
import { ItemId, TrackItem } from "neptune-types/tidal";
import { hasHiRes } from ".";

export class MaxTrack {
private static readonly _idMap: Record<ItemId, Promise<Resource | false>> = {};
public static async fastCacheMaxId(itemId: ItemId): Promise<Resource | false> {
if (itemId === undefined) return false;
return MaxTrack._idMap[itemId];
}
public static async getMaxId(itemId: ItemId | undefined): Promise<Resource | false> {
if (itemId === undefined) return false;

const idMapping = MaxTrack._idMap[itemId];
if (idMapping !== undefined) return idMapping;

const extTrackItem = await ExtendedTrackItem.get(itemId);
const trackItem = extTrackItem?.trackItem;
if (trackItem !== undefined && hasHiRes(trackItem)) return false;

const isrcs = await extTrackItem?.isrcs();
if (isrcs === undefined) return (this._idMap[itemId] = Promise.resolve(false));

return (this._idMap[itemId] = (async () => {
for (const isrc of isrcs) {
for await (const { resource } of fetchIsrcIterable(isrc)) {
if (resource?.id !== undefined && hasHiRes(<TrackItem>resource)) {
if (resource.artifactType !== "track") continue;
const maxTrackItem = await TrackItemCache.ensure(resource?.id);
if (maxTrackItem !== undefined && !hasHiRes(maxTrackItem)) continue;
else return resource;
}
}
}
return false;
})());
}
}
56 changes: 17 additions & 39 deletions plugins/RealMAX/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,56 +1,21 @@
import { ItemId, TrackItem } from "neptune-types/tidal";
import { TrackItemCache } from "@inrixia/lib/Caches/TrackItemCache";
import { fetchIsrcIterable } from "@inrixia/lib/api/tidal/isrc";
import { actions, intercept, store } from "@neptune";
import { ExtendedTrackItem } from "@inrixia/lib/Caches/ExtendedTrackItem";
import { Resource } from "@inrixia/lib/api/tidal/types/ISRC";
import { debounce } from "@inrixia/lib/debounce";

import { Tracer } from "@inrixia/lib/trace";
import safeUnload from "@inrixia/lib/safeUnload";
import { interceptPromise } from "@inrixia/lib/intercept/interceptPromise";
import { MaxTrack } from "./MaxTrack";
import { ContextMenu } from "@inrixia/lib/ContextMenu";
const trace = Tracer("[RealMAX]");

const hasHiRes = (trackItem: TrackItem) => {
export const hasHiRes = (trackItem: TrackItem) => {
const tags = trackItem.mediaMetadata?.tags;
if (tags === undefined) return false;
return tags.findIndex((tag) => tag === "HIRES_LOSSLESS") !== -1;
};

class MaxTrack {
private static readonly _idMap: Record<ItemId, Promise<Resource | false>> = {};
public static async fastCacheMaxId(itemId: ItemId): Promise<Resource | false> {
if (itemId === undefined) return false;
return MaxTrack._idMap[itemId];
}
public static async getMaxId(itemId: ItemId | undefined): Promise<Resource | false> {
if (itemId === undefined) return false;

const idMapping = MaxTrack._idMap[itemId];
if (idMapping !== undefined) return idMapping;

const extTrackItem = await ExtendedTrackItem.get(itemId);
const trackItem = extTrackItem?.trackItem;
if (trackItem !== undefined && hasHiRes(trackItem)) return false;

const isrcs = await extTrackItem?.isrcs();
if (isrcs === undefined) return (this._idMap[itemId] = Promise.resolve(false));

return (this._idMap[itemId] = (async () => {
for (const isrc of isrcs) {
for await (const { resource } of fetchIsrcIterable(isrc)) {
if (resource?.id !== undefined && hasHiRes(<TrackItem>resource)) {
if (resource.artifactType !== "track") continue;
const maxTrackItem = await TrackItemCache.ensure(resource?.id);
if (maxTrackItem !== undefined && !hasHiRes(maxTrackItem)) continue;
else return resource;
}
}
}
return false;
})());
}
}

const unloadIntercept = intercept(
"playbackControls/MEDIA_PRODUCT_TRANSITION",
debounce(async () => {
Expand All @@ -72,6 +37,19 @@ const unloadIntercept = intercept(
}, 125)
);

ContextMenu.onOpen(async (contextSource, contextMenu, trackItems) => {
if (trackItems.length === 0) return;
document.getElementById("realMax-button")?.remove();

const downloadButton = document.createElement("button");
downloadButton.type = "button";
downloadButton.role = "menuitem";
downloadButton.textContent = `RealMAX - Process ${trackItems.length} tracks`;
downloadButton.id = "realMax-button";
downloadButton.className = "context-button"; // Set class name for styling
contextMenu.appendChild(downloadButton);
});

export const onUnload = () => {
unloadIntercept();
safeUnload();
Expand Down
102 changes: 27 additions & 75 deletions plugins/SongDownloader/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const trace = Tracer("[SongDownloader]");
import safeUnload from "@inrixia/lib/safeUnload";

import { settings } from "./Settings";
import { ContextMenu } from "@inrixia/lib/ContextMenu";
export { Settings } from "./Settings";

type DownloadButtoms = Record<string, HTMLButtonElement>;
Expand Down Expand Up @@ -53,87 +54,38 @@ const buttonMethods = (id: string): ButtonMethods => ({
},
});

const intercepts = [
intercept([`contextMenu/OPEN_MEDIA_ITEM`], ([mediaItem]) => queueMediaIds([mediaItem.id])),
intercept([`contextMenu/OPEN_MULTI_MEDIA_ITEM`], ([mediaItems]) => queueMediaIds(mediaItems.ids)),
intercept("contextMenu/OPEN", ([info]) => {
switch (info.type) {
case "ALBUM": {
onAlbum(info.id);
break;
}
case "PLAYLIST": {
onPlaylist(info.id);
break;
}
}
}),
];
export const onUnload = () => {
intercepts.forEach((unload) => unload());
safeUnload();
};

const onAlbum = async (albumId: ItemId) => {
const [{ mediaItems }] = await interceptPromise(
() => actions.content.loadAllAlbumMediaItems({ albumId }),
["content/LOAD_ALL_ALBUM_MEDIA_ITEMS_SUCCESS"],
["content/LOAD_ALL_ALBUM_MEDIA_ITEMS_FAIL"]
);
downloadItems(Object.values<MediaItem>(<any>mediaItems).map((mediaItem) => mediaItem.item));
};
const onPlaylist = async (playlistUUID: ItemId) => {
const [{ items }] = await interceptPromise(
() => actions.content.loadListItemsPage({ loadAll: true, listName: `playlists/${playlistUUID}`, listType: "mediaItems" }),
["content/LOAD_LIST_ITEMS_PAGE_SUCCESS"],
["content/LOAD_LIST_ITEMS_PAGE_FAIL"]
);
downloadItems(Object.values(items).map((mediaItem) => mediaItem?.item));
};

const queueMediaIds = (mediaIds: ItemId[]) => {
Promise.all(mediaIds.map(TrackItemCache.ensure.bind(TrackItemCache)))
.then((tracks) => tracks.filter((item) => item !== undefined))
.then(downloadItems);
};

const downloadItems = (items: (TrackItem | VideoItem)[]) =>
// Wrap in a timeout to ensure that the context menu is open
setTimeout(() => {
const trackItems = items.filter((item) => item.contentType === "track");
if (trackItems.length === 0) return;
export const onUnload = safeUnload;

const contextMenu = document.querySelector(`[data-type="list-container__context-menu"]`);
if (contextMenu === null) return;
if (document.getElementsByClassName("download-button").length >= 1) {
document.getElementsByClassName("download-button")[0].remove();
}
ContextMenu.onOpen(async (contextSource, contextMenu, trackItems) => {
if (trackItems.length === 0) return;
document.getElementById("download-button")?.remove();

const downloadButton = document.createElement("button");
downloadButton.type = "button";
downloadButton.role = "menuitem";
downloadButton.textContent = `Download ${trackItems.length}`;
downloadButton.className = "download-button"; // Set class name for styling
const downloadButton = document.createElement("button");
downloadButton.type = "button";
downloadButton.role = "menuitem";
downloadButton.textContent = `Download ${trackItems.length}`;
downloadButton.id = "download-button";
downloadButton.className = "context-button"; // Set class name for styling

const context = JSON.stringify(trackItems.map((trackItem) => trackItem.id));
const context = JSON.stringify(trackItems.map((trackItem) => trackItem.id).sort());

if (downloadButtons[context]?.disabled === true) {
downloadButton.disabled = true;
downloadButton.classList.add("loading");
if (downloadButtons[context]?.disabled === true) {
downloadButton.disabled = true;
downloadButton.classList.add("loading");
}
downloadButtons[context] = downloadButton;
contextMenu.appendChild(downloadButton);
const { prep, onProgress, clear } = buttonMethods(context);
downloadButton.addEventListener("click", async () => {
if (context === undefined) return;
prep();
for (const trackItem of trackItems) {
if (trackItem.id === undefined) continue;
await downloadTrack(trackItem, { trackId: trackItem.id, desiredQuality: settings.desiredDownloadQuality }, { onProgress }).catch(trace.msg.err.withContext("Error downloading track"));
}
downloadButtons[context] = downloadButton;
contextMenu.appendChild(downloadButton);
const { prep, onProgress, clear } = buttonMethods(context);
downloadButton.addEventListener("click", async () => {
if (context === undefined) return;
prep();
for (const trackItem of trackItems) {
if (trackItem.id === undefined) continue;
await downloadTrack(trackItem, { trackId: trackItem.id, desiredQuality: settings.desiredDownloadQuality }, { onProgress }).catch(trace.msg.err.withContext("Error downloading track"));
}
clear();
});
clear();
});
});

export const downloadTrack = async (track: TrackItem, trackOptions: TrackOptions, options?: DownloadTrackOptions) => {
// Download the bytes
Expand Down
10 changes: 5 additions & 5 deletions plugins/SongDownloader/src/styles.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { setStyle } from "@inrixia/lib/css/setStyle";

const styles = `
.download-button {
.context-button {
align-items: center;
display: flex;
font-weight: 500;
Expand All @@ -11,11 +11,11 @@ const styles = `
color: #b878ff;
position: relative;
}
.download-button:hover {
.context-button:hover {
background-color: #9e46ff;
color: #fff;
}
.download-button::before {
.context-button::before {
content: "";
position: absolute;
top: 0;
Expand All @@ -25,12 +25,12 @@ const styles = `
background: rgba(255, 255, 255, 0.25); /* Loading bar color */
z-index: 1;
}
.download-button.loading {
.context-button.loading {
background-color: #9e46ff;
cursor: not-allowed;
color: #fff;
}
.download-button span {
.context-button span {
z-index: 2;
position: relative;
}
Expand Down
37 changes: 32 additions & 5 deletions plugins/_lib/Caches/AlbumCache.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
import { actions, store } from "@neptune";
import type { Album } from "neptune-types/tidal";
import type { Album, ItemId, MediaItem, TrackItem } from "neptune-types/tidal";
import { interceptPromise } from "../intercept/interceptPromise";
import { SharedObjectStoreExpirable } from "../storage/SharedObjectStoreExpirable";
import { retryPending } from "./retryPending";

import { libTrace } from "../trace";
import { SharedObjectStore } from "../storage/SharedObjectStore";

const DAY = 1000 * 60 * 60 * 24;
export class AlbumCache {
private static readonly _cache: Record<number, Album> = {};
private static readonly _trackItemsCache: SharedObjectStore<ItemId, { albumId: ItemId; trackItems?: TrackItem[] }> = new SharedObjectStore("AlbumCache.trackItems", { keyPath: "albumId" });
public static async get(albumId?: number) {
if (albumId === undefined) return undefined;

let mediaItem = this._cache[albumId];
if (mediaItem !== undefined) return mediaItem;
let album = this._cache[albumId];
if (album !== undefined) return album;

const mediaItems: Record<number, Album> = store.getState().content.albums;
for (const itemId in mediaItems) this._cache[itemId] = mediaItems[itemId];
const albums: Record<number, Album> = store.getState().content.albums;
for (const albumId in albums) this._cache[albumId] = albums[albumId];

if (this._cache[albumId] === undefined) {
const album = await interceptPromise(() => actions.content.loadAlbum({ albumId }), ["content/LOAD_ALBUM_SUCCESS"], [])
Expand All @@ -22,4 +29,24 @@ export class AlbumCache {

return this._cache[albumId];
}
public static async getTrackItems(playlistUUID?: ItemId) {
if (playlistUUID === undefined) return undefined;

let albumTrackItems = await this._trackItemsCache.get(playlistUUID);
const updatePromise = this.updateTrackItems(playlistUUID);
if (albumTrackItems?.trackItems !== undefined) return albumTrackItems.trackItems;
return updatePromise;
}
public static async updateTrackItems(albumId: ItemId) {
const result = await interceptPromise(() => actions.content.loadAllAlbumMediaItems({ albumId }), ["content/LOAD_ALL_ALBUM_MEDIA_ITEMS_SUCCESS"], ["content/LOAD_ALL_ALBUM_MEDIA_ITEMS_FAIL"], {
timeoutMs: 2000,
}).catch(libTrace.warn.withContext("PlaylistCache.getTrackItems.interceptPromise"));
if (result?.[0]?.mediaItems === undefined) {
const albumTrackItems = await this._trackItemsCache.get(albumId);
return albumTrackItems?.trackItems;
}
const trackItems = Array.from((<Immutable.List<MediaItem>>result?.[0]?.mediaItems).map((mediaItem) => mediaItem?.item).filter((item) => item?.contentType === "track"));
await this._trackItemsCache.put({ albumId, trackItems });
return trackItems;
}
}
37 changes: 37 additions & 0 deletions plugins/_lib/Caches/PlaylistItemCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { actions } from "@neptune";
import type { ItemId, MediaItem, TrackItem } from "neptune-types/tidal";
import { interceptPromise } from "../intercept/interceptPromise";
import { SharedObjectStoreExpirable } from "../storage/SharedObjectStoreExpirable";
import { retryPending } from "./retryPending";

import { libTrace } from "../trace";

export class PlaylistCache {
private static readonly _trackItemsCache: SharedObjectStoreExpirable<ItemId, { playlistUUID: ItemId; trackItems: TrackItem[] }> = new SharedObjectStoreExpirable("PlaylistCache.trackItems", {
maxAge: 30000,
storeSchema: { keyPath: "playlistUUID" },
});
public static async getTrackItems(playlistUUID?: ItemId) {
if (playlistUUID === undefined) return undefined;

let playlistTrackItems = await this._trackItemsCache.get(playlistUUID);
const updatePromise = this.updateTrackItems(playlistUUID);
if (playlistTrackItems?.trackItems !== undefined) return playlistTrackItems.trackItems;
return updatePromise;
}
public static async updateTrackItems(playlistUUID: ItemId) {
const result = await interceptPromise(
() => actions.content.loadListItemsPage({ loadAll: true, listName: `playlists/${playlistUUID}`, listType: "mediaItems" }),
["content/LOAD_LIST_ITEMS_PAGE_SUCCESS"],
["content/LOAD_LIST_ITEMS_PAGE_FAIL"],
{ timeoutMs: 2000 }
).catch(libTrace.warn.withContext("PlaylistCache.getTrackItems.interceptPromise"));
if (result?.[0]?.items === undefined) {
const playlistTrackItems = await this._trackItemsCache.get(playlistUUID);
return playlistTrackItems?.trackItems;
}
const trackItems = Array.from((<Immutable.List<MediaItem>>result?.[0]?.items).map((mediaItem) => mediaItem?.item).filter((item) => item?.contentType === "track"));
await this._trackItemsCache.put({ playlistUUID, trackItems });
return trackItems;
}
}
15 changes: 15 additions & 0 deletions plugins/_lib/Caches/retryPending.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export const retryPending = <V>(getter: () => Promise<V>): Promise<V | undefined> =>
new Promise((res) => {
const timeout = setTimeout(() => {
clearInterval(interval);
res(undefined);
}, 5000);
const interval = setInterval(async () => {
const value = await getter();
if (value !== undefined) {
res(value);
clearInterval(interval);
clearTimeout(timeout);
}
}, 100);
});
Loading

0 comments on commit 67b0016

Please sign in to comment.