Project started 🥂

This commit is contained in:
2026-06-02 01:13:22 +03:00
commit 612d0f0125
146 changed files with 15242 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
import { fetchBaseQuery, type BaseQueryFn, type FetchArgs, type FetchBaseQueryError } from '@reduxjs/toolkit/query/react';
import type { RootState } from '../store';
import { getApiBaseUrl } from '../config/runtime-config';
import { logout, setTokens } from '../store/slices/auth';
const rawBaseQuery = () =>
fetchBaseQuery({
baseUrl: getApiBaseUrl(),
prepareHeaders: (headers, { getState }) => {
const token = (getState() as RootState).auth.accessToken;
if (token) headers.set('Authorization', `Bearer ${token}`);
return headers;
},
});
export const baseQueryWithReauth: BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError> =
async (args, api, extraOptions) => {
let result = await rawBaseQuery()(args, api, extraOptions);
if (result.error?.status === 401) {
const state = api.getState() as RootState;
const refreshToken = state.auth.refreshToken;
if (refreshToken) {
const refreshResult = await rawBaseQuery()({
url: '/auth/refresh',
method: 'POST',
body: { refreshToken },
}, api, extraOptions);
if (refreshResult.data) {
const { accessToken, refreshToken: newRefresh, expiresIn } =
(refreshResult.data as { accessToken: string; refreshToken: string; expiresIn: number });
api.dispatch(setTokens({ accessToken, refreshToken: newRefresh, expiresIn }));
result = await rawBaseQuery()(args, api, extraOptions);
} else {
api.dispatch(logout());
}
} else {
api.dispatch(logout());
}
}
return result;
};
+26
View File
@@ -0,0 +1,26 @@
import { api } from '../index';
import type { User } from '../types';
export const adminApi = api.injectEndpoints({
endpoints: (build) => ({
getUsers: build.query<User[], void>({
query: () => '/admin/users',
providesTags: ['User'],
}),
createUser: build.mutation<User, { username: string; password: string; email?: string; role: 'admin' | 'user' }>({
query: (body) => ({ url: '/admin/users', method: 'POST', body }),
invalidatesTags: ['User'],
}),
updateUser: build.mutation<User, { id: string; role?: 'admin' | 'user'; email?: string }>({
query: ({ id, ...body }) => ({ url: `/admin/users/${id}`, method: 'PATCH', body }),
invalidatesTags: ['User'],
}),
deleteUser: build.mutation<void, string>({
query: (id) => ({ url: `/admin/users/${id}`, method: 'DELETE' }),
invalidatesTags: ['User'],
}),
}),
overrideExisting: false,
});
export const { useGetUsersQuery, useCreateUserMutation, useUpdateUserMutation, useDeleteUserMutation } = adminApi;
+23
View File
@@ -0,0 +1,23 @@
import { api } from '../index';
import type { LoginRequest, LoginResponse } from '../types';
export const authApi = api.injectEndpoints({
endpoints: (build) => ({
login: build.mutation<LoginResponse, LoginRequest>({
query: (body) => ({ url: '/auth/login', method: 'POST', body }),
}),
logout: build.mutation<void, void>({
query: () => ({ url: '/auth/logout', method: 'POST' }),
}),
refreshToken: build.mutation<{ accessToken: string; refreshToken: string; expiresIn: number }, { refreshToken: string }>({
query: (body) => ({ url: '/auth/refresh', method: 'POST', body }),
}),
me: build.query<import('../types').User, void>({
query: () => '/auth/me',
providesTags: ['User'],
}),
}),
overrideExisting: false,
});
export const { useLoginMutation, useLogoutMutation, useMeQuery } = authApi;
+26
View File
@@ -0,0 +1,26 @@
import { api } from '../index';
import type { DownloadJob } from '../types';
export const downloadsApi = api.injectEndpoints({
endpoints: (build) => ({
getDownloads: build.query<DownloadJob[], { status?: DownloadJob['status'] } | void>({
query: (params) => ({ url: '/downloads', params: params ?? {} }),
providesTags: ['Download'],
}),
addDownload: build.mutation<DownloadJob, { url: string; metadata?: { title?: string; artist?: string; album?: string } }>({
query: (body) => ({ url: '/downloads', method: 'POST', body }),
invalidatesTags: ['Download'],
}),
cancelDownload: build.mutation<void, string>({
query: (id) => ({ url: `/downloads/${id}`, method: 'DELETE' }),
invalidatesTags: ['Download'],
}),
retryDownload: build.mutation<DownloadJob, string>({
query: (id) => ({ url: `/downloads/${id}/retry`, method: 'POST' }),
invalidatesTags: ['Download'],
}),
}),
overrideExisting: false,
});
export const { useGetDownloadsQuery, useAddDownloadMutation, useCancelDownloadMutation, useRetryDownloadMutation } = downloadsApi;
+65
View File
@@ -0,0 +1,65 @@
import { api } from '../index';
import type { Track, Album, Artist, PaginatedResponse, LibraryFilters } from '../types';
export const libraryApi = api.injectEndpoints({
endpoints: (build) => ({
getTracks: build.query<PaginatedResponse<Track>, LibraryFilters | void>({
query: (filters) => ({ url: '/library/tracks', params: filters ?? {} }),
providesTags: (result) =>
result
? [...result.items.map(({ id }) => ({ type: 'Track' as const, id })), 'Track']
: ['Track'],
}),
getTrack: build.query<Track, string>({
query: (id) => `/library/tracks/${id}`,
providesTags: (_r, _e, id) => [{ type: 'Track', id }],
}),
getAlbums: build.query<PaginatedResponse<Album>, { search?: string; artistId?: string; page?: number; pageSize?: number } | void>({
query: (params) => ({ url: '/library/albums', params: params ?? {} }),
providesTags: (result) =>
result
? [...result.items.map(({ id }) => ({ type: 'Album' as const, id })), 'Album']
: ['Album'],
}),
getAlbum: build.query<Album, string>({
query: (id) => `/library/albums/${id}`,
providesTags: (_r, _e, id) => [{ type: 'Album', id }],
}),
getAlbumTracks: build.query<Track[], string>({
query: (albumId) => `/library/albums/${albumId}/tracks`,
providesTags: (_r, _e, albumId) => [{ type: 'Album', id: albumId }, 'Track'],
}),
getArtists: build.query<PaginatedResponse<Artist>, { search?: string; page?: number; pageSize?: number } | void>({
query: (params) => ({ url: '/library/artists', params: params ?? {} }),
providesTags: (result) =>
result
? [...result.items.map(({ id }) => ({ type: 'Artist' as const, id })), 'Artist']
: ['Artist'],
}),
getArtist: build.query<Artist, string>({
query: (id) => `/library/artists/${id}`,
providesTags: (_r, _e, id) => [{ type: 'Artist', id }],
}),
getArtistAlbums: build.query<Album[], string>({
query: (artistId) => `/library/artists/${artistId}/albums`,
providesTags: (_r, _e, artistId) => [{ type: 'Artist', id: artistId }, 'Album'],
}),
searchLibrary: build.query<{ tracks: Track[]; albums: Album[]; artists: Artist[] }, string>({
query: (q) => ({ url: '/library/search', params: { q } }),
providesTags: ['Track', 'Album', 'Artist'],
}),
}),
overrideExisting: false,
});
export const {
useGetTracksQuery,
useGetTrackQuery,
useGetAlbumsQuery,
useGetAlbumQuery,
useGetAlbumTracksQuery,
useGetArtistsQuery,
useGetArtistQuery,
useGetArtistAlbumsQuery,
useSearchLibraryQuery,
} = libraryApi;
+17
View File
@@ -0,0 +1,17 @@
import { api } from '../index';
export const likesApi = api.injectEndpoints({
endpoints: (build) => ({
likeTrack: build.mutation<void, string>({
query: (trackId) => ({ url: `/likes/tracks/${trackId}`, method: 'PUT' }),
invalidatesTags: (_r, _e, id) => ['Like', { type: 'Track', id }],
}),
unlikeTrack: build.mutation<void, string>({
query: (trackId) => ({ url: `/likes/tracks/${trackId}`, method: 'DELETE' }),
invalidatesTags: (_r, _e, id) => ['Like', { type: 'Track', id }],
}),
}),
overrideExisting: false,
});
export const { useLikeTrackMutation, useUnlikeTrackMutation } = likesApi;
+51
View File
@@ -0,0 +1,51 @@
import { api } from '../index';
import type { Playlist, PlaylistTrack, PaginatedResponse } from '../types';
export const playlistsApi = api.injectEndpoints({
endpoints: (build) => ({
getPlaylists: build.query<PaginatedResponse<Playlist>, void>({
query: () => '/playlists',
providesTags: ['Playlist'],
}),
getPlaylist: build.query<Playlist, string>({
query: (id) => `/playlists/${id}`,
providesTags: (_r, _e, id) => [{ type: 'Playlist', id }],
}),
getPlaylistTracks: build.query<PlaylistTrack[], string>({
query: (id) => `/playlists/${id}/tracks`,
providesTags: (_r, _e, id) => [{ type: 'Playlist', id }, 'Track'],
}),
createPlaylist: build.mutation<Playlist, { name: string; description?: string; isPublic?: boolean }>({
query: (body) => ({ url: '/playlists', method: 'POST', body }),
invalidatesTags: ['Playlist'],
}),
updatePlaylist: build.mutation<Playlist, { id: string; name?: string; description?: string; isPublic?: boolean }>({
query: ({ id, ...body }) => ({ url: `/playlists/${id}`, method: 'PATCH', body }),
invalidatesTags: (_r, _e, { id }) => [{ type: 'Playlist', id }],
}),
deletePlaylist: build.mutation<void, string>({
query: (id) => ({ url: `/playlists/${id}`, method: 'DELETE' }),
invalidatesTags: ['Playlist'],
}),
addTrackToPlaylist: build.mutation<void, { playlistId: string; trackId: string }>({
query: ({ playlistId, trackId }) => ({ url: `/playlists/${playlistId}/tracks`, method: 'POST', body: { trackId } }),
invalidatesTags: (_r, _e, { playlistId }) => [{ type: 'Playlist', id: playlistId }],
}),
removeTrackFromPlaylist: build.mutation<void, { playlistId: string; trackId: string; position: number }>({
query: ({ playlistId, position }) => ({ url: `/playlists/${playlistId}/tracks/${position}`, method: 'DELETE' }),
invalidatesTags: (_r, _e, { playlistId }) => [{ type: 'Playlist', id: playlistId }],
}),
}),
overrideExisting: false,
});
export const {
useGetPlaylistsQuery,
useGetPlaylistQuery,
useGetPlaylistTracksQuery,
useCreatePlaylistMutation,
useUpdatePlaylistMutation,
useDeletePlaylistMutation,
useAddTrackToPlaylistMutation,
useRemoveTrackFromPlaylistMutation,
} = playlistsApi;
+22
View File
@@ -0,0 +1,22 @@
import { api } from '../index';
import type { StorageStats } from '../types';
export const storageApi = api.injectEndpoints({
endpoints: (build) => ({
getStorageStats: build.query<StorageStats, void>({
query: () => '/storage/stats',
providesTags: ['Storage'],
}),
scanStorage: build.mutation<{ jobId: string }, void>({
query: () => ({ url: '/storage/scan', method: 'POST' }),
invalidatesTags: ['Storage', 'Track', 'Album', 'Artist'],
}),
deleteTrackFile: build.mutation<void, string>({
query: (trackId) => ({ url: `/storage/tracks/${trackId}`, method: 'DELETE' }),
invalidatesTags: ['Storage', { type: 'Track', id: undefined }],
}),
}),
overrideExisting: false,
});
export const { useGetStorageStatsQuery, useScanStorageMutation, useDeleteTrackFileMutation } = storageApi;
+13
View File
@@ -0,0 +1,13 @@
import { getApiBaseUrl } from '../../config/runtime-config';
export function getStreamUrl(trackId: string, token: string): string {
const base = getApiBaseUrl();
return `${base}/streaming/tracks/${trackId}?token=${encodeURIComponent(token)}`;
}
export function getCoverUrl(artUrl: string | undefined): string | undefined {
if (!artUrl) return undefined;
if (artUrl.startsWith('http://') || artUrl.startsWith('https://')) return artUrl;
const base = getApiBaseUrl();
return `${base}${artUrl}`;
}
+19
View File
@@ -0,0 +1,19 @@
import { api } from '../index';
import type { Track } from '../types';
export const uploadApi = api.injectEndpoints({
endpoints: (build) => ({
uploadTrack: build.mutation<Track, FormData>({
query: (formData) => ({
url: '/upload',
method: 'POST',
body: formData,
formData: true,
}),
invalidatesTags: ['Track', 'Album', 'Artist', 'Storage'],
}),
}),
overrideExisting: false,
});
export const { useUploadTrackMutation } = uploadApi;
+9
View File
@@ -0,0 +1,9 @@
import { createApi } from '@reduxjs/toolkit/query/react';
import { baseQueryWithReauth } from './baseQuery';
export const api = createApi({
reducerPath: 'api',
baseQuery: baseQueryWithReauth,
tagTypes: ['Track', 'Album', 'Artist', 'Playlist', 'Download', 'Like', 'User', 'Storage'],
endpoints: () => ({}),
});
+132
View File
@@ -0,0 +1,132 @@
export type TrackAvailability = 'server' | 'downloading' | 'error' | 'missing';
export interface Track {
id: string;
title: string;
artistId: string;
artistName: string;
albumId: string;
albumTitle: string;
albumArtUrl?: string;
durationMs: number;
trackNumber?: number;
discNumber?: number;
year?: number;
genre?: string;
availability: TrackAvailability;
fileSize?: number;
format?: string;
bitrate?: number;
liked: boolean;
}
export interface Album {
id: string;
title: string;
artistId: string;
artistName: string;
artUrl?: string;
year?: number;
trackCount: number;
totalDurationMs: number;
genre?: string;
}
export interface Artist {
id: string;
name: string;
artUrl?: string;
albumCount: number;
trackCount: number;
}
export interface Playlist {
id: string;
name: string;
description?: string;
ownerId: string;
trackCount: number;
totalDurationMs: number;
artUrl?: string;
isPublic: boolean;
createdAt: string;
updatedAt: string;
}
export interface PlaylistTrack extends Track {
position: number;
addedAt: string;
}
export interface DownloadJob {
id: string;
url: string;
title?: string;
artist?: string;
album?: string;
status: 'queued' | 'downloading' | 'processing' | 'done' | 'error';
progress: number;
errorMessage?: string;
trackId?: string;
createdAt: string;
updatedAt: string;
}
export interface StorageStats {
totalBytes: number;
usedBytes: number;
trackCount: number;
albumCount: number;
artistCount: number;
}
export interface User {
id: string;
username: string;
email?: string;
role: 'admin' | 'user';
createdAt: string;
lastActiveAt?: string;
}
export interface AuthTokens {
accessToken: string;
refreshToken: string;
expiresIn: number;
}
export interface LoginRequest {
username: string;
password: string;
}
export interface LoginResponse {
user: User;
tokens: AuthTokens;
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
pageSize: number;
hasMore: boolean;
}
export interface LibraryFilters {
search?: string;
genre?: string;
artistId?: string;
albumId?: string;
liked?: boolean;
page?: number;
pageSize?: number;
sortBy?: 'title' | 'artist' | 'album' | 'year' | 'dateAdded';
sortOrder?: 'asc' | 'desc';
}
export interface ApiError {
status: number;
message: string;
code?: string;
}