Update favorite/rating endpoints
- Refactor subsonic api endpoints to set the default auth params - The beforeRequest hook is unable to dynamically set existing params
This commit is contained in:
parent
f879171398
commit
cfa4e5e45c
4 changed files with 135 additions and 89 deletions
|
@ -16,7 +16,6 @@ import type {
|
||||||
RawAlbumArtistListResponse,
|
RawAlbumArtistListResponse,
|
||||||
RatingArgs,
|
RatingArgs,
|
||||||
RawRatingResponse,
|
RawRatingResponse,
|
||||||
FavoriteArgs,
|
|
||||||
RawFavoriteResponse,
|
RawFavoriteResponse,
|
||||||
GenreListArgs,
|
GenreListArgs,
|
||||||
RawGenreListResponse,
|
RawGenreListResponse,
|
||||||
|
@ -37,6 +36,7 @@ import type {
|
||||||
RawUpdatePlaylistResponse,
|
RawUpdatePlaylistResponse,
|
||||||
UserListArgs,
|
UserListArgs,
|
||||||
RawUserListResponse,
|
RawUserListResponse,
|
||||||
|
FavoriteArgs,
|
||||||
} from '/@/renderer/api/types';
|
} from '/@/renderer/api/types';
|
||||||
import { subsonicApi } from '/@/renderer/api/subsonic.api';
|
import { subsonicApi } from '/@/renderer/api/subsonic.api';
|
||||||
import { jellyfinApi } from '/@/renderer/api/jellyfin.api';
|
import { jellyfinApi } from '/@/renderer/api/jellyfin.api';
|
||||||
|
@ -237,8 +237,22 @@ const getUserList = async (args: UserListArgs) => {
|
||||||
return (apiController('getUserList') as ControllerEndpoint['getUserList'])?.(args);
|
return (apiController('getUserList') as ControllerEndpoint['getUserList'])?.(args);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createFavorite = async (args: FavoriteArgs) => {
|
||||||
|
return (apiController('createFavorite') as ControllerEndpoint['createFavorite'])?.(args);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteFavorite = async (args: FavoriteArgs) => {
|
||||||
|
return (apiController('deleteFavorite') as ControllerEndpoint['deleteFavorite'])?.(args);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateRating = async (args: RatingArgs) => {
|
||||||
|
return (apiController('updateRating') as ControllerEndpoint['updateRating'])?.(args);
|
||||||
|
};
|
||||||
|
|
||||||
export const controller = {
|
export const controller = {
|
||||||
|
createFavorite,
|
||||||
createPlaylist,
|
createPlaylist,
|
||||||
|
deleteFavorite,
|
||||||
deletePlaylist,
|
deletePlaylist,
|
||||||
getAlbumArtistList,
|
getAlbumArtistList,
|
||||||
getAlbumDetail,
|
getAlbumDetail,
|
||||||
|
@ -252,4 +266,5 @@ export const controller = {
|
||||||
getSongList,
|
getSongList,
|
||||||
getUserList,
|
getUserList,
|
||||||
updatePlaylist,
|
updatePlaylist,
|
||||||
|
updateRating,
|
||||||
};
|
};
|
||||||
|
|
|
@ -498,26 +498,32 @@ const deletePlaylist = async (args: DeletePlaylistArgs): Promise<null> => {
|
||||||
const createFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
|
const createFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
|
||||||
const { query, server } = args;
|
const { query, server } = args;
|
||||||
|
|
||||||
await api.post(`users/${server?.userId}/favoriteitems/${query.id}`, {
|
for (const id of query.id) {
|
||||||
headers: { 'X-MediaBrowser-Token': server?.credential },
|
await api.post(`users/${server?.userId}/favoriteitems/${id}`, {
|
||||||
prefixUrl: server?.url,
|
headers: { 'X-MediaBrowser-Token': server?.credential },
|
||||||
});
|
prefixUrl: server?.url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: query.id,
|
id: query.id,
|
||||||
|
type: query.type,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
|
const deleteFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
|
||||||
const { query, server } = args;
|
const { query, server } = args;
|
||||||
|
|
||||||
await api.delete(`users/${server?.userId}/favoriteitems/${query.id}`, {
|
for (const id of query.id) {
|
||||||
headers: { 'X-MediaBrowser-Token': server?.credential },
|
await api.delete(`users/${server?.userId}/favoriteitems/${id}`, {
|
||||||
prefixUrl: server?.url,
|
headers: { 'X-MediaBrowser-Token': server?.credential },
|
||||||
});
|
prefixUrl: server?.url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: query.id,
|
id: query.id,
|
||||||
|
type: query.type,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import ky from 'ky';
|
import ky from 'ky';
|
||||||
import md5 from 'md5';
|
import md5 from 'md5';
|
||||||
import { randomString } from '/@/renderer/utils';
|
import { parseSearchParams, randomString } from '/@/renderer/utils';
|
||||||
import type {
|
import type {
|
||||||
SSAlbumListResponse,
|
SSAlbumListResponse,
|
||||||
SSAlbumDetailResponse,
|
SSAlbumDetailResponse,
|
||||||
|
@ -16,13 +16,11 @@ import type {
|
||||||
SSAlbumArtistDetail,
|
SSAlbumArtistDetail,
|
||||||
SSAlbumArtistDetailResponse,
|
SSAlbumArtistDetailResponse,
|
||||||
SSFavoriteParams,
|
SSFavoriteParams,
|
||||||
SSFavoriteResponse,
|
|
||||||
SSRatingParams,
|
SSRatingParams,
|
||||||
SSRatingResponse,
|
|
||||||
SSAlbumArtistDetailParams,
|
SSAlbumArtistDetailParams,
|
||||||
SSAlbumArtistListParams,
|
SSAlbumArtistListParams,
|
||||||
} from '/@/renderer/api/subsonic.types';
|
} from '/@/renderer/api/subsonic.types';
|
||||||
import type {
|
import {
|
||||||
AlbumArtistDetailArgs,
|
AlbumArtistDetailArgs,
|
||||||
AlbumArtistListArgs,
|
AlbumArtistListArgs,
|
||||||
AlbumDetailArgs,
|
AlbumDetailArgs,
|
||||||
|
@ -31,10 +29,12 @@ import type {
|
||||||
FavoriteArgs,
|
FavoriteArgs,
|
||||||
FavoriteResponse,
|
FavoriteResponse,
|
||||||
GenreListArgs,
|
GenreListArgs,
|
||||||
|
LibraryItem,
|
||||||
MusicFolderListArgs,
|
MusicFolderListArgs,
|
||||||
RatingArgs,
|
RatingArgs,
|
||||||
|
RatingResponse,
|
||||||
|
ServerListItem,
|
||||||
} from '/@/renderer/api/types';
|
} from '/@/renderer/api/types';
|
||||||
import { useAuthStore } from '/@/renderer/store';
|
|
||||||
import { toast } from '/@/renderer/components/toast';
|
import { toast } from '/@/renderer/components/toast';
|
||||||
|
|
||||||
const getCoverArtUrl = (args: {
|
const getCoverArtUrl = (args: {
|
||||||
|
@ -65,40 +65,40 @@ const api = ky.create({
|
||||||
async (_request, _options, response) => {
|
async (_request, _options, response) => {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data['subsonic-response'].status !== 'ok') {
|
if (data['subsonic-response'].status !== 'ok') {
|
||||||
toast.warn({ message: 'Issue from Subsonic API' });
|
toast.error({
|
||||||
|
message: data['subsonic-response'].error.message,
|
||||||
|
title: 'Issue from Subsonic API',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(JSON.stringify(data['subsonic-response']), { status: 200 });
|
return new Response(JSON.stringify(data['subsonic-response']), { status: 200 });
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
beforeRequest: [
|
|
||||||
(request) => {
|
|
||||||
const server = useAuthStore.getState().currentServer;
|
|
||||||
|
|
||||||
const searchParams = new URLSearchParams();
|
|
||||||
|
|
||||||
if (server) {
|
|
||||||
const authParams = server.credential.split(/&?\w=/gm);
|
|
||||||
|
|
||||||
searchParams.set('u', server.username);
|
|
||||||
searchParams.set('v', '1.13.0');
|
|
||||||
searchParams.set('c', 'Feishin');
|
|
||||||
searchParams.set('f', 'json');
|
|
||||||
|
|
||||||
if (authParams?.length === 4) {
|
|
||||||
searchParams.set('s', authParams[2]);
|
|
||||||
searchParams.set('t', authParams[3]);
|
|
||||||
} else if (authParams?.length === 3) {
|
|
||||||
searchParams.set('p', authParams[2]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ky(request, { searchParams });
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const getDefaultParams = (server: ServerListItem | null) => {
|
||||||
|
if (!server) return {};
|
||||||
|
|
||||||
|
const authParams = server.credential.split(/&?\w=/gm);
|
||||||
|
|
||||||
|
const params: Record<string, string> = {
|
||||||
|
c: 'Feishin',
|
||||||
|
f: 'json',
|
||||||
|
u: server.username,
|
||||||
|
v: '1.13.0',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (authParams?.length === 4) {
|
||||||
|
params.s = authParams[2];
|
||||||
|
params.t = authParams[3];
|
||||||
|
} else if (authParams?.length === 3) {
|
||||||
|
params.p = authParams[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
};
|
||||||
|
|
||||||
const authenticate = async (
|
const authenticate = async (
|
||||||
url: string,
|
url: string,
|
||||||
body: {
|
body: {
|
||||||
|
@ -129,10 +129,12 @@ const authenticate = async (
|
||||||
|
|
||||||
const getMusicFolderList = async (args: MusicFolderListArgs): Promise<SSMusicFolderList> => {
|
const getMusicFolderList = async (args: MusicFolderListArgs): Promise<SSMusicFolderList> => {
|
||||||
const { signal, server } = args;
|
const { signal, server } = args;
|
||||||
|
const defaultParams = getDefaultParams(server);
|
||||||
|
|
||||||
const data = await api
|
const data = await api
|
||||||
.get('rest/getMusicFolders.view', {
|
.get('rest/getMusicFolders.view', {
|
||||||
prefixUrl: server?.url,
|
prefixUrl: server?.url,
|
||||||
|
searchParams: defaultParams,
|
||||||
signal,
|
signal,
|
||||||
})
|
})
|
||||||
.json<SSMusicFolderListResponse>();
|
.json<SSMusicFolderListResponse>();
|
||||||
|
@ -144,9 +146,11 @@ export const getAlbumArtistDetail = async (
|
||||||
args: AlbumArtistDetailArgs,
|
args: AlbumArtistDetailArgs,
|
||||||
): Promise<SSAlbumArtistDetail> => {
|
): Promise<SSAlbumArtistDetail> => {
|
||||||
const { server, signal, query } = args;
|
const { server, signal, query } = args;
|
||||||
|
const defaultParams = getDefaultParams(server);
|
||||||
|
|
||||||
const searchParams: SSAlbumArtistDetailParams = {
|
const searchParams: SSAlbumArtistDetailParams = {
|
||||||
id: query.id,
|
id: query.id,
|
||||||
|
...defaultParams,
|
||||||
};
|
};
|
||||||
|
|
||||||
const data = await api
|
const data = await api
|
||||||
|
@ -162,9 +166,11 @@ export const getAlbumArtistDetail = async (
|
||||||
|
|
||||||
const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<SSAlbumArtistList> => {
|
const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<SSAlbumArtistList> => {
|
||||||
const { signal, server, query } = args;
|
const { signal, server, query } = args;
|
||||||
|
const defaultParams = getDefaultParams(server);
|
||||||
|
|
||||||
const searchParams: SSAlbumArtistListParams = {
|
const searchParams: SSAlbumArtistListParams = {
|
||||||
musicFolderId: query.musicFolderId,
|
musicFolderId: query.musicFolderId,
|
||||||
|
...defaultParams,
|
||||||
};
|
};
|
||||||
|
|
||||||
const data = await api
|
const data = await api
|
||||||
|
@ -186,10 +192,12 @@ const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<SSAlbumArt
|
||||||
|
|
||||||
const getGenreList = async (args: GenreListArgs): Promise<SSGenreList> => {
|
const getGenreList = async (args: GenreListArgs): Promise<SSGenreList> => {
|
||||||
const { server, signal } = args;
|
const { server, signal } = args;
|
||||||
|
const defaultParams = getDefaultParams(server);
|
||||||
|
|
||||||
const data = await api
|
const data = await api
|
||||||
.get('rest/getGenres.view', {
|
.get('rest/getGenres.view', {
|
||||||
prefixUrl: server?.url,
|
prefixUrl: server?.url,
|
||||||
|
searchParams: defaultParams,
|
||||||
signal,
|
signal,
|
||||||
})
|
})
|
||||||
.json<SSGenreListResponse>();
|
.json<SSGenreListResponse>();
|
||||||
|
@ -199,11 +207,17 @@ const getGenreList = async (args: GenreListArgs): Promise<SSGenreList> => {
|
||||||
|
|
||||||
const getAlbumDetail = async (args: AlbumDetailArgs): Promise<SSAlbumDetail> => {
|
const getAlbumDetail = async (args: AlbumDetailArgs): Promise<SSAlbumDetail> => {
|
||||||
const { server, query, signal } = args;
|
const { server, query, signal } = args;
|
||||||
|
const defaultParams = getDefaultParams(server);
|
||||||
|
|
||||||
|
const searchParams = {
|
||||||
|
id: query.id,
|
||||||
|
...defaultParams,
|
||||||
|
};
|
||||||
|
|
||||||
const data = await api
|
const data = await api
|
||||||
.get('rest/getAlbum.view', {
|
.get('rest/getAlbum.view', {
|
||||||
prefixUrl: server?.url,
|
prefixUrl: server?.url,
|
||||||
searchParams: { id: query.id },
|
searchParams: parseSearchParams(searchParams),
|
||||||
signal,
|
signal,
|
||||||
})
|
})
|
||||||
.json<SSAlbumDetailResponse>();
|
.json<SSAlbumDetailResponse>();
|
||||||
|
@ -214,12 +228,15 @@ const getAlbumDetail = async (args: AlbumDetailArgs): Promise<SSAlbumDetail> =>
|
||||||
|
|
||||||
const getAlbumList = async (args: AlbumListArgs): Promise<SSAlbumList> => {
|
const getAlbumList = async (args: AlbumListArgs): Promise<SSAlbumList> => {
|
||||||
const { server, query, signal } = args;
|
const { server, query, signal } = args;
|
||||||
|
const defaultParams = getDefaultParams(server);
|
||||||
|
|
||||||
const normalizedParams = {};
|
const searchParams = {
|
||||||
|
...defaultParams,
|
||||||
|
};
|
||||||
const data = await api
|
const data = await api
|
||||||
.get('rest/getAlbumList2.view', {
|
.get('rest/getAlbumList2.view', {
|
||||||
prefixUrl: server?.url,
|
prefixUrl: server?.url,
|
||||||
searchParams: normalizedParams,
|
searchParams: parseSearchParams(searchParams),
|
||||||
signal,
|
signal,
|
||||||
})
|
})
|
||||||
.json<SSAlbumListResponse>();
|
.json<SSAlbumListResponse>();
|
||||||
|
@ -233,65 +250,79 @@ const getAlbumList = async (args: AlbumListArgs): Promise<SSAlbumList> => {
|
||||||
|
|
||||||
const createFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
|
const createFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
|
||||||
const { server, query, signal } = args;
|
const { server, query, signal } = args;
|
||||||
|
const defaultParams = getDefaultParams(server);
|
||||||
|
|
||||||
const searchParams: SSFavoriteParams = {
|
for (const id of query.id) {
|
||||||
albumId: query.type === 'album' ? query.id : undefined,
|
const searchParams: SSFavoriteParams = {
|
||||||
artistId: query.type === 'albumArtist' ? query.id : undefined,
|
albumId: query.type === LibraryItem.ALBUM ? id : undefined,
|
||||||
id: query.type === 'song' ? query.id : undefined,
|
artistId: query.type === LibraryItem.ALBUM_ARTIST ? id : undefined,
|
||||||
};
|
id: query.type === LibraryItem.SONG ? id : undefined,
|
||||||
|
...defaultParams,
|
||||||
|
};
|
||||||
|
|
||||||
await api
|
await api.get('rest/star.view', {
|
||||||
.get('rest/star.view', {
|
|
||||||
prefixUrl: server?.url,
|
prefixUrl: server?.url,
|
||||||
searchParams,
|
searchParams: parseSearchParams(searchParams),
|
||||||
signal,
|
signal,
|
||||||
})
|
});
|
||||||
.json<SSFavoriteResponse>();
|
// .json<SSFavoriteResponse>();
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: query.id,
|
id: query.id,
|
||||||
|
type: query.type,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
|
const deleteFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
|
||||||
const { server, query, signal } = args;
|
const { server, query, signal } = args;
|
||||||
|
const defaultParams = getDefaultParams(server);
|
||||||
|
|
||||||
const searchParams: SSFavoriteParams = {
|
for (const id of query.id) {
|
||||||
albumId: query.type === 'album' ? query.id : undefined,
|
const searchParams: SSFavoriteParams = {
|
||||||
artistId: query.type === 'albumArtist' ? query.id : undefined,
|
albumId: query.type === LibraryItem.ALBUM ? id : undefined,
|
||||||
id: query.type === 'song' ? query.id : undefined,
|
artistId: query.type === LibraryItem.ALBUM_ARTIST ? id : undefined,
|
||||||
};
|
id: query.type === LibraryItem.SONG ? id : undefined,
|
||||||
|
...defaultParams,
|
||||||
|
};
|
||||||
|
|
||||||
await api
|
await api.get('rest/unstar.view', {
|
||||||
.get('rest/unstar.view', {
|
|
||||||
prefixUrl: server?.url,
|
prefixUrl: server?.url,
|
||||||
searchParams,
|
searchParams: parseSearchParams(searchParams),
|
||||||
signal,
|
signal,
|
||||||
})
|
});
|
||||||
.json<SSFavoriteResponse>();
|
// .json<SSFavoriteResponse>();
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: query.id,
|
id: query.id,
|
||||||
|
type: query.type,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateRating = async (args: RatingArgs) => {
|
const updateRating = async (args: RatingArgs): Promise<RatingResponse> => {
|
||||||
const { server, query, signal } = args;
|
const { server, query, signal } = args;
|
||||||
|
const defaultParams = getDefaultParams(server);
|
||||||
|
|
||||||
const searchParams: SSRatingParams = {
|
for (const id of query.id) {
|
||||||
|
const searchParams: SSRatingParams = {
|
||||||
|
id,
|
||||||
|
rating: query.rating,
|
||||||
|
...defaultParams,
|
||||||
|
};
|
||||||
|
|
||||||
|
await api.get('rest/setRating.view', {
|
||||||
|
prefixUrl: server?.url,
|
||||||
|
searchParams: parseSearchParams(searchParams),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
// .json<SSRatingResponse>();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
id: query.id,
|
id: query.id,
|
||||||
rating: query.rating,
|
rating: query.rating,
|
||||||
};
|
};
|
||||||
|
|
||||||
const data = await api
|
|
||||||
.get('rest/setRating.view', {
|
|
||||||
prefixUrl: server?.url,
|
|
||||||
searchParams,
|
|
||||||
signal,
|
|
||||||
})
|
|
||||||
.json<SSRatingResponse>();
|
|
||||||
|
|
||||||
return data;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const subsonicApi = {
|
export const subsonicApi = {
|
||||||
|
|
|
@ -753,18 +753,21 @@ export const artistListSortMap: ArtistListSortMap = {
|
||||||
// Favorite
|
// Favorite
|
||||||
export type RawFavoriteResponse = FavoriteResponse | undefined;
|
export type RawFavoriteResponse = FavoriteResponse | undefined;
|
||||||
|
|
||||||
export type FavoriteResponse = { id: string };
|
export type FavoriteResponse = { id: string[]; type: FavoriteQuery['type'] };
|
||||||
|
|
||||||
export type FavoriteQuery = { id: string; type?: 'song' | 'album' | 'albumArtist' };
|
export type FavoriteQuery = {
|
||||||
|
id: string[];
|
||||||
|
type?: LibraryItem.SONG | LibraryItem.ALBUM | LibraryItem.ALBUM_ARTIST;
|
||||||
|
};
|
||||||
|
|
||||||
export type FavoriteArgs = { query: FavoriteQuery } & BaseEndpointArgs;
|
export type FavoriteArgs = { query: FavoriteQuery } & BaseEndpointArgs;
|
||||||
|
|
||||||
// Rating
|
// Rating
|
||||||
export type RawRatingResponse = null | undefined;
|
export type RawRatingResponse = RatingResponse | undefined;
|
||||||
|
|
||||||
export type RatingResponse = null;
|
export type RatingResponse = { id: string[]; rating: number };
|
||||||
|
|
||||||
export type RatingQuery = { id: string; rating: number };
|
export type RatingQuery = { id: string[]; rating: number };
|
||||||
|
|
||||||
export type RatingArgs = { query: RatingQuery } & BaseEndpointArgs;
|
export type RatingArgs = { query: RatingQuery } & BaseEndpointArgs;
|
||||||
|
|
||||||
|
@ -916,15 +919,6 @@ export type MusicFolderListResponse = BasePaginatedResponse<Playlist[]>;
|
||||||
|
|
||||||
export type MusicFolderListArgs = BaseEndpointArgs;
|
export type MusicFolderListArgs = BaseEndpointArgs;
|
||||||
|
|
||||||
// Create Favorite
|
|
||||||
export type RawCreateFavoriteResponse = CreateFavoriteResponse | undefined;
|
|
||||||
|
|
||||||
export type CreateFavoriteResponse = { id: string };
|
|
||||||
|
|
||||||
export type CreateFavoriteQuery = { comment?: string; name: string; public?: boolean };
|
|
||||||
|
|
||||||
export type CreateFavoriteArgs = { query: CreateFavoriteQuery } & BaseEndpointArgs;
|
|
||||||
|
|
||||||
// User list
|
// User list
|
||||||
// Playlist List
|
// Playlist List
|
||||||
export type RawUserListResponse = NDUserList | undefined;
|
export type RawUserListResponse = NDUserList | undefined;
|
||||||
|
|
Reference in a new issue