Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75b0f7bec3 | ||
|
|
3edb1af037 | ||
|
|
0458cbc46c | ||
|
|
f7ffa5d742 | ||
|
|
5d0decbb2a | ||
|
|
3edbf489a3 | ||
|
|
82f3eed9a0 | ||
|
|
db4548b115 |
No files matched your search
@@ -75,6 +75,9 @@ jobs:
|
|||||||
# These suites share one database; dbtest also rolls all migrations down
|
# These suites share one database; dbtest also rolls all migrations down
|
||||||
# and cleans orphan catalog rows. Do not run the packages concurrently.
|
# and cleans orphan catalog rows. Do not run the packages concurrently.
|
||||||
run: go test -p 1 -tags integration -count=1 -v ./internal/dbtest/ ./internal/social/ ./internal/uploads/
|
run: go test -p 1 -tags integration -count=1 -v ./internal/dbtest/ ./internal/social/ ./internal/uploads/
|
||||||
|
- name: Go — релевантность поиска на Meilisearch
|
||||||
|
working-directory: services/api
|
||||||
|
run: go test -tags integration -count=1 -v ./internal/search/
|
||||||
- name: Go — сквозной сценарий пользователя (newApp на Postgres, Redis, Meilisearch)
|
- name: Go — сквозной сценарий пользователя (newApp на Postgres, Redis, Meilisearch)
|
||||||
working-directory: services/api
|
working-directory: services/api
|
||||||
run: go test -tags integration -count=1 -v -run TestUserJourney ./cmd/api/
|
run: go test -tags integration -count=1 -v -run TestUserJourney ./cmd/api/
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export function TrackPage({ id }: { id: string }) {
|
|||||||
<img src={coverSrc(tr.album.cover_url, 300)} alt="" />
|
<img src={coverSrc(tr.album.cover_url, 300)} alt="" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<Cover album={tr.album} size={280} className={c.heroCover} />
|
<Cover album={tr.album} size="100%" className={c.heroCover} />
|
||||||
<div className={c.heroBody}>
|
<div className={c.heroBody}>
|
||||||
<span className={c.kind}>{t('player.track_page')}</span>
|
<span className={c.kind}>{t('player.track_page')}</span>
|
||||||
<h1 className={c.heroTitle}>{tr.title}</h1>
|
<h1 className={c.heroTitle}>{tr.title}</h1>
|
||||||
|
|||||||
@@ -99,6 +99,11 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.listen .heroCover {
|
||||||
|
max-width: 280px;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.listen {
|
.listen {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 28px;
|
gap: 28px;
|
||||||
@@ -118,7 +123,12 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 20px;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listen .heroCover {
|
||||||
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.listen .heroTitle {
|
.listen .heroTitle {
|
||||||
|
|||||||
@@ -75,3 +75,4 @@ export { default as tuneFillIcon } from '@material-symbols/svg-400/rounded/tune-
|
|||||||
export { default as queueIcon } from '@material-symbols/svg-400/rounded/queue_music.svg';
|
export { default as queueIcon } from '@material-symbols/svg-400/rounded/queue_music.svg';
|
||||||
export { default as playlistAddIcon } from '@material-symbols/svg-400/rounded/playlist_add.svg';
|
export { default as playlistAddIcon } from '@material-symbols/svg-400/rounded/playlist_add.svg';
|
||||||
export { default as undoIcon } from '@material-symbols/svg-400/rounded/undo.svg';
|
export { default as undoIcon } from '@material-symbols/svg-400/rounded/undo.svg';
|
||||||
|
export { default as chevronDownIcon } from '@material-symbols/svg-400/rounded/keyboard_arrow_down.svg';
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
.root {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 40;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--bg, #05070d);
|
||||||
|
animation: pipuIn 0.18s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg {
|
||||||
|
position: fixed;
|
||||||
|
inset: -20%;
|
||||||
|
opacity: 0.35;
|
||||||
|
filter: blur(80px) saturate(1.2);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 24px;
|
||||||
|
z-index: 2;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.35);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
color: #fff;
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
width: min(444px, calc(100vw - 32px), calc(100dvh - 240px));
|
||||||
|
min-width: 260px;
|
||||||
|
padding: 72px 0 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shade {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Кнопки на обложке видны только при наведении / фокусе (на тач-экранах — всегда). */
|
||||||
|
.shade,
|
||||||
|
.round,
|
||||||
|
.controls,
|
||||||
|
.moreWrap {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.18s, background 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover .shade,
|
||||||
|
.card:hover .round,
|
||||||
|
.card:hover .controls,
|
||||||
|
.card:hover .moreWrap,
|
||||||
|
.card:has(:focus-visible) .shade,
|
||||||
|
.card:has(:focus-visible) .round,
|
||||||
|
.card:has(:focus-visible) .controls,
|
||||||
|
.card:has(:focus-visible) .moreWrap,
|
||||||
|
.cardOpen .shade,
|
||||||
|
.cardOpen .round,
|
||||||
|
.cardOpen .controls,
|
||||||
|
.cardOpen .moreWrap {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: none) {
|
||||||
|
.shade,
|
||||||
|
.round,
|
||||||
|
.controls,
|
||||||
|
.moreWrap {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.round {
|
||||||
|
position: absolute;
|
||||||
|
flex: none;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(20, 20, 20, 0.82);
|
||||||
|
color: #fff;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.round:hover:not(:disabled) {
|
||||||
|
background: rgba(50, 50, 50, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls .round {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accent {
|
||||||
|
color: #ffe600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topLeft {
|
||||||
|
top: 16px;
|
||||||
|
left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topRight {
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottomRight {
|
||||||
|
right: 16px;
|
||||||
|
bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.moreWrap {
|
||||||
|
position: absolute;
|
||||||
|
left: 16px;
|
||||||
|
bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.moreWrap .round {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
bottom: 56px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 200px;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(20, 20, 20, 0.96);
|
||||||
|
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #fff;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuItem:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 20px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls > * {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ffe600;
|
||||||
|
color: #111;
|
||||||
|
transition: transform 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play:hover:not(:disabled) {
|
||||||
|
transform: scale(1.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.play[aria-busy='true'] {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
text-align: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text, #fff);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artists {
|
||||||
|
color: var(--text2);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range {
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
appearance: none;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: linear-gradient(90deg, #fff var(--pct, 0%), rgba(255, 255, 255, 0.25) var(--pct, 0%));
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range::-webkit-slider-thumb {
|
||||||
|
appearance: none;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range::-moz-range-thumb {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.times {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text2);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { useEffect, useState, type CSSProperties } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { Link } from '@tanstack/react-router';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { ArtistLinks, Cover, coverSrc } from '../entities/catalog';
|
||||||
|
import { AddToPlaylist } from '../entities/playlists';
|
||||||
|
import { usePlayer, usePlayerState } from '../player/context';
|
||||||
|
import { formatDuration } from '../shared/lib/format';
|
||||||
|
import {
|
||||||
|
chevronDownIcon,
|
||||||
|
likeFillIcon,
|
||||||
|
likeIcon,
|
||||||
|
moreIcon,
|
||||||
|
nextIcon,
|
||||||
|
pauseIcon,
|
||||||
|
playIcon,
|
||||||
|
prevIcon,
|
||||||
|
queueIcon,
|
||||||
|
repeatIcon,
|
||||||
|
repeatOneIcon,
|
||||||
|
shareIcon,
|
||||||
|
} from '../shared/ui/icons';
|
||||||
|
import { cx, Icon } from '../shared/ui/ui';
|
||||||
|
import s from './FullPlayer.module.css';
|
||||||
|
|
||||||
|
/** Полноэкранный плеер: обложка с кнопками поверх неё, название и прогресс. Открывается кликом по обложке в плеере. */
|
||||||
|
export function FullPlayer({ onClose, onQueue, share }: { onClose: () => void; onQueue: () => void; share: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const player = usePlayer();
|
||||||
|
const st = usePlayerState();
|
||||||
|
const [menu, setMenu] = useState(false);
|
||||||
|
const locked = st.listening?.locked === true;
|
||||||
|
const track = st.track;
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== 'Escape') return;
|
||||||
|
if (menu) setMenu(false);
|
||||||
|
else onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
const prev = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
document.body.style.overflow = prev;
|
||||||
|
};
|
||||||
|
}, [onClose, menu]);
|
||||||
|
if (!track) return null;
|
||||||
|
const duration = st.durationMs || track.duration_ms;
|
||||||
|
const liked = track.reaction === 'like';
|
||||||
|
return createPortal(
|
||||||
|
<div className={s.root} role="dialog" aria-modal="true" aria-label={t('player.now_playing')}>
|
||||||
|
{track.album.cover_url && (
|
||||||
|
<span className={s.bg} aria-hidden>
|
||||||
|
<img src={coverSrc(track.album.cover_url, 300)} alt="" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button type="button" className={s.close} aria-label={t('form.close')} onClick={onClose}>
|
||||||
|
<Icon src={chevronDownIcon} size={26} />
|
||||||
|
</button>
|
||||||
|
<div className={s.stage}>
|
||||||
|
<div className={cx(s.card, menu && s.cardOpen)}>
|
||||||
|
<Cover album={track.album} size="100%" className={s.cover} />
|
||||||
|
<div className={s.shade} aria-hidden />
|
||||||
|
<button type="button" className={cx(s.round, s.topRight)} aria-label={t('player.queue')} onClick={onQueue}>
|
||||||
|
<Icon src={queueIcon} size={22} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cx(s.round, s.topLeft, st.repeat !== 'off' && s.accent)}
|
||||||
|
aria-label={t(`player.repeat_${st.repeat}`)}
|
||||||
|
disabled={locked}
|
||||||
|
onClick={() => player.cycleRepeat()}
|
||||||
|
>
|
||||||
|
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={20} />
|
||||||
|
</button>
|
||||||
|
<div className={s.controls}>
|
||||||
|
<button type="button" className={s.round} aria-label={t('player.prev')} disabled={locked} onClick={() => player.prev()}>
|
||||||
|
<Icon src={prevIcon} size={22} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={s.play}
|
||||||
|
aria-label={t(st.playing ? 'player.pause' : 'player.play')}
|
||||||
|
aria-busy={st.loading || undefined}
|
||||||
|
disabled={locked}
|
||||||
|
onClick={() => player.toggle()}
|
||||||
|
>
|
||||||
|
<Icon src={st.playing ? pauseIcon : playIcon} size={30} />
|
||||||
|
</button>
|
||||||
|
<button type="button" className={s.round} aria-label={t('player.next')} disabled={locked} onClick={() => player.next()}>
|
||||||
|
<Icon src={nextIcon} size={22} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={s.moreWrap}>
|
||||||
|
<button type="button" className={s.round} aria-label={t('player.more')} aria-expanded={menu} onClick={() => setMenu((v) => !v)}>
|
||||||
|
<Icon src={moreIcon} size={26} />
|
||||||
|
</button>
|
||||||
|
{menu && (
|
||||||
|
<div className={s.menu}>
|
||||||
|
<AddToPlaylist trackIds={[track.id]} className={s.menuItem} />
|
||||||
|
<button type="button" className={s.menuItem} onClick={() => { setMenu(false); share(); }}>
|
||||||
|
<Icon src={shareIcon} size={18} />
|
||||||
|
{t('player.share')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cx(s.round, s.bottomRight, liked && s.accent)}
|
||||||
|
aria-label={t(liked ? 'player.unlike' : 'player.like')}
|
||||||
|
aria-pressed={liked}
|
||||||
|
onClick={() => void player.react(track, liked ? null : 'like').catch(() => undefined)}
|
||||||
|
>
|
||||||
|
<Icon src={liked ? likeFillIcon : likeIcon} size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={s.meta}>
|
||||||
|
<Link to="/track/$id" params={{ id: track.id }} className={s.title} onClick={onClose}>
|
||||||
|
{track.title}
|
||||||
|
</Link>
|
||||||
|
<span className={s.artists}>
|
||||||
|
<ArtistLinks artists={track.artists} hideUrlPreview />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={s.progress}>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className={s.range}
|
||||||
|
min={0}
|
||||||
|
max={duration}
|
||||||
|
step={1000}
|
||||||
|
value={Math.min(st.positionMs, duration)}
|
||||||
|
aria-label={t('player.progress')}
|
||||||
|
aria-valuetext={formatDuration(st.positionMs)}
|
||||||
|
disabled={locked}
|
||||||
|
onChange={(e) => player.seek(Number(e.target.value))}
|
||||||
|
style={{ '--pct': `${(st.positionMs / duration) * 100}%` } as CSSProperties}
|
||||||
|
/>
|
||||||
|
<span className={s.times}>
|
||||||
|
<span>{formatDuration(st.positionMs)}</span>
|
||||||
|
<span>{formatDuration(duration)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
waveIcon,
|
waveIcon,
|
||||||
} from '../shared/ui/icons';
|
} from '../shared/ui/icons';
|
||||||
import { cx, Icon } from '../shared/ui/ui';
|
import { cx, Icon } from '../shared/ui/ui';
|
||||||
|
import { FullPlayer } from './FullPlayer';
|
||||||
import s from './PlayerBar.module.css';
|
import s from './PlayerBar.module.css';
|
||||||
|
|
||||||
/** Плеер: полоса внизу (`bar`, по умолчанию) или плавающий остров (`island`) — выбор в настройках (docs/design.md). */
|
/** Плеер: полоса внизу (`bar`, по умолчанию) или плавающий остров (`island`) — выбор в настройках (docs/design.md). */
|
||||||
@@ -43,6 +44,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
|||||||
const locked = st.listening?.locked === true;
|
const locked = st.listening?.locked === true;
|
||||||
const narrow = useNarrow();
|
const narrow = useNarrow();
|
||||||
const [sheetOpen, setSheetOpen] = useState(false);
|
const [sheetOpen, setSheetOpen] = useState(false);
|
||||||
|
const [fullOpen, setFullOpen] = useState(false);
|
||||||
const [queueOpen, setQueueOpen] = useState(false);
|
const [queueOpen, setQueueOpen] = useState(false);
|
||||||
const queueButton = useRef<HTMLButtonElement>(null);
|
const queueButton = useRef<HTMLButtonElement>(null);
|
||||||
const queueId = useId();
|
const queueId = useId();
|
||||||
@@ -86,13 +88,11 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
|||||||
<footer className={cx(s.bar, variant === 'island' && s.island)} aria-label={t('player.label')}>
|
<footer className={cx(s.bar, variant === 'island' && s.island)} aria-label={t('player.label')}>
|
||||||
<div
|
<div
|
||||||
className={s.now}
|
className={s.now}
|
||||||
onClick={
|
onClick={(e) => {
|
||||||
narrow
|
if ((e.target as HTMLElement).closest('a, button')) return;
|
||||||
? (e) => {
|
if (narrow) setSheetOpen(true);
|
||||||
if (!(e.target as HTMLElement).closest('a, button')) setSheetOpen(true);
|
else setFullOpen(true);
|
||||||
}
|
}}
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{narrow ? (
|
{narrow ? (
|
||||||
<button type="button" className={s.nowArt} aria-label={t('player.expand')} onClick={() => setSheetOpen(true)}>
|
<button type="button" className={s.nowArt} aria-label={t('player.expand')} onClick={() => setSheetOpen(true)}>
|
||||||
@@ -100,7 +100,9 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
|||||||
{st.playing && <span className={s.nowEq}><Equalizer playing /></span>}
|
{st.playing && <span className={s.nowEq}><Equalizer playing /></span>}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<Cover album={track.album} size={52} className={st.playing ? s.coverLive : undefined} />
|
<button type="button" className={s.nowArt} aria-label={t('player.expand')} onClick={() => setFullOpen(true)}>
|
||||||
|
<Cover album={track.album} size={52} className={st.playing ? s.coverLive : undefined} />
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className={s.meta}>
|
<div className={s.meta}>
|
||||||
<span className={s.title} title={track.title}>{track.title}</span>
|
<span className={s.title} title={track.title}>{track.title}</span>
|
||||||
@@ -242,6 +244,16 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
|||||||
share={share}
|
share={share}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{fullOpen && !narrow && (
|
||||||
|
<FullPlayer
|
||||||
|
onClose={() => setFullOpen(false)}
|
||||||
|
onQueue={() => {
|
||||||
|
setFullOpen(false);
|
||||||
|
setQueueOpen(true);
|
||||||
|
}}
|
||||||
|
share={share}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{queueOpen && <QueueDrawer id={queueId} variant={variant} onClose={closeQueue} />}
|
{queueOpen && <QueueDrawer id={queueId} variant={variant} onClose={closeQueue} />}
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -199,10 +199,17 @@ Meilisearch, индексы:
|
|||||||
| `albums` | `title`, `artist_name`, `title_translit` | `year`, `popularity` |
|
| `albums` | `title`, `artist_name`, `title_translit` | `year`, `popularity` |
|
||||||
| `tracks` | `title`, `artist_names`, `album_title`, `title_translit` | `genre`, `year`, `popularity`, `available` |
|
| `tracks` | `title`, `artist_names`, `album_title`, `title_translit` | `genre`, `year`, `popularity`, `available` |
|
||||||
|
|
||||||
- `*_translit` — транслитерация (кириллица ↔ латиница), плюс поле с **исправлением раскладки**
|
- `*_translit` — варианты транслитерации (кириллица ↔ латиница), включая словарь
|
||||||
|
устоявшихся написаний (`Boulevard` → `бульвар`) наряду с побуквенным вариантом.
|
||||||
|
**Исправление раскладки**
|
||||||
(`ghbdtn` → `привет`): при пустом результате клиентский запрос повторяется в альтернативной раскладке
|
(`ghbdtn` → `привет`): при пустом результате клиентский запрос повторяется в альтернативной раскладке
|
||||||
на стороне `api`.
|
на стороне `api`.
|
||||||
- Ранжирование: стандартные правила Meilisearch + `popularity:desc` (глобальные засчитанные прослушивания за 90 дней).
|
- Запрос учитывает все слова (`matchingStrategy=all`), не отбрасывая последнее ради заполнения списка.
|
||||||
|
Опечатки и поиск по префиксу по-прежнему обрабатывает Meilisearch.
|
||||||
|
- Ранжирование: `words`, `typo`, `exactness`, `proximity`, `attribute`, `sort`, `popularity:desc`.
|
||||||
|
Точное имя исполнителя (включая вариант транслитерации) закрепляется перед составными именами
|
||||||
|
в подсказках и не опускается из-за персонализации. Новые варианты появляются после переиндексации;
|
||||||
|
API запускает её при старте.
|
||||||
- `/search/suggest` использует `multi-search` (один запрос — три индекса), отдаёт ≤ 3/3/5. Клиент делает debounce 150 мс,
|
- `/search/suggest` использует `multi-search` (один запрос — три индекса), отдаёт ≤ 3/3/5. Клиент делает debounce 150 мс,
|
||||||
отменяет предыдущий запрос (`AbortController`).
|
отменяет предыдущий запрос (`AbortController`).
|
||||||
- Персонализация: `suggest` берёт из Meilisearch втрое больше кандидатов и переранжирует их в `api`
|
- Персонализация: `suggest` берёт из Meilisearch втрое больше кандидатов и переранжирует их в `api`
|
||||||
|
|||||||
@@ -98,6 +98,10 @@
|
|||||||
передаётся отдельно от обложки. Ближайшая такая картинка наследуется вложенными папками альбомов;
|
передаётся отдельно от обложки. Ближайшая такая картинка наследуется вложенными папками альбомов;
|
||||||
соседние исполнители не смешиваются. Например `Исполнитель/artist.jpg` и две папки альбомов
|
соседние исполнители не смешиваются. Например `Исполнитель/artist.jpg` и две папки альбомов
|
||||||
дают две отдельные пачки с одной фотографией исполнителя и своими обложками.
|
дают две отдельные пачки с одной фотографией исполнителя и своими обложками.
|
||||||
|
- При импорте трека картинки кладутся и в хранилище: `cover.<ext>` — в папку альбома, `artist.<ext>` — в папку
|
||||||
|
исполнителя; папки определяются по `write_template` (сегмент с `{album}` и первый сегмент с `{albumartist}`/`{artist}`).
|
||||||
|
Если в папке уже есть такая картинка (`cover`/`folder`/`front`/`album`/`albumart` или `artist`), она не перезаписывается.
|
||||||
|
Если шаблон не позволяет определить папку (плоская раскладка), картинка не записывается.
|
||||||
После одобрения и импорта трека фотография сохраняется в кеш WebP и ставится исполнителю альбома,
|
После одобрения и импорта трека фотография сохраняется в кеш WebP и ставится исполнителю альбома,
|
||||||
только если у него нет ни локальной аватарки, ни изображения Genius. Гостям она не назначается.
|
только если у него нет ни локальной аватарки, ни изображения Genius. Гостям она не назначается.
|
||||||
Существующая аватарка сохраняется, в том числе при одновременном импорте нескольких альбомов.
|
Существующая аватарка сохраняется, в том числе при одновременном импорте нескольких альбомов.
|
||||||
@@ -178,6 +182,9 @@
|
|||||||
предпросмотр рассчитывается при чтении очереди по текущему шаблону — тем же планировщиком, что и импорт.
|
предпросмотр рассчитывается при чтении очереди по текущему шаблону — тем же планировщиком, что и импорт.
|
||||||
Сохранение шаблона обновляет кеш очереди. Уже импортированные файлы сохраняют фактический путь.
|
Сохранение шаблона обновляет кеш очереди. Уже импортированные файлы сохраняют фактический путь.
|
||||||
Если рассчитать путь нельзя, показаны причина и действие для исправления вместо старого пути.
|
Если рассчитать путь нельзя, показаны причина и действие для исправления вместо старого пути.
|
||||||
|
Отсутствующий год отмечается предупреждением и не блокирует одобрение или импорт:
|
||||||
|
`{year}` опускается вместе со скобками или разделителем, необязательный фрагмент с годом опускается.
|
||||||
|
В теги неизвестный год не записывается.
|
||||||
- Прослушивание staging выдаёт сам API с поддержкой Range: приватные файлы tus (`0640`, каталоги `0750`)
|
- Прослушивание staging выдаёт сам API с поддержкой Range: приватные файлы tus (`0640`, каталоги `0750`)
|
||||||
недоступны пользователю nginx. Выдача не зависит от `X_ACCEL`; права существующих файлов менять не нужно.
|
недоступны пользователю nginx. Выдача не зависит от `X_ACCEL`; права существующих файлов менять не нужно.
|
||||||
Аудио и его метаданные загружаются только после Play (`preload="none"`); раскрытие пачки не запускает запросы к файлам.
|
Аудио и его метаданные загружаются только после Play (`preload="none"`); раскрытие пачки не запускает запросы к файлам.
|
||||||
|
|||||||
@@ -122,7 +122,8 @@
|
|||||||
"source_wave_seed": "Artist wave · {{label}}",
|
"source_wave_seed": "Artist wave · {{label}}",
|
||||||
"queue_wave": "The wave never ends: next tracks are picked as you listen",
|
"queue_wave": "The wave never ends: next tracks are picked as you listen",
|
||||||
"now_playing": "Now playing",
|
"now_playing": "Now playing",
|
||||||
"expand": "Open player"
|
"expand": "Open player",
|
||||||
|
"more": "More"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
|
|||||||
@@ -104,6 +104,7 @@
|
|||||||
"no_target": "No storage accepts uploads (or several do and none is the default)",
|
"no_target": "No storage accepts uploads (or several do and none is the default)",
|
||||||
"no_template": "The storage has no path template",
|
"no_template": "The storage has no path template",
|
||||||
"missing_field": "Not enough metadata to build the storage path",
|
"missing_field": "Not enough metadata to build the storage path",
|
||||||
|
"missing_year": "Year is missing — check the metadata. The year is omitted from the path",
|
||||||
"offset_mismatch": "The upload got out of sync; try again",
|
"offset_mismatch": "The upload got out of sync; try again",
|
||||||
"size_mismatch": "The file size does not match the declared one",
|
"size_mismatch": "The file size does not match the declared one",
|
||||||
"hash_mismatch": "The file was corrupted in transit (SHA-256 mismatch)",
|
"hash_mismatch": "The file was corrupted in transit (SHA-256 mismatch)",
|
||||||
|
|||||||
@@ -122,7 +122,8 @@
|
|||||||
"source_wave_seed": "Волна по исполнителю · {{label}}",
|
"source_wave_seed": "Волна по исполнителю · {{label}}",
|
||||||
"queue_wave": "Волна бесконечна: следующие треки подбираются по ходу прослушивания",
|
"queue_wave": "Волна бесконечна: следующие треки подбираются по ходу прослушивания",
|
||||||
"now_playing": "Сейчас играет",
|
"now_playing": "Сейчас играет",
|
||||||
"expand": "Открыть плеер"
|
"expand": "Открыть плеер",
|
||||||
|
"more": "Ещё"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Настройки",
|
"title": "Настройки",
|
||||||
|
|||||||
@@ -104,6 +104,7 @@
|
|||||||
"no_target": "Нет хранилища, принимающего загрузки (или их несколько и не выбрано по умолчанию)",
|
"no_target": "Нет хранилища, принимающего загрузки (или их несколько и не выбрано по умолчанию)",
|
||||||
"no_template": "У хранилища не задан шаблон пути",
|
"no_template": "У хранилища не задан шаблон пути",
|
||||||
"missing_field": "Не хватает метаданных для пути в хранилище",
|
"missing_field": "Не хватает метаданных для пути в хранилище",
|
||||||
|
"missing_year": "Не указан год — проверьте метаданные. Год пропущен в пути",
|
||||||
"offset_mismatch": "Загрузка рассинхронизировалась — повторите",
|
"offset_mismatch": "Загрузка рассинхронизировалась — повторите",
|
||||||
"size_mismatch": "Размер файла не совпал с заявленным",
|
"size_mismatch": "Размер файла не совпал с заявленным",
|
||||||
"hash_mismatch": "Файл повредился при загрузке (не совпал SHA-256)",
|
"hash_mismatch": "Файл повредился при загрузке (не совпал SHA-256)",
|
||||||
|
|||||||
@@ -254,6 +254,65 @@ func (t Template) Render(in TemplateInput) (string, error) {
|
|||||||
return finishPath(b.String())
|
return finishPath(b.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RenderWithoutMissingYear omits an unknown year and its punctuation for uploads.
|
||||||
|
// Other required metadata still uses strict rendering. Optional fragments retain
|
||||||
|
// their usual all-or-nothing semantics.
|
||||||
|
func (t Template) RenderWithoutMissingYear(in TemplateInput) (string, error) {
|
||||||
|
if fieldValue(in, tnode{field: "year"}) != "" {
|
||||||
|
return t.Render(in)
|
||||||
|
}
|
||||||
|
var src strings.Builder
|
||||||
|
for _, n := range t.nodes {
|
||||||
|
if n.isOpt {
|
||||||
|
if _, ok := renderOptional(in, n.opt); ok {
|
||||||
|
src.WriteString("[")
|
||||||
|
for _, o := range n.opt {
|
||||||
|
src.WriteString(templateNodeSource(o))
|
||||||
|
}
|
||||||
|
src.WriteString("]")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
src.WriteString(templateNodeSource(n))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s := src.String()
|
||||||
|
for _, re := range missingYearParts {
|
||||||
|
s = re.ReplaceAllString(s, "")
|
||||||
|
}
|
||||||
|
// A year-only directory disappears as a whole.
|
||||||
|
var segments []string
|
||||||
|
for _, seg := range strings.Split(s, "/") {
|
||||||
|
if strings.TrimSpace(seg) != "" {
|
||||||
|
segments = append(segments, seg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tpl, err := ParseTemplate(strings.Join(segments, "/"))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return tpl.Render(in)
|
||||||
|
}
|
||||||
|
|
||||||
|
var missingYearParts = []*regexp.Regexp{
|
||||||
|
regexp.MustCompile(`\(\s*\{year\}\s*\)`),
|
||||||
|
regexp.MustCompile(`\{year\}[ \t]*[-–—_][ \t]*`),
|
||||||
|
regexp.MustCompile(`[ \t]*[-–—_][ \t]*\{year\}`),
|
||||||
|
regexp.MustCompile(`\{year\}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
func templateNodeSource(n tnode) string {
|
||||||
|
if n.field == "" {
|
||||||
|
return n.lit
|
||||||
|
}
|
||||||
|
if n.multi {
|
||||||
|
return "{" + n.field + ":1" + n.suffix + "}"
|
||||||
|
}
|
||||||
|
if n.pad > 0 {
|
||||||
|
return fmt.Sprintf("{%s:0%d}", n.field, n.pad)
|
||||||
|
}
|
||||||
|
return "{" + n.field + "}"
|
||||||
|
}
|
||||||
|
|
||||||
func renderOptional(in TemplateInput, nodes []tnode) (string, bool) {
|
func renderOptional(in TemplateInput, nodes []tnode) (string, bool) {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
for _, n := range nodes {
|
for _, n := range nodes {
|
||||||
|
|||||||
@@ -7,6 +7,41 @@ import (
|
|||||||
|
|
||||||
const docTemplate = "{albumartist}/{year} - {album}/{disc:1-}{track:02} - {title}.{ext}"
|
const docTemplate = "{albumartist}/{year} - {album}/{disc:1-}{track:02} - {title}.{ext}"
|
||||||
|
|
||||||
|
func TestRenderWithoutMissingYear(t *testing.T) {
|
||||||
|
for _, pattern := range []string{
|
||||||
|
"{artist}/{album} ({year})/{track:02} - {title}.{ext}",
|
||||||
|
"{artist}/{year} - {album}/{track:02} - {title}.{ext}",
|
||||||
|
"{artist}/{album} - {year}/{track:02} - {title}.{ext}",
|
||||||
|
"{artist}/{year}/{album}/{track:02} - {title}.{ext}",
|
||||||
|
"{artist}/[{year} - ]{album}/{track:02} - {title}.{ext}",
|
||||||
|
} {
|
||||||
|
t.Run(pattern, func(t *testing.T) {
|
||||||
|
tpl, err := ParseTemplate(pattern)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
input := in(Fields{Artist: "A", Album: "B", Title: "C", Track: "1"}, 1, "mp3")
|
||||||
|
got, err := tpl.RenderWithoutMissingYear(input)
|
||||||
|
if err != nil || got != "A/B/01 - C.mp3" {
|
||||||
|
t.Fatalf("got %q, %v", got, err)
|
||||||
|
}
|
||||||
|
input.Fields.Title = ""
|
||||||
|
if _, err := tpl.RenderWithoutMissingYear(input); err == nil {
|
||||||
|
t.Fatal("missing title must still fail")
|
||||||
|
}
|
||||||
|
input.Fields.Title, input.Fields.Year = "C", "2026"
|
||||||
|
want, err := tpl.Render(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err = tpl.RenderWithoutMissingYear(input)
|
||||||
|
if err != nil || got != want {
|
||||||
|
t.Fatalf("known year: got %q, %v; want %q", got, err, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func in(f Fields, discs int, ext string) TemplateInput {
|
func in(f Fields, discs int, ext string) TemplateInput {
|
||||||
return TemplateInput{Fields: f, DiscCount: discs, Ext: ext}
|
return TemplateInput{Fields: f, DiscCount: discs, Ext: ext}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ type indexSpec struct {
|
|||||||
doc func(pgx.Rows) (map[string]any, error)
|
doc func(pgx.Rows) (map[string]any, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
var rankingRules = []string{"words", "typo", "proximity", "attribute", "sort", "exactness", "popularity:desc"}
|
var rankingRules = []string{"words", "typo", "exactness", "proximity", "attribute", "sort", "popularity:desc"}
|
||||||
|
|
||||||
var specs = []indexSpec{
|
var specs = []indexSpec{
|
||||||
{
|
{
|
||||||
@@ -43,7 +43,7 @@ var specs = []indexSpec{
|
|||||||
if err := r.Scan(&id, &name); err != nil {
|
if err := r.Scan(&id, &name); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return map[string]any{"id": id, "name": name, "name_translit": Translit(name), "popularity": 0}, nil
|
return map[string]any{"id": id, "name": name, "name_translit": TranslitVariants(name), "popularity": 0}, nil
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -65,7 +65,7 @@ var specs = []indexSpec{
|
|||||||
}
|
}
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"id": id, "title": title, "artist_name": artist, "year": year, "popularity": 0,
|
"id": id, "title": title, "artist_name": artist, "year": year, "popularity": 0,
|
||||||
"title_translit": Translit(title), "artist_translit": Translit(artist),
|
"title_translit": TranslitVariants(title), "artist_translit": TranslitVariants(artist),
|
||||||
}, nil
|
}, nil
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -97,12 +97,12 @@ var specs = []indexSpec{
|
|||||||
}
|
}
|
||||||
translit := make([]string, 0, len(artists))
|
translit := make([]string, 0, len(artists))
|
||||||
for _, a := range artists {
|
for _, a := range artists {
|
||||||
translit = append(translit, Translit(a))
|
translit = append(translit, TranslitVariants(a)...)
|
||||||
}
|
}
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"id": id, "title": title, "album_title": album, "year": year, "artist_names": artists,
|
"id": id, "title": title, "album_title": album, "year": year, "artist_names": artists,
|
||||||
"genres": genres, "available": available, "popularity": 0,
|
"genres": genres, "available": available, "popularity": 0,
|
||||||
"title_translit": Translit(title), "artist_translit": translit,
|
"title_translit": TranslitVariants(title), "artist_translit": translit,
|
||||||
}, nil
|
}, nil
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -141,13 +141,15 @@ type MultiQuery struct {
|
|||||||
Q string `json:"q"`
|
Q string `json:"q"`
|
||||||
Limit int `json:"limit"`
|
Limit int `json:"limit"`
|
||||||
Offset int `json:"offset,omitempty"`
|
Offset int `json:"offset,omitempty"`
|
||||||
|
MatchingStrategy string `json:"matchingStrategy,omitempty"`
|
||||||
AttributesToRetrieve []string `json:"attributesToRetrieve"`
|
AttributesToRetrieve []string `json:"attributesToRetrieve"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MultiResult — ответ на один запрос: id найденных документов и оценка общего числа.
|
// MultiResult — ответ на один запрос: id найденных документов и оценка общего числа.
|
||||||
type MultiResult struct {
|
type MultiResult struct {
|
||||||
IDs []string
|
IDs []string
|
||||||
Total int
|
Total int
|
||||||
|
ExactIDs []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// MultiSearch выполняет несколько запросов за один вызов.
|
// MultiSearch выполняет несколько запросов за один вызов.
|
||||||
@@ -155,7 +157,9 @@ func (m *Meili) MultiSearch(ctx context.Context, queries []MultiQuery) ([]MultiR
|
|||||||
var res struct {
|
var res struct {
|
||||||
Results []struct {
|
Results []struct {
|
||||||
Hits []struct {
|
Hits []struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
NameTranslit any `json:"name_translit"`
|
||||||
} `json:"hits"`
|
} `json:"hits"`
|
||||||
EstimatedTotalHits int `json:"estimatedTotalHits"`
|
EstimatedTotalHits int `json:"estimatedTotalHits"`
|
||||||
} `json:"results"`
|
} `json:"results"`
|
||||||
@@ -167,8 +171,32 @@ func (m *Meili) MultiSearch(ctx context.Context, queries []MultiQuery) ([]MultiR
|
|||||||
for i, r := range res.Results {
|
for i, r := range res.Results {
|
||||||
for _, h := range r.Hits {
|
for _, h := range r.Hits {
|
||||||
out[i].IDs = append(out[i].IDs, h.ID)
|
out[i].IDs = append(out[i].IDs, h.ID)
|
||||||
|
if exactArtistName(queries[i].Q, h.Name, h.NameTranslit) {
|
||||||
|
out[i].ExactIDs = append(out[i].ExactIDs, h.ID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
out[i].Total = r.EstimatedTotalHits
|
out[i].Total = r.EstimatedTotalHits
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func exactArtistName(query, name string, translit any) bool {
|
||||||
|
key := searchKey(query)
|
||||||
|
if key == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if searchKey(name) == key {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch v := translit.(type) {
|
||||||
|
case string: // indexes built before transliteration alternatives
|
||||||
|
return searchKey(v) == key
|
||||||
|
case []any:
|
||||||
|
for _, item := range v {
|
||||||
|
if s, ok := item.(string); ok && searchKey(s) == key {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -2,13 +2,49 @@ package search
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestQueryRequiresAllWordsAndProtectsExactArtist(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Queries []MultiQuery `json:"queries"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
|
||||||
|
require.Len(t, body.Queries, 3)
|
||||||
|
for _, q := range body.Queries {
|
||||||
|
require.Equal(t, "all", q.MatchingStrategy)
|
||||||
|
}
|
||||||
|
require.Contains(t, body.Queries[0].AttributesToRetrieve, "name_translit")
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"results":[{"hits":[{"id":"collab","name":"Boulevard Depo X 161"},{"id":"exact","name":"BOULEVARD DEPO","name_translit":["боулевард депо","бульвар депо"]}],"estimatedTotalHits":2},{"hits":[]},{"hits":[]}]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
h := &Handler{meili: NewMeili(srv.URL, ""), log: slog.Default()}
|
||||||
|
f, err := h.query(context.Background(), "бульвар депо", limits{artists: 3, albums: 3, tracks: 5})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, []string{"exact"}, f.exactArtists)
|
||||||
|
f = h.personal(context.Background(), f, limits{artists: 1})
|
||||||
|
require.Equal(t, []string{"exact"}, f.artists)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExactArtistName(t *testing.T) {
|
||||||
|
require.True(t, exactArtistName("boulevard depo", "BOULEVARD DEPO", nil))
|
||||||
|
require.True(t, exactArtistName("БУЛЬВАР ДЕПО", "BOULEVARD DEPO", []any{"бульвар депо"}))
|
||||||
|
require.True(t, exactArtistName("кино", "Kino", "кино"))
|
||||||
|
require.False(t, exactArtistName("boulevard depo", "Depo Boulevard", nil))
|
||||||
|
require.False(t, exactArtistName("boulevard depo", "Boulevard Depo X 161", nil))
|
||||||
|
require.False(t, exactArtistName("!", "", nil))
|
||||||
|
}
|
||||||
|
|
||||||
func TestDeleteIndexIfExists(t *testing.T) {
|
func TestDeleteIndexIfExists(t *testing.T) {
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -12,6 +12,24 @@ import (
|
|||||||
// suggestPool — во сколько раз больше кандидатов берётся из Meilisearch для переранжирования подсказок.
|
// suggestPool — во сколько раз больше кандидатов берётся из Meilisearch для переранжирования подсказок.
|
||||||
const suggestPool = 3
|
const suggestPool = 3
|
||||||
|
|
||||||
|
// Exact names stay ahead of related artists, regardless of listening history.
|
||||||
|
func personalizeArtists(found, exact []string, aff map[uuid.UUID]float64, n int) []string {
|
||||||
|
protected := make(map[string]bool, len(exact))
|
||||||
|
for _, id := range exact {
|
||||||
|
protected[id] = true
|
||||||
|
}
|
||||||
|
var first, rest []string
|
||||||
|
for _, id := range found {
|
||||||
|
if protected[id] {
|
||||||
|
first = append(first, id)
|
||||||
|
} else {
|
||||||
|
rest = append(rest, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
first = append(first, personalize(rest, aff, max(0, n-len(first)))...)
|
||||||
|
return first[:min(n, len(first))]
|
||||||
|
}
|
||||||
|
|
||||||
// Affinity — насколько пользователь «знает» исполнителей, альбомы и треки: прослушивания, доигрывания,
|
// Affinity — насколько пользователь «знает» исполнителей, альбомы и треки: прослушивания, доигрывания,
|
||||||
// лайки и дизлайки. Отрицательное — не любит. Нужен для персональных подсказок поиска.
|
// лайки и дизлайки. Отрицательное — не любит. Нужен для персональных подсказок поиска.
|
||||||
type Affinity struct{ pool *pgxpool.Pool }
|
type Affinity struct{ pool *pgxpool.Pool }
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package search
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Use a private index: never replace a running application's search indexes.
|
||||||
|
func TestArtistSearchRelevance(t *testing.T) {
|
||||||
|
url := os.Getenv("TEST_MEILI_URL")
|
||||||
|
if url == "" {
|
||||||
|
t.Skip("TEST_MEILI_URL is not set")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
m := NewMeili(url, os.Getenv("TEST_MEILI_KEY"))
|
||||||
|
index := "search_test_" + uuid.New().String()
|
||||||
|
require.NoError(t, m.call(ctx, http.MethodPost, "/indexes", map[string]any{"uid": index, "primaryKey": "id"}))
|
||||||
|
t.Cleanup(func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
require.NoError(t, m.deleteIndexIfExists(ctx, index))
|
||||||
|
})
|
||||||
|
require.NoError(t, m.call(ctx, http.MethodPatch, "/indexes/"+index+"/settings", specs[0].settings))
|
||||||
|
var docs []map[string]any
|
||||||
|
for id, name := range map[string]string{
|
||||||
|
"exact": "BOULEVARD DEPO", "collaboration": "Boulevard Depo X 161",
|
||||||
|
"reversed": "Depo Boulevard", "unrelated": "Thoma Bulwer",
|
||||||
|
"partial": "Станция бульвар малина",
|
||||||
|
} {
|
||||||
|
docs = append(docs, map[string]any{"id": id, "name": name, "name_translit": TranslitVariants(name), "popularity": 0})
|
||||||
|
}
|
||||||
|
require.NoError(t, m.call(ctx, http.MethodPost, "/indexes/"+index+"/documents", docs))
|
||||||
|
for _, query := range []string{"boulevard depo", "бульвар депо", "боулевард депо"} {
|
||||||
|
t.Run(query, func(t *testing.T) {
|
||||||
|
results, err := m.MultiSearch(ctx, []MultiQuery{{IndexUID: index, Q: query, Limit: 10,
|
||||||
|
MatchingStrategy: "all", AttributesToRetrieve: []string{"id", "name", "name_translit"}}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, results, 1)
|
||||||
|
require.NotEmpty(t, results[0].IDs)
|
||||||
|
require.Equal(t, "exact", results[0].IDs[0])
|
||||||
|
require.NotContains(t, results[0].IDs, "unrelated")
|
||||||
|
require.NotContains(t, results[0].IDs, "partial")
|
||||||
|
require.Contains(t, results[0].ExactIDs, "exact")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ type limits struct{ artists, albums, tracks, offset int }
|
|||||||
|
|
||||||
type found struct {
|
type found struct {
|
||||||
artists, albums, tracks []string
|
artists, albums, tracks []string
|
||||||
|
exactArtists []string
|
||||||
totals [3]int
|
totals [3]int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +51,11 @@ func (h *Handler) query(ctx context.Context, q string, l limits) (found, error)
|
|||||||
var queries []MultiQuery
|
var queries []MultiQuery
|
||||||
add := func(index string, n int) {
|
add := func(index string, n int) {
|
||||||
if n > 0 {
|
if n > 0 {
|
||||||
queries = append(queries, MultiQuery{IndexUID: index, Q: q, Limit: n, Offset: l.offset, AttributesToRetrieve: []string{"id"}})
|
attrs := []string{"id"}
|
||||||
|
if index == IndexArtists {
|
||||||
|
attrs = append(attrs, "name", "name_translit")
|
||||||
|
}
|
||||||
|
queries = append(queries, MultiQuery{IndexUID: index, Q: q, Limit: n, Offset: l.offset, MatchingStrategy: "all", AttributesToRetrieve: attrs})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
add(IndexArtists, l.artists)
|
add(IndexArtists, l.artists)
|
||||||
@@ -71,6 +76,7 @@ func (h *Handler) query(ctx context.Context, q string, l limits) (found, error)
|
|||||||
switch k {
|
switch k {
|
||||||
case 0:
|
case 0:
|
||||||
f.artists = res[i].IDs
|
f.artists = res[i].IDs
|
||||||
|
f.exactArtists = res[i].ExactIDs
|
||||||
case 1:
|
case 1:
|
||||||
f.albums = res[i].IDs
|
f.albums = res[i].IDs
|
||||||
case 2:
|
case 2:
|
||||||
@@ -181,7 +187,7 @@ func (h *Handler) personal(ctx context.Context, f found, want limits) found {
|
|||||||
h.log.WarnContext(ctx, "suggest personalization failed", "err", err)
|
h.log.WarnContext(ctx, "suggest personalization failed", "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
f.artists = personalize(f.artists, aff, want.artists)
|
f.artists = personalizeArtists(f.artists, f.exactArtists, aff, want.artists)
|
||||||
f.albums = personalize(f.albums, aff, want.albums)
|
f.albums = personalize(f.albums, aff, want.albums)
|
||||||
f.tracks = personalize(f.tracks, aff, want.tracks)
|
f.tracks = personalize(f.tracks, aff, want.tracks)
|
||||||
return f
|
return f
|
||||||
|
|||||||
@@ -6,6 +6,36 @@ import (
|
|||||||
"unicode"
|
"unicode"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Established spellings cannot always be derived letter by letter. Keep these
|
||||||
|
// as whole-word alternatives, retaining the mechanical transliteration too.
|
||||||
|
var pronunciationWords = map[string]string{"boulevard": "бульвар"}
|
||||||
|
|
||||||
|
func TranslitVariants(s string) []string {
|
||||||
|
out := []string{}
|
||||||
|
if mechanical := Translit(s); mechanical != "" {
|
||||||
|
out = append(out, mechanical)
|
||||||
|
}
|
||||||
|
words := strings.Fields(strings.ToLower(s))
|
||||||
|
changed := false
|
||||||
|
for i, word := range words {
|
||||||
|
if spoken, ok := pronunciationWords[word]; ok {
|
||||||
|
words[i], changed = spoken, true
|
||||||
|
} else if translated := Translit(word); translated != "" {
|
||||||
|
words[i] = translated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
out = append(out, strings.Join(words, " "))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func searchKey(s string) string {
|
||||||
|
return strings.Join(strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
|
||||||
|
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||||
|
}), " ")
|
||||||
|
}
|
||||||
|
|
||||||
// Кириллица → латиница (упрощённая ГОСТ-подобная схема: «Кино» → «kino», «Щедрин» → «shchedrin»).
|
// Кириллица → латиница (упрощённая ГОСТ-подобная схема: «Кино» → «kino», «Щедрин» → «shchedrin»).
|
||||||
var cyrToLat = map[rune]string{
|
var cyrToLat = map[rune]string{
|
||||||
'а': "a", 'б': "b", 'в': "v", 'г': "g", 'д': "d", 'е': "e", 'ё': "e", 'ж': "zh", 'з': "z", 'и': "i",
|
'а': "a", 'б': "b", 'в': "v", 'г': "g", 'д': "d", 'е': "e", 'ё': "e", 'ж': "zh", 'з': "z", 'и': "i",
|
||||||
|
|||||||
@@ -16,6 +16,23 @@ func TestTranslit(t *testing.T) {
|
|||||||
assert.Equal(t, "", Translit("1984"))
|
assert.Equal(t, "", Translit("1984"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTranslitVariants(t *testing.T) {
|
||||||
|
assert.Equal(t, []string{"боулевард депо", "бульвар депо"}, TranslitVariants("BOULEVARD DEPO"))
|
||||||
|
assert.Equal(t, []string{"боулевард депо кс 161", "бульвар депо кс 161"}, TranslitVariants("Boulevard Depo X 161"))
|
||||||
|
assert.Equal(t, []string{"kino"}, TranslitVariants("Кино"))
|
||||||
|
assert.Empty(t, TranslitVariants("1984"))
|
||||||
|
assert.Equal(t, []string{"боулевардиер"}, TranslitVariants("Boulevardier"), "whole words only")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPersonalizeKeepsExactArtistFirst(t *testing.T) {
|
||||||
|
exact, collaboration, reversed := uuid.NewString(), uuid.NewString(), uuid.NewString()
|
||||||
|
found := []string{collaboration, reversed, exact}
|
||||||
|
aff := map[uuid.UUID]float64{uuid.MustParse(collaboration): 10000, uuid.MustParse(exact): -100}
|
||||||
|
assert.Equal(t, []string{exact, collaboration}, personalizeArtists(found, []string{exact}, aff, 2))
|
||||||
|
assert.Equal(t, []string{exact}, personalizeArtists(found, []string{exact}, aff, 1))
|
||||||
|
assert.Equal(t, found[:2], personalizeArtists(found, nil, nil, 2))
|
||||||
|
}
|
||||||
|
|
||||||
func TestSwitchLayout(t *testing.T) {
|
func TestSwitchLayout(t *testing.T) {
|
||||||
assert.Equal(t, "привет", SwitchLayout("ghbdtn"))
|
assert.Equal(t, "привет", SwitchLayout("ghbdtn"))
|
||||||
assert.Equal(t, "hello", SwitchLayout("руддщ"))
|
assert.Equal(t, "hello", SwitchLayout("руддщ"))
|
||||||
|
|||||||
@@ -143,8 +143,8 @@ func (a *agent) writeFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// write копирует staging сразу под итоговым именем (O_EXCL — существующий файл не перезаписывается, при
|
// write копирует staging сразу под итоговым именем (O_EXCL — существующий файл не перезаписывается, при
|
||||||
// конфликте — « (2)»), fsync, сверяет SHA-256 копии. На музыкальный диск не попадает ничего, кроме самого трека:
|
// конфликте — « (2)»), fsync, сверяет SHA-256 копии. На музыкальный диск не попадает ничего, кроме трека и его
|
||||||
// ни временных файлов, ни служебных пометок. При любой ошибке недописанный файл и созданные для него пустые
|
// картинок cover/artist: ни временных файлов, ни служебных пометок. При любой ошибке недописанный файл и созданные для него пустые
|
||||||
// каталоги удаляются — агент убирает только то, что создал сам.
|
// каталоги удаляются — агент убирает только то, что создал сам.
|
||||||
func (a *agent) write(root, rel, staging string, want []byte) (WriteResult, error) {
|
func (a *agent) write(root, rel, staging string, want []byte) (WriteResult, error) {
|
||||||
dir := filepath.Dir(filepath.Join(root, filepath.FromSlash(rel)))
|
dir := filepath.Dir(filepath.Join(root, filepath.FromSlash(rel)))
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ const (
|
|||||||
CodeSizeMismatch = "uploads.size_mismatch"
|
CodeSizeMismatch = "uploads.size_mismatch"
|
||||||
CodeHashMismatch = "uploads.hash_mismatch"
|
CodeHashMismatch = "uploads.hash_mismatch"
|
||||||
CodeMissingField = "uploads.missing_field"
|
CodeMissingField = "uploads.missing_field"
|
||||||
|
CodeMissingYear = "uploads.missing_year"
|
||||||
CodeImportFailed = "uploads.import_failed"
|
CodeImportFailed = "uploads.import_failed"
|
||||||
CodeTemplate = "uploads.no_template"
|
CodeTemplate = "uploads.no_template"
|
||||||
)
|
)
|
||||||
@@ -80,6 +81,17 @@ type Meta struct {
|
|||||||
Disc *int `json:"disc,omitempty"`
|
Disc *int `json:"disc,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PrimaryArtist — первый исполнитель альбома (при пустом album artist — трека; « & » имя не делит): «A;B» и «A feat. B»
|
||||||
|
// лежат в папке «A», как и в каталоге, где альбом принадлежит первому исполнителю.
|
||||||
|
func (m Meta) PrimaryArtist() string {
|
||||||
|
for _, field := range []string{m.AlbumArtist, m.Artist} {
|
||||||
|
if credits := meta.SplitArtists(field, func(string) bool { return true }); len(credits) > 0 {
|
||||||
|
return credits[0].Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// Fields — значения для шаблона пути.
|
// Fields — значения для шаблона пути.
|
||||||
func (m Meta) Fields() meta.Fields {
|
func (m Meta) Fields() meta.Fields {
|
||||||
num := func(p *int) string {
|
num := func(p *int) string {
|
||||||
@@ -89,7 +101,7 @@ func (m Meta) Fields() meta.Fields {
|
|||||||
return strconv.Itoa(*p)
|
return strconv.Itoa(*p)
|
||||||
}
|
}
|
||||||
return meta.Fields{
|
return meta.Fields{
|
||||||
Artist: m.Artist, AlbumArtist: m.AlbumArtist, Album: m.Album, Title: m.Title, Genre: m.Genre,
|
Artist: m.Artist, AlbumArtist: m.PrimaryArtist(), Album: m.Album, Title: m.Title, Genre: m.Genre,
|
||||||
Year: num(m.Year), Track: num(m.Track), Disc: num(m.Disc),
|
Year: num(m.Year), Track: num(m.Track), Disc: num(m.Disc),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ type PlacementPlanner struct {
|
|||||||
|
|
||||||
// --- Раскладка в хранилище ---
|
// --- Раскладка в хранилище ---
|
||||||
|
|
||||||
// placement — куда ляжет файл; Reason — почему не получится (код), путь тогда nil.
|
// placement — куда ляжет файл; Reason при ненулевом пути — предупреждение.
|
||||||
type placement struct {
|
type placement struct {
|
||||||
SourceID *uuid.UUID
|
SourceID *uuid.UUID
|
||||||
RelPath *string
|
RelPath *string
|
||||||
@@ -123,7 +123,7 @@ func (s *PlacementPlanner) plan(ctx context.Context, f File, m Meta, chosen *uui
|
|||||||
pl placement
|
pl placement
|
||||||
discs int // дисков в альбоме: в каталоге, в пачке, у самого файла
|
discs int // дисков в альбоме: в каталоге, в пачке, у самого файла
|
||||||
)
|
)
|
||||||
if artist, album, maxDisc, err := s.store.MatchCatalog(ctx, meta.Key(m.AlbumArtist), meta.Key(m.Album)); err == nil {
|
if artist, album, maxDisc, err := s.store.MatchCatalog(ctx, meta.Key(m.PrimaryArtist()), meta.Key(m.Album)); err == nil {
|
||||||
pl.ArtistID, pl.AlbumID, discs = artist, album, maxDisc
|
pl.ArtistID, pl.AlbumID, discs = artist, album, maxDisc
|
||||||
}
|
}
|
||||||
src, reason := s.pickSource(ctx, chosen)
|
src, reason := s.pickSource(ctx, chosen)
|
||||||
@@ -147,7 +147,7 @@ func (s *PlacementPlanner) plan(ctx context.Context, f File, m Meta, chosen *uui
|
|||||||
if m.Disc != nil {
|
if m.Disc != nil {
|
||||||
discs = max(discs, *m.Disc)
|
discs = max(discs, *m.Disc)
|
||||||
}
|
}
|
||||||
rel, err := tpl.Render(meta.TemplateInput{Fields: m.Fields(), DiscCount: discs, Ext: extFor(f)})
|
rel, err := tpl.RenderWithoutMissingYear(meta.TemplateInput{Fields: m.Fields(), DiscCount: discs, Ext: extFor(f)})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var mf *meta.MissingFieldError
|
var mf *meta.MissingFieldError
|
||||||
if errors.As(err, &mf) {
|
if errors.As(err, &mf) {
|
||||||
@@ -164,5 +164,8 @@ func (s *PlacementPlanner) plan(ctx context.Context, f File, m Meta, chosen *uui
|
|||||||
}
|
}
|
||||||
rel = reuseExisting(root, rel)
|
rel = reuseExisting(root, rel)
|
||||||
pl.RelPath, pl.root = &rel, root
|
pl.RelPath, pl.root = &rel, root
|
||||||
|
if m.Year == nil || *m.Year <= 0 {
|
||||||
|
pl.Reason = CodeMissingYear
|
||||||
|
}
|
||||||
return pl
|
return pl
|
||||||
}
|
}
|
||||||
@@ -33,6 +33,28 @@ func (previewStore) BatchMaxDisc(context.Context, uuid.UUID, string) (int, error
|
|||||||
|
|
||||||
type previewRoot string
|
type previewRoot string
|
||||||
|
|
||||||
|
func TestPreviewMissingYearStillHasImportPath(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
catalog := &previewCatalog{source: library.Source{ID: uuid.New(), Name: "Base", Type: library.SourceLocal,
|
||||||
|
AcceptsUploads: true, WriteTemplate: nullable("{albumartist}/{albumartist} - {album} ({year})/{title}.{ext}")}}
|
||||||
|
planner := &PlacementPlanner{store: previewStore{}, lib: catalog, roots: previewRoot(root)}
|
||||||
|
f := File{Status: StatusPendingReview, OriginalName: "song.mp3", Meta: &Meta{
|
||||||
|
Artist: "Artist feat. Guest", AlbumArtist: "Artist", Album: "Album", Title: "Song"}}
|
||||||
|
planner.previewPlacement(context.Background(), &f)
|
||||||
|
require.NotNil(t, f.TargetRelPath)
|
||||||
|
require.Equal(t, "Artist/Artist - Album/Song.mp3", *f.TargetRelPath)
|
||||||
|
require.NotNil(t, f.TargetStoragePath)
|
||||||
|
require.Equal(t, CodeMissingYear, *f.StatusReason)
|
||||||
|
pl := planner.plan(context.Background(), f, *f.Meta, f.TargetSourceID)
|
||||||
|
require.Equal(t, f.TargetRelPath, pl.RelPath)
|
||||||
|
require.Nil(t, f.Meta.Year, "path rendering must not invent metadata")
|
||||||
|
year := 2026
|
||||||
|
f.Meta.Year = &year
|
||||||
|
planner.previewPlacement(context.Background(), &f)
|
||||||
|
require.Equal(t, "Artist/Artist - Album (2026)/Song.mp3", *f.TargetRelPath)
|
||||||
|
require.Nil(t, f.StatusReason)
|
||||||
|
}
|
||||||
|
|
||||||
func (r previewRoot) Root(library.Source) (string, error) { return string(r), nil }
|
func (r previewRoot) Root(library.Source) (string, error) { return string(r), nil }
|
||||||
|
|
||||||
func TestPreviewUsesCurrentImportTemplate(t *testing.T) {
|
func TestPreviewUsesCurrentImportTemplate(t *testing.T) {
|
||||||
@@ -42,8 +64,9 @@ func TestPreviewUsesCurrentImportTemplate(t *testing.T) {
|
|||||||
AcceptsUploads: true, WriteTemplate: nullable("{albumartist}/{album}/{title}.{ext}")}}
|
AcceptsUploads: true, WriteTemplate: nullable("{albumartist}/{album}/{title}.{ext}")}}
|
||||||
planner := &PlacementPlanner{store: previewStore{}, lib: catalog, roots: previewRoot(root)}
|
planner := &PlacementPlanner{store: previewStore{}, lib: catalog, roots: previewRoot(root)}
|
||||||
track := 3
|
track := 3
|
||||||
|
year := 2026
|
||||||
f := File{Status: StatusPendingReview, OriginalName: "song.flac", Meta: &Meta{
|
f := File{Status: StatusPendingReview, OriginalName: "song.flac", Meta: &Meta{
|
||||||
Artist: "Artist", AlbumArtist: "Artist", Album: "Album", Title: "Song", Track: &track},
|
Artist: "Artist", AlbumArtist: "Artist", Album: "Album", Title: "Song", Track: &track, Year: &year},
|
||||||
TargetSourceID: &catalog.source.ID, TargetRelPath: nullable("stale/path.flac"), StatusReason: nullable(CodeNoTarget)}
|
TargetSourceID: &catalog.source.ID, TargetRelPath: nullable("stale/path.flac"), StatusReason: nullable(CodeNoTarget)}
|
||||||
planner.previewPlacement(ctx, &f)
|
planner.previewPlacement(ctx, &f)
|
||||||
require.Equal(t, "Artist/Album/Song.flac", *f.TargetRelPath)
|
require.Equal(t, "Artist/Album/Song.flac", *f.TargetRelPath)
|
||||||
|
|||||||
@@ -452,7 +452,7 @@ func (s *Service) reviewOne(ctx context.Context, reviewer, id uuid.UUID, approve
|
|||||||
return apperr.Conflict(CodeMissingField, "file has no metadata")
|
return apperr.Conflict(CodeMissingField, "file has no metadata")
|
||||||
}
|
}
|
||||||
pl := s.plan(ctx, f, *f.Meta, f.TargetSourceID)
|
pl := s.plan(ctx, f, *f.Meta, f.TargetSourceID)
|
||||||
if pl.Reason != "" {
|
if pl.Reason != "" && pl.RelPath == nil {
|
||||||
return apperr.Conflict(pl.Reason, "cannot place the file into a storage")
|
return apperr.Conflict(pl.Reason, "cannot place the file into a storage")
|
||||||
}
|
}
|
||||||
reason = ""
|
reason = ""
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package uploads
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"pipu/api/internal/library"
|
||||||
|
"pipu/api/internal/storageagent"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
albumFieldRe = regexp.MustCompile(`\{album[:}]`)
|
||||||
|
artistFieldRe = regexp.MustCompile(`\{(albumartist|artist)[:}]`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Имена картинок, которые сканер библиотеки уже считает обложкой альбома / фотографией исполнителя.
|
||||||
|
var (
|
||||||
|
coverSidecarNames = []string{"cover", "folder", "front", "album", "albumart"}
|
||||||
|
artistSidecarNames = []string{"artist"}
|
||||||
|
)
|
||||||
|
|
||||||
|
// sidecarDirs — каталоги альбома и исполнителя внутри rel по структуре шаблона записи.
|
||||||
|
// "" — шаблон не позволяет определить такой каталог (плоская раскладка, необязательные сегменты).
|
||||||
|
func sidecarDirs(template, rel string) (albumDir, artistDir string) {
|
||||||
|
tpl, got := strings.Split(template, "/"), strings.Split(rel, "/")
|
||||||
|
if len(tpl) != len(got) {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
album, artist := -1, -1
|
||||||
|
for i, seg := range tpl[:len(tpl)-1] {
|
||||||
|
if album < 0 && albumFieldRe.MatchString(seg) {
|
||||||
|
album = i
|
||||||
|
}
|
||||||
|
if artist < 0 && artistFieldRe.MatchString(seg) {
|
||||||
|
artist = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if album >= 0 {
|
||||||
|
albumDir = strings.Join(got[:album+1], "/")
|
||||||
|
}
|
||||||
|
if artist >= 0 && artist != album {
|
||||||
|
artistDir = strings.Join(got[:artist+1], "/")
|
||||||
|
}
|
||||||
|
return albumDir, artistDir
|
||||||
|
}
|
||||||
|
|
||||||
|
func imageExt(data []byte) string {
|
||||||
|
switch http.DetectContentType(data) {
|
||||||
|
case "image/jpeg":
|
||||||
|
return ".jpg"
|
||||||
|
case "image/png":
|
||||||
|
return ".png"
|
||||||
|
case "image/webp":
|
||||||
|
return ".webp"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasSidecar(dir string, names []string) bool {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ext := strings.ToLower(filepath.Ext(e.Name()))
|
||||||
|
base := strings.ToLower(strings.TrimSuffix(e.Name(), filepath.Ext(e.Name())))
|
||||||
|
if ext != ".jpg" && ext != ".jpeg" && ext != ".png" && ext != ".webp" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, n := range names {
|
||||||
|
if base == n {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// placeSidecars кладёт cover.* в папку альбома и artist.* в папку исполнителя рядом с импортированным треком.
|
||||||
|
// Существующие картинки не трогаются; сбой не отменяет импорт трека — картинку добавит следующий файл пачки.
|
||||||
|
func (s *Service) placeSidecars(ctx context.Context, f File, pl placement, rel string) {
|
||||||
|
if pl.src.WriteTemplate == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
albumDir, artistDir := sidecarDirs(*pl.src.WriteTemplate, rel)
|
||||||
|
if albumDir != "" && f.CoverPath != nil && *f.CoverPath != "" {
|
||||||
|
s.placeSidecar(ctx, pl, *f.CoverPath, albumDir, "cover", coverSidecarNames)
|
||||||
|
}
|
||||||
|
if artistDir != "" && f.ArtistImagePath != nil && *f.ArtistImagePath != "" {
|
||||||
|
s.placeSidecar(ctx, pl, *f.ArtistImagePath, artistDir, "artist", artistSidecarNames)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) placeSidecar(ctx context.Context, pl placement, staging, dir, name string, existing []string) {
|
||||||
|
log := s.log.With("source_id", pl.src.ID, "dir", dir, "image", name)
|
||||||
|
data, err := os.ReadFile(s.stagingAbs(staging))
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("upload sidecar: read staging", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ext := imageExt(data)
|
||||||
|
if ext == "" || hasSidecar(filepath.Join(pl.root, filepath.FromSlash(dir)), existing) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
req := storageagent.WriteRequest{SourceID: pl.src.ID.String(), RelPath: path.Join(dir, name+ext), StagingPath: staging, SHA256: hex.EncodeToString(sum[:])}
|
||||||
|
if pl.src.Type == library.SourceLocal {
|
||||||
|
lp := ""
|
||||||
|
if pl.src.LocalPath != nil {
|
||||||
|
lp = *pl.src.LocalPath
|
||||||
|
}
|
||||||
|
req.LocalPath = &lp
|
||||||
|
}
|
||||||
|
if _, err := s.agent.WriteFile(ctx, req); err != nil {
|
||||||
|
log.Warn("upload sidecar: write", "err", fmt.Errorf("storage-agent: %w", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package uploads
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSidecarDirs(t *testing.T) {
|
||||||
|
tpl := "{albumartist}/{year} - {album}/{disc:1-}{track:02} - {title}.{ext}"
|
||||||
|
album, artist := sidecarDirs(tpl, "Artist/2020 - Album/01 - Song.flac")
|
||||||
|
require.Equal(t, "Artist/2020 - Album", album)
|
||||||
|
require.Equal(t, "Artist", artist)
|
||||||
|
|
||||||
|
album, artist = sidecarDirs("{albumartist}/{album}/{disc:1}/{track:02} - {title}.{ext}", "A/B/1/01 - S.flac")
|
||||||
|
require.Equal(t, "A/B", album)
|
||||||
|
require.Equal(t, "A", artist)
|
||||||
|
|
||||||
|
album, artist = sidecarDirs("{albumartist} - {album}/{title}.{ext}", "A - B/S.flac")
|
||||||
|
require.Equal(t, "A - B", album)
|
||||||
|
require.Empty(t, artist)
|
||||||
|
|
||||||
|
album, artist = sidecarDirs("{albumartist} - {title}.{ext}", "A - S.flac")
|
||||||
|
require.Empty(t, album)
|
||||||
|
require.Empty(t, artist)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrimaryArtistKeepsFeaturingInFirstArtistFolder(t *testing.T) {
|
||||||
|
require.Equal(t, "BOULEVARD DEPO", Meta{AlbumArtist: "BOULEVARD DEPO;JEEMBO"}.PrimaryArtist())
|
||||||
|
require.Equal(t, "BOULEVARD DEPO", Meta{Artist: "BOULEVARD DEPO feat. SP4K"}.PrimaryArtist())
|
||||||
|
require.Equal(t, "Simon & Garfunkel", Meta{AlbumArtist: "Simon & Garfunkel"}.Fields().AlbumArtist)
|
||||||
|
}
|
||||||
@@ -298,7 +298,7 @@ func (s *Service) importFile(ctx context.Context, f File) error {
|
|||||||
return errors.New("no metadata")
|
return errors.New("no metadata")
|
||||||
}
|
}
|
||||||
pl := s.plan(ctx, f, *f.Meta, f.TargetSourceID)
|
pl := s.plan(ctx, f, *f.Meta, f.TargetSourceID)
|
||||||
if pl.Reason != "" {
|
if pl.Reason != "" && pl.RelPath == nil {
|
||||||
return errors.New(pl.Reason)
|
return errors.New(pl.Reason)
|
||||||
}
|
}
|
||||||
rel := *pl.RelPath
|
rel := *pl.RelPath
|
||||||
@@ -343,6 +343,7 @@ func (s *Service) importFile(ctx context.Context, f File) error {
|
|||||||
return fmt.Errorf("artist image: %w", err)
|
return fmt.Errorf("artist image: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
s.placeSidecars(ctx, f, pl, rel)
|
||||||
if err := s.store.CompleteImport(ctx, f.ID, pl.src.ID, rel, fileID); err != nil {
|
if err := s.store.CompleteImport(ctx, f.ID, pl.src.ID, rel, fileID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user