Self Hosted YT-DLP MP3 Downloader -- Modern UI/UX & File Management -- MeTube Fork
mp3 docker selfhosting media management files music
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: Search and Sort Artists on Homepage

+114 -3
+6 -1
app/main.py
··· 487 487 item_path = os.path.join(base, item) 488 488 if os.path.isdir(item_path): 489 489 file_count, total_size = _count_artist_mp3_stats(item_path) 490 + try: 491 + modified_at = os.path.getmtime(item_path) 492 + except OSError: 493 + modified_at = 0 490 494 artists.append({ 491 495 'id': item, 492 496 'name': item, 493 497 'path': item, 494 498 'file_count': file_count, 495 - 'total_size': total_size 499 + 'total_size': total_size, 500 + 'modified_at': modified_at 496 501 }) 497 502 artists.sort(key=lambda x: x['name'].lower()) 498 503 return web.json_response(artists)
+107 -2
ui/src/app/components/home.component.ts
··· 1 1 import { Component, OnInit, inject, Output, EventEmitter } from '@angular/core'; 2 2 import { CommonModule } from '@angular/common'; 3 3 import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; 4 - import { faUserCircle, faMusic, faSpinner } from '@fortawesome/free-solid-svg-icons'; // faUserCircle for empty state only 4 + import { faUserCircle, faMusic, faSpinner, faSearch, faSort } from '@fortawesome/free-solid-svg-icons'; 5 5 import { MusicService, Artist } from '../services/music.service'; 6 + 7 + export type ArtistSortOption = 'name_asc' | 'name_desc' | 'recently_added' | 'last_updated' | 'most_tracks' | 'most_storage'; 6 8 7 9 @Component({ 8 10 selector: 'app-home', ··· 18 20 <p class="home-subtitle text-muted">Click an artist to open their page</p> 19 21 </div> 20 22 23 + @if (!loading && artists.length > 0) { 24 + <div class="home-toolbar"> 25 + <div class="search-wrap"> 26 + <fa-icon [icon]="faSearch" class="search-icon" /> 27 + <input 28 + type="text" 29 + class="form-control search-input" 30 + placeholder="Search artists..." 31 + [value]="searchQuery" 32 + (input)="onSearchInput($event)" 33 + autocomplete="off" /> 34 + </div> 35 + <div class="sort-wrap"> 36 + <fa-icon [icon]="faSort" class="sort-icon" /> 37 + <select class="form-select sort-select" (change)="onSortChange($event)"> 38 + <option value="name_asc">Alphabetical (A–Z)</option> 39 + <option value="name_desc">Alphabetical (Z–A)</option> 40 + <option value="recently_added">Recently added</option> 41 + <option value="last_updated">Last updated</option> 42 + <option value="most_tracks">Most tracks</option> 43 + <option value="most_storage">Most storage</option> 44 + </select> 45 + </div> 46 + </div> 47 + } 48 + 21 49 @if (loading) { 22 50 <div class="text-center p-5"> 23 51 <fa-icon [icon]="faSpinner" size="2x" class="text-muted fa-spin" /> ··· 29 57 <h4>No artists yet</h4> 30 58 <p class="text-muted">Add an artist from the sidebar to get started.</p> 31 59 </div> 60 + } @else if (filteredArtists.length === 0) { 61 + <div class="empty-state"> 62 + <fa-icon [icon]="faSearch" size="4x" class="text-muted mb-3" /> 63 + <h4>No artists match your search</h4> 64 + <p class="text-muted">Try a different search term.</p> 65 + </div> 32 66 } @else { 33 67 <div class="artists-grid"> 34 - @for (artist of artists; track artist.id) { 68 + @for (artist of filteredArtists; track artist.id) { 35 69 <button 36 70 type="button" 37 71 class="artist-card" ··· 70 104 font-size: 0.95rem; 71 105 } 72 106 107 + .home-toolbar { 108 + display: flex; 109 + flex-wrap: wrap; 110 + gap: 1rem; 111 + align-items: center; 112 + margin-bottom: 1.5rem; 113 + } 114 + 115 + .search-wrap { 116 + position: relative; 117 + flex: 1; 118 + min-width: 200px; 119 + max-width: 320px; 120 + } 121 + 122 + .search-icon { 123 + position: absolute; 124 + left: 0.75rem; 125 + top: 50%; 126 + transform: translateY(-50%); 127 + color: var(--bs-secondary); 128 + pointer-events: none; 129 + } 130 + 131 + .search-input { 132 + padding-left: 2.25rem; 133 + } 134 + 135 + .sort-wrap { 136 + position: relative; 137 + display: flex; 138 + align-items: center; 139 + min-width: 180px; 140 + } 141 + 142 + .sort-icon { 143 + color: var(--bs-secondary); 144 + margin-right: 0.5rem; 145 + flex-shrink: 0; 146 + } 147 + 148 + .sort-select { 149 + flex: 1; 150 + } 151 + 73 152 .empty-state { 74 153 display: flex; 75 154 flex-direction: column; ··· 136 215 137 216 artists: Artist[] = []; 138 217 loading = false; 218 + searchQuery = ''; 219 + sortBy: ArtistSortOption = 'name_asc'; 139 220 140 221 faUserCircle = faUserCircle; 141 222 faMusic = faMusic; 142 223 faSpinner = faSpinner; 224 + faSearch = faSearch; 225 + faSort = faSort; 226 + 227 + get filteredArtists(): Artist[] { 228 + const q = this.searchQuery.trim().toLowerCase(); 229 + let list = q 230 + ? this.artists.filter(a => a.name.toLowerCase().includes(q)) 231 + : [...this.artists]; 232 + const opt = this.sortBy; 233 + if (opt === 'name_asc') list.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); 234 + else if (opt === 'name_desc') list.sort((a, b) => b.name.localeCompare(a.name, undefined, { sensitivity: 'base' })); 235 + else if (opt === 'recently_added' || opt === 'last_updated') list.sort((a, b) => (b.modified_at ?? 0) - (a.modified_at ?? 0)); 236 + else if (opt === 'most_tracks') list.sort((a, b) => (b.file_count ?? 0) - (a.file_count ?? 0)); 237 + else if (opt === 'most_storage') list.sort((a, b) => (b.total_size ?? 0) - (a.total_size ?? 0)); 238 + return list; 239 + } 143 240 144 241 ngOnInit() { 145 242 this.loadArtists(); 243 + } 244 + 245 + onSearchInput(e: Event) { 246 + this.searchQuery = (e.target as HTMLInputElement).value; 247 + } 248 + 249 + onSortChange(e: Event) { 250 + this.sortBy = (e.target as HTMLSelectElement).value as ArtistSortOption; 146 251 } 147 252 148 253 loadArtists() {
+1
ui/src/app/services/music.service.ts
··· 10 10 path?: string; 11 11 file_count?: number; 12 12 total_size?: number; 13 + modified_at?: number; 13 14 albums?: Album[]; 14 15 singles?: Single[]; 15 16 }