Added similarity search
Build and Publish / Build and Publish Docker Image (push) Successful in 3m29s

This commit is contained in:
Aleksandr Bogomiakov
2026-08-10 01:44:40 +01:00
parent 05d68724f3
commit 77cbb62d84
22 changed files with 3092 additions and 17 deletions
+11
View File
@@ -653,6 +653,17 @@
</button>
</div>
<section class="user-settings-section">
<div class="user-settings-section-head">
<div>
<h4>{{ t.player_interface_language }}</h4>
<p>{{ t.player_language_description }}</p>
</div>
<button class="settings-secondary-btn"
onclick="location.href='/set-lang?lang={% if t.lang.code() == "en" %}ru{% else %}en{% endif %}&next='+encodeURIComponent(location.pathname+location.search+location.hash)">{{ t.player_switch_language }}</button>
</div>
</section>
<section class="user-settings-section">
<div class="user-settings-section-head">
<div>
+58
View File
@@ -44,6 +44,9 @@ const T = {
lastfmDisconnectFailed: "{{ t.player_lastfm_disconnect_failed }}",
startRadio: "{{ t.player_start_radio }}",
radioFailed: "{{ t.player_radio_failed }}",
searchSimilarTo: "{{ t.player_search_similar_to }}",
findSimilar: "{{ t.player_find_similar }}",
similarityFailed: "{{ t.player_similarity_failed }}",
share: "{{ t.player_share }}",
shareTrack: "{{ t.player_share_track }}",
shareQueue: "{{ t.player_share_queue }}",
@@ -2503,6 +2506,8 @@ document.addEventListener('alpine:init', () => {
searchQuery: '',
searchResults: null,
searchLoading: false,
similaritySearchLabel: '',
similaritySearchError: '',
federationSearch: { loading: false, error: '', artists: [], releases: [], tracks: [] },
artistFederation: { loading: false, error: '', releases: [], tracks: [] },
federationPreparing: {},
@@ -2594,6 +2599,8 @@ document.addEventListener('alpine:init', () => {
this.searchQuery = q;
this.search(q, options);
}
} else if (view === 'similar' && id) {
this.searchSimilar(id, options);
} else {
this.goArtists(options);
}
@@ -3047,6 +3054,7 @@ document.addEventListener('alpine:init', () => {
openTrackInfo(track) {
Alpine.store('info').openRows(T.trackInfoTitle, this.trackInfoRows(track), [
{ label: T.startRadio, run: () => this.startRadio('track', track?.id) },
{ label: T.findSimilar, run: () => this.searchSimilar(track) },
]);
},
@@ -3385,6 +3393,8 @@ document.addEventListener('alpine:init', () => {
}
this.view = 'search';
this.searchLoading = true;
this.similaritySearchLabel = '';
this.similaritySearchError = '';
this.startFederationSearch(q);
try {
const res = await fetch(`/api/player/search?q=${encodeURIComponent(q)}&limit=10`);
@@ -3397,11 +3407,59 @@ document.addEventListener('alpine:init', () => {
this._afterNavigation(options);
},
async searchSimilar(trackOrId, options = {}) {
const id = Number(typeof trackOrId === 'object' ? trackOrId?.id : trackOrId);
if (!Number.isInteger(id) || id <= 0) return;
const track = typeof trackOrId === 'object' ? trackOrId : null;
const artist = (track?.artists || []).map(item => item.name).filter(Boolean).join(', ');
const initialLabel = track
? [track.title, artist].filter(Boolean).join(' — ')
: '';
this._beginNavigation(`#similar/${id}`, options);
if (this.view !== 'search') this._previousView = this.view;
this.stopFederationSearch();
this.view = 'search';
this.searchQuery = initialLabel;
this.similaritySearchLabel = initialLabel;
this.similaritySearchError = '';
this.searchLoading = true;
this.searchResults = null;
this.federationSearch = { loading: true, error: '', artists: [], releases: [], tracks: [] };
Alpine.store('info').close();
try {
const response = await fetch(`/api/player/similarity/${id}`);
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || T.similarityFailed);
this.similaritySearchLabel = data.label || initialLabel;
this.searchQuery = this.similaritySearchLabel;
this.searchResults = {
artists: [],
releases: [],
tracks: Array.isArray(data.tracks) ? data.tracks : [],
};
this.federationSearch = {
loading: false,
error: data.federation_error || '',
artists: [],
releases: [],
tracks: Array.isArray(data.federation_tracks) ? data.federation_tracks : [],
};
} catch (error) {
this.searchResults = { artists: [], releases: [], tracks: [] };
this.federationSearch = { loading: false, error: '', artists: [], releases: [], tracks: [] };
this.similaritySearchError = error?.message || T.similarityFailed;
}
this.searchLoading = false;
this._afterNavigation(options);
},
clearSearch() {
this.stopFederationSearch();
this.searchQuery = '';
this.searchResults = null;
this.searchLoading = false;
this.similaritySearchLabel = '';
this.similaritySearchError = '';
if (this.view === 'search') {
this.view = this._previousView || 'artists';
this._setHash(this.view === 'my_uploads' ? '#uploads' : '#artists');
+12 -1
View File
@@ -379,12 +379,23 @@
<!-- Search Results -->
<template x-if="$store.library.view === 'search'">
<div>
<h2 class="search-similarity-title"
x-show="$store.library.similaritySearchLabel"
x-cloak>
<span>{{ t.player_search_similar_to }}</span>
<strong x-text="$store.library.similaritySearchLabel"></strong>
</h2>
<template x-if="$store.library.searchLoading">
<div class="loading-spinner"><div class="spinner"></div></div>
</template>
<template x-if="!$store.library.searchLoading && $store.library.similaritySearchError">
<div class="empty-state">
<p x-text="$store.library.similaritySearchError"></p>
</div>
</template>
<template x-if="!$store.library.searchLoading && $store.library.searchResults">
<div>
<template x-if="$store.library.searchResults.artists.length === 0 && $store.library.searchResults.releases.length === 0 && $store.library.searchResults.tracks.length === 0">
<template x-if="!$store.library.similaritySearchError && $store.library.searchResults.artists.length === 0 && $store.library.searchResults.releases.length === 0 && $store.library.searchResults.tracks.length === 0">
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<p>{{ t.player_no_results }}</p>
+15
View File
@@ -2872,6 +2872,21 @@ button.user-stat:hover {
margin-bottom: 12px;
}
.search-similarity-title {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
margin: 0 0 20px;
color: var(--text-muted);
font-size: 16px;
font-weight: 500;
}
.search-similarity-title strong {
color: var(--text);
font-size: 20px;
}
.search-artists-row {
display: flex;
gap: 16px;