feat: add personal playlists and shareable track links
EOF Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
3a097c15c8
commit
fa79394869
46 files changed
+2307
-292
No files matched your search
@@ -1331,10 +1331,72 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/me/playlists": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["listPlaylists"];
|
||||
put?: never;
|
||||
post: operations["createPlaylist"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/me/playlists/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getPlaylist"];
|
||||
put: operations["updatePlaylist"];
|
||||
post?: never;
|
||||
delete: operations["deletePlaylist"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: {
|
||||
PlaylistInput: {
|
||||
name: string;
|
||||
description: string;
|
||||
track_ids: string[];
|
||||
};
|
||||
PlaylistUpdate: components["schemas"]["PlaylistInput"] & {
|
||||
revision: number;
|
||||
};
|
||||
PlaylistSummary: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
revision: number;
|
||||
track_count: number;
|
||||
/** Format: int64 */
|
||||
duration_ms: number;
|
||||
/** Format: date-time */
|
||||
created_at: string;
|
||||
/** Format: date-time */
|
||||
updated_at: string;
|
||||
};
|
||||
Playlist: components["schemas"]["PlaylistSummary"] & {
|
||||
tracks: components["schemas"]["Track"][];
|
||||
};
|
||||
PlaylistList: {
|
||||
items: components["schemas"]["PlaylistSummary"][];
|
||||
};
|
||||
Onboarding: {
|
||||
required: boolean;
|
||||
artists: {
|
||||
@@ -1596,7 +1658,7 @@ export interface components {
|
||||
/** @enum {string} */
|
||||
Role: "admin" | "moderator" | "user";
|
||||
/** @enum {string} */
|
||||
ClientType: "web" | "android";
|
||||
ClientType: "web" | "android" | "desktop";
|
||||
LoginRequest: {
|
||||
username: string;
|
||||
password: string;
|
||||
@@ -1605,7 +1667,7 @@ export interface components {
|
||||
device_name?: string;
|
||||
};
|
||||
RefreshRequest: {
|
||||
/** @description Только Android; веб передаёт токен в cookie */
|
||||
/** @description Android и desktop; веб передаёт токен в cookie */
|
||||
refresh_token?: string;
|
||||
};
|
||||
AuthTokens: {
|
||||
@@ -1614,7 +1676,7 @@ export interface components {
|
||||
token_type: "Bearer";
|
||||
/** @description Срок жизни access-токена, секунды */
|
||||
expires_in: number;
|
||||
/** @description Только для `client_type: android` */
|
||||
/** @description Для `client_type: android` и `client_type: desktop` */
|
||||
refresh_token?: string;
|
||||
user: components["schemas"]["Me"];
|
||||
};
|
||||
@@ -4171,4 +4233,121 @@ export interface operations {
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
listPlaylists: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Success */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PlaylistList"];
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
createPlaylist: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["PlaylistInput"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Success */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Playlist"];
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
getPlaylist: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Success */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Playlist"];
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
updatePlaylist: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["PlaylistUpdate"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Success */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Playlist"];
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
deletePlaylist: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Deleted */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -93,8 +93,8 @@ export class AuthSession {
|
||||
private readonly listeners = new Set<(s: AuthState) => void>();
|
||||
|
||||
constructor(opts: AuthSessionOptions) {
|
||||
if (opts.clientType === 'android' && !opts.refreshStore) {
|
||||
throw new Error('AuthSession: android client needs a refreshStore');
|
||||
if (opts.clientType !== 'web' && !opts.refreshStore) {
|
||||
throw new Error(`AuthSession: ${opts.clientType} client needs a refreshStore`);
|
||||
}
|
||||
this.opts = opts;
|
||||
this.baseFetch = opts.fetch ?? ((...args) => globalThis.fetch(...args));
|
||||
|
||||
@@ -22,5 +22,6 @@ export {
|
||||
type Track,
|
||||
} from './player';
|
||||
export { normalizeGain, TARGET_LUFS } from './gain';
|
||||
export { trackShareUrl } from './share';
|
||||
export { checkServer, normalizeServerUrl, type ServerCheck } from './server';
|
||||
export { initialWaveState, mix, rgb, rgba, step, WAVE_COLORS, waveScale, type Rgb, type WaveState } from './wave-anim';
|
||||
@@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { trackShareUrl } from './share';
|
||||
|
||||
describe('trackShareUrl', () => {
|
||||
it('builds a page URL without stream credentials', () => {
|
||||
expect(trackShareUrl('https://music.example', 'ab/cd')).toBe('https://music.example/track/ab%2Fcd');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Public page URL: never includes playback credentials or stream URLs. */
|
||||
export function trackShareUrl(server: string, trackId: string): string {
|
||||
return new URL(`/track/${encodeURIComponent(trackId)}`, server).href;
|
||||
}
|
||||
@@ -54,6 +54,10 @@
|
||||
"wave_soon": "An endless stream tuned to your taste will appear once the server has a library."
|
||||
},
|
||||
"player": {
|
||||
"share": "Share track",
|
||||
"link_copied": "Track link copied",
|
||||
"share_error": "Could not share the link. Please try again.",
|
||||
"track_page": "Track",
|
||||
"empty": "Nothing is playing",
|
||||
"label": "Player",
|
||||
"play": "Play",
|
||||
@@ -302,5 +306,27 @@
|
||||
"pick_more_few": "Pick {{count}} more artists",
|
||||
"pick_more_many": "Pick {{count}} more artists",
|
||||
"pick_more_other": "Pick {{count}} more artists"
|
||||
},
|
||||
"playlists": {
|
||||
"name": "Name",
|
||||
"delete_confirm": "Delete playlist “{{name}}”? Tracks will remain in your library.",
|
||||
"manage": "Manage tracks",
|
||||
"title": "Playlists",
|
||||
"move_down": "Move {{name}} down",
|
||||
"edit": "Edit playlist",
|
||||
"description": "Description",
|
||||
"add": "Add to playlist",
|
||||
"search": "Search playlists",
|
||||
"create": "Create playlist",
|
||||
"find_tracks": "Find music",
|
||||
"remove_track": "Remove {{name}} from playlist",
|
||||
"save": "Save",
|
||||
"private": "Private playlist",
|
||||
"empty": "Your playlists will appear here. Create your first and add your favourite music.",
|
||||
"empty_tracks": "No tracks yet. Use Add to playlist in search, albums or your collection.",
|
||||
"loading": "Loading playlists…",
|
||||
"move_up": "Move {{name}} up",
|
||||
"delete": "Delete playlist",
|
||||
"no_results": "No playlists found"
|
||||
}
|
||||
}
|
||||
@@ -93,5 +93,12 @@
|
||||
"size_mismatch": "The file size does not match the declared one",
|
||||
"hash_mismatch": "The file was corrupted in transit (SHA-256 mismatch)",
|
||||
"import_failed": "Could not write the file to the storage"
|
||||
},
|
||||
"playlist": {
|
||||
"not_found": "Playlist not found",
|
||||
"invalid_name": "Enter a name between 1 and 120 characters",
|
||||
"invalid_tracks": "Tracks must be unique and exist in the library",
|
||||
"too_large": "Maximum 2000 tracks and 2000 description characters",
|
||||
"conflict": "This playlist changed on another device. Close the editor and try again with the refreshed data."
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,10 @@
|
||||
"wave_soon": "Бесконечная лента под ваш вкус появится, когда сервер подключит библиотеку."
|
||||
},
|
||||
"player": {
|
||||
"share": "Поделиться треком",
|
||||
"link_copied": "Ссылка на трек скопирована",
|
||||
"share_error": "Не удалось поделиться ссылкой. Попробуйте ещё раз.",
|
||||
"track_page": "Трек",
|
||||
"empty": "Ничего не играет",
|
||||
"label": "Плеер",
|
||||
"play": "Слушать",
|
||||
@@ -302,5 +306,27 @@
|
||||
"pick_more_few": "Выберите ещё {{count}} исполнителя",
|
||||
"pick_more_many": "Выберите ещё {{count}} исполнителей",
|
||||
"pick_more_other": "Выберите ещё {{count}} исполнителя"
|
||||
},
|
||||
"playlists": {
|
||||
"name": "Название",
|
||||
"delete_confirm": "Удалить плейлист «{{name}}»? Треки останутся в библиотеке.",
|
||||
"manage": "Управлять треками",
|
||||
"title": "Плейлисты",
|
||||
"move_down": "Переместить «{{name}}» ниже",
|
||||
"edit": "Изменить плейлист",
|
||||
"description": "Описание",
|
||||
"add": "Добавить в плейлист",
|
||||
"search": "Поиск плейлистов",
|
||||
"create": "Создать плейлист",
|
||||
"find_tracks": "Найти музыку",
|
||||
"remove_track": "Убрать «{{name}}» из плейлиста",
|
||||
"save": "Сохранить",
|
||||
"private": "Личный плейлист",
|
||||
"empty": "Здесь будут ваши плейлисты. Создайте первый и добавьте любимую музыку.",
|
||||
"empty_tracks": "В плейлисте пока нет треков. Добавляйте их кнопкой «Добавить в плейлист» в поиске, альбомах или коллекции.",
|
||||
"loading": "Загрузка плейлистов…",
|
||||
"move_up": "Переместить «{{name}}» выше",
|
||||
"delete": "Удалить плейлист",
|
||||
"no_results": "Плейлисты не найдены"
|
||||
}
|
||||
}
|
||||
@@ -93,5 +93,12 @@
|
||||
"size_mismatch": "Размер файла не совпал с заявленным",
|
||||
"hash_mismatch": "Файл повредился при загрузке (не совпал SHA-256)",
|
||||
"import_failed": "Не удалось записать файл в хранилище"
|
||||
},
|
||||
"playlist": {
|
||||
"not_found": "Плейлист не найден",
|
||||
"invalid_name": "Введите название от 1 до 120 символов",
|
||||
"invalid_tracks": "Треки не должны повторяться и должны существовать в библиотеке",
|
||||
"too_large": "Максимум 2000 треков и 2000 символов описания",
|
||||
"conflict": "Плейлист изменён на другом устройстве. Закройте редактор и повторите изменение с обновлёнными данными."
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user