mirror of
https://github.com/aleksilassila/reiverr.git
synced 2026-04-21 16:25:11 +02:00
Initial work on getting series working
This commit is contained in:
@@ -2,7 +2,7 @@ import createClient from 'openapi-fetch';
|
||||
import { log, request } from '$lib/utils';
|
||||
import type { paths } from '$lib/apis/radarr/radarr.generated';
|
||||
import type { components } from '$lib/apis/radarr/radarr.generated';
|
||||
import { fetchTmdbMovie } from '$lib/apis/tmdbApi';
|
||||
import { getTmdbMovie } from '$lib/apis/tmdb/tmdbApi';
|
||||
import { PUBLIC_RADARR_API_KEY, PUBLIC_RADARR_BASE_URL } from '$env/static/public';
|
||||
|
||||
export type RadarrMovie = components['schemas']['MovieResource'];
|
||||
@@ -36,9 +36,9 @@ export const getRadarrMovies = (): Promise<RadarrMovie[]> =>
|
||||
params: {}
|
||||
}).then((r) => r.data || []);
|
||||
|
||||
export const requestRadarrMovie = () => request(getRadarrMovie);
|
||||
export const requestRadarrMovie = () => request(getRadarrMovieByTmdbId);
|
||||
|
||||
export const getRadarrMovie = (tmdbId: string): Promise<RadarrMovie | undefined> =>
|
||||
export const getRadarrMovieByTmdbId = (tmdbId: string): Promise<RadarrMovie | undefined> =>
|
||||
RadarrApi.get('/api/v3/movie', {
|
||||
params: {
|
||||
query: {
|
||||
@@ -47,11 +47,9 @@ export const getRadarrMovie = (tmdbId: string): Promise<RadarrMovie | undefined>
|
||||
}
|
||||
}).then((r) => r.data?.find((m) => (m.tmdbId as any) == tmdbId));
|
||||
|
||||
export const requestAddRadarrMovie = () => request(addRadarrMovie);
|
||||
|
||||
export const addRadarrMovie = async (tmdbId: string) => {
|
||||
const tmdbMovie = await fetchTmdbMovie(tmdbId);
|
||||
const radarrMovie = await getMovieByTmdbIdByTmdbId(tmdbId);
|
||||
const tmdbMovie = await getTmdbMovie(tmdbId);
|
||||
const radarrMovie = await lookupRadarrMovieByTmdbId(tmdbId);
|
||||
console.log('fetched movies', tmdbMovie, radarrMovie);
|
||||
|
||||
if (radarrMovie?.id) throw new Error('Movie already exists');
|
||||
@@ -94,15 +92,11 @@ export const cancelDownloadRadarrMovie = async (downloadId: number) => {
|
||||
return deleteResponse.response.ok;
|
||||
};
|
||||
|
||||
export const requestRadarrReleases = () => request(fetchRadarrReleases);
|
||||
|
||||
export const fetchRadarrReleases = (movieId: string) =>
|
||||
RadarrApi.get('/api/v3/release', { params: { query: { movieId: Number(movieId) } } }).then(
|
||||
(r) => r.data
|
||||
);
|
||||
|
||||
export const requestDownloadRadarrMovie = () => request(downloadRadarrMovie);
|
||||
|
||||
export const downloadRadarrMovie = (guid: string) =>
|
||||
RadarrApi.post('/api/v3/release', {
|
||||
params: {},
|
||||
@@ -121,8 +115,6 @@ export const deleteRadarrMovie = (id: number) =>
|
||||
}
|
||||
}).then((res) => res.response.ok);
|
||||
|
||||
export const requestRadarrQueuedById = () => request(getRadarrDownload);
|
||||
|
||||
export const getRadarrDownloads = (): Promise<RadarrDownload[]> =>
|
||||
RadarrApi.get('/api/v3/queue', {
|
||||
params: {
|
||||
@@ -132,10 +124,10 @@ export const getRadarrDownloads = (): Promise<RadarrDownload[]> =>
|
||||
}
|
||||
}).then((r) => (r.data?.records?.filter((record) => record.movie) as RadarrDownload[]) || []);
|
||||
|
||||
export const getRadarrDownload = (id: string) =>
|
||||
getRadarrDownloads().then((downloads) => downloads.find((d) => d.movie.id === Number(id)));
|
||||
export const getRadarrDownloadById = (radarrId: number) =>
|
||||
getRadarrDownloads().then((downloads) => downloads.find((d) => d.movie.id === radarrId));
|
||||
|
||||
const getMovieByTmdbIdByTmdbId = (tmdbId: string) =>
|
||||
const lookupRadarrMovieByTmdbId = (tmdbId: string) =>
|
||||
RadarrApi.get('/api/v3/movie/lookup/tmdb', {
|
||||
params: {
|
||||
query: {
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import createClient from 'openapi-fetch';
|
||||
import type { paths } from '$lib/apis/sonarr/sonarr.generated';
|
||||
import type { components, paths } from '$lib/apis/sonarr/sonarr.generated';
|
||||
import { PUBLIC_SONARR_API_KEY, PUBLIC_SONARR_BASE_URL } from '$env/static/public';
|
||||
import type { SeriesResource } from '$lib/types';
|
||||
|
||||
export type SonarrSeries = components['schemas']['SeriesResource'];
|
||||
// export type MovieFileResource = components['schemas']['MovieFileResource'];
|
||||
export type SonarrReleaseResource = components['schemas']['ReleaseResource'];
|
||||
export type SonarrDownload = components['schemas']['QueueResource'] & { series: SonarrSeries };
|
||||
export type DiskSpaceInfo = components['schemas']['DiskSpaceResource'];
|
||||
|
||||
export const SonarrApi = createClient<paths>({
|
||||
baseUrl: PUBLIC_SONARR_BASE_URL,
|
||||
@@ -8,3 +15,32 @@ export const SonarrApi = createClient<paths>({
|
||||
'X-Api-Key': PUBLIC_SONARR_API_KEY
|
||||
}
|
||||
});
|
||||
|
||||
export const getSonarrSeries = (): Promise<SeriesResource[]> =>
|
||||
SonarrApi.get('/api/v3/series', {
|
||||
params: {}
|
||||
}).then((r) => r.data || []);
|
||||
|
||||
export const getSonarrSeriesByTvdbId = (tvdbId: number): Promise<SeriesResource | undefined> =>
|
||||
SonarrApi.get('/api/v3/series', {
|
||||
params: {
|
||||
query: {
|
||||
tvdbId: tvdbId
|
||||
}
|
||||
}
|
||||
}).then((r) => r.data?.find((m) => m.tvdbId === tvdbId));
|
||||
|
||||
export const getSonarrDownloads = (): Promise<SonarrDownload[]> =>
|
||||
SonarrApi.get('/api/v3/queue', {
|
||||
params: {
|
||||
query: {
|
||||
includeSeries: true
|
||||
}
|
||||
}
|
||||
}).then((r) => (r.data?.records?.filter((record) => record.series) as SonarrDownload[]) || []);
|
||||
|
||||
export const getRadarrDownloadById = (sonarrId: number) =>
|
||||
getSonarrDownloads().then((downloads) => downloads.find((d) => d.series.id === sonarrId));
|
||||
|
||||
export const getDiskSpace = (): Promise<DiskSpaceInfo[]> =>
|
||||
SonarrApi.get('/api/v3/diskspace', {}).then((d) => d.data || []);
|
||||
|
||||
19910
src/lib/apis/tmdb/tmdb.generated.d.ts
vendored
Normal file
19910
src/lib/apis/tmdb/tmdb.generated.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,54 @@
|
||||
import axios from 'axios';
|
||||
import { PUBLIC_TMDB_API_KEY } from '$env/static/public';
|
||||
import { request } from '$lib/utils';
|
||||
import type { paths } from './tmdb.generated';
|
||||
import createClient from 'openapi-fetch';
|
||||
|
||||
export const TmdbApiOpen = createClient<paths>({
|
||||
baseUrl: 'https://api.themoviedb.org',
|
||||
headers: {
|
||||
Authorization: `Bearer ${PUBLIC_TMDB_API_KEY}`
|
||||
}
|
||||
});
|
||||
|
||||
export const getTmdbMovie = async (tmdbId: number) =>
|
||||
await TmdbApiOpen.get('/3/movie/{movie_id}', {
|
||||
params: {
|
||||
path: {
|
||||
movie_id: tmdbId
|
||||
}
|
||||
}
|
||||
}).then((res) => res.data);
|
||||
|
||||
export const getTmdbPopularMovies = () =>
|
||||
TmdbApiOpen.get('/3/movie/popular', {
|
||||
params: {}
|
||||
}).then((res) => res.data?.results || []);
|
||||
|
||||
export const getTmdbIdFromTvdbId = async (tvdbId: number) =>
|
||||
TmdbApiOpen.get('/3/find/{external_id}', {
|
||||
params: {
|
||||
path: {
|
||||
external_id: String(tvdbId)
|
||||
},
|
||||
query: {
|
||||
external_source: 'tvdb_id'
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((res) => res.data?.tv_results?.[0])
|
||||
.then((res: any) => res?.id as number | undefined);
|
||||
|
||||
export const getTmdbSeriesImages = async (tmdbId: number) =>
|
||||
TmdbApiOpen.get('/3/tv/{series_id}/images', {
|
||||
params: {
|
||||
path: {
|
||||
series_id: tmdbId
|
||||
}
|
||||
}
|
||||
}).then((res) => res.data);
|
||||
|
||||
// Deprecated hereon forward
|
||||
|
||||
export const TmdbApi = axios.create({
|
||||
baseURL: 'https://api.themoviedb.org/3',
|
||||
@@ -1,38 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { TmdbMovie } from '$lib/apis/tmdbApi';
|
||||
import { onMount } from 'svelte';
|
||||
import { fetchTmdbMovie, fetchTmdbMovieImages } from '$lib/apis/tmdbApi';
|
||||
import { TMDB_IMAGES } from '$lib/constants';
|
||||
import { getJellyfinItemByTmdbId } from '$lib/apis/jellyfin/jellyfinApi';
|
||||
import CardPlaceholder from './CardPlaceholder.svelte';
|
||||
import Card from './Card.svelte';
|
||||
|
||||
export let tmdbId: string;
|
||||
|
||||
export let type: 'default' | 'download' | 'in-library' = 'default';
|
||||
|
||||
let tmdbMoviePromise: Promise<TmdbMovie>;
|
||||
let jellyfinItemPromise;
|
||||
let radarrItemPromise;
|
||||
let backdropUrlPromise;
|
||||
|
||||
onMount(async () => {
|
||||
if (!tmdbId) throw new Error('No tmdbId provided');
|
||||
|
||||
backdropUrlPromise = fetchTmdbMovieImages(String(tmdbId)).then(
|
||||
(r) => TMDB_IMAGES + r.backdrops.filter((b) => b.iso_639_1 === 'en')[0].file_path
|
||||
);
|
||||
tmdbMoviePromise = fetchTmdbMovie(tmdbId);
|
||||
if (type === 'in-library') jellyfinItemPromise = getJellyfinItemByTmdbId(tmdbId);
|
||||
if (type === 'download')
|
||||
radarrItemPromise = fetch(`/movie/${tmdbId}/radarr`).then((r) => r.json());
|
||||
});
|
||||
</script>
|
||||
|
||||
{#await Promise.all([tmdbMoviePromise, jellyfinItemPromise, backdropUrlPromise])}
|
||||
<CardPlaceholder {...$$restProps} />
|
||||
{:then [tmdbMovie, jellyfinItem, backdropUrl]}
|
||||
<Card {...$$restProps} {tmdbMovie} {backdropUrl} {jellyfinItem} />
|
||||
{:catch err}
|
||||
Error
|
||||
{/await}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { RadarrMovie } from '$lib/apis/radarr/radarrApi';
|
||||
import { fetchTmdbMovieImages } from '$lib/apis/tmdbApi';
|
||||
import type { TmdbMovie } from '$lib/apis/tmdbApi';
|
||||
import { fetchTmdbMovieImages } from '$lib/apis/tmdb/tmdbApi';
|
||||
import type { TmdbMovie } from '$lib/apis/tmdb/tmdbApi';
|
||||
|
||||
export interface CardProps {
|
||||
tmdbId: string;
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import ModalContent from '../Modal/ModalContent.svelte';
|
||||
import { Cross1, Cross2, MagnifyingGlass } from 'radix-icons-svelte';
|
||||
import IconButton from '../IconButton.svelte';
|
||||
import { TmdbApi } from '$lib/apis/tmdbApi';
|
||||
import type { MultiSearchResponse } from '$lib/apis/tmdbApi';
|
||||
import { TmdbApi } from '$lib/apis/tmdb/tmdbApi';
|
||||
import type { MultiSearchResponse } from '$lib/apis/tmdb/tmdbApi';
|
||||
import { TMDB_IMAGES } from '$lib/constants';
|
||||
import ModalHeader from '../Modal/ModalHeader.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { TmdbApi } from '$lib/apis/tmdbApi';
|
||||
import type { TmdbMovie } from '$lib/apis/tmdbApi';
|
||||
import { TmdbApi } from '$lib/apis/tmdb/tmdbApi';
|
||||
import type { TmdbMovie } from '$lib/apis/tmdb/tmdbApi';
|
||||
import { getContext, onMount } from 'svelte';
|
||||
import { TMDB_IMAGES } from '$lib/constants';
|
||||
import { formatMinutesToTime } from '$lib/utils';
|
||||
|
||||
@@ -10,14 +10,14 @@
|
||||
|
||||
let isRequestModalVisible = false;
|
||||
export let tmdbId: string;
|
||||
export let jellyfinStreamDisabled;
|
||||
export let openJellyfinStream;
|
||||
export let jellyfinStreamDisabled: boolean;
|
||||
export let openJellyfinStream: () => void;
|
||||
|
||||
let response;
|
||||
let response: Promise<any>;
|
||||
|
||||
const headerStyle = 'uppercase tracking-widest font-bold';
|
||||
|
||||
let refetchTimeout;
|
||||
let refetchTimeout: NodeJS.Timeout;
|
||||
let isRefetching = false;
|
||||
async function refetch() {
|
||||
console.log('refetching...');
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
import { fade, fly } from 'svelte/transition';
|
||||
import { TMDB_IMAGES } from '$lib/constants';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import type { CastMember, TmdbMovie, Video } from '$lib/apis/tmdbApi';
|
||||
import { fetchTmdbMovieCredits, fetchTmdbMovieVideos } from '$lib/apis/tmdbApi';
|
||||
import type { CastMember, TmdbMovie, Video } from '$lib/apis/tmdb/tmdbApi';
|
||||
import { fetchTmdbMovieCredits, fetchTmdbMovieVideos } from '$lib/apis/tmdb/tmdbApi';
|
||||
import LibraryDetails from './LibraryDetails.svelte';
|
||||
import { getJellyfinItemByTmdbId } from '$lib/apis/jellyfin/jellyfinApi';
|
||||
import HeightHider from '../HeightHider.svelte';
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import StatsPlaceholder from './StatsPlaceholder.svelte';
|
||||
import StatsContainer from './StatsContainer.svelte';
|
||||
import SonarrIcon from '../svgs/SonarrIcon.svelte';
|
||||
import { PUBLIC_SONARR_BASE_URL } from '$env/static/public';
|
||||
|
||||
export let large = false;
|
||||
|
||||
@@ -25,6 +26,7 @@
|
||||
{large}
|
||||
title="Sonarr"
|
||||
subtitle="Shows Provider"
|
||||
href={PUBLIC_SONARR_BASE_URL}
|
||||
stats={[
|
||||
{ title: 'Movies', value: String(moviesAmount) },
|
||||
{ title: 'Space Taken', value: formatSize(120_000_000_000) },
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import { getJellyfinContinueWatching, type JellyfinItem } from '$lib/apis/jellyfin/jellyfinApi';
|
||||
import {
|
||||
getRadarrDownloads,
|
||||
getRadarrMovies,
|
||||
RadarrApi,
|
||||
type RadarrDownload,
|
||||
type RadarrMovie
|
||||
} from '$lib/apis/radarr/radarrApi';
|
||||
import type { CardProps } from '$lib/components/Card/card';
|
||||
import {
|
||||
getSonarrDownloads,
|
||||
getSonarrSeries,
|
||||
type SonarrDownload,
|
||||
type SonarrSeries
|
||||
} from '$lib/apis/sonarr/sonarrApi';
|
||||
import {
|
||||
fetchTmdbMovieImages,
|
||||
getTmdbIdFromTvdbId,
|
||||
getTmdbSeriesImages
|
||||
} from '$lib/apis/tmdb/tmdbApi';
|
||||
import { writable } from 'svelte/store';
|
||||
import { fetchCardProps } from '$lib/components/Card/card';
|
||||
import { fetchTmdbMovieImages } from '$lib/apis/tmdbApi';
|
||||
import { getJellyfinContinueWatching, type JellyfinItem } from '$lib/apis/jellyfin/jellyfinApi';
|
||||
|
||||
export interface PlayableRadarrMovie extends RadarrMovie {
|
||||
interface PlayableItem {
|
||||
cardBackdropUrl: string;
|
||||
download?: {
|
||||
progress: number;
|
||||
@@ -23,15 +30,27 @@ export interface PlayableRadarrMovie extends RadarrMovie {
|
||||
};
|
||||
}
|
||||
|
||||
export interface PlayableRadarrMovie extends RadarrMovie, PlayableItem {}
|
||||
export interface PlayableSonarrSeries extends SonarrSeries, PlayableItem {
|
||||
tmdbId?: number;
|
||||
}
|
||||
|
||||
export interface Library {
|
||||
movies: PlayableRadarrMovie[];
|
||||
totalMovies: number;
|
||||
getItem: (tmdbId: number) => PlayableRadarrMovie | undefined;
|
||||
series: PlayableSonarrSeries[];
|
||||
totalSeries: number;
|
||||
getMovie: (tmdbId: number) => PlayableRadarrMovie | undefined;
|
||||
getSeries: (tmdbId: number) => PlayableSonarrSeries | undefined;
|
||||
}
|
||||
|
||||
async function getLibrary(): Promise<Library> {
|
||||
const radarrMoviesPromise = getRadarrMovies();
|
||||
const radarrDownloadsPromise = getRadarrDownloads();
|
||||
|
||||
const sonarrSeriesPromise = getSonarrSeries();
|
||||
const sonarrDownloadsPromise = getSonarrDownloads();
|
||||
|
||||
const continueWatchingPromise = getJellyfinContinueWatching();
|
||||
|
||||
const movies: PlayableRadarrMovie[] = await radarrMoviesPromise.then(async (radarrMovies) => {
|
||||
@@ -41,10 +60,20 @@ async function getLibrary(): Promise<Library> {
|
||||
return getLibraryMovies(radarrMovies, radarrDownloads, continueWatching);
|
||||
});
|
||||
|
||||
const series: PlayableSonarrSeries[] = await sonarrSeriesPromise.then(async (sonarrSeries) => {
|
||||
const sonarrDownloads = await sonarrDownloadsPromise;
|
||||
const continueWatching = await continueWatchingPromise;
|
||||
|
||||
return getLibrarySeries(sonarrSeries, sonarrDownloads, continueWatching);
|
||||
});
|
||||
|
||||
return {
|
||||
movies,
|
||||
totalMovies: movies?.length || 0,
|
||||
getItem: (tmdbId: number) => movies.find((m) => m.tmdbId === tmdbId)
|
||||
series,
|
||||
totalSeries: series?.length || 0,
|
||||
getMovie: (tmdbId: number) => movies.find((m) => m.tmdbId === tmdbId),
|
||||
getSeries: (tmdbId: number) => series.find((s) => s.tmdbId === tmdbId)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -61,12 +90,11 @@ async function getLibraryMovies(
|
||||
(i) => i.ProviderIds?.Tmdb === String(m.tmdbId)
|
||||
);
|
||||
|
||||
const downloadProgress = radarrDownload
|
||||
? radarrDownload.sizeleft && radarrDownload.size
|
||||
const downloadProgress =
|
||||
radarrDownload?.sizeleft && radarrDownload?.size
|
||||
? ((radarrDownload.size - radarrDownload.sizeleft) / radarrDownload.size) * 100
|
||||
: 0
|
||||
: undefined;
|
||||
const completionTime = radarrDownload ? radarrDownload.estimatedCompletionTime : undefined;
|
||||
: undefined;
|
||||
const completionTime = radarrDownload?.estimatedCompletionTime || undefined;
|
||||
const download =
|
||||
downloadProgress && completionTime
|
||||
? { progress: downloadProgress, completionTime }
|
||||
@@ -80,12 +108,12 @@ async function getLibraryMovies(
|
||||
length && watchingProgress ? { length, progress: watchingProgress } : undefined;
|
||||
|
||||
const backdropUrl = await fetchTmdbMovieImages(String(m.tmdbId)).then(
|
||||
(r) => r.backdrops.filter((b) => b.iso_639_1 === 'en')[0].file_path
|
||||
(r) => r.backdrops.find((b) => b.iso_639_1 === 'en')?.file_path
|
||||
);
|
||||
|
||||
return {
|
||||
...m,
|
||||
cardBackdropUrl: backdropUrl,
|
||||
cardBackdropUrl: backdropUrl || '',
|
||||
download,
|
||||
continueWatching
|
||||
};
|
||||
@@ -93,3 +121,51 @@ async function getLibraryMovies(
|
||||
|
||||
return await Promise.all(playableMoviesPromises);
|
||||
}
|
||||
|
||||
async function getLibrarySeries(
|
||||
sonarrSeries: SonarrSeries[],
|
||||
sonarrDownloads: SonarrDownload[],
|
||||
jellyfinContinueWatching: JellyfinItem[]
|
||||
): Promise<PlayableSonarrSeries[]> {
|
||||
const playableSeriesPromises = sonarrSeries.map(async (s) => {
|
||||
const sonarrDownload = sonarrDownloads.find((d) => d.series.tvdbId === s.tvdbId);
|
||||
const jellyfinItem = jellyfinContinueWatching.find(
|
||||
(i) => i.ProviderIds?.TvdbId === String(s.tvdbId)
|
||||
);
|
||||
|
||||
const downloadProgress =
|
||||
sonarrDownload?.sizeleft && sonarrDownload?.size
|
||||
? ((sonarrDownload.size - sonarrDownload.sizeleft) / sonarrDownload.size) * 100
|
||||
: undefined;
|
||||
const completionTime = sonarrDownload?.estimatedCompletionTime || undefined;
|
||||
const download =
|
||||
downloadProgress && completionTime
|
||||
? { progress: downloadProgress, completionTime }
|
||||
: undefined;
|
||||
|
||||
const length = jellyfinItem?.RunTimeTicks
|
||||
? jellyfinItem.RunTimeTicks / 10_000_000 / 60
|
||||
: undefined;
|
||||
const watchingProgress = jellyfinItem?.UserData?.PlayedPercentage;
|
||||
const continueWatching =
|
||||
length && watchingProgress ? { length, progress: watchingProgress } : undefined;
|
||||
|
||||
const tmdbId = s.tvdbId ? await getTmdbIdFromTvdbId(s.tvdbId) : undefined;
|
||||
|
||||
const backdropUrl = tmdbId
|
||||
? await getTmdbSeriesImages(tmdbId).then(
|
||||
(r) => r?.backdrops?.find((b) => b.iso_639_1 === 'en')?.file_path
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...s,
|
||||
tmdbId,
|
||||
cardBackdropUrl: backdropUrl || '',
|
||||
download,
|
||||
continueWatching
|
||||
};
|
||||
});
|
||||
|
||||
return await Promise.all(playableSeriesPromises);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Genre } from '$lib/apis/tmdbApi';
|
||||
import type { Genre } from '$lib/apis/tmdb/tmdbApi';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export function formatMinutesToTime(minutes: number) {
|
||||
|
||||
Reference in New Issue
Block a user