@involvex/youtube-music-cli 0.0.33 → 0.0.34

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 CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.0.34](https://github.com/involvex/youtube-music-cli/compare/v0.0.33...v0.0.34) (2026-02-22)
2
+
3
+ ### Features
4
+
5
+ - **search:** implement search filters by artist, album, and year ([d3edbe6](https://github.com/involvex/youtube-music-cli/commit/d3edbe6b61fd0afa363e1c19b420d73fd6ebbab7))
6
+
1
7
  ## [0.0.33](https://github.com/involvex/youtube-music-cli/compare/v0.0.32...v0.0.33) (2026-02-22)
2
8
 
3
9
  ### Features
@@ -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 [results, setResults] = useState([]);
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
- setResults(response.results);
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
- setResults([]);
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
- } }), (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: results, selectedIndex: navState.selectedResult, isActive: !isTyping, onMixCreated: handleMixCreated, onDownloadStatus: handleDownloadStatus })), !isLoading && navState.hasSearched && results.length === 0 && !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
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
  }
@@ -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;
@@ -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
+ }
@@ -63,6 +63,10 @@ export declare const KEYBINDINGS: {
63
63
  readonly PREV_RESULT: readonly ["shift+tab"];
64
64
  readonly INCREASE_RESULTS: readonly ["]"];
65
65
  readonly DECREASE_RESULTS: readonly ["["];
66
+ readonly SEARCH_FILTER_ARTIST: readonly ["ctrl+a"];
67
+ readonly SEARCH_FILTER_ALBUM: readonly ["ctrl+l"];
68
+ readonly SEARCH_FILTER_YEAR: readonly ["ctrl+y"];
69
+ readonly SEARCH_FILTER_DURATION: readonly ["ctrl+d"];
66
70
  readonly ADD_TO_PLAYLIST: readonly ["a"];
67
71
  readonly REMOVE_FROM_PLAYLIST: readonly ["d"];
68
72
  readonly CREATE_PLAYLIST: readonly ["c"];
@@ -74,6 +74,10 @@ export const KEYBINDINGS = {
74
74
  PREV_RESULT: ['shift+tab'],
75
75
  INCREASE_RESULTS: [']'],
76
76
  DECREASE_RESULTS: ['['],
77
+ SEARCH_FILTER_ARTIST: ['ctrl+a'],
78
+ SEARCH_FILTER_ALBUM: ['ctrl+l'],
79
+ SEARCH_FILTER_YEAR: ['ctrl+y'],
80
+ SEARCH_FILTER_DURATION: ['ctrl+d'],
77
81
  // Playlist
78
82
  ADD_TO_PLAYLIST: ['a'],
79
83
  REMOVE_FROM_PLAYLIST: ['d'],
@@ -0,0 +1,2 @@
1
+ import type { SearchFilters, SearchResult } from '../types/youtube-music.types.ts';
2
+ export declare function applySearchFilters(results: SearchResult[], filters: SearchFilters): SearchResult[];
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@involvex/youtube-music-cli",
3
- "version": "0.0.33",
3
+ "version": "0.0.34",
4
4
  "description": "- A Commandline music player for youtube-music",
5
5
  "repository": {
6
6
  "type": "git",