@involvex/youtube-music-cli 0.0.33 → 0.0.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/dist/source/components/layouts/HistoryLayout.d.ts +1 -0
- package/dist/source/components/layouts/HistoryLayout.js +37 -0
- package/dist/source/components/layouts/MainLayout.js +7 -0
- package/dist/source/components/layouts/SearchLayout.js +71 -7
- package/dist/source/main.js +2 -1
- package/dist/source/services/history/history.service.d.ts +4 -0
- package/dist/source/services/history/history.service.js +83 -0
- package/dist/source/services/player/player.service.d.ts +4 -0
- package/dist/source/services/player/player.service.js +46 -36
- package/dist/source/stores/history.store.d.ts +11 -0
- package/dist/source/stores/history.store.js +64 -0
- package/dist/source/stores/navigation.store.js +17 -0
- package/dist/source/types/actions.d.ts +8 -1
- package/dist/source/types/history.types.d.ts +10 -0
- package/dist/source/types/history.types.js +1 -0
- package/dist/source/types/navigation.types.d.ts +4 -2
- package/dist/source/types/youtube-music.types.d.ts +7 -0
- package/dist/source/utils/constants.d.ts +6 -0
- package/dist/source/utils/constants.js +6 -0
- package/dist/source/utils/search-filters.d.ts +2 -0
- package/dist/source/utils/search-filters.js +100 -0
- package/dist/youtube-music-cli.exe +0 -0
- package/package.json +1 -1
- package/readme.md +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.0.35](https://github.com/involvex/youtube-music-cli/compare/v0.0.34...v0.0.35) (2026-02-22)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
- add history layout for recently played tracks ([a33c93c](https://github.com/involvex/youtube-music-cli/commit/a33c93c485e8e0e42a4d233cb60b06f4c08452cb))
|
|
6
|
+
|
|
7
|
+
## [0.0.34](https://github.com/involvex/youtube-music-cli/compare/v0.0.33...v0.0.34) (2026-02-22)
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
- **search:** implement search filters by artist, album, and year ([d3edbe6](https://github.com/involvex/youtube-music-cli/commit/d3edbe6b61fd0afa363e1c19b420d73fd6ebbab7))
|
|
12
|
+
|
|
1
13
|
## [0.0.33](https://github.com/involvex/youtube-music-cli/compare/v0.0.32...v0.0.33) (2026-02-22)
|
|
2
14
|
|
|
3
15
|
### Features
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default function HistoryLayout(): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { useTheme } from "../../hooks/useTheme.js";
|
|
4
|
+
import { useHistory } from "../../stores/history.store.js";
|
|
5
|
+
import { useTerminalSize } from "../../hooks/useTerminalSize.js";
|
|
6
|
+
import { truncate } from "../../utils/format.js";
|
|
7
|
+
import { useKeyBinding } from "../../hooks/useKeyboard.js";
|
|
8
|
+
import { KEYBINDINGS } from "../../utils/constants.js";
|
|
9
|
+
import { useNavigation } from "../../hooks/useNavigation.js";
|
|
10
|
+
const DATE_FORMATTER = new Intl.DateTimeFormat(undefined, {
|
|
11
|
+
dateStyle: 'medium',
|
|
12
|
+
timeStyle: 'short',
|
|
13
|
+
});
|
|
14
|
+
function formatTimestamp(iso) {
|
|
15
|
+
const date = new Date(iso);
|
|
16
|
+
if (Number.isNaN(date.getTime())) {
|
|
17
|
+
return iso;
|
|
18
|
+
}
|
|
19
|
+
return DATE_FORMATTER.format(date);
|
|
20
|
+
}
|
|
21
|
+
export default function HistoryLayout() {
|
|
22
|
+
const { theme } = useTheme();
|
|
23
|
+
const { history } = useHistory();
|
|
24
|
+
const { columns } = useTerminalSize();
|
|
25
|
+
const { dispatch } = useNavigation();
|
|
26
|
+
useKeyBinding(KEYBINDINGS.BACK, () => {
|
|
27
|
+
dispatch({ category: 'GO_BACK' });
|
|
28
|
+
});
|
|
29
|
+
const maxTitleLength = Math.max(30, columns - 20);
|
|
30
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, gap: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: theme.colors.primary, bold: true, children: "Recently Played" }) }), history.length === 0 ? (_jsx(Text, { color: theme.colors.dim, children: "No listening history yet." })) : (history.map(entry => {
|
|
31
|
+
const artists = entry.track.artists
|
|
32
|
+
?.map(artist => artist.name)
|
|
33
|
+
.join(', ')
|
|
34
|
+
.trim();
|
|
35
|
+
return (_jsxs(Box, { flexDirection: "column", paddingY: 1, borderStyle: "round", borderColor: theme.colors.dim, children: [_jsx(Text, { color: theme.colors.secondary, children: formatTimestamp(entry.playedAt) }), _jsxs(Box, { children: [_jsx(Text, { color: theme.colors.text, bold: true, children: truncate(entry.track.title, maxTitleLength) }), _jsx(Text, { color: theme.colors.dim, children: artists ? ` • ${artists}` : '' })] }), entry.track.album?.name && (_jsxs(Text, { color: theme.colors.dim, children: ["Album: ", entry.track.album.name] }))] }, `${entry.playedAt}-${entry.track.videoId}`));
|
|
36
|
+
})), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.colors.dim, children: "Esc to go back \u2022 Shift+H to reopen history" }) })] }));
|
|
37
|
+
}
|
|
@@ -20,6 +20,7 @@ import SearchHistory from "../search/SearchHistory.js";
|
|
|
20
20
|
import KeybindingsLayout from "../config/KeybindingsLayout.js";
|
|
21
21
|
import TrendingLayout from "./TrendingLayout.js";
|
|
22
22
|
import ExploreLayout from "./ExploreLayout.js";
|
|
23
|
+
import HistoryLayout from "./HistoryLayout.js";
|
|
23
24
|
import ImportLayout from "../import/ImportLayout.js";
|
|
24
25
|
import ExportLayout from "../export/ExportLayout.js";
|
|
25
26
|
import { KEYBINDINGS, VIEW } from "../../utils/constants.js";
|
|
@@ -51,6 +52,9 @@ function MainLayout() {
|
|
|
51
52
|
const goToSettings = useCallback(() => {
|
|
52
53
|
dispatch({ category: 'NAVIGATE', view: VIEW.SETTINGS });
|
|
53
54
|
}, [dispatch]);
|
|
55
|
+
const goToHistory = useCallback(() => {
|
|
56
|
+
dispatch({ category: 'NAVIGATE', view: VIEW.HISTORY });
|
|
57
|
+
}, [dispatch]);
|
|
54
58
|
const goToHelp = useCallback(() => {
|
|
55
59
|
if (navState.currentView === VIEW.HELP) {
|
|
56
60
|
dispatch({ category: 'GO_BACK' });
|
|
@@ -125,6 +129,7 @@ function MainLayout() {
|
|
|
125
129
|
useKeyBinding(KEYBINDINGS.PLAYLISTS, goToPlaylists);
|
|
126
130
|
useKeyBinding(KEYBINDINGS.PLUGINS, goToPlugins);
|
|
127
131
|
useKeyBinding(KEYBINDINGS.SUGGESTIONS, goToSuggestions);
|
|
132
|
+
useKeyBinding(KEYBINDINGS.HISTORY, goToHistory);
|
|
128
133
|
useKeyBinding(KEYBINDINGS.SETTINGS, goToSettings);
|
|
129
134
|
useKeyBinding(KEYBINDINGS.HELP, goToHelp);
|
|
130
135
|
useKeyBinding(['M'], togglePlayerMode);
|
|
@@ -154,6 +159,8 @@ function MainLayout() {
|
|
|
154
159
|
return _jsx(PlaylistList, {}, "playlists");
|
|
155
160
|
case 'suggestions':
|
|
156
161
|
return _jsx(Suggestions, {}, "suggestions");
|
|
162
|
+
case 'history':
|
|
163
|
+
return _jsx(HistoryLayout, {}, "history");
|
|
157
164
|
case 'settings':
|
|
158
165
|
return _jsx(Settings, {}, "settings");
|
|
159
166
|
case 'plugins':
|
|
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import { useNavigation } from "../../hooks/useNavigation.js";
|
|
4
4
|
import { useYouTubeMusic } from "../../hooks/useYouTubeMusic.js";
|
|
5
5
|
import SearchResults from "../search/SearchResults.js";
|
|
6
|
-
import { useState, useCallback, useEffect, useRef } from 'react';
|
|
6
|
+
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
|
|
7
7
|
import React from 'react';
|
|
8
8
|
import { useTheme } from "../../hooks/useTheme.js";
|
|
9
9
|
import SearchBar from "../search/SearchBar.js";
|
|
@@ -12,17 +12,57 @@ import { KEYBINDINGS, VIEW } from "../../utils/constants.js";
|
|
|
12
12
|
import { Box, Text } from 'ink';
|
|
13
13
|
import { usePlayer } from "../../hooks/usePlayer.js";
|
|
14
14
|
import { ICONS } from "../../utils/icons.js";
|
|
15
|
+
import TextInput from 'ink-text-input';
|
|
16
|
+
import { applySearchFilters } from "../../utils/search-filters.js";
|
|
17
|
+
const FILTER_LABELS = {
|
|
18
|
+
artist: 'Artist',
|
|
19
|
+
album: 'Album',
|
|
20
|
+
year: 'Year',
|
|
21
|
+
};
|
|
22
|
+
const DURATION_ORDER = [
|
|
23
|
+
'all',
|
|
24
|
+
'short',
|
|
25
|
+
'medium',
|
|
26
|
+
'long',
|
|
27
|
+
];
|
|
15
28
|
function SearchLayout() {
|
|
16
29
|
const { theme } = useTheme();
|
|
17
30
|
const { state: navState, dispatch } = useNavigation();
|
|
18
31
|
const { state: playerState } = usePlayer();
|
|
19
32
|
const { isLoading, error, search } = useYouTubeMusic();
|
|
20
|
-
const [
|
|
33
|
+
const [rawResults, setRawResults] = useState([]);
|
|
34
|
+
const filteredResults = useMemo(() => applySearchFilters(rawResults, navState.searchFilters), [rawResults, navState.searchFilters]);
|
|
21
35
|
const [isTyping, setIsTyping] = useState(true);
|
|
22
36
|
const [isSearching, setIsSearching] = useState(false);
|
|
23
37
|
const [actionMessage, setActionMessage] = useState(null);
|
|
24
38
|
const actionTimeoutRef = useRef(null);
|
|
25
39
|
const lastAutoSearchedQueryRef = useRef(null);
|
|
40
|
+
const [editingFilter, setEditingFilter] = useState(null);
|
|
41
|
+
const [filterDraft, setFilterDraft] = useState('');
|
|
42
|
+
const describeFilterValue = (value) => value?.trim() ? value.trim() : 'Any';
|
|
43
|
+
const handleFilterSubmit = useCallback((value) => {
|
|
44
|
+
if (!editingFilter)
|
|
45
|
+
return;
|
|
46
|
+
dispatch({
|
|
47
|
+
category: 'SET_SEARCH_FILTERS',
|
|
48
|
+
filters: { [editingFilter]: value.trim() },
|
|
49
|
+
});
|
|
50
|
+
setEditingFilter(null);
|
|
51
|
+
setFilterDraft('');
|
|
52
|
+
}, [dispatch, editingFilter]);
|
|
53
|
+
const beginFilterEdit = useCallback((field) => {
|
|
54
|
+
setEditingFilter(field);
|
|
55
|
+
setFilterDraft(navState.searchFilters[field] ?? '');
|
|
56
|
+
}, [navState.searchFilters]);
|
|
57
|
+
const cycleDurationFilter = useCallback(() => {
|
|
58
|
+
const currentIndex = DURATION_ORDER.indexOf(navState.searchFilters.duration ?? 'all');
|
|
59
|
+
const nextIndex = (currentIndex + 1) % DURATION_ORDER.length;
|
|
60
|
+
const nextDuration = DURATION_ORDER[nextIndex];
|
|
61
|
+
dispatch({
|
|
62
|
+
category: 'SET_SEARCH_FILTERS',
|
|
63
|
+
filters: { duration: nextDuration },
|
|
64
|
+
});
|
|
65
|
+
}, [dispatch, navState.searchFilters.duration]);
|
|
26
66
|
// Handle search action
|
|
27
67
|
const performSearch = useCallback(async (query) => {
|
|
28
68
|
if (!query || isSearching)
|
|
@@ -33,7 +73,7 @@ function SearchLayout() {
|
|
|
33
73
|
limit: navState.searchLimit,
|
|
34
74
|
});
|
|
35
75
|
if (response) {
|
|
36
|
-
|
|
76
|
+
setRawResults(response.results);
|
|
37
77
|
dispatch({ category: 'SET_SELECTED_RESULT', index: 0 });
|
|
38
78
|
dispatch({ category: 'SET_HAS_SEARCHED', hasSearched: true });
|
|
39
79
|
// Defer focus switch to avoid consuming the same Enter key
|
|
@@ -58,6 +98,10 @@ function SearchLayout() {
|
|
|
58
98
|
}
|
|
59
99
|
}, [isTyping, dispatch]);
|
|
60
100
|
useKeyBinding(['h'], goToHistory);
|
|
101
|
+
useKeyBinding(KEYBINDINGS.SEARCH_FILTER_ARTIST, () => beginFilterEdit('artist'));
|
|
102
|
+
useKeyBinding(KEYBINDINGS.SEARCH_FILTER_ALBUM, () => beginFilterEdit('album'));
|
|
103
|
+
useKeyBinding(KEYBINDINGS.SEARCH_FILTER_YEAR, () => beginFilterEdit('year'));
|
|
104
|
+
useKeyBinding(KEYBINDINGS.SEARCH_FILTER_DURATION, cycleDurationFilter);
|
|
61
105
|
// Initial search if query is in state (usually from CLI flags)
|
|
62
106
|
useEffect(() => {
|
|
63
107
|
const query = navState.searchQuery.trim();
|
|
@@ -74,6 +118,11 @@ function SearchLayout() {
|
|
|
74
118
|
}, [navState.searchQuery, navState.hasSearched, performSearch]);
|
|
75
119
|
// Handle going back
|
|
76
120
|
const goBack = useCallback(() => {
|
|
121
|
+
if (editingFilter) {
|
|
122
|
+
setEditingFilter(null);
|
|
123
|
+
setFilterDraft('');
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
77
126
|
if (!isTyping) {
|
|
78
127
|
setIsTyping(true); // Back to typing if in results
|
|
79
128
|
dispatch({ category: 'SET_HAS_SEARCHED', hasSearched: false });
|
|
@@ -81,7 +130,7 @@ function SearchLayout() {
|
|
|
81
130
|
else {
|
|
82
131
|
dispatch({ category: 'GO_BACK' });
|
|
83
132
|
}
|
|
84
|
-
}, [isTyping, dispatch]);
|
|
133
|
+
}, [editingFilter, isTyping, dispatch]);
|
|
85
134
|
useKeyBinding(KEYBINDINGS.BACK, goBack);
|
|
86
135
|
const handleMixCreated = useCallback((message) => {
|
|
87
136
|
setActionMessage(message);
|
|
@@ -113,16 +162,31 @@ function SearchLayout() {
|
|
|
113
162
|
// Reset search state when leaving view
|
|
114
163
|
useEffect(() => {
|
|
115
164
|
return () => {
|
|
116
|
-
|
|
165
|
+
setRawResults([]);
|
|
117
166
|
dispatch({ category: 'SET_HAS_SEARCHED', hasSearched: false });
|
|
118
167
|
dispatch({ category: 'SET_SEARCH_QUERY', query: '' });
|
|
119
168
|
lastAutoSearchedQueryRef.current = null;
|
|
120
169
|
};
|
|
121
170
|
}, [dispatch]);
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
if (filteredResults.length > 0 &&
|
|
173
|
+
navState.selectedResult >= filteredResults.length) {
|
|
174
|
+
dispatch({ category: 'SET_SELECTED_RESULT', index: 0 });
|
|
175
|
+
}
|
|
176
|
+
}, [dispatch, filteredResults.length, navState.selectedResult]);
|
|
177
|
+
const artistFilterLabel = describeFilterValue(navState.searchFilters.artist);
|
|
178
|
+
const albumFilterLabel = describeFilterValue(navState.searchFilters.album);
|
|
179
|
+
const yearFilterLabel = describeFilterValue(navState.searchFilters.year);
|
|
180
|
+
const durationFilterLabel = navState.searchFilters.duration && navState.searchFilters.duration !== 'all'
|
|
181
|
+
? navState.searchFilters.duration
|
|
182
|
+
: 'Any';
|
|
122
183
|
return (_jsxs(Box, { flexDirection: "column", children: [playerState.currentTrack && (_jsxs(Box, { children: [_jsx(Text, { color: theme.colors.dim, children: playerState.isPlaying ? `${ICONS.PLAY} ` : `${ICONS.PAUSE} ` }), _jsx(Text, { color: theme.colors.primary, bold: true, children: playerState.currentTrack.title }), playerState.currentTrack.artists &&
|
|
123
|
-
playerState.currentTrack.artists.length > 0 && (_jsxs(Text, { color: theme.colors.secondary, children: [' • ', playerState.currentTrack.artists.map(a => a.name).join(', ')] }))] })), _jsxs(Text, { color: theme.colors.dim, children: ["Limit: ", navState.searchLimit, " (Use [ or ] to adjust)"] }), _jsx(SearchBar, { isActive: isTyping && !isSearching, onInput: input => {
|
|
184
|
+
playerState.currentTrack.artists.length > 0 && (_jsxs(Text, { color: theme.colors.secondary, children: [' • ', playerState.currentTrack.artists.map(a => a.name).join(', ')] }))] })), _jsxs(Text, { color: theme.colors.dim, children: ["Limit: ", navState.searchLimit, " (Use [ or ] to adjust)"] }), _jsx(SearchBar, { isActive: !editingFilter && isTyping && !isSearching, onInput: input => {
|
|
124
185
|
void performSearch(input);
|
|
125
|
-
} }), (
|
|
186
|
+
} }), editingFilter ? (_jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: theme.colors.primary, bold: true, children: ["Set ", FILTER_LABELS[editingFilter], " filter:"] }), _jsx(TextInput, { value: filterDraft, onChange: setFilterDraft, onSubmit: handleFilterSubmit, placeholder: "Type value and hit Enter", focus: true })] }), _jsx(Text, { color: theme.colors.dim, children: "Press Enter to save (empty to clear) or Esc to cancel." })] })) : (_jsx(Box, { marginY: 1, children: _jsxs(Text, { color: theme.colors.dim, children: ["Filters: Artist=", artistFilterLabel, ", Album=", albumFilterLabel, ", Year=", yearFilterLabel, ", Duration=", durationFilterLabel, " (Ctrl+A Artist, Ctrl+L Album, Ctrl+Y Year, Ctrl+D Duration)"] }) })), (isLoading || isSearching) && (_jsx(Text, { color: theme.colors.accent, children: "Searching..." })), error && _jsx(Text, { color: theme.colors.error, children: error }), !isLoading && navState.hasSearched && (_jsx(SearchResults, { results: filteredResults, selectedIndex: navState.selectedResult, isActive: !isTyping, onMixCreated: handleMixCreated, onDownloadStatus: handleDownloadStatus })), !isLoading &&
|
|
187
|
+
navState.hasSearched &&
|
|
188
|
+
filteredResults.length === 0 &&
|
|
189
|
+
!error && _jsx(Text, { color: theme.colors.dim, children: "No results found" }), actionMessage && (_jsx(Text, { color: theme.colors.accent, children: actionMessage })), _jsx(Text, { color: theme.colors.dim, children: isTyping
|
|
126
190
|
? 'Type to search, Enter to start, Esc to clear'
|
|
127
191
|
: `Arrows to navigate, Enter to play, M mix, Shift+D download, ]/[ more/fewer results (${navState.searchLimit}), H history, Esc to type` })] }));
|
|
128
192
|
}
|
package/dist/source/main.js
CHANGED
|
@@ -5,6 +5,7 @@ import { PluginsProvider } from "./stores/plugins.store.js";
|
|
|
5
5
|
import MainLayout from "./components/layouts/MainLayout.js";
|
|
6
6
|
import { ThemeProvider } from "./contexts/theme.context.js";
|
|
7
7
|
import { PlayerProvider } from "./stores/player.store.js";
|
|
8
|
+
import { HistoryProvider } from "./stores/history.store.js";
|
|
8
9
|
import { ErrorBoundary } from "./components/common/ErrorBoundary.js";
|
|
9
10
|
import { KeyboardManager } from "./hooks/useKeyboard.js";
|
|
10
11
|
import { KeyboardBlockProvider } from "./hooks/useKeyboardBlocker.js";
|
|
@@ -122,5 +123,5 @@ function HeadlessLayout({ flags }) {
|
|
|
122
123
|
return (_jsx(Box, { padding: 1, children: _jsx(Text, { color: "green", children: "Headless mode active." }) }));
|
|
123
124
|
}
|
|
124
125
|
export default function Main({ flags }) {
|
|
125
|
-
return (_jsx(ErrorBoundary, { children: _jsx(ThemeProvider, { children: _jsx(PlayerProvider, { children: _jsx(NavigationProvider, { children: _jsx(PluginsProvider, { children: _jsx(KeyboardBlockProvider, { children: _jsxs(Box, { flexDirection: "column", children: [_jsx(KeyboardManager, {}), flags?.headless ? (_jsx(HeadlessLayout, { flags: flags })) : (_jsxs(_Fragment, { children: [_jsx(Initializer, { flags: flags }), _jsx(MainLayout, {})] }))] }) }) }) }) }) }) }));
|
|
126
|
+
return (_jsx(ErrorBoundary, { children: _jsx(ThemeProvider, { children: _jsx(PlayerProvider, { children: _jsx(HistoryProvider, { children: _jsx(NavigationProvider, { children: _jsx(PluginsProvider, { children: _jsx(KeyboardBlockProvider, { children: _jsxs(Box, { flexDirection: "column", children: [_jsx(KeyboardManager, {}), flags?.headless ? (_jsx(HeadlessLayout, { flags: flags })) : (_jsxs(_Fragment, { children: [_jsx(Initializer, { flags: flags }), _jsx(MainLayout, {})] }))] }) }) }) }) }) }) }) }));
|
|
126
127
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { CONFIG_DIR } from "../../utils/constants.js";
|
|
5
|
+
import { logger } from "../logger/logger.service.js";
|
|
6
|
+
const HISTORY_FILE = join(CONFIG_DIR, 'history.json');
|
|
7
|
+
const SCHEMA_VERSION = 1;
|
|
8
|
+
const defaultHistory = {
|
|
9
|
+
schemaVersion: SCHEMA_VERSION,
|
|
10
|
+
entries: [],
|
|
11
|
+
lastUpdated: new Date().toISOString(),
|
|
12
|
+
};
|
|
13
|
+
export async function saveHistory(entries) {
|
|
14
|
+
try {
|
|
15
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
16
|
+
await mkdir(CONFIG_DIR, { recursive: true });
|
|
17
|
+
}
|
|
18
|
+
const stateToSave = {
|
|
19
|
+
...defaultHistory,
|
|
20
|
+
entries,
|
|
21
|
+
lastUpdated: new Date().toISOString(),
|
|
22
|
+
};
|
|
23
|
+
const tempFile = `${HISTORY_FILE}.tmp`;
|
|
24
|
+
await writeFile(tempFile, JSON.stringify(stateToSave, null, 2), 'utf8');
|
|
25
|
+
if (process.platform === 'win32' && existsSync(HISTORY_FILE)) {
|
|
26
|
+
await import('node:fs/promises').then(fs => fs.unlink(HISTORY_FILE));
|
|
27
|
+
}
|
|
28
|
+
await import('node:fs/promises').then(fs => fs.rename(tempFile, HISTORY_FILE));
|
|
29
|
+
logger.debug('HistoryService', 'Saved listening history', {
|
|
30
|
+
count: entries.length,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
logger.error('HistoryService', 'Failed to save listening history', {
|
|
35
|
+
error: error instanceof Error ? error.message : String(error),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export async function loadHistory() {
|
|
40
|
+
try {
|
|
41
|
+
if (!existsSync(HISTORY_FILE)) {
|
|
42
|
+
logger.debug('HistoryService', 'No history file found');
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
const data = await readFile(HISTORY_FILE, 'utf8');
|
|
46
|
+
const persisted = JSON.parse(data);
|
|
47
|
+
if (persisted.schemaVersion !== SCHEMA_VERSION) {
|
|
48
|
+
logger.warn('HistoryService', 'Schema version mismatch', {
|
|
49
|
+
expected: SCHEMA_VERSION,
|
|
50
|
+
found: persisted.schemaVersion,
|
|
51
|
+
});
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
if (!Array.isArray(persisted.entries)) {
|
|
55
|
+
logger.warn('HistoryService', 'Invalid history format, resetting');
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
logger.info('HistoryService', 'Loaded listening history', {
|
|
59
|
+
count: persisted.entries.length,
|
|
60
|
+
lastUpdated: persisted.lastUpdated,
|
|
61
|
+
});
|
|
62
|
+
return persisted.entries;
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
logger.error('HistoryService', 'Failed to load listening history', {
|
|
66
|
+
error: error instanceof Error ? error.message : String(error),
|
|
67
|
+
});
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export async function clearHistory() {
|
|
72
|
+
try {
|
|
73
|
+
if (existsSync(HISTORY_FILE)) {
|
|
74
|
+
await import('node:fs/promises').then(fs => fs.unlink(HISTORY_FILE));
|
|
75
|
+
logger.info('HistoryService', 'Cleared listening history');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
logger.error('HistoryService', 'Failed to clear listening history', {
|
|
80
|
+
error: error instanceof Error ? error.message : String(error),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -7,6 +7,10 @@ export type PlayOptions = {
|
|
|
7
7
|
crossfadeDuration?: number;
|
|
8
8
|
equalizerPreset?: EqualizerPreset;
|
|
9
9
|
};
|
|
10
|
+
export type MpvArgsOptions = PlayOptions & {
|
|
11
|
+
volume: number;
|
|
12
|
+
};
|
|
13
|
+
export declare function buildMpvArgs(url: string, ipcPath: string, options: MpvArgsOptions): string[];
|
|
10
14
|
export type PlayerEventCallback = (event: {
|
|
11
15
|
timePos?: number;
|
|
12
16
|
duration?: number;
|
|
@@ -2,6 +2,44 @@
|
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
3
|
import { connect } from 'node:net';
|
|
4
4
|
import { logger } from "../logger/logger.service.js";
|
|
5
|
+
export function buildMpvArgs(url, ipcPath, options) {
|
|
6
|
+
const gapless = options.gaplessPlayback ?? true;
|
|
7
|
+
const crossfadeDuration = Math.max(0, options.crossfadeDuration ?? 0);
|
|
8
|
+
const eqPreset = options.equalizerPreset ?? 'flat';
|
|
9
|
+
const audioFilters = [];
|
|
10
|
+
if (options.audioNormalization) {
|
|
11
|
+
audioFilters.push('dynaudnorm');
|
|
12
|
+
}
|
|
13
|
+
if (crossfadeDuration > 0) {
|
|
14
|
+
audioFilters.push(`acrossfade=d=${crossfadeDuration}`);
|
|
15
|
+
}
|
|
16
|
+
const presetFilters = EQUALIZER_PRESET_FILTERS[eqPreset] ?? [];
|
|
17
|
+
if (presetFilters.length > 0) {
|
|
18
|
+
audioFilters.push(...presetFilters);
|
|
19
|
+
}
|
|
20
|
+
const mpvArgs = [
|
|
21
|
+
'--no-video',
|
|
22
|
+
'--no-terminal',
|
|
23
|
+
`--volume=${options.volume}`,
|
|
24
|
+
'--no-audio-display',
|
|
25
|
+
'--really-quiet',
|
|
26
|
+
'--msg-level=all=error',
|
|
27
|
+
`--input-ipc-server=${ipcPath}`,
|
|
28
|
+
'--idle=yes',
|
|
29
|
+
'--cache=yes',
|
|
30
|
+
'--cache-secs=30',
|
|
31
|
+
'--network-timeout=10',
|
|
32
|
+
`--gapless-audio=${gapless ? 'yes' : 'no'}`,
|
|
33
|
+
];
|
|
34
|
+
if (audioFilters.length > 0) {
|
|
35
|
+
mpvArgs.push(`--af=${audioFilters.join(',')}`);
|
|
36
|
+
}
|
|
37
|
+
if (options.proxy) {
|
|
38
|
+
mpvArgs.push(`--http-proxy=${options.proxy}`);
|
|
39
|
+
}
|
|
40
|
+
mpvArgs.push(url);
|
|
41
|
+
return mpvArgs;
|
|
42
|
+
}
|
|
5
43
|
const EQUALIZER_PRESET_FILTERS = {
|
|
6
44
|
flat: [],
|
|
7
45
|
bass_boost: ['equalizer=f=60:width_type=o:width=2:g=5'],
|
|
@@ -225,42 +263,14 @@ class PlayerService {
|
|
|
225
263
|
volume: this.currentVolume,
|
|
226
264
|
ipcPath: this.ipcPath,
|
|
227
265
|
});
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
audioFilters.push(`acrossfade=d=${crossfadeDuration}`);
|
|
237
|
-
}
|
|
238
|
-
const presetFilters = EQUALIZER_PRESET_FILTERS[eqPreset] ?? [];
|
|
239
|
-
if (presetFilters.length > 0) {
|
|
240
|
-
audioFilters.push(...presetFilters);
|
|
241
|
-
}
|
|
242
|
-
// Spawn mpv with JSON IPC for better control
|
|
243
|
-
const mpvArgs = [
|
|
244
|
-
'--no-video', // Audio only
|
|
245
|
-
'--no-terminal', // Don't read from stdin
|
|
246
|
-
`--volume=${this.currentVolume}`,
|
|
247
|
-
'--no-audio-display', // Don't show album art in terminal
|
|
248
|
-
'--really-quiet', // Minimal output
|
|
249
|
-
'--msg-level=all=error', // Only show errors
|
|
250
|
-
`--input-ipc-server=${this.ipcPath}`, // Enable IPC
|
|
251
|
-
'--idle=yes', // Keep mpv running after playback ends
|
|
252
|
-
'--cache=yes', // Enable cache for network streams
|
|
253
|
-
'--cache-secs=30', // Buffer 30 seconds ahead
|
|
254
|
-
'--network-timeout=10', // 10s network timeout
|
|
255
|
-
`--gapless-audio=${gapless ? 'yes' : 'no'}`,
|
|
256
|
-
];
|
|
257
|
-
if (audioFilters.length > 0) {
|
|
258
|
-
mpvArgs.push(`--af=${audioFilters.join(',')}`);
|
|
259
|
-
}
|
|
260
|
-
if (options?.proxy) {
|
|
261
|
-
mpvArgs.push(`--http-proxy=${options.proxy}`);
|
|
262
|
-
}
|
|
263
|
-
mpvArgs.push(playUrl);
|
|
266
|
+
const mpvArgs = buildMpvArgs(playUrl, this.ipcPath, {
|
|
267
|
+
volume: this.currentVolume,
|
|
268
|
+
audioNormalization: options?.audioNormalization,
|
|
269
|
+
proxy: options?.proxy,
|
|
270
|
+
gaplessPlayback: options?.gaplessPlayback,
|
|
271
|
+
crossfadeDuration: options?.crossfadeDuration,
|
|
272
|
+
equalizerPreset: options?.equalizerPreset,
|
|
273
|
+
});
|
|
264
274
|
// Capture process in local var so stale exit handlers from a killed
|
|
265
275
|
// process don't overwrite state belonging to a newly-spawned process.
|
|
266
276
|
const spawnedProcess = spawn(this.getMpvCommand(), mpvArgs, {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import type { HistoryEntry } from '../types/history.types.ts';
|
|
3
|
+
type HistoryState = HistoryEntry[];
|
|
4
|
+
type HistoryContextValue = {
|
|
5
|
+
history: HistoryState;
|
|
6
|
+
};
|
|
7
|
+
export declare function HistoryProvider({ children }: {
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
10
|
+
export declare function useHistory(): HistoryContextValue;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useContext, useEffect, useMemo, useReducer, useRef, } from 'react';
|
|
3
|
+
import { usePlayer } from "../hooks/usePlayer.js";
|
|
4
|
+
import { loadHistory, saveHistory } from "../services/history/history.service.js";
|
|
5
|
+
const MAX_HISTORY_ENTRIES = 500;
|
|
6
|
+
function historyReducer(state, action) {
|
|
7
|
+
switch (action.category) {
|
|
8
|
+
case 'SET_HISTORY':
|
|
9
|
+
return action.entries;
|
|
10
|
+
case 'ADD_ENTRY':
|
|
11
|
+
return [action.entry, ...state].slice(0, MAX_HISTORY_ENTRIES);
|
|
12
|
+
default:
|
|
13
|
+
return state;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const HistoryContext = createContext(null);
|
|
17
|
+
export function HistoryProvider({ children }) {
|
|
18
|
+
const [state, dispatch] = useReducer(historyReducer, []);
|
|
19
|
+
const { state: playerState } = usePlayer();
|
|
20
|
+
const lastLoggedId = useRef(null);
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
let cancelled = false;
|
|
23
|
+
void loadHistory().then(entries => {
|
|
24
|
+
if (!cancelled) {
|
|
25
|
+
dispatch({ category: 'SET_HISTORY', entries });
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
return () => {
|
|
29
|
+
cancelled = true;
|
|
30
|
+
};
|
|
31
|
+
}, []);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!playerState.currentTrack) {
|
|
34
|
+
lastLoggedId.current = null;
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (!playerState.isPlaying) {
|
|
38
|
+
lastLoggedId.current = null;
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const videoId = playerState.currentTrack.videoId;
|
|
42
|
+
if (lastLoggedId.current === videoId) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
lastLoggedId.current = videoId;
|
|
46
|
+
const entry = {
|
|
47
|
+
track: playerState.currentTrack,
|
|
48
|
+
playedAt: new Date().toISOString(),
|
|
49
|
+
};
|
|
50
|
+
dispatch({ category: 'ADD_ENTRY', entry });
|
|
51
|
+
}, [playerState.currentTrack, playerState.isPlaying]);
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
void saveHistory(state);
|
|
54
|
+
}, [state]);
|
|
55
|
+
const value = useMemo(() => ({ history: state }), [state]);
|
|
56
|
+
return (_jsx(HistoryContext.Provider, { value: value, children: children }));
|
|
57
|
+
}
|
|
58
|
+
export function useHistory() {
|
|
59
|
+
const context = useContext(HistoryContext);
|
|
60
|
+
if (!context) {
|
|
61
|
+
throw new Error('useHistory must be used within HistoryProvider');
|
|
62
|
+
}
|
|
63
|
+
return context;
|
|
64
|
+
}
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { createContext, useContext, useReducer, useMemo, } from 'react';
|
|
3
|
+
const defaultSearchFilters = {
|
|
4
|
+
artist: '',
|
|
5
|
+
album: '',
|
|
6
|
+
year: '',
|
|
7
|
+
duration: 'all',
|
|
8
|
+
};
|
|
3
9
|
const initialState = {
|
|
4
10
|
currentView: 'player',
|
|
5
11
|
previousView: null,
|
|
@@ -12,6 +18,7 @@ const initialState = {
|
|
|
12
18
|
searchLimit: 10,
|
|
13
19
|
history: [],
|
|
14
20
|
playerMode: 'full',
|
|
21
|
+
searchFilters: defaultSearchFilters,
|
|
15
22
|
};
|
|
16
23
|
function navigationReducer(state, action) {
|
|
17
24
|
switch (action.category) {
|
|
@@ -38,6 +45,16 @@ function navigationReducer(state, action) {
|
|
|
38
45
|
return { ...state, searchQuery: action.query };
|
|
39
46
|
case 'SET_SEARCH_CATEGORY':
|
|
40
47
|
return { ...state, searchCategory: action.category };
|
|
48
|
+
case 'SET_SEARCH_FILTERS':
|
|
49
|
+
return {
|
|
50
|
+
...state,
|
|
51
|
+
searchFilters: { ...state.searchFilters, ...action.filters },
|
|
52
|
+
};
|
|
53
|
+
case 'CLEAR_SEARCH_FILTERS':
|
|
54
|
+
return {
|
|
55
|
+
...state,
|
|
56
|
+
searchFilters: defaultSearchFilters,
|
|
57
|
+
};
|
|
41
58
|
case 'SET_SELECTED_RESULT':
|
|
42
59
|
return { ...state, selectedResult: action.index };
|
|
43
60
|
case 'SET_SELECTED_PLAYLIST':
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Track } from './youtube-music.types.ts';
|
|
1
|
+
import type { SearchFilters, Track } from './youtube-music.types.ts';
|
|
2
2
|
export interface PlayAction {
|
|
3
3
|
readonly category: 'PLAY';
|
|
4
4
|
track: Track;
|
|
@@ -111,6 +111,13 @@ export interface SetSearchCategoryAction {
|
|
|
111
111
|
readonly category: 'SET_SEARCH_CATEGORY';
|
|
112
112
|
searchType: string;
|
|
113
113
|
}
|
|
114
|
+
export interface SetSearchFiltersAction {
|
|
115
|
+
readonly category: 'SET_SEARCH_FILTERS';
|
|
116
|
+
filters: Partial<SearchFilters>;
|
|
117
|
+
}
|
|
118
|
+
export interface ClearSearchFiltersAction {
|
|
119
|
+
readonly category: 'CLEAR_SEARCH_FILTERS';
|
|
120
|
+
}
|
|
114
121
|
export interface SetSelectedResultAction {
|
|
115
122
|
readonly category: 'SET_SELECTED_RESULT';
|
|
116
123
|
index: number;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { NavigateAction, GoBackAction, SetSearchQueryAction, SetSearchCategoryAction, SetSelectedResultAction, SetSelectedPlaylistAction, SetHasSearchedAction, SetSearchLimitAction, TogglePlayerModeAction, DetachAction } from './actions.ts';
|
|
1
|
+
import type { NavigateAction, GoBackAction, SetSearchQueryAction, SetSearchCategoryAction, SetSearchFiltersAction, ClearSearchFiltersAction, SetSelectedResultAction, SetSelectedPlaylistAction, SetHasSearchedAction, SetSearchLimitAction, TogglePlayerModeAction, DetachAction } from './actions.ts';
|
|
2
|
+
import type { SearchFilters } from './youtube-music.types.ts';
|
|
2
3
|
export interface NavigationState {
|
|
3
4
|
currentView: string;
|
|
4
5
|
previousView: string | null;
|
|
@@ -11,5 +12,6 @@ export interface NavigationState {
|
|
|
11
12
|
searchLimit: number;
|
|
12
13
|
history: string[];
|
|
13
14
|
playerMode: 'full' | 'mini';
|
|
15
|
+
searchFilters: SearchFilters;
|
|
14
16
|
}
|
|
15
|
-
export type NavigationAction = NavigateAction | GoBackAction | SetSearchQueryAction | SetSearchCategoryAction | SetSelectedResultAction | SetSelectedPlaylistAction | SetHasSearchedAction | SetSearchLimitAction | TogglePlayerModeAction | DetachAction;
|
|
17
|
+
export type NavigationAction = NavigateAction | GoBackAction | SetSearchQueryAction | SetSearchCategoryAction | SetSearchFiltersAction | ClearSearchFiltersAction | SetSelectedResultAction | SetSelectedPlaylistAction | SetHasSearchedAction | SetSearchLimitAction | TogglePlayerModeAction | DetachAction;
|
|
@@ -33,3 +33,10 @@ export interface SearchOptions {
|
|
|
33
33
|
limit?: number;
|
|
34
34
|
continuation?: string;
|
|
35
35
|
}
|
|
36
|
+
export type SearchDurationFilter = 'all' | 'short' | 'medium' | 'long';
|
|
37
|
+
export interface SearchFilters {
|
|
38
|
+
artist?: string;
|
|
39
|
+
album?: string;
|
|
40
|
+
year?: string;
|
|
41
|
+
duration?: SearchDurationFilter;
|
|
42
|
+
}
|
|
@@ -20,6 +20,7 @@ export declare const VIEW: {
|
|
|
20
20
|
readonly EXPLORE: "explore";
|
|
21
21
|
readonly IMPORT: "import";
|
|
22
22
|
readonly EXPORT_PLAYLISTS: "export_playlists";
|
|
23
|
+
readonly HISTORY: "history";
|
|
23
24
|
};
|
|
24
25
|
export declare const SEARCH_TYPE: {
|
|
25
26
|
readonly ALL: "all";
|
|
@@ -34,6 +35,7 @@ export declare const KEYBINDINGS: {
|
|
|
34
35
|
readonly SEARCH: readonly ["/"];
|
|
35
36
|
readonly PLAYLISTS: readonly ["shift+p"];
|
|
36
37
|
readonly SUGGESTIONS: readonly ["g"];
|
|
38
|
+
readonly HISTORY: readonly ["shift+h"];
|
|
37
39
|
readonly SETTINGS: readonly [","];
|
|
38
40
|
readonly PLUGINS: readonly ["p"];
|
|
39
41
|
readonly DETACH: readonly ["shift+q"];
|
|
@@ -63,6 +65,10 @@ export declare const KEYBINDINGS: {
|
|
|
63
65
|
readonly PREV_RESULT: readonly ["shift+tab"];
|
|
64
66
|
readonly INCREASE_RESULTS: readonly ["]"];
|
|
65
67
|
readonly DECREASE_RESULTS: readonly ["["];
|
|
68
|
+
readonly SEARCH_FILTER_ARTIST: readonly ["ctrl+a"];
|
|
69
|
+
readonly SEARCH_FILTER_ALBUM: readonly ["ctrl+l"];
|
|
70
|
+
readonly SEARCH_FILTER_YEAR: readonly ["ctrl+y"];
|
|
71
|
+
readonly SEARCH_FILTER_DURATION: readonly ["ctrl+d"];
|
|
66
72
|
readonly ADD_TO_PLAYLIST: readonly ["a"];
|
|
67
73
|
readonly REMOVE_FROM_PLAYLIST: readonly ["d"];
|
|
68
74
|
readonly CREATE_PLAYLIST: readonly ["c"];
|
|
@@ -25,6 +25,7 @@ export const VIEW = {
|
|
|
25
25
|
EXPLORE: 'explore',
|
|
26
26
|
IMPORT: 'import',
|
|
27
27
|
EXPORT_PLAYLISTS: 'export_playlists',
|
|
28
|
+
HISTORY: 'history',
|
|
28
29
|
};
|
|
29
30
|
// Search types
|
|
30
31
|
export const SEARCH_TYPE = {
|
|
@@ -42,6 +43,7 @@ export const KEYBINDINGS = {
|
|
|
42
43
|
SEARCH: ['/'],
|
|
43
44
|
PLAYLISTS: ['shift+p'],
|
|
44
45
|
SUGGESTIONS: ['g'],
|
|
46
|
+
HISTORY: ['shift+h'],
|
|
45
47
|
SETTINGS: [','],
|
|
46
48
|
PLUGINS: ['p'],
|
|
47
49
|
DETACH: ['shift+q'],
|
|
@@ -74,6 +76,10 @@ export const KEYBINDINGS = {
|
|
|
74
76
|
PREV_RESULT: ['shift+tab'],
|
|
75
77
|
INCREASE_RESULTS: [']'],
|
|
76
78
|
DECREASE_RESULTS: ['['],
|
|
79
|
+
SEARCH_FILTER_ARTIST: ['ctrl+a'],
|
|
80
|
+
SEARCH_FILTER_ALBUM: ['ctrl+l'],
|
|
81
|
+
SEARCH_FILTER_YEAR: ['ctrl+y'],
|
|
82
|
+
SEARCH_FILTER_DURATION: ['ctrl+d'],
|
|
77
83
|
// Playlist
|
|
78
84
|
ADD_TO_PLAYLIST: ['a'],
|
|
79
85
|
REMOVE_FROM_PLAYLIST: ['d'],
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const DURATION_BUCKETS = {
|
|
2
|
+
short: { min: 0, max: 180 },
|
|
3
|
+
medium: { min: 181, max: 300 },
|
|
4
|
+
long: { min: 301, max: Number.POSITIVE_INFINITY },
|
|
5
|
+
};
|
|
6
|
+
function includesIgnoreCase(value, filter) {
|
|
7
|
+
return Boolean(value && value.toLowerCase().includes(filter));
|
|
8
|
+
}
|
|
9
|
+
function isSongResult(result) {
|
|
10
|
+
return result.type === 'song';
|
|
11
|
+
}
|
|
12
|
+
function isAlbumResult(result) {
|
|
13
|
+
return result.type === 'album';
|
|
14
|
+
}
|
|
15
|
+
function isArtistResult(result) {
|
|
16
|
+
return result.type === 'artist';
|
|
17
|
+
}
|
|
18
|
+
function isPlaylistResult(result) {
|
|
19
|
+
return result.type === 'playlist';
|
|
20
|
+
}
|
|
21
|
+
function matchesArtistFilter(result, filter) {
|
|
22
|
+
if (!filter)
|
|
23
|
+
return true;
|
|
24
|
+
if (isSongResult(result)) {
|
|
25
|
+
const track = result.data;
|
|
26
|
+
if (track.artists.some(artist => includesIgnoreCase(artist.name, filter))) {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
if (track.album?.artists?.some(artist => includesIgnoreCase(artist.name, filter))) {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (isAlbumResult(result)) {
|
|
34
|
+
return result.data.artists.some(artist => includesIgnoreCase(artist.name, filter));
|
|
35
|
+
}
|
|
36
|
+
if (isArtistResult(result)) {
|
|
37
|
+
return includesIgnoreCase(result.data.name, filter);
|
|
38
|
+
}
|
|
39
|
+
if (isPlaylistResult(result)) {
|
|
40
|
+
return includesIgnoreCase(result.data.name, filter);
|
|
41
|
+
}
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
function matchesAlbumFilter(result, filter) {
|
|
45
|
+
if (!filter)
|
|
46
|
+
return true;
|
|
47
|
+
if (isSongResult(result)) {
|
|
48
|
+
return includesIgnoreCase(result.data.album?.name, filter);
|
|
49
|
+
}
|
|
50
|
+
if (isAlbumResult(result)) {
|
|
51
|
+
return includesIgnoreCase(result.data.name, filter);
|
|
52
|
+
}
|
|
53
|
+
if (isPlaylistResult(result)) {
|
|
54
|
+
return includesIgnoreCase(result.data.name, filter);
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
function matchesYearFilter(result, filter) {
|
|
59
|
+
if (!filter)
|
|
60
|
+
return true;
|
|
61
|
+
const normalizedFilter = filter.toLowerCase();
|
|
62
|
+
const textSources = [];
|
|
63
|
+
if (isSongResult(result)) {
|
|
64
|
+
textSources.push(result.data.title, result.data.album?.name);
|
|
65
|
+
}
|
|
66
|
+
if (isAlbumResult(result)) {
|
|
67
|
+
textSources.push(result.data.name);
|
|
68
|
+
textSources.push(...result.data.artists.map(artist => artist.name));
|
|
69
|
+
}
|
|
70
|
+
if (isArtistResult(result)) {
|
|
71
|
+
textSources.push(result.data.name);
|
|
72
|
+
}
|
|
73
|
+
if (isPlaylistResult(result)) {
|
|
74
|
+
textSources.push(result.data.name);
|
|
75
|
+
}
|
|
76
|
+
return textSources.some(source => includesIgnoreCase(source, normalizedFilter));
|
|
77
|
+
}
|
|
78
|
+
function matchesDurationFilter(result, filter) {
|
|
79
|
+
if (!filter || filter === 'all') {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
if (!isSongResult(result)) {
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
const duration = result.data.duration ?? 0;
|
|
86
|
+
const range = DURATION_BUCKETS[filter];
|
|
87
|
+
return duration >= range.min && duration <= range.max;
|
|
88
|
+
}
|
|
89
|
+
export function applySearchFilters(results, filters) {
|
|
90
|
+
const artistFilter = filters.artist?.trim().toLowerCase() ?? '';
|
|
91
|
+
const albumFilter = filters.album?.trim().toLowerCase() ?? '';
|
|
92
|
+
const yearFilter = filters.year?.trim() ?? '';
|
|
93
|
+
const durationFilter = filters.duration;
|
|
94
|
+
return results.filter(result => {
|
|
95
|
+
return (matchesArtistFilter(result, artistFilter) &&
|
|
96
|
+
matchesAlbumFilter(result, albumFilter) &&
|
|
97
|
+
matchesYearFilter(result, yearFilter) &&
|
|
98
|
+
matchesDurationFilter(result, durationFilter));
|
|
99
|
+
});
|
|
100
|
+
}
|
|
Binary file
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -28,6 +28,10 @@ A powerful Terminal User Interface (TUI) music player for YouTube Music
|
|
|
28
28
|
- 💾 **Downloads** - Save tracks/playlists/artists with `Shift+D`
|
|
29
29
|
- 🏷️ **Metadata Tagging** - Auto-tag title/artist/album with optional cover art
|
|
30
30
|
|
|
31
|
+
## Roadmap
|
|
32
|
+
|
|
33
|
+
Visit [`SUGGESTIONS.md`](SUGGESTIONS.md) for the full backlog and use `docs/roadmap.md` to understand the current implementation focus (crossfade + gapless playback) and the next steps planned for equalizer/enhancements. The roadmap doc also explains how to pick up work so reviewers and contributors remain aligned.
|
|
34
|
+
|
|
31
35
|
## Prerequisites
|
|
32
36
|
|
|
33
37
|
**Required:**
|