Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
358 changes: 343 additions & 15 deletions package-lock.json

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"@react-router/dev": "^7.3.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/electron-squirrel-startup": "^1.0.2",
"@types/lodash": "^4.17.16",
"@types/react": "^19.0.10",
Expand All @@ -60,9 +61,11 @@
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-react-hooks": "^5.2.0",
"jsdom": "^26.0.0",
"prismock": "^1.35.4",
"ts-node": "^10.9.2",
"typescript": "^5.8.2",
"vite": "^6.2.5",
"vite-plugin-svgr": "^4.5.0",
"vitest": "^3.1.1",
"vitest-mock-extended": "^3.0.1"
},
Expand All @@ -77,7 +80,6 @@
"@react-router/serve": "^7.3.0",
"@tanstack/react-query": "^5.68.0",
"@tanstack/react-virtual": "^3.13.4",
"@testing-library/user-event": "^14.6.1",
"clsx": "^2.1.1",
"electron-settings": "^4.0.4",
"electron-squirrel-startup": "^1.0.1",
Expand All @@ -93,11 +95,9 @@
"react-modal": "^3.16.3",
"react-responsive": "^10.0.1",
"react-router": "^7.3.0",
"run-applescript": "^7.0.0",
"sha1": "^1.1.1",
"unzipper": "^0.12.3",
"use-debounce": "^10.0.4",
"vite-plugin-svgr": "^4.3.0",
"zip-a-folder": "^3.1.9",
"zustand": "^5.0.3"
}
Expand Down
194 changes: 130 additions & 64 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { capitalize, deburr, uniqBy } from 'lodash';
import type { MouseEvent } from 'react';
import { capitalize, deburr, uniqBy } from "lodash";
import type { MouseEvent } from "react";
import type {
ReleaseType,
Release,
Expand All @@ -14,33 +14,51 @@ import type {
ArtistWithReleases,
GroupWithArtists,
WithAdditionalArtists,

} from "@/types/types";
import { getCover } from './links';

const releaseTypes: ReleaseType[] =
['Album', 'Compilation', 'EP', 'Single', 'Bootleg', 'Various', 'Tribute', 'Soundtrack'];

export const VARIOUS_ARTISTS_FOLDER = '[V:A]';
export const VARIOUS_ARTISTS_NAME = '_VV_AA_';

export function getReleaseTitle({ title, subReleases = [] }:
Pick<ReleaseWithArtistAndSubreleases, 'title' | 'subReleases'>): string {
import { getCover } from "./links";

const releaseTypes: ReleaseType[] = [
"Album",
"Compilation",
"EP",
"Single",
"Bootleg",
"Various",
"Tribute",
"Soundtrack",
];

export const VARIOUS_ARTISTS_FOLDER = "[V:A]";
export const VARIOUS_ARTISTS_NAME = "_VV_AA_";

export function getReleaseTitle({
title,
subReleases = [],
}: Pick<ReleaseWithArtistAndSubreleases, "title" | "subReleases">): string {
if (!subReleases.length) {
return title;
}
const match = title.match(/(.*) CD(\d+)/);
return match ? match[1] : title;
}

export function getReleaseArtist(
{ artist, additionalArtists }: Pick<ReleaseWithArtist & WithAdditionalArtists, 'artist' | 'additionalArtists'>
) {
return [artist, ...additionalArtists].map(x => normalizeArtistName(x.name)).join(', ');
export function getReleaseArtist({
artist,
additionalArtists,
}: Pick<
ReleaseWithArtist & WithAdditionalArtists,
"artist" | "additionalArtists"
>) {
return [artist, ...additionalArtists]
.map((x) => normalizeArtistName(x.name))
.join(", ");
}

export function getUniqueArtists(releases: ReleaseWithArtist[]): Artist[] {
return uniqBy(releases.map(x => x.artist), (x => x.id));
return uniqBy(
releases.map((x) => x.artist),
(x) => x.id
);
}

export function getMainReleaseTitle(release: ReleaseWithArtist) {
Expand All @@ -52,12 +70,15 @@ export function getMainReleaseTitle(release: ReleaseWithArtist) {
}

export function countReleasesByType(releases: Release[]): ReleaseCountByType {
return releases.reduce((memo, { type }) => ({ ...memo, [type]: memo[type] ? memo[type] + 1 : 1 }), {} as ReleaseCountByType);
return releases.reduce(
(memo, { type }) => ({ ...memo, [type]: memo[type] ? memo[type] + 1 : 1 }),
{} as ReleaseCountByType
);
}

export function estimateListCardSize() {
return {
width: '100%',
width: "100%",
height: 6 * 16,
};
}
Expand All @@ -66,7 +87,7 @@ export function normalizeTitle(title: string) {
return title
.replace(/ CD(\d+)/, "")
.replaceAll(/\(\w: (.*)\)/g, "")
.replaceAll(' : ', ' / ')
.replaceAll(" : ", " / ")
.trim();
}

Expand All @@ -78,25 +99,29 @@ export function normalizeArtistName(name: string) {
}

export function normalizeArtistDisplayName(name: string) {
return name === VARIOUS_ARTISTS_NAME ? 'Various Artists' : name;
return name === VARIOUS_ARTISTS_NAME ? "Various Artists" : name;
}

export function getDiscInfo({ subReleases }: ReleaseWithArtistAndSubreleases) {
return subReleases.length
? `(${subReleases.length + 1} discs)`
: null;
return subReleases.length ? `(${subReleases.length + 1} discs)` : null;
}

export function getReleaseDuration(release: ReleaseWithArtistAndTracksAndSubreleases) {
export function getReleaseDuration(
release: ReleaseWithArtistAndTracksAndSubreleases
) {
const allTracks = [
...release.tracks,
...(release.subReleases.length ? release.subReleases.flatMap(x => x.tracks) : [])
...(release.subReleases.length
? release.subReleases.flatMap((x) => x.tracks)
: []),
];

return {
trackCount: allTracks.length,
duration: formatDuration(allTracks.reduce((memo, { duration }) => memo += duration, 0))
}
duration: formatDuration(
allTracks.reduce((memo, { duration }) => (memo += duration), 0)
),
};
}

export function formatDuration(duration: number) {
Expand All @@ -112,37 +137,52 @@ export function formatDuration(duration: number) {
return formatted;
}


export function isEmpty(obj: object) {
return Object.keys(obj).length === 0;
}

export function sortReleasesByTypeAndYear(releases: ReleaseWithArtist[]) {
return releaseTypes
.flatMap(type => releases
.filter(x => x.type === type)
type Sortable = number | string | boolean | Date;

export function sortBy(key: string, order: "asc" | "desc" = "asc") {
return (a: Record<string, Sortable>, b: Record<string, Sortable>) =>
(a[key] > b[key] ? 1 : -1) * (order === "asc" ? 1 : -1);
}

export function sortReleasesByTypeAndYear(
releases: Pick<Release, "type" | "year" | "title">[]
) {
return releaseTypes.flatMap((type) =>
releases
.filter((x) => x.type === type)
.sort((a, b) => {
if (!a.year || !b.year) {
return 0;
}
if (a.year === b.year) {
return a.title.toLowerCase() > b.title.toLowerCase() ? 1 : -1;
}
return Math.sign(a.year - b.year)
return Math.sign(a.year - b.year);
})
)
);
}

export function getReleaseWithTracklistHeight(release: ReleaseWithArtistAndTracksAndSubreleases): number {
const maxTracks = Math.max(...[
release,
...release.subReleases
].map(x => x.tracks?.length));
export function getReleaseWithTracklistHeight(
release: ReleaseWithArtistAndTracksAndSubreleases
): number {
const maxTracks = Math.max(
...[release, ...release.subReleases].map((x) => x.tracks?.length)
);
// cover height + margin + gap + tracks
return 128 + 16 + 4 + (release.subReleases.length ? 32 : 0) + (maxTracks * (40 + 4));
return (
128 + 16 + 4 + (release.subReleases.length ? 32 : 0) + maxTracks * (40 + 4)
);
}

export async function mapSeries<T, U>(array: T[], callback: (item: T, index: number) => Promise<U>, interval = 0): Promise<U[]> {
export async function mapSeries<T, U>(
array: T[],
callback: (item: T, index: number) => Promise<U>,
interval = 0
): Promise<U[]> {
if (!array.length) {
return [];
}
Expand All @@ -162,16 +202,19 @@ export async function mapSeries<T, U>(array: T[], callback: (item: T, index: num
});
}

export function sortByQueryPosition<K extends string, T extends {
[Property in K]: string;
}>(query: string, key: keyof T, a: T, b: T) {
export function sortByQueryPosition<
K extends string,
T extends {
[Property in K]: string;
},
>(query: string, key: keyof T, a: T, b: T) {
const posA = a[key].toLowerCase().indexOf(query.toLowerCase());
const posB = b[key].toLowerCase().indexOf(query.toLowerCase());
return Math.sign(posA - posB);
}

export function wait(ms = 100) {
return new Promise(resolve => setTimeout(resolve, ms));
return new Promise((resolve) => setTimeout(resolve, ms));
}

export function getReleaseContextMenuParams({
Expand Down Expand Up @@ -202,7 +245,7 @@ export function refreshCovers(releases: Release[]) {
const seed = `${Math.random() * 100000}`.slice(0, 5);
element.src = `${getCover(hash)}?_=${seed}`;
});
})
});
}

export function withStopPropagation(handler: (event: MouseEvent) => void) {
Expand All @@ -212,13 +255,19 @@ export function withStopPropagation(handler: (event: MouseEvent) => void) {
};
}

type Item = CollectionWithReleases | ArtistWithReleases | ReleaseWithArtistAndSubreleases | GroupWithArtists;
type Item =
| CollectionWithReleases
| ArtistWithReleases
| ReleaseWithArtistAndSubreleases
| GroupWithArtists;

export function getCoverRelease(item: Item): ReleaseWithArtistAndSubreleases | null {
export function getCoverRelease(
item: Item
): ReleaseWithArtistAndSubreleases | null {
if (item._type === "release") {
return item;
}
if (item._type === 'group') {
if (item._type === "group") {
const coverArtist = item.coverArtist || item.artists[0];
return coverArtist ? getCoverRelease(coverArtist) : null;
}
Expand All @@ -231,24 +280,41 @@ type NewReleaseInfo = {
newTitle: string;
newType: ReleaseType;
newYear: number;
}

type EditReleaseParam = (
Pick<Release, 'id' | 'path' | 'hash' | 'title' | 'artist_id' | 'year' | 'type' | 'discTitle' | 'discNumber'>
& NewReleaseInfo
);
};

export function didReleaseInfoChange(infos: EditReleaseParam[], excludeDiscTitle?: boolean) {
return infos.some(
(x: EditReleaseParam) => ['path', 'title', 'type', 'year', 'discTitle'].slice(0, excludeDiscTitle ? -1 : undefined)
.some(key => x[key as keyof EditReleaseParam] !== x[`new${capitalize(key)}` as keyof NewReleaseInfo])
type EditReleaseParam = Pick<
Release,
| "id"
| "path"
| "hash"
| "title"
| "artist_id"
| "year"
| "type"
| "discTitle"
| "discNumber"
> &
NewReleaseInfo;

export function didReleaseInfoChange(
infos: EditReleaseParam[],
excludeDiscTitle?: boolean
) {
return infos.some((x: EditReleaseParam) =>
["path", "title", "type", "year", "discTitle"]
.slice(0, excludeDiscTitle ? -1 : undefined)
.some(
(key) =>
x[key as keyof EditReleaseParam] !==
x[`new${capitalize(key)}` as keyof NewReleaseInfo]
)
);
}

export function withCoverRelease(artist: ArtistWithReleases) {
return {
...artist,
coverRelease: artist.coverRelease || artist.releases[0]
coverRelease: artist.coverRelease || artist.releases[0],
};
}

Expand All @@ -261,7 +327,7 @@ export function normalizeDiacritics(input: string) {
input
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/\((\d+)\)$/, '')
.replace(/\((\d+)\)$/, "")
.trim()
);
}
}
8 changes: 8 additions & 0 deletions src/main/__mocks__/settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { beforeEach } from "vitest";
import { mockReset } from "vitest-mock-extended";

beforeEach(() => {
mockReset(initSettings);
});

export const initSettings = vi.fn();
Loading