├── .gitattributes
├── projects
├── library
│ ├── previews
│ │ ├── albums.png
│ │ ├── artists.png
│ │ ├── playlists.png
│ │ └── collections.png
│ ├── src
│ │ ├── settings.json
│ │ ├── components
│ │ │ ├── local_album_menu.tsx
│ │ │ ├── leading_icon.tsx
│ │ │ ├── pin_icon.tsx
│ │ │ ├── collapse_button.tsx
│ │ │ ├── folder_fallback.tsx
│ │ │ ├── artist_menu_item.tsx
│ │ │ ├── load_more_card.tsx
│ │ │ ├── toggle_filters.tsx
│ │ │ ├── expand_button.tsx
│ │ │ ├── back_button.tsx
│ │ │ ├── text_input_dialog.tsx
│ │ │ ├── add_button.tsx
│ │ │ ├── nav_context.tsx
│ │ │ ├── searchbar.tsx
│ │ │ ├── folder_menu.tsx
│ │ │ ├── custom_card.tsx
│ │ │ ├── collection_menu.tsx
│ │ │ └── album_menu_item.tsx
│ │ ├── styles
│ │ │ ├── icon_filled.svg
│ │ │ ├── icon_unfilled.svg
│ │ │ ├── app.scss
│ │ │ └── external.scss
│ │ ├── types
│ │ │ ├── library_types.ts
│ │ │ └── platform.ts
│ │ ├── extensions
│ │ │ ├── folder_image_wrapper.ts
│ │ │ └── extension.tsx
│ │ ├── utils
│ │ │ └── collection_sort.ts
│ │ ├── app.tsx
│ │ └── pages
│ │ │ ├── artists.tsx
│ │ │ ├── shows.tsx
│ │ │ ├── albums.tsx
│ │ │ ├── collections.tsx
│ │ │ └── playlists.tsx
│ ├── package.json
│ └── README.md
├── stats
│ ├── previews
│ │ ├── top_albums.png
│ │ ├── top_artists.png
│ │ ├── top_charts.png
│ │ ├── top_genres.png
│ │ ├── top_tracks.png
│ │ ├── library_analysis.png
│ │ └── playlist_analysis.png
│ ├── src
│ │ ├── styles
│ │ │ ├── placeholder.png
│ │ │ ├── icon_filled.svg
│ │ │ ├── icon_unfilled.svg
│ │ │ └── app.scss
│ │ ├── settings.json
│ │ ├── components
│ │ │ ├── cards
│ │ │ │ ├── stat_card.tsx
│ │ │ │ └── chart_card.tsx
│ │ │ ├── shelf.tsx
│ │ │ ├── buttons
│ │ │ │ ├── refresh_button.tsx
│ │ │ │ └── create_playlist_button.tsx
│ │ │ ├── inline_grid.tsx
│ │ │ └── tracklist.tsx
│ │ ├── api
│ │ │ ├── platform.ts
│ │ │ ├── lastfm.ts
│ │ │ └── spotify.ts
│ │ ├── extensions
│ │ │ ├── cache.ts
│ │ │ └── extension.tsx
│ │ ├── types
│ │ │ ├── stats_types.ts
│ │ │ ├── lastfm.ts
│ │ │ ├── graph_ql.ts
│ │ │ └── spotify.ts
│ │ ├── pages
│ │ │ ├── top_albums.tsx
│ │ │ ├── top_tracks.tsx
│ │ │ ├── top_artists.tsx
│ │ │ ├── playlist.tsx
│ │ │ ├── top_genres.tsx
│ │ │ ├── library.tsx
│ │ │ └── charts.tsx
│ │ ├── app.tsx
│ │ └── utils
│ │ │ ├── converter.ts
│ │ │ └── track_helper.ts
│ ├── package.json
│ ├── install.sh
│ ├── install.ps1
│ └── README.md
└── shared
│ └── src
│ ├── placeholders
│ ├── def_placeholder.png
│ └── folder_placeholder.png
│ ├── status
│ ├── useStatus.tsx
│ └── status.tsx
│ ├── types
│ ├── react_query.ts
│ └── platform.ts
│ ├── config
│ ├── config_types.ts
│ ├── config_modal.scss
│ ├── config_wrapper.tsx
│ └── config_modal.tsx
│ ├── components
│ ├── page_container.tsx
│ ├── navigation
│ │ └── navigation_bar.tsx
│ ├── settings_button.tsx
│ └── spotify_card.tsx
│ ├── dropdown
│ ├── useDropdownMenu.tsx
│ ├── useSortDropdownMenu.tsx
│ ├── sort_dropdown.tsx
│ └── dropdown.tsx
│ ├── icons
│ └── arrows.tsx
│ └── shared.scss
├── package.json
├── tsconfig.json
├── manifest.json
├── README.md
└── .gitignore
/.gitattributes:
--------------------------------------------------------------------------------
1 | dist/* linguist-vendored
--------------------------------------------------------------------------------
/projects/library/previews/albums.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/harbassan/spicetify-apps/HEAD/projects/library/previews/albums.png
--------------------------------------------------------------------------------
/projects/library/previews/artists.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/harbassan/spicetify-apps/HEAD/projects/library/previews/artists.png
--------------------------------------------------------------------------------
/projects/library/previews/playlists.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/harbassan/spicetify-apps/HEAD/projects/library/previews/playlists.png
--------------------------------------------------------------------------------
/projects/stats/previews/top_albums.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/harbassan/spicetify-apps/HEAD/projects/stats/previews/top_albums.png
--------------------------------------------------------------------------------
/projects/stats/previews/top_artists.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/harbassan/spicetify-apps/HEAD/projects/stats/previews/top_artists.png
--------------------------------------------------------------------------------
/projects/stats/previews/top_charts.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/harbassan/spicetify-apps/HEAD/projects/stats/previews/top_charts.png
--------------------------------------------------------------------------------
/projects/stats/previews/top_genres.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/harbassan/spicetify-apps/HEAD/projects/stats/previews/top_genres.png
--------------------------------------------------------------------------------
/projects/stats/previews/top_tracks.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/harbassan/spicetify-apps/HEAD/projects/stats/previews/top_tracks.png
--------------------------------------------------------------------------------
/projects/library/previews/collections.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/harbassan/spicetify-apps/HEAD/projects/library/previews/collections.png
--------------------------------------------------------------------------------
/projects/stats/src/styles/placeholder.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/harbassan/spicetify-apps/HEAD/projects/stats/src/styles/placeholder.png
--------------------------------------------------------------------------------
/projects/stats/previews/library_analysis.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/harbassan/spicetify-apps/HEAD/projects/stats/previews/library_analysis.png
--------------------------------------------------------------------------------
/projects/stats/previews/playlist_analysis.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/harbassan/spicetify-apps/HEAD/projects/stats/previews/playlist_analysis.png
--------------------------------------------------------------------------------
/projects/shared/src/placeholders/def_placeholder.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/harbassan/spicetify-apps/HEAD/projects/shared/src/placeholders/def_placeholder.png
--------------------------------------------------------------------------------
/projects/shared/src/placeholders/folder_placeholder.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/harbassan/spicetify-apps/HEAD/projects/shared/src/placeholders/folder_placeholder.png
--------------------------------------------------------------------------------
/projects/stats/src/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "displayName": "Statistics",
3 | "nameId": "stats",
4 | "icon": "styles/icon_unfilled.svg",
5 | "activeIcon": "styles/icon_filled.svg"
6 | }
--------------------------------------------------------------------------------
/projects/library/src/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "displayName": "Your Library",
3 | "nameId": "library",
4 | "icon": "styles/icon_unfilled.svg",
5 | "activeIcon": "styles/icon_filled.svg"
6 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "spicetify-apps",
3 | "private": true,
4 | "version": "1.0.0",
5 | "author": "harbassan",
6 | "license": "MIT",
7 | "workspaces": [
8 | "stats",
9 | "library"
10 | ]
11 | }
--------------------------------------------------------------------------------
/projects/library/src/components/local_album_menu.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const LocalAlbumMenu = ({ id }: { id: string }) => {
4 | const { Menu, MenuItem } = Spicetify.ReactComponent;
5 |
6 | return
;
7 | };
8 |
9 | export default LocalAlbumMenu;
10 |
--------------------------------------------------------------------------------
/projects/library/src/styles/icon_filled.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/projects/library/src/styles/icon_unfilled.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/projects/library/src/components/leading_icon.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const LeadingIcon = ({ path }: { path: string }) => {
4 | return (
5 | ${path}`,
9 | }}
10 | iconSize={16}
11 | />
12 | );
13 | };
14 |
15 | export default LeadingIcon;
16 |
--------------------------------------------------------------------------------
/projects/library/src/components/pin_icon.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const PinIcon = () => (
4 |
5 |
6 |
7 | );
8 |
9 | export default PinIcon;
10 |
--------------------------------------------------------------------------------
/projects/library/src/components/collapse_button.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { LeftArrow } from "../../../shared/src/icons/arrows";
3 |
4 | const collapseLibrary = () => {
5 | Spicetify.Platform.LocalStorageAPI.setItem("left-sidebar-state", 1);
6 | };
7 |
8 | const CollapseButton = () => {
9 | const { ButtonTertiary } = Spicetify.ReactComponent;
10 |
11 | return ;
12 | };
13 |
14 | export default CollapseButton;
15 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2022",
4 | "jsx": "react",
5 | "module": "ES2022",
6 | "moduleResolution": "bundler",
7 | "esModuleInterop": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "strict": true,
10 | "skipLibCheck": true,
11 | "lib": [
12 | "ES2022",
13 | "DOM"
14 | ],
15 | "baseUrl": ".",
16 | "paths": {
17 | "@shared/*": ["./projects/shared/src/*"]
18 | },
19 | },
20 | "include": [
21 | "./projects/*/src/**/*"]
22 | }
23 |
--------------------------------------------------------------------------------
/projects/shared/src/status/useStatus.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import Status from "./status";
3 |
4 | const useStatus = (status: "success" | "error" | "pending", error: Error | null) => {
5 | if (status === "pending") {
6 | return ;
7 | }
8 |
9 | if (status === "error") {
10 | return ;
11 | }
12 |
13 | return null;
14 | };
15 |
16 | export default useStatus;
17 |
--------------------------------------------------------------------------------
/manifest.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "name": "Statistics",
4 | "description": "See your top artists, tracks, genres and an analysis of your music library.",
5 | "preview": "stats/previews/top_genres.png",
6 | "readme": "stats/README.md",
7 | "tags": ["stats"]
8 | },
9 | {
10 | "name": "Library",
11 | "description": "An extended music library with additional features.",
12 | "preview": "library/previews/albums.png",
13 | "readme": "library/README.md",
14 | "tags": ["library"]
15 | }
16 | ]
17 |
--------------------------------------------------------------------------------
/projects/library/src/components/folder_fallback.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const FolderSVG = (e: any): React.ReactElement => {
4 | return (
5 | ',
12 | }}
13 | {...e}
14 | />
15 | );
16 | };
17 |
18 | export default FolderSVG;
19 |
--------------------------------------------------------------------------------
/projects/stats/src/components/cards/stat_card.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | interface StatCardProps {
4 | label: string;
5 | value: number | string;
6 | }
7 |
8 | function StatCard({ label, value }: StatCardProps) {
9 | const { TextComponent } = Spicetify.ReactComponent;
10 |
11 | return (
12 |
13 |
14 | {value}
15 |
16 |
17 | {label}
18 |
19 |
20 | );
21 | }
22 |
23 | export default StatCard;
24 |
--------------------------------------------------------------------------------
/projects/library/src/components/artist_menu_item.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import LeadingIcon from "./leading_icon";
3 |
4 | const ArtistMenuItem = () => {
5 | const { MenuItem } = Spicetify.ReactComponent;
6 | const { SVGIcons } = Spicetify;
7 |
8 | const context = React.useContext(Spicetify.ContextMenuV2._context);
9 | const uri = context?.props?.uri;
10 |
11 | return (
12 | }
15 | onClick={() => CollectionsWrapper.createCollectionFromDiscog(uri)}
16 | >
17 | Create Discog Collection
18 |
19 | );
20 | };
21 |
22 | export default ArtistMenuItem;
23 |
--------------------------------------------------------------------------------
/projects/library/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "spicetify-library",
3 | "version": "1.1.1",
4 | "private": true,
5 | "scripts": {
6 | "build": "spicetify-creator",
7 | "build-local": "spicetify-creator --out=dist --minify",
8 | "watch": "spicetify-creator --watch"
9 | },
10 | "license": "MIT",
11 | "devDependencies": {
12 | "@types/react": "^18.0.33",
13 | "@types/react-dom": "^18.0.11",
14 | "spicetify-creator": "^1.0.17"
15 | },
16 | "dependencies": {
17 | "uuid": "^9.0.1"
18 | },
19 | "description": "---",
20 | "main": "index.js",
21 | "keywords": [],
22 | "author": ""
23 | }
24 |
--------------------------------------------------------------------------------
/projects/shared/src/types/react_query.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | QueryClientProvider as QueryClientProviderT,
3 | QueryClient as QueryClientT,
4 | useQuery as useQueryT,
5 | useInfiniteQuery as useInfiniteQueryT,
6 | } from "@tanstack/react-query";
7 |
8 |
9 | export const ReactQuery = Spicetify.ReactQuery;
10 |
11 | export const useQuery: typeof useQueryT = (...args) => ReactQuery.useQuery(...args);
12 | export const useInfiniteQuery: typeof useInfiniteQueryT = (...args) => ReactQuery.useInfiniteQuery(...args);
13 | export const getQueryClient = () => ReactQuery.QueryClient as QueryClientT;
14 | export const QueryClientProvider: typeof QueryClientProviderT = (...args) => ReactQuery.QueryClientProvider(...args);
15 |
--------------------------------------------------------------------------------
/projects/stats/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "spicetify-stats",
3 | "version": "1.1.3",
4 | "private": true,
5 | "scripts": {
6 | "build": "spicetify-creator",
7 | "build-local": "spicetify-creator --out=dist --minify",
8 | "watch": "spicetify-creator --watch"
9 | },
10 | "license": "MIT",
11 | "devDependencies": {
12 | "@tanstack/react-query": "^5.49.0",
13 | "@types/react": "^18.0.33",
14 | "@types/react-dom": "^18.0.11",
15 | "spicetify-creator": "^1.0.17"
16 | },
17 | "dependencies": {
18 | "lodash": "^4.17.21"
19 | },
20 | "description": "---",
21 | "main": "index.js",
22 | "keywords": [],
23 | "author": ""
24 | }
25 |
--------------------------------------------------------------------------------
/projects/library/src/components/load_more_card.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const LoadMoreCard = (props: any) => {
4 | const { callback } = props;
5 | return (
6 |
7 |
8 |
13 |
14 |
15 | Load More
16 |
17 |
18 | );
19 | };
20 |
21 | export default LoadMoreCard;
22 |
--------------------------------------------------------------------------------
/projects/library/src/types/library_types.ts:
--------------------------------------------------------------------------------
1 | import type CollectionsWrapper from "../extensions/collections_wrapper";
2 | import type FolderImageWrapper from "../extensions/folder_image_wrapper";
3 |
4 | declare global {
5 | var SpicetifyLibrary: any;
6 | var CollectionsWrapper: CollectionsWrapper;
7 | var FolderImageWrapper: FolderImageWrapper;
8 | }
9 |
10 | export interface Config {
11 | "card-size": number;
12 | "extended-search": boolean;
13 | localAlbums: boolean;
14 | includeLikedSongs: boolean;
15 | includeLocalFiles: boolean;
16 | "show-artists": boolean;
17 | "show-albums": boolean;
18 | "show-playlists": boolean;
19 | "show-shows": boolean;
20 | "show-collections": boolean;
21 | }
22 |
23 | export interface ConfigWrapper {
24 | config: Config;
25 | launchModal: () => void;
26 | }
27 |
--------------------------------------------------------------------------------
/projects/stats/src/components/shelf.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | interface ShelfProps {
4 | title: string;
5 | children: React.ReactElement | React.ReactElement[];
6 | }
7 |
8 | function Shelf(props: ShelfProps): React.ReactElement {
9 | const { TextComponent } = Spicetify.ReactComponent;
10 | const { title, children } = props;
11 |
12 | return (
13 |
23 | );
24 | }
25 |
26 | export default React.memo(Shelf);
27 |
--------------------------------------------------------------------------------
/projects/stats/src/styles/icon_filled.svg:
--------------------------------------------------------------------------------
1 |
11 |
--------------------------------------------------------------------------------
/projects/stats/src/styles/icon_unfilled.svg:
--------------------------------------------------------------------------------
1 |
11 |
--------------------------------------------------------------------------------
/projects/shared/src/config/config_types.ts:
--------------------------------------------------------------------------------
1 | export type ConfigProps = Record;
2 |
3 | export interface ConfigWrapperProps {
4 | config: ConfigProps;
5 | launchModal: (callback: () => void) => void;
6 | }
7 |
8 | export type ModalStructureProps = ModalStructureRowProps[];
9 |
10 | type SharedProps = {
11 | name: string;
12 | key: string;
13 | desc?: string;
14 | def: any;
15 | sectionHeader?: string;
16 | callback?: (value: any) => void;
17 | };
18 |
19 | type ModalStructureRowProps = SharedProps &
20 | (
21 | | { type: "toggle" /* other props for toggle */ }
22 | | { type: "text"; placeholder?: string /* other props for text */ }
23 | | { type: "dropdown"; options: string[] /* other props for dropdown */ }
24 | | { type: "slider"; min: number; max: number; step: number /* other props for slider */ }
25 | );
26 |
--------------------------------------------------------------------------------
/projects/library/src/components/toggle_filters.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { DownArrow, UpArrow } from "../../../shared/src/icons/arrows";
3 |
4 | const ToggleFiltersButton = () => {
5 | const [direction, setDirection] = React.useState(
6 | document.body.classList.contains("show-ylx-filters") ? "up" : "down",
7 | );
8 | const { ButtonTertiary } = Spicetify.ReactComponent;
9 |
10 | const toggleDirection = () => {
11 | if (direction === "down") {
12 | document.body.classList.add("show-ylx-filters");
13 | setDirection("up");
14 | } else {
15 | setDirection("down");
16 | document.body.classList.remove("show-ylx-filters");
17 | }
18 | };
19 |
20 | const Icon = direction === "down" ? DownArrow : UpArrow;
21 |
22 | return ;
23 | };
24 |
25 | export default ToggleFiltersButton;
26 |
--------------------------------------------------------------------------------
/projects/library/src/components/expand_button.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const expandLibrary = () => {
4 | Spicetify.Platform.LocalStorageAPI.setItem("ylx-sidebar-state", 0);
5 | };
6 |
7 | const ExpandIcon = () => {
8 | const { IconComponent } = Spicetify.ReactComponent;
9 |
10 | return (
11 | ',
16 | }}
17 | iconSize={16}
18 | />
19 | );
20 | };
21 |
22 | const ExpandButton = () => {
23 | const { ButtonTertiary } = Spicetify.ReactComponent;
24 |
25 | return ;
26 | };
27 |
28 | export default ExpandButton;
29 |
--------------------------------------------------------------------------------
/projects/shared/src/components/page_container.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | interface PageContainerProps {
4 | lhs: React.ReactNode[];
5 | rhs?: React.ReactNode[];
6 | children: React.ReactElement | React.ReactElement[];
7 | }
8 |
9 | const PageContainer = (props: PageContainerProps) => {
10 | const { rhs, lhs, children } = props;
11 | const { TextComponent } = Spicetify.ReactComponent;
12 |
13 | function parseNodes(nodes: React.ReactNode[]) {
14 | return nodes.map(node => typeof node === "string"
15 | ?
16 | : node
17 | );
18 | }
19 | return (
20 |
21 |
22 |
{parseNodes(lhs)}
23 |
{rhs}
24 |
25 | {children}
26 |
27 | );
28 | };
29 |
30 | export default PageContainer;
31 |
--------------------------------------------------------------------------------
/projects/library/src/components/back_button.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | function BackIcon(): React.ReactElement {
4 | return (
5 | ',
10 | }}
11 | iconSize={16}
12 | />
13 | );
14 | }
15 |
16 | function BackButton({ url }: { url: string }) {
17 | const { TooltipWrapper, ButtonTertiary } = Spicetify.ReactComponent;
18 |
19 | function navigate() {
20 | Spicetify.Platform.History.replace(`/library/${url}`);
21 | Spicetify.LocalStorage.set("library:active-link", url);
22 | }
23 |
24 | return (
25 |
26 |
27 |
28 |
29 |
30 | );
31 | }
32 |
33 | export default BackButton;
34 |
35 |
--------------------------------------------------------------------------------
/projects/stats/install.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Define variables
4 | CUSTOM_APPS_DIR="$HOME/.config/spicetify/CustomApps"
5 | STATS_APP_DIR="$CUSTOM_APPS_DIR/stats"
6 | REPO="harbassan/spicetify-apps"
7 | ZIP_FILE="/tmp/spicetify-stats.zip"
8 | TEMP_DIR="/tmp/spicetify-stats"
9 |
10 | # Create CustomApps directory if it doesn't exist
11 | mkdir -p "$CUSTOM_APPS_DIR"
12 |
13 | # Get the latest release download URL
14 | LATEST_RELEASE_URL=$(curl -s "https://api.github.com/repos/$REPO/releases" | grep -B10 "spicetify-stats.release.zip" | grep "browser_download_url" | head -n1 | cut -d '"' -f 4)
15 |
16 | # Download the zip file
17 | curl -L -o "$ZIP_FILE" "$LATEST_RELEASE_URL"
18 |
19 | # Unzip the file
20 | unzip "$ZIP_FILE" -d "$TEMP_DIR"
21 |
22 | # Move the unzipped folder to the correct location
23 | rm -rf "$STATS_APP_DIR/stats" # Ensure the target is empty
24 | cp -r "$TEMP_DIR/stats" "$STATS_APP_DIR/"
25 |
26 | # Apply Spicetify configuration
27 | spicetify config custom_apps stats
28 | spicetify apply
29 |
30 | # Clean up
31 | rm -rf "$ZIP_FILE" "$TEMP_DIR"
32 |
33 | echo "Installation complete. Enjoy your new stats app!"
34 |
--------------------------------------------------------------------------------
/projects/library/src/components/text_input_dialog.tsx:
--------------------------------------------------------------------------------
1 | import React, { type FormEvent } from "react";
2 |
3 | const TextInputDialog = (props: { def?: string; placeholder: string; onSave: (value: string) => void }) => {
4 | const { def, placeholder, onSave } = props;
5 |
6 | const [value, setValue] = React.useState(def || "");
7 |
8 | const onSubmit = (e: FormEvent) => {
9 | e.preventDefault();
10 | Spicetify.PopupModal.hide();
11 | onSave(value);
12 | };
13 |
14 | return (
15 | <>
16 |
30 | >
31 | );
32 | };
33 |
34 | export default TextInputDialog;
35 |
--------------------------------------------------------------------------------
/projects/shared/src/components/navigation/navigation_bar.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom";
3 |
4 | function NavigationBar({ links, selected, storekey }: { links: string[], selected: string, storekey: string }) {
5 | // @ts-ignore
6 | const { Chip } = Spicetify.ReactComponent;
7 |
8 | function navigate(page: string) {
9 | Spicetify.Platform.History.push(`/${storekey.split(":")[0]}/${page}`);
10 | Spicetify.LocalStorage.set(storekey, page);
11 | }
12 |
13 | return ReactDOM.createPortal(
14 |
15 |
16 |
17 | {links.map(link =>
18 | navigate(link)}>{link}
19 | )}
20 |
21 |
22 |
,
23 | document.querySelector(".main-topBar-topbarContentWrapper")!
24 | );
25 | };
26 |
27 | export default NavigationBar;
28 |
--------------------------------------------------------------------------------
/projects/library/src/extensions/folder_image_wrapper.ts:
--------------------------------------------------------------------------------
1 | class FolderImageWrapper extends EventTarget {
2 | _folderImages: Record;
3 |
4 | constructor() {
5 | super();
6 | this._folderImages = JSON.parse(localStorage.getItem("library:folderImages") || "{}");
7 | }
8 |
9 | static INSTANCE = new FolderImageWrapper();
10 |
11 | getFolderImage(uri: string) {
12 | return this._folderImages[uri];
13 | }
14 |
15 | getFolderImages() {
16 | return this._folderImages;
17 | }
18 |
19 | setFolderImage({ uri, url }: { uri: string; url: string }) {
20 | this._folderImages[uri] = url;
21 |
22 | this.saveFolderImages();
23 | Spicetify.showNotification("Folder image updated");
24 | }
25 |
26 | removeFolderImage(uri: string) {
27 | delete this._folderImages[uri];
28 |
29 | this.saveFolderImages();
30 | Spicetify.showNotification("Folder image removed");
31 | }
32 |
33 | saveFolderImages() {
34 | this.dispatchEvent(new CustomEvent("update", { detail: this._folderImages }));
35 | localStorage.setItem("library:folderImages", JSON.stringify(this._folderImages));
36 | }
37 | }
38 |
39 | window.FolderImageWrapper = FolderImageWrapper.INSTANCE;
40 |
41 | export default FolderImageWrapper;
42 |
--------------------------------------------------------------------------------
/projects/stats/src/api/platform.ts:
--------------------------------------------------------------------------------
1 | import type { getAlbumResponse } from "../types/graph_ql";
2 | import type { PlaylistResponse, RootlistResponse } from "../../../shared/types/platform";
3 |
4 | export const getFullPlaylist = async (uri: string) => {
5 | const playlist = (await Spicetify.Platform.PlaylistAPI.getPlaylist(uri)) as PlaylistResponse;
6 | const tracks = playlist.contents.items;
7 | return tracks;
8 | };
9 |
10 | export const getRootlist = async () => {
11 | const rootlist = (await Spicetify.Platform.RootlistAPI.getContents({ flatten: true })) as RootlistResponse;
12 | return rootlist.items;
13 | };
14 |
15 | export const getAlbumMeta = (uri: string) => {
16 | return (
17 | Spicetify.GraphQL.Request(Spicetify.GraphQL.Definitions.getAlbum, {
18 | uri,
19 | offset: 0,
20 | limit: 1,
21 | locale: Spicetify.Locale.getLocale(),
22 | }) as Promise
23 | ).then((res) => res?.data?.albumUnion);
24 | };
25 |
26 | export const getAlbumMetas = (uris: string[]) => {
27 | return Promise.all(uris.map((uri) => getAlbumMeta(uri)));
28 | };
29 |
30 | export const queryInLibrary = async (uris: string[]) => {
31 | return Spicetify.Platform.LibraryAPI.contains(...uris) as Promise;
32 | };
33 |
--------------------------------------------------------------------------------
/projects/stats/src/extensions/cache.ts:
--------------------------------------------------------------------------------
1 | const cache: Record = {};
2 |
3 | export const set = (key: string, value: T) => {
4 | cache[key] = value;
5 | };
6 |
7 | const invalidate = (key: string) => {
8 | delete cache[key];
9 | };
10 |
11 | // cache a specific function
12 | export const cacher = (cb: () => Promise) => {
13 | return async ({ queryKey }: { queryKey: string[] }) => {
14 | const key = queryKey.join("-");
15 | if (cache[key]) return cache[key] as T;
16 | const result = await cb();
17 | set(key, result);
18 | return result;
19 | };
20 | };
21 |
22 | // cache a batch function
23 | export const batchCacher = (prefix: string, cb: (ids: string[]) => Promise) => {
24 | return async (ids: string[]) => {
25 | const cached = ids.map((id) => cache[`${prefix}-${id}`] as T);
26 | const uncached = ids.filter((_, index) => !cached[index]);
27 | const results = await cb(uncached);
28 | results.forEach((result, index) => set(`${prefix}-${uncached[index]}`, result));
29 | return [...cached.filter(Boolean), ...results];
30 | };
31 | };
32 |
33 | export const invalidator = (queryKey: string[], refetch: () => Promise) => {
34 | invalidate(queryKey.join("-"));
35 | refetch();
36 | };
37 |
--------------------------------------------------------------------------------
/projects/library/src/utils/collection_sort.ts:
--------------------------------------------------------------------------------
1 | import { CollectionChild } from "../extensions/collections_wrapper";
2 |
3 | function collectionSort(order: string, reverse: boolean) {
4 | const sortBy = (a: CollectionChild, b: CollectionChild) => {
5 | if (a.pinned || b.pinned) return 0;
6 | switch (order) {
7 | case "0":
8 | return a.name.replace(/^the\s+/i, '').localeCompare(b.name.replace(/^the\s+/i, ''));
9 | case "1":
10 | return new Date(b.addedAt).getTime() - new Date(a.addedAt).getTime();
11 | case "2":
12 | if (a.type === "collection") return -1;
13 | if (b.type === "collection") return 1;
14 | return a.artists[0].name.replace(/^the\s+/i, '').localeCompare(b.artists[0].name.replace(/^the\s+/i, ''));
15 | case "6":
16 | // @ts-ignore Date contructor does accept null as a parameter
17 | return new Date(b.lastPlayedAt).getTime() - new Date(a.lastPlayedAt).getTime();
18 | default:
19 | return 0;
20 | }
21 | };
22 |
23 | return reverse ? (a: any, b: any) => sortBy(b, a) : sortBy;
24 | }
25 |
26 | export default collectionSort;
--------------------------------------------------------------------------------
/projects/library/src/components/add_button.tsx:
--------------------------------------------------------------------------------
1 | // biome-ignore lint:
2 | import React from "react";
3 |
4 | interface AddButtonProps {
5 | Menu: typeof Spicetify.ReactComponent.Menu;
6 | }
7 |
8 | function AddIcon(): React.ReactElement {
9 | return (
10 | ',
15 | }}
16 | iconSize={16}
17 | />
18 | );
19 | }
20 |
21 | function AddButton(props: AddButtonProps): React.ReactElement {
22 | const { ReactComponent } = Spicetify;
23 | const { TooltipWrapper, ButtonTertiary, ContextMenu } = ReactComponent;
24 | const { Menu } = props;
25 |
26 | return (
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | );
35 | }
36 |
37 | export default AddButton;
38 |
--------------------------------------------------------------------------------
/projects/stats/src/components/buttons/refresh_button.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | interface RefreshButtonProps {
4 | callback: () => void;
5 | }
6 |
7 | function RefreshIcon(): React.ReactElement {
8 | return (
9 | ',
15 | }}
16 | />
17 | );
18 | }
19 |
20 | function RefreshButton(props: RefreshButtonProps): React.ReactElement {
21 | const { ButtonTertiary, TooltipWrapper } = Spicetify.ReactComponent;
22 | const { callback } = props;
23 |
24 | return (
25 |
26 |
27 |
28 | );
29 | }
30 |
31 | export default RefreshButton;
32 |
--------------------------------------------------------------------------------
/projects/stats/src/components/cards/chart_card.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const ChartLine = (name: string, value: number, limit: number, total: number) => {
4 | return (
5 |
6 |
12 | {name}
13 |
14 |
{`${Math.round((value / total) * 100)}%`}
15 |
16 | );
17 | };
18 |
19 | const ChartCard = ({ data }: { data: Record }) => {
20 | const [extended, setExtended] = React.useState(false);
21 |
22 | const keys = Object.keys(data)
23 | .sort((a, b) => data[b] - data[a])
24 | .slice(0, extended ? 50 : 10);
25 |
26 | const total = Object.values(data).reduce((acc, curr) => acc + curr, 0);
27 |
28 | return (
29 |
30 | {keys.map((key) => ChartLine(key, data[key], data[keys[0]], total))}
31 |
40 |
41 | );
42 | };
43 |
44 | export default ChartCard;
45 |
--------------------------------------------------------------------------------
/projects/shared/src/dropdown/useDropdownMenu.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import DropdownMenu from "./dropdown";
3 |
4 | interface OptionProps {
5 | id: string;
6 | name: string;
7 | }
8 |
9 | // don't know why type inference doesn't work when consuming this hook
10 | type ReturnType = [
11 | dropdown: React.JSX.Element,
12 | activeOption: OptionProps,
13 | setActiveOption: React.Dispatch>,
14 | setAvailableOptions: React.Dispatch>
15 | ];
16 |
17 | const useDropdownMenu = (options: OptionProps[], storageVariable?: string) => {
18 | const initialOptionID = storageVariable && Spicetify.LocalStorage.get(`${storageVariable}:active-option`);
19 | const initialOption = initialOptionID && options.find((e) => e.id === initialOptionID);
20 | const [activeOption, setActiveOption] = useState(initialOption || options[0]);
21 | const [availableOptions, setAvailableOptions] = useState(options);
22 | const dropdown = (
23 | {
27 | setActiveOption(option);
28 | if (storageVariable) Spicetify.LocalStorage.set(`${storageVariable}:active-option`, option.id);
29 | }}
30 | />
31 | );
32 |
33 | return [dropdown, activeOption, setActiveOption, setAvailableOptions] as ReturnType;
34 | };
35 |
36 | export default useDropdownMenu;
37 |
--------------------------------------------------------------------------------
/projects/library/src/components/nav_context.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | interface Navigation {
4 | current: string;
5 | navigate: (route: string) => void;
6 | goBack: () => void;
7 | getParam: () => string | undefined;
8 | };
9 |
10 | export const NavigationContext = React.createContext(null);
11 |
12 | export const NavigationProvider = ({children }: { children: React.ReactNode }) => {
13 | const [current, setCurrent] = React.useState(undefined!);
14 | const [history, setHistory] = React.useState([]);
15 |
16 | const navigate = React.useCallback((newRoute: string) => {
17 | setHistory((prev) => (newRoute !== current ? [...prev, current] : prev));
18 | if (newRoute !== current) setCurrent(newRoute);
19 | }, [current]);
20 |
21 | const goBack = React.useCallback(() => {
22 | setHistory((prev) => {
23 | if (prev.length === 0) return prev;
24 | const newHistory = [...prev];
25 | const last = newHistory.pop()!;
26 | setCurrent(last);
27 | return newHistory;
28 | });
29 | }, []);
30 |
31 | const getParam = React.useCallback(() => {
32 | const [, param] = current.split("/") || [];
33 | return param;
34 | }, [current]);
35 |
36 | return (
37 |
38 | {children}
39 |
40 | );
41 | };
42 |
43 | export const useNavigation = () => {
44 | const ctx = React.useContext(NavigationContext);
45 | return ctx as Navigation;
46 | };
--------------------------------------------------------------------------------
/projects/stats/src/components/buttons/create_playlist_button.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import { InfoToCreatePlaylist } from "../../types/stats_types";
4 |
5 | interface CreatePlaylistButtonProps {
6 | infoToCreatePlaylist: InfoToCreatePlaylist;
7 | }
8 |
9 | async function createPlaylistAsync(infoToCreatePlaylist: InfoToCreatePlaylist): Promise {
10 | const { Platform, showNotification } = Spicetify;
11 | const { RootlistAPI, PlaylistAPI } = Platform;
12 |
13 | try {
14 | const { playlistName, itemsUris } = infoToCreatePlaylist;
15 | const playlistUri = await RootlistAPI.createPlaylist(playlistName, { before: "start" });
16 | await PlaylistAPI.add(playlistUri, itemsUris, { before: "start" });
17 | } catch (error) {
18 | console.error(error);
19 | showNotification("Failed to create playlist", true, 1000);
20 | }
21 | }
22 |
23 | function CreatePlaylistButton(props: CreatePlaylistButtonProps): React.ReactElement {
24 | const { TooltipWrapper, ButtonSecondary } = Spicetify.ReactComponent;
25 | const { infoToCreatePlaylist } = props;
26 |
27 | return (
28 |
29 | createPlaylistAsync(infoToCreatePlaylist)}
35 | className="stats-make-playlist-button"
36 | />
37 |
38 | );
39 | }
40 |
41 | export default CreatePlaylistButton;
42 |
--------------------------------------------------------------------------------
/projects/shared/src/icons/arrows.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export const LeftArrow = () => {
4 | const { IconComponent } = Spicetify.ReactComponent;
5 |
6 | return (
7 | ',
12 | }}
13 | iconSize={16}
14 | />
15 | );
16 | };
17 |
18 | export const UpArrow = () => {
19 | const { IconComponent } = Spicetify.ReactComponent;
20 |
21 | return (
22 | ',
27 | }}
28 | iconSize={16}
29 | />
30 | );
31 | };
32 |
33 | export const DownArrow = () => {
34 | const { IconComponent } = Spicetify.ReactComponent;
35 |
36 | return (
37 | ',
42 | }}
43 | iconSize={16}
44 | />
45 | );
46 | };
47 |
--------------------------------------------------------------------------------
/projects/shared/src/dropdown/useSortDropdownMenu.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import SortDropdownMenu from "./sort_dropdown";
3 |
4 | interface OptionProps {
5 | id: string;
6 | name: string;
7 | }
8 |
9 | // don't know why type inference doesn't work when consuming this hook
10 | type ReturnType = [
11 | dropdown: React.JSX.Element,
12 | activeOption: OptionProps,
13 | isReversed: boolean,
14 | setActiveOption: React.Dispatch>,
15 | setAvailableOptions: React.Dispatch>,
16 | ];
17 |
18 | const useSortDropdownMenu = (options: OptionProps[], storageVariable?: string) => {
19 | const initialOptionID = storageVariable && Spicetify.LocalStorage.get(`${storageVariable}:active-option`);
20 | const initialOption = initialOptionID && options.find((e) => e.id === initialOptionID);
21 | const [activeOption, setActiveOption] = useState(initialOption || options[0]);
22 | const [isReversed, setIsReversed] = useState(false);
23 | const [availableOptions, setAvailableOptions] = useState(options);
24 | const dropdown = (
25 | {
30 | setIsReversed((prev) => option.id === activeOption.id ? !prev : prev);
31 | setActiveOption(option);
32 | if (storageVariable) Spicetify.LocalStorage.set(`${storageVariable}:active-option`, option.id);
33 | }}
34 | />
35 | );
36 |
37 | return [dropdown, activeOption, isReversed, setActiveOption, setAvailableOptions] as ReturnType;
38 | };
39 |
40 | export default useSortDropdownMenu;
--------------------------------------------------------------------------------
/projects/library/src/components/searchbar.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | interface SearchBarProps {
4 | setSearch: (value: string) => void;
5 | placeholder?: string;
6 | }
7 |
8 | const searchIconPath = ``;
9 |
10 | const SearchBar = (props: SearchBarProps) => {
11 | const { setSearch, placeholder } = props;
12 | const { IconComponent } = Spicetify.ReactComponent;
13 |
14 | const handleChange = (e: React.ChangeEvent) => {
15 | setSearch(e.target.value);
16 | };
17 |
18 | const searchIcon =
22 |
23 | return (
24 |
25 |
37 |
38 |
39 | {searchIcon}
40 |
41 |
42 |
45 |
46 | );
47 | };
48 |
49 | export default SearchBar;
50 |
--------------------------------------------------------------------------------
/projects/stats/install.ps1:
--------------------------------------------------------------------------------
1 | # Define variables
2 | $customAppsDir = "$env:APPDATA\spicetify\CustomApps"
3 | $statsAppDir = "$customAppsDir\stats"
4 | $repo = "harbassan/spicetify-apps"
5 | $zipFile = "$env:TEMP\spicetify-stats.zip"
6 | $tempDir = "$env:TEMP\spicetify-stats"
7 |
8 | # Create CustomApps directory if it doesn't exist
9 | If (!(Test-Path -Path $customAppsDir)) {
10 | New-Item -ItemType Directory -Path $customAppsDir
11 | }
12 |
13 | # Get the latest STATS release download URL
14 | $latestRelease = (Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases") | Where-Object {
15 | $_.tag_name -match "stats-v[0-9]+\.[0-9]+\.[0-9]+"
16 | } | Select-Object -First 1;
17 | $latestReleaseDownloadUrl = (Invoke-RestMethod -Uri $latestRelease.url).assets[0].browser_download_url
18 |
19 | # Download the zip file
20 | Invoke-WebRequest -Uri $latestReleaseDownloadUrl -OutFile $zipFile
21 |
22 | # Unzip the file
23 | Expand-Archive -Path $zipFile -DestinationPath $tempDir -Force
24 |
25 | # Move the unzipped folder to the correct location
26 | if (Test-Path -Path "$statsAppDir\*") {
27 | Remove-Item -Path "$statsAppDir\*" -Recurse -Force
28 | Write-Host "warning " -ForegroundColor DarkYellow -NoNewline
29 | Write-Host "`"$statsAppDir`" Pre-existing file/s were found and deleted."
30 | }
31 |
32 | Move-Item -Path "$tempDir\*" -Destination $statsAppDir -Force
33 |
34 | # Apply Spicetify configuration
35 | spicetify config custom_apps stats
36 | spicetify apply
37 |
38 | # Clean up
39 | Remove-Item -Path $zipFile, $tempDir -Recurse -Force
40 |
41 | Write-Host "success " -ForegroundColor DarkGreen -NoNewline
42 | Write-Host "Installation complete. Enjoy your new stats app!"
43 |
--------------------------------------------------------------------------------
/projects/library/README.md:
--------------------------------------------------------------------------------
1 | # Spicetify Library
2 |
3 | ### A custom app that makes managing your library a better experience.
4 |
5 | ---
6 |
7 | ### Album Collections
8 |
9 | 
10 |
11 | ---
12 |
13 | ### Playlist Folder Images
14 |
15 | 
16 |
17 | ---
18 |
19 | ### Manual Installation
20 |
21 | Download the zip file in the [latest release](https://github.com/harbassan/spicetify-apps/releases?q=library&expanded=true), rename the unzipped folder to `library`, then place that folder into your `CustomApps` folder in the `spicetify` directory and you're all done. If everything's correct, the structure should be similar to this:
22 |
23 | ```
24 | 📦spicetify\CustomApps
25 | ┣ 📂marketplace
26 | ┣ etc...
27 | ┗ 📂library
28 | ┃ ┣ 📜extension.js
29 | ┃ ┣ 📜index.js
30 | ┃ ┣ 📜manifest.json
31 | ┃ ┗ 📜style.css
32 | ```
33 |
34 | Finally, run these commands to apply:
35 |
36 | ```powershell
37 | spicetify config custom_apps library
38 | spicetify apply
39 | ```
40 |
41 | That's it. Enjoy.
42 |
43 | For more help on installing visit the [Spicetify Docs](https://spicetify.app/docs/advanced-usage/custom-apps#installing).
44 |
45 | ### Uninstallation
46 |
47 | To uninstall the app, run these commands:
48 |
49 | ```powershell
50 | spicetify config custom_apps library-
51 | spicetify apply
52 | ```
53 |
54 | If you want to remove the app completely, just delete the `library` folder after running the above commands.
55 |
56 | ---
57 |
58 | If you have any questions or issues regarding the app, open an issue on this repo. While doing so, please specify your spicetify version and installation method.
59 |
60 | If you like the app, I'd be really grateful if you liked the repo ❤️.
61 |
--------------------------------------------------------------------------------
/projects/library/src/components/folder_menu.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import LeadingIcon from "./leading_icon";
3 | import TextInputDialog from "./text_input_dialog";
4 |
5 | const editIconPath =
6 | '';
7 |
8 | const deleteIconPath =
9 | '';
10 |
11 | const FolderMenu = ({ uri }: { uri: string }) => {
12 | const { MenuItem, Menu } = Spicetify.ReactComponent;
13 |
14 | const image = FolderImageWrapper.getFolderImage(uri);
15 |
16 | const setImage = () => {
17 | const setNewImage = (newUrl: string) => {
18 | FolderImageWrapper.setFolderImage({ uri, url: newUrl });
19 | };
20 |
21 | Spicetify.PopupModal.display({
22 | title: "Set Folder Image",
23 | content: ,
24 | });
25 | };
26 |
27 | const removeImage = () => {
28 | FolderImageWrapper.removeFolderImage(uri);
29 | };
30 |
31 | return (
32 |
42 | );
43 | };
44 |
45 | export default FolderMenu;
46 |
--------------------------------------------------------------------------------
/projects/stats/src/types/stats_types.ts:
--------------------------------------------------------------------------------
1 | declare global {
2 | var SpicetifyStats: any;
3 | }
4 |
5 | export interface Config {
6 | "api-key": string | null;
7 | "lastfm-user": string | null;
8 | "use-lastfm": boolean;
9 | "show-artists": boolean;
10 | "show-tracks": boolean;
11 | "show-genres": boolean;
12 | "show-library": boolean;
13 | "show-charts": boolean;
14 | }
15 |
16 | export interface ConfigWrapper {
17 | config: Config;
18 | launchModal: () => void;
19 | }
20 |
21 | export interface InfoToCreatePlaylist {
22 | playlistName: string;
23 | itemsUris: string[];
24 | }
25 |
26 | export interface LastFMMinifiedArtist {
27 | name: string;
28 | playcount: number;
29 | uri: string;
30 | image: undefined;
31 | type: "lastfm";
32 | }
33 |
34 | export interface SpotifyMinifiedArtist {
35 | name: string;
36 | uri: string;
37 | id: string;
38 | image?: string;
39 | genres: string[];
40 | playcount?: number;
41 | type: "spotify";
42 | }
43 |
44 | export interface LastFMMinifiedAlbum extends LastFMMinifiedArtist {}
45 | export interface SpotifyMinifiedAlbum extends Omit {}
46 |
47 | export interface SpotifyMinifiedTrack {
48 | id: string;
49 | uri: string;
50 | name: string;
51 | duration_ms: number;
52 | popularity: number;
53 | playcount?: number;
54 | explicit: boolean;
55 | image?: string;
56 | artists: {
57 | name: string;
58 | uri: string;
59 | }[];
60 | album: {
61 | name: string;
62 | uri: string;
63 | release_date: string;
64 | };
65 | type: "spotify";
66 | }
67 |
68 | export interface LastFMMinifiedTrack {
69 | name: string;
70 | uri: string;
71 | playcount: number;
72 | duration_ms: number;
73 | artists: {
74 | name: string;
75 | uri: string;
76 | }[];
77 | type: "lastfm";
78 | }
79 |
--------------------------------------------------------------------------------
/projects/stats/src/components/inline_grid.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | interface InlineGridProps {
4 | special?: boolean;
5 | children: React.ReactElement | React.ReactElement[];
6 | }
7 |
8 | function scrollGrid(event: React.MouseEvent): void {
9 | const { target } = event;
10 | if (!(target instanceof HTMLElement)) return;
11 |
12 | const grid = target.parentNode?.querySelector("div");
13 | if (!grid) return;
14 | grid.scrollLeft += grid.clientWidth;
15 |
16 | if (grid.scrollWidth - grid.clientWidth - grid.scrollLeft <= grid.clientWidth) {
17 | grid.setAttribute("data-scroll", "end");
18 | } else {
19 | grid.setAttribute("data-scroll", "both");
20 | }
21 | }
22 |
23 | function scrollGridLeft(event: React.MouseEvent): void {
24 | const { target } = event;
25 | if (!(target instanceof HTMLElement)) return;
26 |
27 | const grid = target.parentNode?.querySelector("div");
28 | if (!grid) return;
29 | grid.scrollLeft -= grid.clientWidth;
30 |
31 | if (grid.scrollLeft <= grid.clientWidth) {
32 | grid.setAttribute("data-scroll", "start");
33 | } else {
34 | grid.setAttribute("data-scroll", "both");
35 | }
36 | }
37 |
38 | function InlineGrid(props: InlineGridProps): React.ReactElement {
39 | const { children, special } = props;
40 | return (
41 |
42 |
45 |
48 |
52 | {children}
53 |
54 |
55 | );
56 | }
57 |
58 | export default React.memo(InlineGrid);
59 |
--------------------------------------------------------------------------------
/projects/shared/src/status/status.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const ErrorIcon = () => {
4 | return (
5 |
9 | );
10 | };
11 |
12 | const LibraryIcon = () => {
13 | return (
14 |
25 | );
26 | };
27 |
28 | const Status = (props: { icon: "error" | "library"; heading: string; subheading: string }) => {
29 | const [isVisible, setIsVisible] = React.useState(false);
30 |
31 | React.useEffect(() => {
32 | const to = setTimeout(() => {
33 | setIsVisible(true);
34 | }, 500);
35 | return () => clearTimeout(to);
36 | }, []);
37 |
38 | return isVisible ? (
39 | <>
40 |
41 | {props.icon === "error" ? : }
42 |
{props.heading}
43 | {props.subheading}
44 |
45 | >
46 | ) : (
47 | <>>
48 | );
49 | };
50 |
51 | export default Status;
52 |
--------------------------------------------------------------------------------
/projects/stats/src/types/lastfm.ts:
--------------------------------------------------------------------------------
1 | export interface TopTracksResponse {
2 | toptracks: TopTracks;
3 | }
4 |
5 | export interface TopArtistsResponse {
6 | topartists: TopArtists;
7 | }
8 |
9 | export interface TopAlbumsResponse {
10 | topalbums: TopAlbums;
11 | }
12 |
13 | export interface ArtistChartResponse {
14 | artists: TopArtists;
15 | }
16 |
17 | export interface TrackChartResponse {
18 | tracks: TopTracks;
19 | }
20 |
21 | interface ResponseAttr {
22 | user?: string;
23 | totalPages: string;
24 | page: string;
25 | perPage: string;
26 | total: string;
27 | }
28 |
29 | interface TopTracks {
30 | track: Track[];
31 | "@attr": ResponseAttr;
32 | }
33 |
34 | interface TopArtists {
35 | artist: Artist[];
36 | "@attr": ResponseAttr;
37 | }
38 |
39 | interface TopAlbums {
40 | album: Album[];
41 | "@attr": ResponseAttr;
42 | }
43 |
44 | export interface Track {
45 | streamable: Streamable;
46 | mbid: string;
47 | name: string;
48 | image: Image[];
49 | artist: ArtistSimplified;
50 | url: string;
51 | duration: string;
52 | "@attr": ItemAttr;
53 | playcount: string;
54 | }
55 |
56 | interface ArtistSimplified {
57 | url: string;
58 | name: string;
59 | mbid: string;
60 | }
61 |
62 | export interface Artist extends ArtistSimplified {
63 | streamable: string;
64 | image: Image[];
65 | playcount: string;
66 | "@attr": ItemAttr;
67 | }
68 |
69 | export interface Album {
70 | artist: ArtistSimplified;
71 | image: Image[];
72 | mbid: string;
73 | url: string;
74 | playcount: string;
75 | "@attr": ItemAttr;
76 | name: string;
77 | }
78 |
79 | interface ItemAttr {
80 | rank: string;
81 | }
82 |
83 | interface Image {
84 | size: Size;
85 | "#text": string;
86 | }
87 |
88 | enum Size {
89 | Extralarge = "extralarge",
90 | Large = "large",
91 | Medium = "medium",
92 | Small = "small",
93 | }
94 |
95 | interface Streamable {
96 | fulltrack: string;
97 | "#text": string;
98 | }
99 |
--------------------------------------------------------------------------------
/projects/shared/src/components/settings_button.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { ConfigWrapperProps } from "@shared/config/config_types";
3 |
4 | interface SettingsButtonProps {
5 | configWrapper: ConfigWrapperProps;
6 | }
7 |
8 | function SettingsIcon(): React.ReactElement {
9 | return (
10 | ',
14 | }}
15 | iconSize={16}
16 | />
17 | );
18 | }
19 |
20 | function SettingsButton(props: SettingsButtonProps): React.ReactElement {
21 | const { TooltipWrapper, ButtonTertiary } = Spicetify.ReactComponent;
22 | const { configWrapper } = props;
23 |
24 | return (
25 |
26 |
32 |
33 | );
34 | }
35 |
36 | export default SettingsButton;
37 |
--------------------------------------------------------------------------------
/projects/library/src/styles/app.scss:
--------------------------------------------------------------------------------
1 | :root {
2 | --library-card-size: 180px;
3 | --library-searchbar-size: 200px;
4 | }
5 |
6 | #library-app {
7 | .header-right .x-filterBox-expandedOrHasFilter .x-filterBox-filterInput {
8 | width: var(--library-searchbar-size);
9 | }
10 |
11 | .grid {
12 | grid-template-columns: repeat(auto-fill, minmax(var(--library-card-size), 1fr)) !important;
13 | }
14 |
15 | .main-card-cardContainer {
16 | width: 100%;
17 | height: 100%;
18 | }
19 |
20 | .load-more-card {
21 | div:nth-child(2) {
22 | text-align: center;
23 | font-size: var(--encore-text-size-base);
24 | }
25 |
26 | div:first-child {
27 | fill: var(--text-subdued);
28 | width: 80%;
29 | margin: 0 auto;
30 | }
31 |
32 | &:hover {
33 | cursor: pointer;
34 | }
35 |
36 | display: flex;
37 | gap: 10px;
38 | flex-direction: column;
39 | justify-content: center;
40 | }
41 |
42 | .x-filterBox-searchIcon {
43 | --encore-icon-height: var(--encore-graphic-size-decorative-smaller);
44 | --encore-icon-width: var(--encore-graphic-size-decorative-smaller);
45 | }
46 |
47 | }
48 |
49 | .text-input-form {
50 | display: flex;
51 | flex-direction: column;
52 | gap: 18px;
53 |
54 | .text-input {
55 | background: rgba(var(--spice-rgb-selected-row), .1);
56 | border: 1px solid transparent;
57 | border-radius: 4px;
58 | color: var(--spice-text);
59 | font-family: inherit;
60 | font-size: 14px;
61 | height: 32px;
62 | padding: 0 12px;
63 | width: 100%;
64 |
65 | &:focus {
66 | background-color: var(--spice-tab-active);
67 | border: 1px solid var(--spice-button-disabled);
68 | outline: none;
69 | }
70 | }
71 |
72 | button {
73 | align-self: end;
74 | }
75 | }
--------------------------------------------------------------------------------
/projects/shared/src/config/config_modal.scss:
--------------------------------------------------------------------------------
1 | .config-container {
2 | gap: 10px;
3 | display: flex;
4 | flex-direction: column;
5 |
6 | .section-header {
7 | box-sizing: border-box;
8 | -webkit-tap-highlight-color: transparent;
9 | margin-block: 0px;
10 | font-size: 1.125rem;
11 | font-weight: 700;
12 | color: var(--spice-text);
13 | }
14 |
15 | .col.description {
16 | box-sizing: border-box;
17 | -webkit-tap-highlight-color: transparent;
18 | margin-block: 0px;
19 | font-size: 0.875rem;
20 | font-weight: 400;
21 | color: var(--spice-subtext);
22 | }
23 |
24 |
25 | // Hide disabled toggle rows (i.e. the "Extensions" one)
26 | .disabled {
27 | opacity: 0;
28 | pointer-events: none;
29 |
30 | }
31 |
32 | .text-input {
33 | background: rgba(var(--spice-rgb-selected-row),.1);
34 | border: 1px solid transparent;
35 | border-radius: 4px;
36 | color: var(--spice-text);
37 | font-family: inherit;
38 | font-size: 14px;
39 | height: 32px;
40 | padding: 0 12px;
41 | width: 100%;
42 |
43 | &:focus {
44 | background-color: var(--spice-tab-active);
45 | border: 1px solid var(--spice-button-disabled);
46 | outline: none;
47 | }
48 | }
49 |
50 | .dropdown-input {
51 | background-color: var(--spice-tab-active);
52 | border: 0;
53 | border-radius: 4px;
54 | color: rgba(var(--spice-rgb-selected-row),.7);
55 | font-size: 14px;
56 | font-weight: 400;
57 | height: 32px;
58 | letter-spacing: .24px;
59 | line-height: 20px;
60 | padding: 0 32px 0 12px;
61 | width: 100%;
62 | }
63 |
64 | .tooltip-icon {
65 | float: right;
66 | margin-left: 10px;
67 | display: flex;
68 | align-items: center;
69 | height: 22px;
70 | fill: var(--spice-subtext);
71 |
72 | &:hover {
73 | fill: var(--spice-text);
74 | }
75 | }
76 |
77 | .tooltip {
78 | text-align: center;
79 | }
80 |
81 | .setting-row {
82 | display: flex;
83 | justify-content: space-between;
84 | height: 25px;
85 | }
86 |
87 | .playback-progressbar {
88 | width: 200px;
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Spicetify Apps
2 |
3 | 
4 | 
5 | 
6 |
7 | ---
8 |
9 | ## [Statistics](projects/stats/README.md)
10 |
11 | | Top Artists | Library Analysis | Top Tracks | |
12 | | :----------------------------------------: | :---------------------------------------------: | :---------------------------------------: | :-----------: |
13 | |  |  |  | And Much More |
14 |
15 |
16 | ---
17 |
18 | ## [Library](projects/library/README.md)
19 |
20 | | Full Pages | Album Collections | Folder Images |
21 | | :----------------------------------------: | :---------------------------------------------: | :---------------------------------------: |
22 | |  |  |  |
23 |
24 |
25 | ---
26 |
27 | The installation instructions are the same for both apps, and can be found in either's readme.
28 |
29 | If you have any questions or issues regarding the apps open an issue on this repo. Please specify your spicetify version, and the app you're reffering to, e.g
30 |
31 | [Statistics] Artist cards not rendering ...
32 |
33 | If you really like the apps i'd be grateful if you liked the repo ❤️.
34 |
--------------------------------------------------------------------------------
/projects/shared/src/config/config_wrapper.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ConfigModal from "./config_modal";
3 | import { ConfigProps, ModalStructureProps } from "./config_types";
4 |
5 | // works with both extensions and custom apps
6 |
7 | class ConfigWrapper {
8 | Config: ConfigProps;
9 | launchModal: (callback?: (config: ConfigProps) => void) => void;
10 |
11 | constructor(modalStructure: ModalStructureProps, key: string) {
12 | const config = modalStructure.map((modalStructureRow) => {
13 | const value = ConfigWrapper.getLocalStorageDataFromKey(
14 | `${key}:config:${modalStructureRow.key}`,
15 | modalStructureRow.def
16 | );
17 | modalStructureRow.callback?.(value);
18 | return { [modalStructureRow.key]: value };
19 | });
20 |
21 | this.Config = Object.assign({}, ...config);
22 |
23 | this.launchModal = (callback) => {
24 | const updateConfig = (config: ConfigProps) => {
25 | this.Config = { ...config };
26 | callback?.(config);
27 | };
28 |
29 | Spicetify.PopupModal.display({
30 | title: `${key.charAt(0).toUpperCase() + key.slice(1)} Settings`,
31 | // @ts-ignore
32 | content: (
33 |
39 | ),
40 | isLarge: true,
41 | });
42 | };
43 | }
44 |
45 | static getLocalStorageDataFromKey = (key: string, fallback?: any) => {
46 | const data = localStorage.getItem(key);
47 | if (data) {
48 | try {
49 | return JSON.parse(data);
50 | } catch (err) {
51 | return data;
52 | }
53 | } else {
54 | return fallback;
55 | }
56 | };
57 | }
58 |
59 | export default ConfigWrapper;
60 |
--------------------------------------------------------------------------------
/projects/stats/src/api/lastfm.ts:
--------------------------------------------------------------------------------
1 | import type * as LastFM from "../types/lastfm";
2 | import { apiFetch } from "./spotify";
3 |
4 | const lfmperiods = {
5 | extra_short_term: "7day",
6 | short_term: "1month",
7 | medium_term: "6month",
8 | long_term: "overall",
9 | } as const;
10 |
11 | const val = (res: T | undefined) => {
12 | if (!res || (Array.isArray(res) && !res.length)) throw new Error("Lastfm returned an empty result. Try again later.");
13 | return res;
14 | };
15 |
16 | export const getTopTracks = (key: string, user: string, range: keyof typeof lfmperiods) => {
17 | const url = `http://ws.audioscrobbler.com/2.0/?method=user.gettoptracks&user=${user}&api_key=${key}&limit=100&format=json&period=${lfmperiods[range]}`;
18 | return apiFetch("lfmTopTracks", url).then((res) => val(res?.toptracks?.track));
19 | };
20 |
21 | export const getTopArtists = (key: string, user: string, range: keyof typeof lfmperiods) => {
22 | const url = `http://ws.audioscrobbler.com/2.0/?method=user.gettopartists&user=${user}&api_key=${key}&limit=100&format=json&period=${lfmperiods[range]}`;
23 | return apiFetch("lfmTopArtists", url).then((res) => val(res?.topartists?.artist));
24 | };
25 |
26 | export const getTopAlbums = (key: string, user: string, range: keyof typeof lfmperiods) => {
27 | const url = `http://ws.audioscrobbler.com/2.0/?method=user.gettopalbums&user=${user}&api_key=${key}&limit=100&format=json&period=${lfmperiods[range]}`;
28 | return apiFetch("lfmTopAlbums", url).then((res) => val(res?.topalbums?.album));
29 | };
30 |
31 | export const getArtistChart = (key: string) => {
32 | const url = `http://ws.audioscrobbler.com/2.0/?method=chart.gettopartists&api_key=${key}&format=json`;
33 | return apiFetch("lfmArtistChart", url).then((res) => val(res?.artists?.artist));
34 | };
35 |
36 | export const getTrackChart = (key: string) => {
37 | const url = `http://ws.audioscrobbler.com/2.0/?method=chart.gettoptracks&api_key=${key}&format=json`;
38 | return apiFetch("lfmTrackChart", url).then((res) => val(res?.tracks?.track));
39 | };
40 |
--------------------------------------------------------------------------------
/projects/library/src/types/platform.ts:
--------------------------------------------------------------------------------
1 | export interface GetContentsResponse {
2 | primaryFilter: string;
3 | passedFilterIds: string[];
4 | availableFilters: SelectedSortOrder[];
5 | selectedFilters: SelectedSortOrder[];
6 | availableSortOrders: SelectedSortOrder[];
7 | selectedSortOrder: SelectedSortOrder;
8 | limit: number;
9 | offset: number;
10 | items: T[];
11 | unfilteredTotalLength: number;
12 | totalLength: number;
13 | hasUnfilteredItems: boolean;
14 | hasTextFilter: boolean;
15 | reorderAllowed: boolean;
16 | openedFolderName: string;
17 | parentFolderUri: string;
18 | openedFolderIsPlayable: boolean;
19 | }
20 |
21 | export interface SelectedSortOrder {
22 | id: string;
23 | name: string;
24 | }
25 |
26 | export interface Item {
27 | uri: string;
28 | name: string;
29 | images?: Image[];
30 | pinned: boolean;
31 | addedAt: Date;
32 | lastPlayedAt: Date | null;
33 | canPin: number;
34 | }
35 |
36 | export interface ArtistItem extends Item {
37 | type: "artist";
38 | }
39 |
40 | export interface AlbumItem extends Item {
41 | type: "album";
42 | artists: Artist[];
43 | isPremiumOnly: boolean;
44 | }
45 |
46 | export interface PlaylistItem extends Item {
47 | type: "playlist";
48 | canReorder: boolean;
49 | isEmpty: boolean;
50 | owner: Owner;
51 | isOwnedBySelf: boolean;
52 | isLoading: boolean;
53 | canAddTo: boolean;
54 | isBooklist: boolean;
55 | }
56 |
57 | export interface FolderItem extends Item {
58 | type: "folder";
59 | rowId: string;
60 | canReorder: boolean;
61 | isEmpty: boolean;
62 | numberOfFolders: number;
63 | numberOfPlaylists: number;
64 | isFlattened: boolean;
65 | }
66 |
67 | export interface ShowItem extends Item {
68 | type: "show";
69 | publisher: string;
70 | }
71 |
72 | export interface Artist {
73 | type: "artist";
74 | name: string;
75 | uri: string;
76 | }
77 |
78 | export interface Image {
79 | url: string;
80 | width: number;
81 | height: number;
82 | }
83 |
84 | export interface Owner {
85 | type: "user";
86 | name: string;
87 | uri: string;
88 | id: string;
89 | username: string;
90 | images: Image[];
91 | }
92 |
93 | export interface UpdateEvent {
94 | data: { list: string };
95 | defaultPrevented: boolean;
96 | immediateStopped: boolean;
97 | stopped: boolean;
98 | type: "update";
99 | }
100 |
--------------------------------------------------------------------------------
/projects/shared/src/components/spotify_card.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | interface SpotifyCardProps {
4 | type: "artist" | "album" | "lastfm" | "playlist";
5 | uri: string;
6 | header: string;
7 | subheader: string;
8 | imageUrl?: string;
9 | artistUri?: string;
10 | badge?: string | React.ReactElement;
11 | provider?: "spotify" | "lastfm";
12 | }
13 |
14 | /**
15 | * renders a Spotify card component with contextual menu support
16 | * - for "lastfm" provider, the card opens external links instead of navigating within Spotify
17 | * - right-click triggers a context menu with type-specific actions (ArtistMenu, AlbumMenu, or PlaylistMenu)
18 | */
19 | function SpotifyCard(props: SpotifyCardProps): React.ReactElement {
20 | // @ts-ignore
21 | const { Cards, TextComponent, ArtistMenu, AlbumMenu, PlaylistMenu, ContextMenu } = Spicetify.ReactComponent;
22 | const { FeatureCard: Card, CardImage } = Cards;
23 | const { type, header, uri, imageUrl, subheader, artistUri, badge, provider = "spotify" } = props;
24 |
25 | const Menu = () => {
26 | switch (type) {
27 | case "artist":
28 | return ;
29 | case "album":
30 | return ;
31 | case "playlist":
32 | return ;
33 | default:
34 | return <>>;
35 | }
36 | };
37 |
38 | let lastfmProps = {};
39 |
40 | if (provider === "lastfm") {
41 | lastfmProps = {
42 | onClick: () => window.open(uri, "_blank"),
43 | isPlayable: false,
44 | delegateNavigation: true,
45 | };
46 | };
47 |
48 | return (
49 |
50 |
51 |
(
55 |
65 | )}
66 | renderSubHeaderContent={() => (
67 |
68 | {subheader}
69 |
70 | )}
71 | uri={uri}
72 | {...lastfmProps}
73 | />
74 | {badge && {badge}
}
75 |
76 |
77 | );
78 | }
79 |
80 | export default SpotifyCard;
81 |
--------------------------------------------------------------------------------
/projects/shared/src/shared.scss:
--------------------------------------------------------------------------------
1 | .grid {
2 | --grid-gap: 24px;
3 | grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)) !important;
4 | }
5 |
6 | .loadingWrapper {
7 | display: flex;
8 | justify-content: center;
9 | align-items: center;
10 | min-height: 60vh;
11 | flex-direction: column;
12 | gap: 16px;
13 |
14 | .status-icon {
15 | width: 40px;
16 | height: 40px;
17 | fill: currentColor;
18 | }
19 | }
20 |
21 | .page-content {
22 | display: flex;
23 | flex-direction: column;
24 | gap: 24px;
25 | }
26 |
27 | .badge {
28 | position: absolute;
29 | top: 3%;
30 | left: 3%;
31 | height: 30px;
32 | width: 30px;
33 | border-radius: 50%;
34 | background-color: rgb(65, 110, 170);
35 | color: white;
36 | display: flex;
37 | align-items: center;
38 | justify-content: center;
39 | font-size: 12px;
40 | }
41 |
42 | .page-header {
43 | align-content: space-between;
44 | align-items: center;
45 | display: flex;
46 | justify-content: space-between;
47 | margin: 16px 0;
48 |
49 | .header-right,
50 | .header-left {
51 | display: flex;
52 | align-items: center;
53 | gap: 8px;
54 | }
55 |
56 | .header-right {
57 | justify-content: flex-end;
58 | }
59 |
60 | .header-left {
61 | justify-content: flex-start;
62 | }
63 | }
64 |
65 | .new-update {
66 | background-color: var(--spice-player);
67 | color: var(--spice-text);
68 | border-radius: 8px;
69 | padding: 2px 12px;
70 | margin: 0 24px;
71 | border: 0px;
72 | }
73 |
74 | .x-sortBox-sortDropdown {
75 | fill: rgba(var(--spice-rgb-selected-row), .7);
76 |
77 | &>span {
78 | display: flex;
79 | align-items: center;
80 | gap: 10px;
81 |
82 | &>svg {
83 | width: 14px;
84 | height: 14px;
85 | }
86 | }
87 | }
88 |
89 | // make sure the popup modal close button is always clickable
90 | .main-trackCreditsModal-closeBtn {
91 | -webkit-app-region: no-drag;
92 | }
93 |
94 | .navbar-container {
95 | padding-inline: 40px;
96 | margin-inline: auto;
97 | display: flex;
98 | align-items: center;
99 | height: 56px;
100 | padding: 0 var(--content-spacing);
101 | }
102 |
103 | #library-app > :first-child, #stats-app > :first-child {
104 | margin-top: 64px;
105 | }
106 |
--------------------------------------------------------------------------------
/projects/stats/src/pages/top_albums.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import useDropdownMenu from "@shared/dropdown/useDropdownMenu";
3 | import SpotifyCard from "@shared/components/spotify_card";
4 | import PageContainer from "@shared/components/page_container";
5 | import type { Config, ConfigWrapper } from "../types/stats_types";
6 | import RefreshButton from "../components/buttons/refresh_button";
7 | import SettingsButton from "@shared/components/settings_button";
8 | import type { SpotifyRange } from "../types/spotify";
9 | import * as lastFM from "../api/lastfm";
10 | import { convertAlbum } from "../utils/converter";
11 | import { useQuery } from "@shared/types/react_query";
12 | import useStatus from "@shared/status/useStatus";
13 | import { DropdownOptions } from "./top_artists";
14 | import { cacher, invalidator } from "../extensions/cache";
15 |
16 | export const getTopAlbums = async (timeRange: SpotifyRange, config: Config) => {
17 | const { "lastfm-user": user, "api-key": key } = config;
18 | if (!user || !key) throw new Error("Missing LastFM API Key or Username");
19 | const response = await lastFM.getTopAlbums(key, user, timeRange);
20 | return Promise.all(response.map(convertAlbum));
21 | };
22 |
23 | const AlbumsPage = ({ configWrapper }: { configWrapper: ConfigWrapper }) => {
24 | const [dropdown, activeOption] = useDropdownMenu(DropdownOptions(configWrapper), "stats:top-albums");
25 |
26 | const { status, error, data, refetch } = useQuery({
27 | queryKey: ["top-albums", activeOption.id],
28 | queryFn: cacher(() => getTopAlbums(activeOption.id as SpotifyRange, configWrapper.config)),
29 | });
30 |
31 | const Status = useStatus(status, error);
32 |
33 | const props = {
34 | lhs: ["Top Albums"],
35 | rhs: [
36 | dropdown,
37 | invalidator(["top-albums", activeOption.id], refetch)} />,
38 | ,
39 | ],
40 | };
41 |
42 | if (Status) return {Status};
43 |
44 | const topAlbums = data as NonNullable;
45 |
46 | const albumCards = topAlbums.map((album, index) => {
47 | return (
48 |
57 | );
58 | });
59 |
60 | return (
61 |
62 | {albumCards}
63 |
64 | );
65 | };
66 |
67 | export default React.memo(AlbumsPage);
68 |
--------------------------------------------------------------------------------
/projects/shared/src/dropdown/sort_dropdown.tsx:
--------------------------------------------------------------------------------
1 | import { DownArrow, UpArrow } from "../icons/arrows";
2 | import React from "react";
3 |
4 | interface Option {
5 | id: string;
6 | name: string;
7 | }
8 |
9 | interface DropdownMenuProps {
10 | options: Option[];
11 | activeOption: Option;
12 | switchCallback: (option: Option) => void;
13 | }
14 |
15 | interface SortDropdownMenuProps extends DropdownMenuProps {
16 | isReversed: boolean;
17 | }
18 |
19 | interface MenuItemProps {
20 | option: Option;
21 | isActive: boolean;
22 | switchCallback: (option: Option) => void;
23 | }
24 |
25 | interface SortMenuItemProps extends MenuItemProps {
26 | isReversed: boolean;
27 | }
28 | const SortMenuItem = (props: SortMenuItemProps) => {
29 | const { ReactComponent } = Spicetify;
30 | const { option, isActive, isReversed, switchCallback } = props;
31 |
32 | const activeStyle = {
33 | backgroundColor: "rgba(var(--spice-rgb-selected-row),.1)",
34 | };
35 |
36 | return (
37 | switchCallback(option)}
40 | data-checked={isActive}
41 | trailingIcon={isActive ? isReversed ? : : undefined}
42 | style={isActive ? activeStyle : undefined}
43 | >
44 | {option.name}
45 |
46 | );
47 | };
48 |
49 | const SortDropdownMenu = (props: SortDropdownMenuProps) => {
50 | const { ContextMenu, Menu, TextComponent } = Spicetify.ReactComponent;
51 | const { options, activeOption, isReversed, switchCallback } = props;
52 |
53 | const optionItems = options.map((option) => {
54 | return (
55 |
61 | );
62 | });
63 |
64 | const MenuWrapper = (props: Spicetify.ReactComponent.MenuProps) => {
65 | return ;
66 | };
67 |
68 | return (
69 | } trigger="click">
70 |
87 |
88 | );
89 | };
90 |
91 | export default SortDropdownMenu;
--------------------------------------------------------------------------------
/projects/stats/README.md:
--------------------------------------------------------------------------------
1 | # Spicetify Stats
2 |
3 | ### A custom app that shows you your top artists, tracks, genres and an analysis of your whole library, including individual playlists.
4 |
5 | ---
6 |
7 | ### Top Artists
8 |
9 | 
10 |
11 | ---
12 |
13 | ### Top Tracks
14 |
15 | 
16 |
17 | ---
18 |
19 | ### Top Genres
20 |
21 | 
22 |
23 | ---
24 |
25 | ### Library Analysis
26 |
27 | 
28 |
29 | ---
30 |
31 | ### Playlist Analysis
32 |
33 | 
34 |
35 | ---
36 |
37 | ### Top Albums (works with Last.fm Sync only)
38 |
39 | 
40 |
41 | ---
42 |
43 | ### Last.fm Daily Charts
44 |
45 | 
46 |
47 | ---
48 | ### Automatic Installation (Linux)
49 |
50 | ```sh
51 | sh <(curl -s https://raw.githubusercontent.com/harbassan/spicetify-apps/main/stats/install.sh)
52 | ```
53 |
54 | ### Automatic Installation (Windows, Powershell)
55 |
56 | ```ps1
57 | iwr -useb "https://raw.githubusercontent.com/harbassan/spicetify-apps/refs/heads/main/projects/stats/install.ps1" | iex
58 | ```
59 |
60 | ### Manual Installation
61 |
62 | Download the zip file in the [latest release](https://github.com/harbassan/spicetify-apps/releases?q=stats&expanded=true), rename the unzipped folder to `stats`, then place that folder into your `CustomApps` folder in the `spicetify` directory and you're all done. If everything's correct, the structure should be similar to this:
63 |
64 | ```
65 | 📦spicetify\CustomApps
66 | ┣ 📂marketplace
67 | ┣ etc...
68 | ┗ 📂stats
69 | ┃ ┣ 📜extension.js
70 | ┃ ┣ 📜index.js
71 | ┃ ┣ 📜manifest.json
72 | ┃ ┗ 📜style.css
73 | ```
74 |
75 | Finally, run these commands to apply:
76 |
77 | ```powershell
78 | spicetify config custom_apps stats
79 | spicetify apply
80 | ```
81 |
82 | That's it. Enjoy.
83 |
84 | For more help on installing visit the [Spicetify Docs](https://spicetify.app/docs/advanced-usage/custom-apps#installing).
85 |
86 | ### Uninstallation
87 |
88 | To uninstall the app, run these commands:
89 |
90 | ```powershell
91 | spicetify config custom_apps stats-
92 | spicetify apply
93 | ```
94 |
95 | If you want to remove the app completely, just delete the `stats` folder after running the above commands.
96 |
97 | ---
98 |
99 | If you have any questions or issues regarding the app, open an issue on this repo. While doing so, please specify your spicetify version and installation method.
100 |
101 | If you like the app, I'd be really grateful if you liked the repo ❤️.
102 |
--------------------------------------------------------------------------------
/projects/shared/src/types/platform.ts:
--------------------------------------------------------------------------------
1 | export interface PlaylistResponse {
2 | metadata: Metadata;
3 | contents: Contents;
4 | }
5 |
6 | export interface RootlistResponse {
7 | items: Metadata[];
8 | }
9 |
10 | export interface Contents {
11 | items: (ContentsTrack | ContentsEpisode)[];
12 | offset: number;
13 | limit: number;
14 | totalLength: number;
15 | }
16 |
17 | export interface ContentsTrack {
18 | uid: string;
19 | playIndex: null;
20 | addedAt: Date;
21 | addedBy: Owner;
22 | formatListAttributes: unknown;
23 | type: "track";
24 | uri: string;
25 | name: string;
26 | album: Album;
27 | artists: Artist[];
28 | discNumber: number;
29 | trackNumber: number;
30 | duration: {
31 | milliseconds: number;
32 | };
33 | isExplicit: boolean;
34 | isLocal: boolean;
35 | isPlayable: boolean;
36 | is19PlusOnly: boolean;
37 | }
38 |
39 | export interface ContentsEpisode {
40 | type: "episode";
41 | }
42 |
43 | export interface Owner {
44 | type: "user";
45 | uri: string;
46 | username: string;
47 | displayName: string;
48 | images: Image[];
49 | }
50 |
51 | export interface Image {
52 | url: string;
53 | label: Label;
54 | }
55 |
56 | export enum Label {
57 | Large = "large",
58 | Small = "small",
59 | Standard = "standard",
60 | Xlarge = "xlarge",
61 | }
62 |
63 | export interface Album {
64 | type: "album";
65 | uri: string;
66 | name: string;
67 | artist: Artist;
68 | images: Image[];
69 | }
70 |
71 | export interface Artist {
72 | type: "artist";
73 | uri: string;
74 | name: string;
75 | }
76 |
77 | export interface Metadata {
78 | type: string;
79 | uri: string;
80 | name: string;
81 | description: string;
82 | images: Image[];
83 | madeFor: null;
84 | owner: Owner;
85 | totalLength: number;
86 | unfilteredTotalLength: number;
87 | totalLikes: number;
88 | duration: MetadataDuration;
89 | isLoaded: boolean;
90 | isOwnedBySelf: boolean;
91 | isPublished: boolean;
92 | hasEpisodes: boolean;
93 | hasSpotifyTracks: boolean;
94 | hasSpotifyAudiobooks: boolean;
95 | canAdd: boolean;
96 | canRemove: boolean;
97 | canPlay: boolean;
98 | formatListData: null;
99 | canReportAnnotationAbuse: boolean;
100 | hasDateAdded: boolean;
101 | permissions: Permissions;
102 | collaborators: Collaborators;
103 | }
104 |
105 | export interface Collaborators {
106 | count: number;
107 | items: CollaboratorsItem[];
108 | }
109 |
110 | export interface CollaboratorsItem {
111 | isOwner: boolean;
112 | tracksAdded: number;
113 | user: Owner;
114 | }
115 |
116 | export interface MetadataDuration {
117 | milliseconds: number;
118 | isEstimate: boolean;
119 | }
120 |
121 | export interface Permissions {
122 | canView: boolean;
123 | canAdministratePermissions: boolean;
124 | canCancelMembership: boolean;
125 | isPrivate: boolean;
126 | }
127 |
--------------------------------------------------------------------------------
/projects/stats/src/pages/top_tracks.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import TrackRow from "../components/track_row";
3 | import PageContainer from "@shared/components/page_container";
4 | import Tracklist from "../components/tracklist";
5 | import useDropdownMenu from "@shared/dropdown/useDropdownMenu";
6 | import type { Config, ConfigWrapper } from "../types/stats_types";
7 | import * as lastFM from "../api/lastfm";
8 | import * as spotify from "../api/spotify";
9 | import RefreshButton from "../components/buttons/refresh_button";
10 | import SettingsButton from "@shared/components/settings_button";
11 | import { DropdownOptions } from "./top_artists";
12 | import type { SpotifyRange } from "../types/spotify";
13 | import { convertTrack, minifyTrack } from "../utils/converter";
14 | import { useQuery } from "@shared/types/react_query";
15 | import useStatus from "@shared/status/useStatus";
16 | import { cacher, invalidator } from "../extensions/cache";
17 | import { parseLiked } from "../utils/track_helper";
18 |
19 | export const getTopTracks = async (timeRange: SpotifyRange, config: Config) => {
20 | if (config["use-lastfm"]) {
21 | const { "lastfm-user": user, "api-key": key } = config;
22 | if (!user || !key) throw new Error("Missing LastFM API Key or Username");
23 | const response = await lastFM.getTopTracks(key, user, timeRange);
24 | return Promise.all(response.map(convertTrack));
25 | }
26 | const response = await spotify.getTopTracks(timeRange);
27 | return response.map(minifyTrack);
28 | };
29 |
30 | const TracksPage = ({ configWrapper }: { configWrapper: ConfigWrapper }) => {
31 | const [dropdown, activeOption] = useDropdownMenu(DropdownOptions(configWrapper), "stats:top-tracks");
32 |
33 | const { status, error, data, refetch } = useQuery({
34 | queryKey: ["top-tracks", activeOption.id],
35 | queryFn: (props) =>
36 | cacher(() => getTopTracks(activeOption.id as SpotifyRange, configWrapper.config))(props).then(parseLiked),
37 | });
38 |
39 | const Status = useStatus(status, error);
40 |
41 | const props = {
42 | lhs: ["Top Tracks"],
43 | rhs: [
44 | dropdown,
45 | invalidator(["top-tracks", activeOption.id], refetch)} />,
46 | ,
47 | ],
48 | };
49 |
50 | if (Status) return {Status};
51 |
52 | const topTracks = data as NonNullable;
53 |
54 | const infoToCreatePlaylist = {
55 | playlistName: `Top Songs - ${activeOption.name}`,
56 | itemsUris: topTracks.map((track) => track.uri),
57 | };
58 |
59 | const trackRows = topTracks.map((track, index) => (
60 | track.uri)} />
61 | ));
62 |
63 | return (
64 |
65 | {trackRows}
66 |
67 | );
68 | };
69 |
70 | export default React.memo(TracksPage);
71 |
--------------------------------------------------------------------------------
/projects/shared/src/dropdown/dropdown.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | interface Option {
4 | id: string;
5 | name: string;
6 | }
7 |
8 | interface DropdownMenuProps {
9 | options: Option[];
10 | activeOption: Option;
11 | switchCallback: (option: Option) => void;
12 | }
13 |
14 | interface MenuItemProps {
15 | option: Option;
16 | isActive: boolean;
17 | switchCallback: (option: Option) => void;
18 | }
19 |
20 | function CheckIcon() {
21 | return (
22 | ',
27 | }}
28 | />
29 | );
30 | }
31 |
32 | const MenuItem = (props: MenuItemProps) => {
33 | const { ReactComponent } = Spicetify;
34 | const { option, isActive, switchCallback } = props;
35 |
36 | const activeStyle = {
37 | backgroundColor: "rgba(var(--spice-rgb-selected-row),.1)",
38 | };
39 |
40 | return (
41 | switchCallback(option)}
44 | data-checked={isActive}
45 | trailingIcon={isActive ? : undefined}
46 | style={isActive ? activeStyle : undefined}
47 | >
48 | {option.name}
49 |
50 | );
51 | };
52 |
53 | const DropdownMenu = (props: DropdownMenuProps) => {
54 | const { ContextMenu, Menu, TextComponent } = Spicetify.ReactComponent;
55 | const { options, activeOption, switchCallback } = props;
56 |
57 | const optionItems = options.map((option) => {
58 | return ;
59 | });
60 |
61 | const MenuWrapper = (props: Spicetify.ReactComponent.MenuProps) => {
62 | return ;
63 | };
64 |
65 | return (
66 | } trigger="click">
67 |
83 |
84 | );
85 | };
86 |
87 | export default DropdownMenu;
88 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 |
2 | # Created by https://www.toptal.com/developers/gitignore/api/node
3 | # Edit at https://www.toptal.com/developers/gitignore?templates=node
4 |
5 | ### Node ###
6 | # Logs
7 | logs
8 | *.log
9 | npm-debug.log*
10 | yarn-debug.log*
11 | yarn-error.log*
12 | lerna-debug.log*
13 | .pnpm-debug.log*
14 |
15 | # Diagnostic reports (https://nodejs.org/api/report.html)
16 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
17 |
18 | # Runtime data
19 | pids
20 | *.pid
21 | *.seed
22 | *.pid.lock
23 |
24 | # Directory for instrumented libs generated by jscoverage/JSCover
25 | lib-cov
26 |
27 | # Coverage directory used by tools like istanbul
28 | coverage
29 | *.lcov
30 |
31 | # nyc test coverage
32 | .nyc_output
33 |
34 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
35 | .grunt
36 |
37 | # Bower dependency directory (https://bower.io/)
38 | bower_components
39 |
40 | # node-waf configuration
41 | .lock-wscript
42 |
43 | # Compiled binary addons (https://nodejs.org/api/addons.html)
44 | build/Release
45 |
46 | # Dependency directories
47 | node_modules/
48 | jspm_packages/
49 |
50 | # Snowpack dependency directory (https://snowpack.dev/)
51 | web_modules/
52 |
53 | # TypeScript cache
54 | *.tsbuildinfo
55 |
56 | # Optional npm cache directory
57 | .npm
58 |
59 | # Optional eslint cache
60 | .eslintcache
61 |
62 | # Optional stylelint cache
63 | .stylelintcache
64 |
65 | # Microbundle cache
66 | .rpt2_cache/
67 | .rts2_cache_cjs/
68 | .rts2_cache_es/
69 | .rts2_cache_umd/
70 |
71 | # Optional REPL history
72 | .node_repl_history
73 |
74 | # Output of 'npm pack'
75 | *.tgz
76 |
77 | # Yarn Integrity file
78 | .yarn-integrity
79 |
80 | # dotenv environment variable files
81 | .env
82 | .env.development.local
83 | .env.test.local
84 | .env.production.local
85 | .env.local
86 |
87 | # parcel-bundler cache (https://parceljs.org/)
88 | .cache
89 | .parcel-cache
90 |
91 | # Next.js build output
92 | .next
93 | out
94 |
95 | # Nuxt.js build / generate output
96 | .nuxt
97 |
98 | # Gatsby files
99 | .cache/
100 | # Comment in the public line in if your project uses Gatsby and not Next.js
101 | # https://nextjs.org/blog/next-9-1#public-directory-support
102 | # public
103 |
104 | # vuepress build output
105 | .vuepress/dist
106 |
107 | # vuepress v2.x temp and cache directory
108 | .temp
109 |
110 | # Docusaurus cache and generated files
111 | .docusaurus
112 |
113 | # Serverless directories
114 | .serverless/
115 |
116 | # FuseBox cache
117 | .fusebox/
118 |
119 | # DynamoDB Local files
120 | .dynamodb/
121 |
122 | # TernJS port file
123 | .tern-port
124 |
125 | # Stores VSCode versions used for testing VSCode extensions
126 | .vscode-test
127 |
128 | # yarn v2
129 | .yarn/cache
130 | .yarn/unplugged
131 | .yarn/build-state.yml
132 | .yarn/install-state.gz
133 | .pnp.*
134 |
135 | ### Node Patch ###
136 | # Serverless Webpack directories
137 | .webpack/
138 |
139 | # Optional stylelint cache
140 |
141 | # SvelteKit build / generate output
142 | .svelte-kit
143 |
144 | # End of https://www.toptal.com/developers/gitignore/api/node
145 |
--------------------------------------------------------------------------------
/projects/library/src/components/custom_card.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import CollectionMenu from "./collection_menu";
4 | import FolderMenu from "./folder_menu";
5 | import FolderSVG from "./folder_fallback";
6 | import LocalAlbumMenu from "./local_album_menu";
7 |
8 | interface CustomCardProps {
9 | type: "folder" | "show" | "collection" | "localalbum";
10 | uri: string;
11 | header: string;
12 | subheader: string;
13 | imageUrl?: string;
14 | badge?: string | React.ReactElement;
15 | }
16 |
17 | function CustomCard(props: CustomCardProps): React.ReactElement {
18 | // @ts-ignore
19 | const { Cards, TextComponent, PodcastShowMenu, ContextMenu } = Spicetify.ReactComponent;
20 | const { FeatureCard: Card, CardImage } = Cards;
21 | const { History } = Spicetify.Platform;
22 | const { type, header, uri, imageUrl, subheader, badge } = props;
23 |
24 | const Menu = () => {
25 | switch (type) {
26 | case "show":
27 | return ;
28 | case "collection":
29 | return ;
30 | case "folder":
31 | return ;
32 | case "localalbum":
33 | return ;
34 | default:
35 | return <>>;
36 | }
37 | };
38 |
39 |
40 | const additionalProps = (() => {
41 | switch (type) {
42 | case "folder":
43 | return {
44 | delegateNavigation: true,
45 | onClick: () => {
46 | Spicetify.Platform.History.replace(`/library/Playlists/${uri}`);
47 | Spicetify.LocalStorage.set("library:active-link", `Playlists/${uri}`);
48 | },
49 | }
50 | case "collection":
51 | return {
52 | delegateNavigation: true,
53 | onClick: () => {
54 | Spicetify.Platform.History.replace(`/library/Collections/${uri}`);
55 | Spicetify.LocalStorage.set("library:active-link", `Collections/${uri}`);
56 | },
57 | }
58 | case "localalbum":
59 | return {
60 | delegateNavigation: true,
61 | onClick: () => {
62 | History.push({ pathname: "better-local-files/album", state: { uri } });
63 | },
64 | }
65 | }
66 | })()
67 |
68 | const isCollection = type === "collection" || type === "folder";
69 |
70 | return (
71 |
72 |
73 |
(
77 |
87 | )}
88 | renderSubHeaderContent={() => (
89 |
90 | {subheader}
91 |
92 | )}
93 | uri={uri}
94 | {...additionalProps}
95 | />
96 | {badge && {badge}
}
97 |
98 |
99 | );
100 | }
101 |
102 | export default CustomCard;
103 |
--------------------------------------------------------------------------------
/projects/stats/src/pages/top_artists.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import useDropdownMenu from "@shared/dropdown/useDropdownMenu";
3 | import SpotifyCard from "@shared/components/spotify_card";
4 | import PageContainer from "@shared/components/page_container";
5 | import type { Config, ConfigWrapper } from "../types/stats_types";
6 | import SettingsButton from "@shared/components/settings_button";
7 | import RefreshButton from "../components/buttons/refresh_button";
8 | import * as lastFM from "../api/lastfm";
9 | import * as spotify from "../api/spotify";
10 | import { SpotifyRange } from "../types/spotify";
11 | import { convertArtist, minifyArtist } from "../utils/converter";
12 | import useStatus from "@shared/status/useStatus";
13 | import { useQuery } from "@shared/types/react_query";
14 | import { cacher, invalidator } from "../extensions/cache";
15 |
16 | export const getTopArtists = async (timeRange: SpotifyRange, config: Config) => {
17 | if (config["use-lastfm"]) {
18 | const { "lastfm-user": user, "api-key": key } = config;
19 | if (!user || !key) throw new Error("Missing LastFM API Key or Username");
20 | const response = await lastFM.getTopArtists(key, user, timeRange);
21 | return Promise.all(response.map(convertArtist));
22 | }
23 | const response = await spotify.getTopArtists(timeRange);
24 | return response.map(minifyArtist);
25 | };
26 |
27 | export const DropdownOptions = ({ config: { "use-lastfm": useLastFM } }: ConfigWrapper) =>
28 | [
29 | useLastFM && { id: "extra_short_term", name: "Past Week" },
30 | { id: SpotifyRange.Short, name: "Past Month" },
31 | { id: SpotifyRange.Medium, name: "Past 6 Months" },
32 | { id: SpotifyRange.Long, name: "All Time" },
33 | ].filter(Boolean) as { id: string; name: string }[];
34 |
35 | const ArtistsPage = ({ configWrapper }: { configWrapper: ConfigWrapper }) => {
36 | const [dropdown, activeOption] = useDropdownMenu(DropdownOptions(configWrapper), "stats:top-artists");
37 |
38 | const { status, error, data, refetch } = useQuery({
39 | queryKey: ["top-artists", activeOption.id],
40 | queryFn: cacher(() => getTopArtists(activeOption.id as SpotifyRange, configWrapper.config)),
41 | });
42 |
43 | const Status = useStatus(status, error);
44 |
45 | const props = {
46 | lhs: ["Top Artists"],
47 | rhs: [
48 | dropdown,
49 | invalidator(["top-artists", activeOption.id], refetch)} />,
50 | ,
51 | ],
52 | };
53 |
54 | if (Status) return {Status};
55 |
56 | const topArtists = data as NonNullable;
57 |
58 | const artistCards = topArtists.map((artist, index) => (
59 |
68 | ));
69 |
70 | return (
71 | <>
72 |
73 | {{artistCards}
}
74 |
75 | >
76 | );
77 | };
78 |
79 | export default React.memo(ArtistsPage);
80 |
--------------------------------------------------------------------------------
/projects/library/src/components/collection_menu.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import TextInputDialog from "./text_input_dialog";
3 | import LeadingIcon from "./leading_icon";
4 |
5 | const editIconPath =
6 | '';
7 |
8 | const deleteIconPath =
9 | '';
10 |
11 | const addIconPath =
12 | '';
13 |
14 | const CollectionMenu = ({ id }: { id: string }) => {
15 | const { Menu, MenuItem } = Spicetify.ReactComponent;
16 |
17 | const deleteCollection = () => {
18 | CollectionsWrapper.deleteCollection(id);
19 | };
20 |
21 | const deleteCollectionAndAlbums = () => {
22 | CollectionsWrapper.deleteCollectionAndAlbums(id);
23 | };
24 |
25 | const renameCollection = () => {
26 | const name = CollectionsWrapper.getCollection(id)?.name;
27 |
28 | const rename = (newName: string) => {
29 | CollectionsWrapper.renameCollection(id, newName);
30 | };
31 |
32 | Spicetify.PopupModal.display({
33 | title: "Rename Collection",
34 | content: ,
35 | });
36 | };
37 |
38 | const collection = CollectionsWrapper.getCollection(id);
39 | const image = collection?.image;
40 | const synced = collection?.syncedPlaylistUri;
41 |
42 | const setCollectionImage = () => {
43 | const setImg = (imgUrl: string) => {
44 | CollectionsWrapper.setCollectionImage(id, imgUrl);
45 | };
46 |
47 | Spicetify.PopupModal.display({
48 | title: "Set Collection Image",
49 | content: ,
50 | });
51 | };
52 |
53 | const removeImage = () => {
54 | CollectionsWrapper.removeCollectionImage(id);
55 | };
56 |
57 | const convertToPlaylist = () => {
58 | CollectionsWrapper.convertToPlaylist(id);
59 | };
60 |
61 | const unsyncPlaylist = () => {
62 | CollectionsWrapper.unsyncCollection(id);
63 | };
64 |
65 | return (
66 |
94 | );
95 | };
96 |
97 | export default CollectionMenu;
98 |
--------------------------------------------------------------------------------
/projects/library/src/components/album_menu_item.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import LeadingIcon from "./leading_icon";
3 | import TextInputDialog from "./text_input_dialog";
4 | import SearchBar from "./searchbar";
5 |
6 | const createCollection = () => {
7 | const onSave = (value: string) => {
8 | CollectionsWrapper.createCollection(value);
9 | };
10 |
11 | Spicetify.PopupModal.display({
12 | title: "Create Collection",
13 | content: ,
14 | });
15 | };
16 |
17 | const CollectionSearchMenu = () => {
18 | const { MenuItem } = Spicetify.ReactComponent;
19 | const { SVGIcons } = Spicetify;
20 |
21 | const [textFilter, setTextFilter] = React.useState("");
22 | const [collections, setCollections] = React.useState
24 | > | null>(null);
25 |
26 | const context = React.useContext(Spicetify.ContextMenuV2._context);
27 | const uri = context?.props?.uri || context?.props?.id;
28 |
29 | React.useEffect(() => {
30 | const fetchCollections = async () => {
31 | setCollections(await CollectionsWrapper.getContents({ textFilter, limit: 20, offset: 0 }));
32 | };
33 | fetchCollections();
34 | }, [textFilter]);
35 |
36 | if (!collections) return <>>;
37 |
38 | const addToCollection = (collectionUri: string) => {
39 | CollectionsWrapper.addAlbumToCollection(collectionUri, uri);
40 | };
41 |
42 | const activeCollections = CollectionsWrapper.getCollectionsWithAlbum(uri);
43 | const hasCollections = activeCollections.length > 0;
44 |
45 | const removeFromCollections = () => {
46 | for (const collection of activeCollections) {
47 | CollectionsWrapper.removeAlbumFromCollection(collection.uri, uri);
48 | }
49 | };
50 |
51 | const allCollectionsLength = collections.totalLength;
52 |
53 | const menuItems = collections.items.map((collection, index) => {
54 | return (
55 |
64 | );
65 | });
66 |
67 | const menuLength = allCollectionsLength + (hasCollections ? 1 : 0);
68 |
69 | return (
70 |
75 |
76 |
77 |
78 |
79 |
80 |
} onClick={createCollection}>
81 | Create collection
82 |
83 | {hasCollections && (
84 |
}
87 | onClick={removeFromCollections}
88 | >
89 | Remove from all
90 |
91 | )}
92 | {menuItems}
93 |
94 | );
95 | };
96 |
97 | const AlbumMenuItem = () => {
98 | // @ts-expect-error
99 | const { MenuSubMenuItem } = Spicetify.ReactComponent;
100 | const { SVGIcons } = Spicetify;
101 |
102 | return (
103 | }
107 | >
108 |
109 |
110 | );
111 | };
112 |
113 | export default AlbumMenuItem;
114 |
--------------------------------------------------------------------------------
/projects/stats/src/extensions/extension.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import PlaylistPage from "../pages/playlist";
3 | import { version as STATS_VERSION } from "../../package.json";
4 | import ConfigWrapper from "@shared/config/config_wrapper";
5 |
6 | // contruct global class for stats methods
7 | class SpicetifyStats {
8 | ConfigWrapper = new ConfigWrapper(
9 | [
10 | {
11 | name: "Last.fm Api Key",
12 | key: "api-key",
13 | type: "text",
14 | def: null,
15 | placeholder: "Enter API Key",
16 | desc: `You can get this by visiting www.last.fm/api/account/create and simply entering any name.
You'll need to make an account first, which is a plus.`,
17 | sectionHeader: "Last.fm Integration",
18 | },
19 | {
20 | name: "Last.fm Username",
21 | key: "lastfm-user",
22 | type: "text",
23 | def: null,
24 | placeholder: "Enter Username",
25 | },
26 | {
27 | name: "Use Last.fm for Stats",
28 | key: "use-lastfm",
29 | type: "toggle",
30 | def: false,
31 | desc: "Last.fm charts your stats purely based on the streaming count, whereas Spotify factors in other variables",
32 | },
33 | {
34 | name: "Artists Page",
35 | key: "show-artists",
36 | type: "toggle",
37 | def: true,
38 | sectionHeader: "Pages",
39 | },
40 | { name: "Tracks Page", key: "show-tracks", type: "toggle", def: true },
41 | {
42 | name: "Albums Page",
43 | key: "show-albums",
44 | type: "toggle",
45 | def: false,
46 | desc: "Requires Last.fm API key and username",
47 | },
48 | { name: "Genres Page", key: "show-genres", type: "toggle", def: true },
49 | { name: "Library Page", key: "show-library", type: "toggle", def: true },
50 | {
51 | name: "Charts Page",
52 | key: "show-charts",
53 | type: "toggle",
54 | def: true,
55 | desc: "Requires Last.fm API key",
56 | },
57 | ],
58 | "stats",
59 | );
60 | }
61 | window.SpicetifyStats = new SpicetifyStats();
62 |
63 | (function stats() {
64 | const {
65 | PopupModal,
66 | LocalStorage,
67 | Topbar,
68 | Platform: { History },
69 | } = Spicetify;
70 |
71 | if (!PopupModal || !LocalStorage || !Topbar || !History) {
72 | setTimeout(stats, 300);
73 | return;
74 | }
75 |
76 | const version = localStorage.getItem("stats:version");
77 | if (!version || version !== STATS_VERSION) {
78 | for (let i = 0; i < localStorage.length; i++) {
79 | const key = localStorage.key(i) as string;
80 | if (key.startsWith("stats:") && !key.startsWith("stats:config:")) {
81 | localStorage.removeItem(key);
82 | }
83 | }
84 | localStorage.setItem("stats:version", STATS_VERSION);
85 | }
86 |
87 | const styleLink = document.createElement("link");
88 | styleLink.rel = "stylesheet";
89 | styleLink.href = "/spicetify-routes-stats.css";
90 | document.head.appendChild(styleLink);
91 |
92 | const playlistEdit = new Topbar.Button("playlist-stats", "visualizer", () => {
93 | const playlistUri = `spotify:playlist:${History.location.pathname.split("/")[2]}`;
94 | // @ts-ignore
95 | PopupModal.display({ title: "Playlist Stats", content: , isLarge: true });
96 | }, false, true);
97 | playlistEdit.element.classList.add("playlist-stats-button");
98 | playlistEdit.element.classList.toggle("hidden", true);
99 |
100 | function setTopbarButtonVisibility(pathname: string): void {
101 | const [, type, uid] = pathname.split("/");
102 | const isPlaylistPage = type === "playlist" && uid;
103 | playlistEdit.element.classList.toggle("hidden", !isPlaylistPage);
104 | }
105 | setTopbarButtonVisibility(History.location.pathname);
106 |
107 | History.listen(({ pathname }: { pathname: string }) => {
108 | setTopbarButtonVisibility(pathname);
109 | });
110 | })();
111 |
--------------------------------------------------------------------------------
/projects/stats/src/pages/playlist.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from "react";
2 | import StatCard from "../components/cards/stat_card";
3 | import ChartCard from "../components/cards/chart_card";
4 | import SpotifyCard from "@shared/components/spotify_card";
5 | import Shelf from "../components/shelf";
6 | import useStatus from "@shared/status/useStatus";
7 | import { parseStat, parseTracks } from "../utils/track_helper";
8 | import { getFullPlaylist } from "../api/platform";
9 |
10 | const getPlaylist = async (uri: string) => {
11 | const contents = await getFullPlaylist(uri);
12 | return parseTracks(contents);
13 | };
14 |
15 | // ? my shitty useQuery replacement because react-query is not working within the popup
16 | const useQueryShitty = (callback: () => Promise) => {
17 | const [error, setError] = React.useState(null);
18 | const [data, setData] = React.useState(null);
19 | const [status, setStatus] = React.useState<"pending" | "error" | "success">("pending");
20 |
21 | React.useEffect(() => {
22 | const fetchData = async () => {
23 | try {
24 | const data = await callback();
25 | setData(data);
26 | setStatus("success");
27 | } catch (e) {
28 | console.log(e);
29 | setError(e as Error);
30 | setStatus("error");
31 | }
32 | };
33 |
34 | fetchData();
35 | }, [callback]);
36 |
37 | return { status, error, data };
38 | };
39 |
40 | const PlaylistPage = ({ uri }: { uri: string }) => {
41 | const query = useCallback(() => getPlaylist(uri), [uri]);
42 |
43 | const { status, error, data } = useQueryShitty(query);
44 |
45 | const Status = useStatus(status, error);
46 |
47 | if (Status) return Status;
48 |
49 | const analysis = data as NonNullable;
50 |
51 | const statCards = Object.entries(analysis.analysis).map(([key, value]) => {
52 | return ;
53 | });
54 |
55 | const artistCards = analysis.artists.contents.slice(0, 10).map((artist) => {
56 | return (
57 |
65 | );
66 | });
67 |
68 | const albumCards = analysis.albums.contents.slice(0, 10).map((album) => {
69 | return (
70 |
78 | );
79 | });
80 |
81 | return (
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 | {statCards}
93 |
94 | {/*
95 | {artistCards}
96 | */}
97 | {/*
98 | {albumCards}
99 | */}
100 |
101 |
102 |
103 |
104 | );
105 | };
106 |
107 | export default React.memo(PlaylistPage);
108 |
--------------------------------------------------------------------------------
/projects/library/src/app.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import AlbumsPage from "./pages/albums";
4 | import ArtistsPage from "./pages/artists";
5 | import ShowsPage from "./pages/shows";
6 | import PlaylistsPage from "./pages/playlists";
7 | import CollectionsPage from "./pages/collections";
8 |
9 | import { version } from "../package.json";
10 |
11 | import NavigationBar from "@shared/components/navigation/navigation_bar"
12 |
13 | import "./styles/app.scss";
14 | import "./styles/external.scss";
15 | import "../../shared/src/config/config_modal.scss";
16 | import "../../shared/src/shared.scss";
17 |
18 | import { ConfigWrapper } from "./types/library_types";
19 |
20 | const checkForUpdates = (setNewUpdate: (a: boolean) => void) => {
21 | fetch("https://api.github.com/repos/harbassan/spicetify-apps/releases")
22 | .then((res) => res.json())
23 | .then(
24 | (result) => {
25 | const releases = result.filter((release: any) => release.name.startsWith("library"));
26 | setNewUpdate(releases[0].name.slice(9) !== version);
27 | },
28 | (error) => {
29 | console.log("Failed to check for updates", error);
30 | },
31 | );
32 | };
33 |
34 | const NavbarContainer = ({ configWrapper }: { configWrapper: ConfigWrapper }) => {
35 | const pages: Record = {
36 | ["Artists"]: ,
37 | ["Albums"]: ,
38 | ["Shows"]: ,
39 | ["Playlists"]: ,
40 | ["Collections"]: ,
41 | };
42 |
43 | const tabPages = ["Playlists", "Albums", "Collections", "Artists", "Shows"].filter(
44 | (page) => configWrapper.config[`show-${page.toLowerCase()}` as keyof ConfigWrapper["config"]],
45 | );
46 |
47 | const [newUpdate, setNewUpdate] = React.useState(false);
48 |
49 | const activePage = Spicetify.Platform.History.location.pathname.split("/")[2];
50 |
51 | React.useEffect(() => {
52 | checkForUpdates(setNewUpdate);
53 | }, []);
54 |
55 | React.useEffect(() => {
56 | if (activePage === undefined) {
57 | const stored = Spicetify.LocalStorage.get("library:active-link") || "Playlists";
58 | Spicetify.Platform.History.replace(`library/${stored}`);
59 | }
60 | }, [activePage]);
61 |
62 | if (activePage === undefined) return <>>;
63 |
64 | return (
65 | <>
66 |
67 | {newUpdate && (
68 |
72 | )}
73 | {pages[activePage]}
74 | >
75 | );
76 | };
77 |
78 | const waitForReady = async (callback: () => void) => {
79 | if (Spicetify.Platform && Spicetify.Platform.LibraryAPI && Spicetify.ReactQuery && SpicetifyLibrary) {
80 | callback();
81 | } else {
82 | setTimeout(() => waitForReady(callback), 1000);
83 | }
84 | }
85 |
86 | const App = () => {
87 | const [config, setConfig] = React.useState({} as ConfigWrapper["config"]);
88 | const [ready, setReady] = React.useState(false);
89 |
90 | // otherwise app crashes if its first page on spotify load
91 | if (!ready) {
92 | waitForReady(() => {
93 | setConfig({ ...SpicetifyLibrary.ConfigWrapper.Config });
94 | setReady(true);
95 | });
96 | return <>>;
97 | }
98 |
99 |
100 | const launchModal = () => {
101 | SpicetifyLibrary.ConfigWrapper.launchModal(setConfig);
102 | };
103 |
104 | const configWrapper = {
105 | config: config,
106 | launchModal,
107 | };
108 |
109 | return (
110 |
111 |
112 |
113 | );
114 | };
115 |
116 | export default App;
117 |
--------------------------------------------------------------------------------
/projects/stats/src/app.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import ArtistsPage from "./pages/top_artists";
4 | import TracksPage from "./pages/top_tracks";
5 | import GenresPage from "./pages/top_genres";
6 | import LibraryPage from "./pages/library";
7 | import ChartsPage from "./pages/charts";
8 | import AlbumsPage from "./pages/top_albums";
9 |
10 | import { version } from "../package.json";
11 |
12 | import NavigationBar from "@shared/components/navigation/navigation_bar"
13 |
14 | import "./styles/app.scss";
15 | import "../../shared/src/config/config_modal.scss";
16 | import "../../shared/src/shared.scss";
17 |
18 | import { ConfigWrapper } from "./types/stats_types";
19 |
20 |
21 | const checkForUpdates = (setNewUpdate: (a: boolean) => void) => {
22 | fetch("https://api.github.com/repos/harbassan/spicetify-apps/releases")
23 | .then((res) => res.json())
24 | .then(
25 | (result) => {
26 | const releases = result.filter((release: any) => release.name.startsWith("stats"));
27 | setNewUpdate(releases[0].name.slice(7) !== version);
28 | },
29 | (error) => {
30 | console.log("Failed to check for updates", error);
31 | },
32 | );
33 | };
34 |
35 | const NavbarContainer = ({ configWrapper }: { configWrapper: ConfigWrapper }) => {
36 | const pages: Record = {
37 | ["Artists"]: ,
38 | ["Tracks"]: ,
39 | ["Albums"]: ,
40 | ["Genres"]: ,
41 | ["Library"]: ,
42 | ["Charts"]: ,
43 | };
44 |
45 | const tabPages = ["Artists", "Tracks", "Albums", "Genres", "Library", "Charts"].filter(
46 | (page) => configWrapper.config[`show-${page.toLowerCase()}` as keyof ConfigWrapper["config"]],
47 | );
48 |
49 | const [newUpdate, setNewUpdate] = React.useState(false);
50 |
51 | const activePage = Spicetify.Platform.History.location.pathname.split("/")[2];
52 |
53 | React.useEffect(() => {
54 | checkForUpdates(setNewUpdate);
55 | }, []);
56 |
57 | React.useEffect(() => {
58 | if (activePage === undefined) {
59 | const stored = Spicetify.LocalStorage.get("stats:active-link") || "Artists";
60 | Spicetify.Platform.History.replace(`stats/${stored}`);
61 | }
62 | }, [activePage]);
63 |
64 | if (activePage === undefined) return <>>;
65 |
66 | return (
67 | <>
68 |
69 | {newUpdate && (
70 |
74 | )}
75 | {pages[activePage]}
76 | >
77 | );
78 | };
79 |
80 | const waitForReady = async (callback: () => void) => {
81 | if (Spicetify.Platform && Spicetify.Platform.RootlistAPI && Spicetify.ReactQuery && SpicetifyStats) {
82 | callback();
83 | } else {
84 | setTimeout(() => waitForReady(callback), 1000);
85 | }
86 | }
87 |
88 | const App = () => {
89 | const [config, setConfig] = React.useState({} as ConfigWrapper["config"]);
90 | const [ready, setReady] = React.useState(false);
91 |
92 | // otherwise app crashes if its first page on spotify load
93 | if (!ready) {
94 | waitForReady(() => {
95 | setConfig({ ...SpicetifyStats.ConfigWrapper.Config });
96 | setReady(true);
97 | });
98 | return <>>;
99 | }
100 |
101 | const launchModal = () => {
102 | SpicetifyStats.ConfigWrapper.launchModal(setConfig);
103 | };
104 |
105 | const configWrapper = {
106 | config: config,
107 | launchModal,
108 | };
109 |
110 | return (
111 |
112 |
113 |
114 | );
115 |
116 | };
117 |
118 | export default App;
119 |
--------------------------------------------------------------------------------
/projects/stats/src/api/spotify.ts:
--------------------------------------------------------------------------------
1 | import type * as Spotify from "../types/spotify";
2 |
3 | export const apiFetch = async (name: string, url: string, log = true): Promise => {
4 | try {
5 | const timeStart = window.performance.now();
6 | const response = await Spicetify.CosmosAsync.get(url);
7 | if (response.code || response.error)
8 | throw new Error(
9 | `Failed to fetch the info from server. Try again later. ${name.includes("lfm") ? "Check your LFM API key and username." : ""}`,
10 | );
11 | if (log) console.log("stats -", name, "fetch time:", window.performance.now() - timeStart);
12 | return response;
13 | } catch (error) {
14 | console.log("stats -", name, "request failed:", error);
15 | throw error;
16 | }
17 | };
18 |
19 | const val = (res: T | undefined) => {
20 | if (!res || (Array.isArray(res) && !res.length))
21 | throw new Error("Spotify returned an empty result. Try again later.");
22 | return res;
23 | };
24 |
25 | const f = (param: string) => {
26 | return encodeURIComponent(param.replace(/'/g, ""));
27 | };
28 |
29 | export const getTopTracks = (range: Spotify.SpotifyRange) => {
30 | return apiFetch(
31 | "topTracks",
32 | `https://api.spotify.com/v1/me/top/tracks?limit=50&offset=0&time_range=${range}`,
33 | ).then((res) => val(res.items));
34 | };
35 |
36 | export const getTopArtists = (range: Spotify.SpotifyRange) => {
37 | return apiFetch(
38 | "topArtists",
39 | `https://api.spotify.com/v1/me/top/artists?limit=50&offset=0&time_range=${range}`,
40 | ).then((res) => val(res.items));
41 | };
42 |
43 | /**
44 | * @param ids - max: 50
45 | */
46 | export const getArtistMetas = (ids: string[]) => {
47 | return apiFetch("artistMetas", `https://api.spotify.com/v1/artists?ids=${ids}`).then(
48 | (res) => res.artists,
49 | );
50 | };
51 |
52 | export const getAlbumMetas = (ids: string[]) => {
53 | return apiFetch("albumMetas", `https://api.spotify.com/v1/albums?ids=${ids}`).then(
54 | (res) => res.albums,
55 | );
56 | };
57 |
58 | export const getTrackMetas = (ids: string[]) => {
59 | return apiFetch("trackMetas", `https://api.spotify.com/v1/tracks?ids=${ids}`).then(
60 | (res) => res.tracks,
61 | );
62 | };
63 |
64 | export const getAudioFeatures = (ids: string[]) => {
65 | return apiFetch(
66 | "audioFeatures",
67 | `https://api.spotify.com/v1/audio-features?ids=${ids}`,
68 | ).then((res) => res.audio_features);
69 | };
70 |
71 | export const searchForTrack = (track: string, artist: string) => {
72 | return apiFetch(
73 | "searchForTrack",
74 | `https://api.spotify.com/v1/search?q=track:${f(track)}+artist:${f(artist)}&type=track&limit=50`,
75 | ).then((res) => res.tracks.items);
76 | };
77 |
78 | export const searchForArtist = (artist: string) => {
79 | return apiFetch(
80 | "searchForArtist",
81 | `https://api.spotify.com/v1/search?q=artist:${f(artist)}&type=artist&limit=50`,
82 | ).then((res) => res.artists.items);
83 | };
84 |
85 | export const searchForAlbum = (album: string, artist: string) => {
86 | return apiFetch(
87 | "searchForAlbum",
88 | `https://api.spotify.com/v1/search?q=album:${f(album)}+artist:${f(artist)}&type=album&limit=50`,
89 | ).then((res) => res.albums.items);
90 | };
91 |
92 | export const queryLiked = (ids: string[]) => {
93 | return apiFetch("queryLiked", `https://api.spotify.com/v1/me/tracks/contains?ids=${ids}`);
94 | };
95 |
96 | export const getPlaylistMeta = (id: string) => {
97 | return apiFetch("playlistMeta", `https://api.spotify.com/v1/playlists/${id}`);
98 | };
99 |
100 | export const getUserPlaylists = () => {
101 | return apiFetch("userPlaylists", "https://api.spotify.com/v1/me/playlists").then(
102 | (res) => res.items,
103 | );
104 | };
105 |
--------------------------------------------------------------------------------
/projects/stats/src/components/tracklist.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const Tracklist = ({ playcount = false, minified = false, children }) => {
4 | const height = children.length;
5 | return (
6 |
12 | {!minified && (
13 |
14 |
22 |
29 | #
30 |
31 |
38 |
43 |
44 | {playcount && (
45 |
52 |
57 |
58 | )}
59 |
66 |
71 |
72 |
79 |
80 |
99 |
100 |
101 |
102 |
103 | )}
104 |
107 |
108 | );
109 | };
110 |
111 | export default Tracklist;
112 |
--------------------------------------------------------------------------------
/projects/stats/src/utils/converter.ts:
--------------------------------------------------------------------------------
1 | import { searchForAlbum, searchForArtist, searchForTrack } from "../api/spotify";
2 | import { cacher, set } from "../extensions/cache";
3 | import type * as LastFM from "../types/lastfm";
4 | import type * as Spotify from "../types/spotify";
5 | import type {
6 | LastFMMinifiedAlbum,
7 | LastFMMinifiedArtist,
8 | LastFMMinifiedTrack,
9 | SpotifyMinifiedAlbum,
10 | SpotifyMinifiedArtist,
11 | SpotifyMinifiedTrack,
12 | } from "../types/stats_types";
13 |
14 | export const minifyArtist = (artist: Spotify.Artist): SpotifyMinifiedArtist => ({
15 | id: artist.id,
16 | name: artist.name,
17 | image: artist.images?.at(0)?.url,
18 | uri: artist.uri,
19 | genres: artist.genres,
20 | type: "spotify",
21 | });
22 |
23 | export const minifyAlbum = (album: Spotify.SimplifiedAlbum): SpotifyMinifiedAlbum => ({
24 | id: album.id,
25 | uri: album.uri,
26 | name: album.name,
27 | image: album.images[0]?.url,
28 | type: "spotify",
29 | });
30 |
31 | export const minifyTrack = (track: Spotify.Track): SpotifyMinifiedTrack => ({
32 | id: track.id,
33 | uri: track.uri,
34 | name: track.name,
35 | duration_ms: track.duration_ms,
36 | popularity: track.popularity,
37 | explicit: track.explicit,
38 | image: track.album.images.at(-1)?.url,
39 | artists: track.artists.map((artist) => ({
40 | name: artist.name,
41 | uri: artist.uri,
42 | })),
43 | album: {
44 | name: track.album.name,
45 | uri: track.album.uri,
46 | release_date: track.album.release_date,
47 | },
48 | type: "spotify",
49 | });
50 |
51 | export const convertArtist = async (artist: LastFM.Artist) => {
52 | const spotifyArtist = await cacher(async () => {
53 | const searchRes = await searchForArtist(artist.name);
54 | const spotifyArtists = searchRes.filter(
55 | (a) => a.name.localeCompare(artist.name, undefined, { sensitivity: "base" }) === 0,
56 | );
57 | return spotifyArtists.sort((a, b) => b.popularity - a.popularity)[0];
58 | })({ queryKey: ["searchForArtist", artist.name] });
59 | if (!spotifyArtist)
60 | return {
61 | name: artist.name,
62 | playcount: Number(artist.playcount),
63 | uri: artist.url,
64 | type: "lastfm",
65 | } as LastFMMinifiedArtist;
66 | set(`artist-${spotifyArtist.id}`, spotifyArtist);
67 | return {
68 | ...minifyArtist(spotifyArtist),
69 | playcount: Number(artist.playcount),
70 | name: artist.name,
71 | } as SpotifyMinifiedArtist;
72 | };
73 |
74 | export const convertAlbum = async (album: LastFM.Album) => {
75 | const spotifyAlbum = await cacher(async () => {
76 | const searchRes = await searchForAlbum(album.name, album.artist.name);
77 | return searchRes.find((a) => a.name.localeCompare(album.name, undefined, { sensitivity: "base" }) === 0);
78 | })({
79 | queryKey: ["searchForAlbum", album.name, album.artist.name],
80 | });
81 | if (!spotifyAlbum)
82 | return {
83 | uri: album.url,
84 | name: album.name,
85 | playcount: Number(album.playcount),
86 | type: "lastfm",
87 | } as LastFMMinifiedAlbum;
88 | return {
89 | ...minifyAlbum(spotifyAlbum),
90 | playcount: Number(album.playcount),
91 | name: album.name,
92 | } as SpotifyMinifiedAlbum;
93 | };
94 |
95 | export const convertTrack = async (track: LastFM.Track) => {
96 | const spotifyTrack = await cacher(async () => {
97 | const searchRes = await searchForTrack(track.name, track.artist.name);
98 | return searchRes.find((t) => t.name.localeCompare(track.name, undefined, { sensitivity: "base" }) === 0);
99 | })({
100 | queryKey: ["searchForTrack", track.name, track.artist.name],
101 | });
102 | if (!spotifyTrack)
103 | return {
104 | uri: track.url,
105 | name: track.name,
106 | playcount: Number(track.playcount),
107 | duration_ms: Number(track.duration) * 1000,
108 | artists: [
109 | {
110 | name: track.artist.name,
111 | uri: track.artist.url,
112 | },
113 | ],
114 | type: "lastfm",
115 | } as LastFMMinifiedTrack;
116 | return {
117 | ...minifyTrack(spotifyTrack),
118 | playcount: Number(track.playcount),
119 | name: track.name,
120 | } as SpotifyMinifiedTrack;
121 | };
122 |
--------------------------------------------------------------------------------
/projects/stats/src/styles/app.scss:
--------------------------------------------------------------------------------
1 | #stats-app {
2 | .stats-gridInline {
3 | --grid-gap: 24px;
4 | grid-template-columns: repeat(10, 180px) !important;
5 | overflow-x: hidden;
6 | scroll-behavior: smooth;
7 | margin-top: 5px;
8 | transform: none !important;
9 | will-change: auto !important;
10 | }
11 |
12 | .grid:nth-child(2) {
13 | margin-top: 24px;
14 | }
15 |
16 |
17 |
18 | [data-scroll="both"] {
19 | mask-image: linear-gradient(to right, transparent, black 10%, black 90%, transparent);
20 | }
21 |
22 | [data-scroll="end"] {
23 | mask-image: linear-gradient(to right, transparent, black 10%);
24 | }
25 |
26 | [data-scroll="start"] {
27 | mask-image: linear-gradient(to right, black 90%, transparent);
28 | }
29 |
30 | .stats-libraryOverview {
31 | display: flex;
32 | gap: 24px;
33 | align-items: center;
34 | }
35 |
36 | .stats-trackPageTitle {
37 | display: flex;
38 | gap: 24px;
39 | align-items: center;
40 | }
41 |
42 | .stats-scrollButton {
43 | width: 40px;
44 | border-radius: 8px;
45 | border: none;
46 | padding: 0;
47 | margin-right: 5px;
48 | background-color: var(--spice-player);
49 | color: var(--spice-subtext);
50 |
51 | &:hover {
52 | background-color: var(--spice-card);
53 | color: var(--spice-text);
54 | }
55 | }
56 |
57 | .stats-tracklistHeader>div {
58 | display: flex;
59 | -webkit-app-region: no-drag;
60 | gap: 20px;
61 | align-items: center;
62 | }
63 |
64 | .stats-make-playlist-button {
65 | margin-inline-start: 12px;
66 | }
67 |
68 | .stats-genreCard {
69 | display: flex;
70 | flex-direction: column;
71 | gap: 10px;
72 | padding: 16px;
73 | border-radius: 8px;
74 | background: var(--spice-player);
75 | position: relative;
76 | }
77 |
78 | .stats-genreRow {
79 | width: 100%;
80 | height: 20px;
81 | display: flex;
82 | gap: 10px;
83 | }
84 |
85 | .stats-genreRowFill {
86 | background: var(--spice-button);
87 | height: 100%;
88 | border-radius: 8px;
89 | display: flex;
90 | align-items: center;
91 | }
92 |
93 | .stats-genreText {
94 | color: var(--spice-player);
95 | font-size: 0.875rem;
96 | margin-left: 7px;
97 | font-weight: bold;
98 | white-space: nowrap;
99 | overflow: hidden;
100 | text-overflow: ellipsis;
101 | }
102 |
103 | .stats-genreValue {
104 | color: var(--spice-text);
105 | font-size: 0.875rem;
106 | }
107 |
108 | .stats-genreCard+.stats-gridInlineSection {
109 | margin-top: 3px;
110 | }
111 |
112 | // fix styles for old trackrow clone component
113 | .main-trackList-rowHeartButton,
114 | .main-trackList-rowMoreButton {
115 | background-color: transparent;
116 | border: none;
117 | fill: currentColor;
118 | }
119 |
120 | .main-trackList-rowPlayPauseIcon,
121 | .main-trackList-durationHeader {
122 | fill: currentColor;
123 | }
124 |
125 | .main-trackList-rowBadges {
126 | color: var(--spice-text);
127 | }
128 |
129 | .extend-button {
130 | background-color: transparent;
131 | border: none;
132 | position: absolute;
133 | right: 20px;
134 | bottom: 20px;
135 | font-size: 14px;
136 | color: var(--spice-subtext);
137 |
138 | &:hover {
139 | color: var(--spice-text);
140 | }
141 | }
142 |
143 | .main-card-cardContainer {
144 | width: 100%;
145 | height: 100%;
146 | }
147 |
148 | }
149 |
150 | .GenericModal[aria-label="Playlist Stats"] .main-embedWidgetGenerator-container {
151 | width: 80vw;
152 | height: 80vh;
153 | background-color: var(--spice-main);
154 | }
155 |
156 | .GenericModal[aria-label="Playlist Stats"] .main-shelf-title {
157 | color: var(--spice-text);
158 | }
159 |
160 | .playlist-stats-button.hidden {
161 | display: none;
162 | }
--------------------------------------------------------------------------------
/projects/stats/src/pages/top_genres.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import useDropdownMenu from "@shared/dropdown/useDropdownMenu";
3 | import StatCard from "../components/cards/stat_card";
4 | import ChartCard from "../components/cards/chart_card";
5 | import InlineGrid from "../components/inline_grid";
6 | import PageContainer from "@shared/components/page_container";
7 | import Shelf from "../components/shelf";
8 | import { DropdownOptions } from "./top_artists";
9 | import { getTopTracks } from "./top_tracks";
10 | import type { Config, ConfigWrapper, LastFMMinifiedTrack, SpotifyMinifiedTrack } from "../types/stats_types";
11 | import RefreshButton from "../components/buttons/refresh_button";
12 | import SettingsButton from "@shared/components/settings_button";
13 | import type { SpotifyRange } from "../types/spotify";
14 | import { getMeanAudioFeatures, parseArtists, parseStat } from "../utils/track_helper";
15 | import { useQuery } from "@shared/types/react_query";
16 | import useStatus from "@shared/status/useStatus";
17 | import { cacher, invalidator } from "../extensions/cache";
18 |
19 | const parseAlbums = (albums: SpotifyMinifiedTrack["album"][]) => {
20 | const releaseYears = {} as Record;
21 | for (const album of albums) {
22 | const year = album.release_date.slice(0, 4);
23 | releaseYears[year] = (releaseYears[year] || 0) + 1;
24 | }
25 | return releaseYears;
26 | };
27 |
28 | const parseTracks = async (tracks: (SpotifyMinifiedTrack | LastFMMinifiedTrack)[]) => {
29 | const trackIDs: string[] = [];
30 | const albumsRaw: SpotifyMinifiedTrack["album"][] = [];
31 | const artistsRaw: SpotifyMinifiedTrack["artists"] = [];
32 | let explicit = 0;
33 | let popularity = 0;
34 |
35 | for (const track of tracks) {
36 | if (track.type !== "spotify") continue;
37 | popularity += track.popularity;
38 | explicit += track.explicit ? 1 : 0;
39 | trackIDs.push(track.id);
40 | albumsRaw.push(track.album);
41 | artistsRaw.push(...track.artists);
42 | }
43 |
44 | explicit = explicit / trackIDs.length;
45 | popularity = popularity / trackIDs.length;
46 |
47 | const audioFeatures = await getMeanAudioFeatures(trackIDs);
48 | const analysis = { ...audioFeatures, popularity, explicit };
49 | const { genres } = await parseArtists(artistsRaw);
50 | const releaseYears = parseAlbums(albumsRaw);
51 |
52 | return { analysis, genres, releaseYears };
53 | };
54 |
55 | const getGenres = async (time_range: SpotifyRange, config: Config) => {
56 | const topTracks = await cacher(() => getTopTracks(time_range, config))({ queryKey: ["top-tracks", time_range] });
57 | return parseTracks(topTracks);
58 | };
59 |
60 | const GenresPage = ({ configWrapper }: { configWrapper: ConfigWrapper }) => {
61 | const [dropdown, activeOption] = useDropdownMenu(DropdownOptions(configWrapper), "stats:top-genres");
62 |
63 | const { status, error, data, refetch } = useQuery({
64 | queryKey: ["top-genres", activeOption.id],
65 | queryFn: cacher(() => getGenres(activeOption.id as SpotifyRange, configWrapper.config)),
66 | });
67 |
68 | const Status = useStatus(status, error);
69 |
70 | const props = {
71 | lhs: ["Top Genres"],
72 | rhs: [
73 | dropdown,
74 | invalidator(["top-genres", activeOption.id], refetch)} />,
75 | ,
76 | ],
77 | };
78 |
79 | if (Status) return {Status};
80 |
81 | const analysis = data as NonNullable;
82 |
83 | const statCards = Object.entries(analysis.analysis).map(([key, value]) => {
84 | return ;
85 | });
86 |
87 | // const obscureTracks = topGenres.obscureTracks.map((track: Track, index: number) => (
88 | // track.uri)} />
89 | // ));
90 |
91 | return (
92 |
93 |
94 |
95 | {statCards}
96 |
97 |
98 |
99 |
100 | {/*
101 | {obscureTracks}
102 | */}
103 |
104 | );
105 | };
106 |
107 | export default React.memo(GenresPage);
108 |
--------------------------------------------------------------------------------
/projects/stats/src/pages/library.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import useDropdownMenu from "@shared/dropdown/useDropdownMenu";
3 | import StatCard from "../components/cards/stat_card";
4 | import ChartCard from "../components/cards/chart_card";
5 | import SpotifyCard from "@shared/components/spotify_card";
6 | import InlineGrid from "../components/inline_grid";
7 | import PageContainer from "@shared/components/page_container";
8 | import Shelf from "../components/shelf";
9 | import type { ConfigWrapper } from "../types/stats_types";
10 | import RefreshButton from "../components/buttons/refresh_button";
11 | import SettingsButton from "@shared/components/settings_button";
12 | import { useQuery } from "@shared/types/react_query";
13 | import useStatus from "@shared/status/useStatus";
14 | import { parseStat, parseTracks } from "../utils/track_helper";
15 | import { cacher, invalidator } from "../extensions/cache";
16 | import { getFullPlaylist, getRootlist } from "../api/platform";
17 |
18 | const DropdownOptions = [
19 | { id: "owned", name: "My Playlists" },
20 | { id: "all", name: "All Playlists" },
21 | ];
22 |
23 | const getLibrary = async (type: "owned" | "all") => {
24 | let playlists = await getRootlist();
25 | if (type === "owned") playlists = playlists.filter((p) => p.isOwnedBySelf);
26 | if (playlists.length === 0) throw new Error("You have no playlists saved");
27 | const contents = await Promise.all(playlists.map((p) => getFullPlaylist(p.uri)));
28 | const analysis = await parseTracks(contents.flat());
29 | return { ...analysis, playlists: playlists.length };
30 | };
31 |
32 | const LibraryPage = ({ configWrapper }: { configWrapper: ConfigWrapper }) => {
33 | const [dropdown, activeOption] = useDropdownMenu(DropdownOptions, "stats:library");
34 |
35 | const { status, error, data, refetch } = useQuery({
36 | queryKey: ["library", activeOption.id],
37 | queryFn: cacher(() => getLibrary(activeOption.id as "owned" | "all")),
38 | });
39 |
40 | const props = {
41 | lhs: ["Library Analysis"],
42 | rhs: [
43 | dropdown,
44 | invalidator(["library", activeOption.id], refetch)} />,
45 | ,
46 | ],
47 | };
48 |
49 | const Status = useStatus(status, error);
50 |
51 | if (Status) return {Status};
52 |
53 | const analysis = data as NonNullable;
54 |
55 | const statCards = Object.entries(analysis.analysis).map(([key, value]) => {
56 | return ;
57 | });
58 |
59 | const artistCards = analysis.artists.contents.slice(0, 10).map((artist) => {
60 | return (
61 |
69 | );
70 | });
71 |
72 | const albumCards = analysis.albums.contents.slice(0, 10).map((album) => {
73 | return (
74 |
82 | );
83 | });
84 |
85 | return (
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 | {statCards}
98 |
99 |
100 | {artistCards}
101 |
102 |
103 | {albumCards}
104 |
105 |
106 |
107 |
108 |
109 | );
110 | };
111 |
112 | export default React.memo(LibraryPage);
113 |
--------------------------------------------------------------------------------
/projects/library/src/pages/artists.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from "react";
2 | import SearchBar from "../components/searchbar";
3 | import PageContainer from "@shared/components/page_container";
4 | import SpotifyCard from "@shared/components/spotify_card";
5 | import SettingsButton from "@shared/components/settings_button";
6 | import type { ConfigWrapper } from "../types/library_types";
7 | import LoadMoreCard from "../components/load_more_card";
8 | import LeadingIcon from "../components/leading_icon";
9 | import AddButton from "../components/add_button";
10 | import TextInputDialog from "../components/text_input_dialog";
11 | import useStatus from "@shared/status/useStatus";
12 | import { useInfiniteQuery } from "@shared/types/react_query";
13 | import type { ArtistItem, GetContentsResponse, UpdateEvent } from "../types/platform";
14 | import PinIcon from "../components/pin_icon";
15 | import useSortDropdownMenu from "@shared/dropdown/useSortDropdownMenu";
16 |
17 | const AddMenu = () => {
18 | const { MenuItem, Menu } = Spicetify.ReactComponent;
19 | const { SVGIcons } = Spicetify;
20 |
21 | const addAlbum = () => {
22 | const onSave = (value: string) => {
23 | Spicetify.Platform.LibraryAPI.add({ uris: [value] });
24 | };
25 |
26 | Spicetify.PopupModal.display({
27 | title: "Add Artist",
28 | // @ts-ignore
29 | content: ,
30 | });
31 | };
32 |
33 | return (
34 |
39 | );
40 | };
41 |
42 | function isValidArtist(artist: ArtistItem) {
43 | return artist.name && artist.uri;
44 | }
45 |
46 | const limit = 200;
47 |
48 | const sortOptions = [
49 | { id: "0", name: "Name" },
50 | { id: "1", name: "Date Added" },
51 | ];
52 |
53 | const ArtistsPage = ({ configWrapper }: { configWrapper: ConfigWrapper }) => {
54 | const [sortDropdown, sortOption, isReversed] = useSortDropdownMenu(sortOptions, "library:artists");
55 | const [textFilter, setTextFilter] = React.useState("");
56 |
57 | const fetchArtists = async ({ pageParam }: { pageParam: number }) => {
58 | const res = (await Spicetify.Platform.LibraryAPI.getContents({
59 | filters: ["1"],
60 | sortOrder: sortOption.id,
61 | textFilter,
62 | offset: pageParam,
63 | sortDirection: isReversed ? "reverse" : undefined,
64 | limit,
65 | })) as GetContentsResponse;
66 | if (!res.items?.length) throw new Error("No artists found");
67 | return res;
68 | };
69 |
70 | const { data, status, error, hasNextPage, fetchNextPage, refetch } = useInfiniteQuery({
71 | queryKey: ["library:artists", sortOption.id, isReversed, textFilter],
72 | queryFn: fetchArtists,
73 | initialPageParam: 0,
74 | getNextPageParam: (lastPage) => {
75 | const current = lastPage.offset + limit;
76 | if (lastPage.totalLength > current) return current;
77 | },
78 | });
79 |
80 | useEffect(() => {
81 | const update = (e: UpdateEvent) => {
82 | if (e.data.list === "artists") refetch();
83 | };
84 | Spicetify.Platform.LibraryAPI.getEvents()._emitter.addListener("update", update, {});
85 | return () => {
86 | Spicetify.Platform.LibraryAPI.getEvents()._emitter.removeListener("update", update);
87 | };
88 | }, [refetch]);
89 |
90 | const Status = useStatus(status, error);
91 |
92 | const props = {
93 | lhs: ["Artists"],
94 | rhs: [
95 | } />,
96 | sortDropdown,
97 | ,
98 | ,
99 | ],
100 | };
101 |
102 | if (Status) return {Status};
103 |
104 | const contents = data as NonNullable;
105 |
106 | const artists = contents.pages.flatMap((page) => page.items);
107 |
108 | const artistCards = artists.filter(isValidArtist).map((artist) => (
109 | : undefined}
116 | />
117 | ));
118 |
119 | if (hasNextPage) artistCards.push();
120 |
121 | return (
122 |
123 | {artistCards}
124 |
125 | );
126 | };
127 |
128 | export default ArtistsPage;
129 |
--------------------------------------------------------------------------------
/projects/library/src/pages/shows.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from "react";
2 | import SearchBar from "../components/searchbar";
3 | import PageContainer from "@shared/components/page_container";
4 | import SettingsButton from "@shared/components/settings_button";
5 | import type { ConfigWrapper } from "../types/library_types";
6 | import SpotifyCard from "@shared/components/spotify_card";
7 | import LoadMoreCard from "../components/load_more_card";
8 | import LeadingIcon from "../components/leading_icon";
9 | import AddButton from "../components/add_button";
10 | import TextInputDialog from "../components/text_input_dialog";
11 | import { useInfiniteQuery } from "@shared/types/react_query";
12 | import type { GetContentsResponse, ShowItem, UpdateEvent } from "../types/platform";
13 | import useStatus from "@shared/status/useStatus";
14 | import PinIcon from "../components/pin_icon";
15 | import useSortDropdownMenu from "@shared/dropdown/useSortDropdownMenu";
16 | import CustomCard from "../components/custom_card";
17 |
18 | const AddMenu = () => {
19 | const { MenuItem, Menu } = Spicetify.ReactComponent;
20 | const { SVGIcons } = Spicetify;
21 |
22 | const addAlbum = () => {
23 | const onSave = (value: string) => {
24 | Spicetify.Platform.LibraryAPI.add({ uris: [value] });
25 | };
26 |
27 | Spicetify.PopupModal.display({
28 | title: "Add Show",
29 | // @ts-ignore
30 | content: ,
31 | });
32 | };
33 |
34 | return (
35 |
40 | );
41 | };
42 |
43 | function isValidShow(show: ShowItem) {
44 | return show.name && show.uri;
45 | }
46 |
47 | const limit = 200;
48 |
49 | const sortOptions = [
50 | { id: "0", name: "Name" },
51 | { id: "1", name: "Date Added" },
52 | ];
53 |
54 | const ShowsPage = ({ configWrapper }: { configWrapper: ConfigWrapper }) => {
55 | const [sortDropdown, sortOption, isReversed] = useSortDropdownMenu(sortOptions, "library:shows");
56 | const [textFilter, setTextFilter] = React.useState("");
57 |
58 | const fetchShows = async ({ pageParam }: { pageParam: number }) => {
59 | const res = (await Spicetify.Platform.LibraryAPI.getContents({
60 | filters: ["3"],
61 | sortOrder: sortOption.id,
62 | textFilter,
63 | sortDirection: isReversed ? "reverse" : undefined,
64 | offset: pageParam,
65 | limit,
66 | })) as GetContentsResponse;
67 | if (!res.items?.length) throw new Error("No shows found");
68 | return res;
69 | };
70 |
71 | const { data, status, error, hasNextPage, fetchNextPage, refetch } = useInfiniteQuery({
72 | queryKey: ["library:shows", sortOption.id, isReversed, textFilter],
73 | queryFn: fetchShows,
74 | initialPageParam: 0,
75 | getNextPageParam: (lastPage) => {
76 | const current = lastPage.offset + limit;
77 | if (lastPage.totalLength > current) return current;
78 | },
79 | });
80 |
81 | useEffect(() => {
82 | const update = (e: UpdateEvent) => {
83 | if (e.data.list === "shows") refetch();
84 | };
85 | Spicetify.Platform.LibraryAPI.getEvents()._emitter.addListener("update", update, {});
86 | return () => {
87 | Spicetify.Platform.LibraryAPI.getEvents()._emitter.removeListener("update", update);
88 | };
89 | }, [refetch]);
90 |
91 | const Status = useStatus(status, error);
92 |
93 | const props = {
94 | lhs: ["Shows"],
95 | rhs: [
96 | } />,
97 | sortDropdown,
98 | ,
99 | ,
100 | ],
101 | };
102 |
103 | if (Status) return {Status};
104 |
105 | const contents = data as NonNullable;
106 |
107 | const shows = contents.pages.flatMap((page) => page.items);
108 |
109 | const showCards = shows.filter(isValidShow).map((show) => (
110 | : undefined}
117 | />
118 | ));
119 |
120 | if (hasNextPage) showCards.push();
121 |
122 | return (
123 |
124 | {showCards}
125 |
126 | );
127 | };
128 |
129 | export default ShowsPage;
130 |
--------------------------------------------------------------------------------
/projects/stats/src/types/graph_ql.ts:
--------------------------------------------------------------------------------
1 | export interface getAlbumResponse {
2 | data: Data;
3 | extensions: null;
4 | }
5 |
6 | export interface Data {
7 | albumUnion: AlbumUnion;
8 | }
9 |
10 | export interface AlbumUnion {
11 | __typename: string;
12 | copyright: Copyright;
13 | courtesyLine: string;
14 | date: AlbumUnionDate;
15 | label: string;
16 | name: string;
17 | playability: ItemPlayability;
18 | saved: boolean;
19 | sharingInfo: AlbumUnionSharingInfo;
20 | tracks: AlbumUnionTracks;
21 | type: Type;
22 | uri: string;
23 | artists: AlbumUnionArtists;
24 | coverArt: CoverArt;
25 | discs: Discs;
26 | releases: Releases;
27 | moreAlbumsByArtist: MoreAlbumsByArtist;
28 | }
29 |
30 | export interface AlbumUnionArtists {
31 | items: AlbumArtist[];
32 | totalCount: number;
33 | }
34 |
35 | export interface AlbumArtist {
36 | id: string;
37 | profile: Profile;
38 | sharingInfo: SharingInfo;
39 | uri: string;
40 | visuals: Visuals;
41 | }
42 |
43 | export interface Profile {
44 | name: string;
45 | }
46 |
47 | export interface SharingInfo {
48 | shareUrl: string;
49 | }
50 |
51 | export interface Visuals {
52 | avatarImage: AvatarImage;
53 | }
54 |
55 | export interface AvatarImage {
56 | sources: Source[];
57 | }
58 |
59 | export interface Source {
60 | height: number;
61 | url: string;
62 | width: number;
63 | }
64 |
65 | export interface Copyright {
66 | items: CopyrightItem[];
67 | totalCount: number;
68 | }
69 |
70 | export interface CopyrightItem {
71 | text: string;
72 | type: string;
73 | }
74 |
75 | export interface CoverArt {
76 | extractedColors: ExtractedColors;
77 | sources: Source[];
78 | }
79 |
80 | export interface ExtractedColors {
81 | colorDark: Color;
82 | colorLight: Color;
83 | colorRaw: Color;
84 | }
85 |
86 | export interface Color {
87 | hex: string;
88 | }
89 |
90 | export interface AlbumUnionDate {
91 | isoString: Date;
92 | precision: string;
93 | }
94 |
95 | export interface Discs {
96 | items: DiscsItem[];
97 | totalCount: number;
98 | }
99 |
100 | export interface DiscsItem {
101 | number: number;
102 | tracks: AssociatedVideosClass;
103 | }
104 |
105 | export interface AssociatedVideosClass {
106 | totalCount: number;
107 | }
108 |
109 | export interface MoreAlbumsByArtist {
110 | items: MoreAlbumsByArtistItem[];
111 | }
112 |
113 | export interface MoreAlbumsByArtistItem {
114 | discography: Discography;
115 | }
116 |
117 | export interface Discography {
118 | popularReleasesAlbums: PopularReleasesAlbums;
119 | }
120 |
121 | export interface PopularReleasesAlbums {
122 | items: PopularReleasesAlbumsItem[];
123 | }
124 |
125 | export interface PopularReleasesAlbumsItem {
126 | coverArt: AvatarImage;
127 | date: ItemDate;
128 | id: string;
129 | name: string;
130 | playability: ItemPlayability;
131 | sharingInfo: AlbumUnionSharingInfo;
132 | type: Type;
133 | uri: string;
134 | }
135 |
136 | export interface ItemDate {
137 | year: number;
138 | }
139 |
140 | export interface ItemPlayability {
141 | playable: boolean;
142 | reason: "PLAYABLE";
143 | }
144 |
145 | export interface AlbumUnionSharingInfo {
146 | shareId: string;
147 | shareUrl: string;
148 | }
149 |
150 | export enum Type {
151 | Album = "ALBUM",
152 | Single = "SINGLE",
153 | }
154 |
155 | export interface Releases {
156 | items: ReleasesItem[];
157 | totalCount: number;
158 | }
159 |
160 | export interface ReleasesItem {
161 | name: string;
162 | uri: string;
163 | }
164 |
165 | export interface AlbumUnionTracks {
166 | items: TracksItem[];
167 | totalCount: number;
168 | }
169 |
170 | export interface TracksItem {
171 | track: Track;
172 | uid: string;
173 | }
174 |
175 | export interface Track {
176 | artists: TrackArtists;
177 | associations: Associations;
178 | contentRating: ContentRating;
179 | discNumber: number;
180 | duration: Duration;
181 | name: string;
182 | playability: TrackPlayability;
183 | playcount: string;
184 | relinkingInformation: null;
185 | saved: boolean;
186 | trackNumber: number;
187 | uri: string;
188 | }
189 |
190 | export interface TrackArtists {
191 | items: TrackArtist[];
192 | }
193 |
194 | export interface TrackArtist {
195 | profile: Profile;
196 | uri: string;
197 | }
198 |
199 | export interface Associations {
200 | associatedVideos: AssociatedVideosClass;
201 | }
202 |
203 | export interface ContentRating {
204 | label: string;
205 | }
206 |
207 | export interface Duration {
208 | totalMilliseconds: number;
209 | }
210 |
211 | export interface TrackPlayability {
212 | playable: boolean;
213 | }
214 |
--------------------------------------------------------------------------------
/projects/stats/src/types/spotify.ts:
--------------------------------------------------------------------------------
1 | // note: some fields are missing from the types as they are not relevant to the project
2 |
3 | export enum SpotifyRange {
4 | Short = "short_term",
5 | Medium = "medium_term",
6 | Long = "long_term",
7 | }
8 |
9 | interface AudioFeatures {
10 | acousticness: number;
11 | danceability: number;
12 | energy: number;
13 | instrumentalness: number;
14 | key: number;
15 | liveness: number;
16 | loudness: number;
17 | mode: number;
18 | speechiness: number;
19 | tempo: number;
20 | time_signature: number;
21 | valence: number;
22 | }
23 |
24 | interface AudioFeaturesWrapper extends AudioFeatures {
25 | uri: string;
26 | id: string;
27 | analysis_url: string;
28 | duration_ms: number;
29 | track_href: string;
30 | type: "audio_features";
31 | }
32 |
33 | interface Image {
34 | url: string;
35 | height: number | null;
36 | width: number | null;
37 | }
38 |
39 | export interface SimplifiedArtist {
40 | href: string;
41 | id: string;
42 | name: string;
43 | type: "artist";
44 | uri: string;
45 | }
46 |
47 | export interface Artist extends SimplifiedArtist {
48 | followers: {
49 | total: number;
50 | };
51 | genres: string[];
52 | images: Image[];
53 | popularity: number;
54 | }
55 |
56 | export interface SimplifiedAlbum {
57 | album_type: "album" | "single" | "compilation";
58 | total_tracks: number;
59 | href: string;
60 | id: string;
61 | images: Image[];
62 | name: string;
63 | release_date: string;
64 | release_date_precision: "year" | "month" | "day";
65 | type: "album";
66 | uri: string;
67 | artists: SimplifiedArtist[];
68 | }
69 |
70 | export interface Album extends SimplifiedAlbum {
71 | tracks: Items;
72 | external_ids: {
73 | isrc: string;
74 | ean: string;
75 | upc: string;
76 | };
77 | genres: string[]; // this seems to be missing in the response
78 | label: string;
79 | popularity: number;
80 | }
81 |
82 | interface SimplifiedTrack {
83 | disc_number: number;
84 | duration_ms: number;
85 | explicit: boolean;
86 | href: string;
87 | id: string;
88 | name: string;
89 | track_number: number;
90 | type: "track";
91 | uri: string;
92 | is_local: boolean;
93 | }
94 |
95 | export interface Track extends SimplifiedTrack {
96 | album: SimplifiedAlbum;
97 | artists: SimplifiedArtist[];
98 | external_ids?: {
99 | isrc: string;
100 | ean: string;
101 | upc: string;
102 | };
103 | popularity: number;
104 | }
105 |
106 | interface PlaylistSimplified {
107 | collaborative: boolean;
108 | description: string | null;
109 | external_urls: {
110 | spotify: string;
111 | };
112 | href: string;
113 | id: string;
114 | images: Image[];
115 | name: string;
116 | owner: User;
117 | public: boolean | null;
118 | snapshot_id: string;
119 | tracks: {
120 | href: string;
121 | total: number;
122 | };
123 | type: "playlist";
124 | uri: string;
125 | }
126 |
127 | interface Playlist extends PlaylistSimplified {
128 | followers: {
129 | href: string | null;
130 | total: number;
131 | };
132 | tracks: Items;
133 | }
134 |
135 | interface User {
136 | external_urls: {
137 | spotify: string;
138 | };
139 | followers: {
140 | href: string | null;
141 | total: number;
142 | };
143 | href: string;
144 | id: string;
145 | type: "user";
146 | uri: string;
147 | }
148 |
149 | interface Episode {
150 | type: "episode";
151 | }
152 |
153 | export interface PlaylistTrack {
154 | added_at: string;
155 | added_by: User;
156 | is_local: boolean;
157 | track: Track | Episode | null; // the track can be unavailable
158 | }
159 |
160 | interface Items {
161 | href: string;
162 | limit: number;
163 | next: string | null;
164 | offset: number;
165 | previous: string | null;
166 | total: number;
167 | items: T[];
168 | }
169 |
170 | export interface SeveralTracksResponse {
171 | tracks: Track[];
172 | }
173 |
174 | export interface SeveralArtistsResponse {
175 | artists: Artist[];
176 | }
177 |
178 | export interface SeveralAlbumsResponse {
179 | albums: Album[];
180 | }
181 |
182 | export interface SeveralAudioFeaturesResponse {
183 | audio_features: (AudioFeaturesWrapper | null)[];
184 | }
185 |
186 | export interface SearchResponse {
187 | tracks: Items