feat: sort completed downloads by newest first (closes #610)

This commit is contained in:
ddmoney420
2026-03-01 19:02:04 -07:00
parent 7427cbb0c0
commit da84753e20
2 changed files with 57 additions and 27 deletions

View File

@@ -424,7 +424,14 @@
</table> </table>
</div> </div>
<div class="metube-section-header">Completed</div> <div class="metube-section-header d-flex align-items-center">
Completed
<button type="button" class="btn btn-sm btn-outline-secondary ms-3"
(click)="toggleSortOrder()"
ngbTooltip="{{ sortAscending ? 'Oldest first' : 'Newest first' }}">
<fa-icon [icon]="sortAscending ? faSortAmountUp : faSortAmountDown" />
</button>
</div>
<div class="px-2 py-3 border-bottom"> <div class="px-2 py-3 border-bottom">
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDelSelected (click)="delSelectedDownloads('done')"><fa-icon [icon]="faTrashAlt" />&nbsp; Clear selected</button> <button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDelSelected (click)="delSelectedDownloads('done')"><fa-icon [icon]="faTrashAlt" />&nbsp; Clear selected</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneClearCompleted (click)="clearCompletedDownloads()"><fa-icon [icon]="faCheckCircle" />&nbsp; Clear completed</button> <button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneClearCompleted (click)="clearCompletedDownloads()"><fa-icon [icon]="faCheckCircle" />&nbsp; Clear completed</button>
@@ -445,58 +452,58 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (download of downloads.done | keyvalue: asIsOrder; track download.value.id) { @for (entry of sortedDone(); track entry[1].id) {
<tr [class.disabled]='download.value.deleting'> <tr [class.disabled]='entry[1].deleting'>
<td> <td>
<app-slave-checkbox [id]="download.key" [master]="doneMasterCheckboxRef" [checkable]="download.value" /> <app-slave-checkbox [id]="entry[0]" [master]="doneMasterCheckboxRef" [checkable]="entry[1]" />
</td> </td>
<td> <td>
<div style="display: inline-block; width: 1.5rem;"> <div style="display: inline-block; width: 1.5rem;">
@if (download.value.status === 'finished') { @if (entry[1].status === 'finished') {
<fa-icon [icon]="faCheckCircle" class="text-success" /> <fa-icon [icon]="faCheckCircle" class="text-success" />
} }
@if (download.value.status === 'error') { @if (entry[1].status === 'error') {
<fa-icon [icon]="faTimesCircle" class="text-danger" /> <fa-icon [icon]="faTimesCircle" class="text-danger" />
} }
</div> </div>
<span ngbTooltip="{{buildResultItemTooltip(download.value)}}">@if (!!download.value.filename) { <span ngbTooltip="{{buildResultItemTooltip(entry[1])}}">@if (!!entry[1].filename) {
<a href="{{buildDownloadLink(download.value)}}" target="_blank">{{ download.value.title }}</a> <a href="{{buildDownloadLink(entry[1])}}" target="_blank">{{ entry[1].title }}</a>
} @else { } @else {
{{download.value.title}} {{entry[1].title}}
@if (download.value.msg) { @if (entry[1].msg) {
<span><br>{{download.value.msg}}</span> <span><br>{{entry[1].msg}}</span>
} }
@if (download.value.error) { @if (entry[1].error) {
<span><br>Error: {{download.value.error}}</span> <span><br>Error: {{entry[1].error}}</span>
} }
}</span> }</span>
</td> </td>
<td> <td>
@if (download.value.size) { @if (entry[1].size) {
<span>{{ download.value.size | fileSize }}</span> <span>{{ entry[1].size | fileSize }}</span>
} }
</td> </td>
<td> <td>
<div class="d-flex"> <div class="d-flex">
@if (download.value.status === 'error') { @if (entry[1].status === 'error') {
<button type="button" class="btn btn-link" (click)="retryDownload(download.key, download.value)"><fa-icon [icon]="faRedoAlt" /></button> <button type="button" class="btn btn-link" (click)="retryDownload(entry[0], entry[1])"><fa-icon [icon]="faRedoAlt" /></button>
} }
@if (download.value.filename) { @if (entry[1].filename) {
<a href="{{buildDownloadLink(download.value)}}" download class="btn btn-link"><fa-icon [icon]="faDownload" /></a> <a href="{{buildDownloadLink(entry[1])}}" download class="btn btn-link"><fa-icon [icon]="faDownload" /></a>
} }
<a href="{{download.value.url}}" target="_blank" class="btn btn-link"><fa-icon [icon]="faExternalLinkAlt" /></a> <a href="{{entry[1].url}}" target="_blank" class="btn btn-link"><fa-icon [icon]="faExternalLinkAlt" /></a>
<button type="button" class="btn btn-link" (click)="delDownload('done', download.key)"><fa-icon [icon]="faTrashAlt" /></button> <button type="button" class="btn btn-link" (click)="delDownload('done', entry[0])"><fa-icon [icon]="faTrashAlt" /></button>
</div> </div>
</td> </td>
</tr> </tr>
@if (download.value.chapter_files && download.value.chapter_files.length > 0) { @if (entry[1].chapter_files && entry[1].chapter_files.length > 0) {
@for (chapterFile of download.value.chapter_files; track chapterFile.filename) { @for (chapterFile of entry[1].chapter_files; track chapterFile.filename) {
<tr [class.disabled]='download.value.deleting'> <tr [class.disabled]='entry[1].deleting'>
<td></td> <td></td>
<td> <td>
<div style="padding-left: 2rem;"> <div style="padding-left: 2rem;">
<fa-icon [icon]="faCheckCircle" class="text-success me-2" /> <fa-icon [icon]="faCheckCircle" class="text-success me-2" />
<a href="{{buildChapterDownloadLink(download.value, chapterFile.filename)}}" target="_blank">{{ <a href="{{buildChapterDownloadLink(entry[1], chapterFile.filename)}}" target="_blank">{{
getChapterFileName(chapterFile.filename) }}</a> getChapterFileName(chapterFile.filename) }}</a>
</div> </div>
</td> </td>
@@ -507,7 +514,7 @@
</td> </td>
<td> <td>
<div class="d-flex"> <div class="d-flex">
<a href="{{buildChapterDownloadLink(download.value, chapterFile.filename)}}" download <a href="{{buildChapterDownloadLink(entry[1], chapterFile.filename)}}" download
class="btn btn-link"><fa-icon [icon]="faDownload" /></a> class="btn btn-link"><fa-icon [icon]="faDownload" /></a>
</div> </div>
</td> </td>

View File

@@ -6,7 +6,7 @@ import { FormsModule } from '@angular/forms';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { NgSelectModule } from '@ng-select/ng-select'; import { NgSelectModule } from '@ng-select/ng-select';
import { faTrashAlt, faCheckCircle, faTimesCircle, faRedoAlt, faSun, faMoon, faCheck, faCircleHalfStroke, faDownload, faExternalLinkAlt, faFileImport, faFileExport, faCopy, faClock, faTachometerAlt } from '@fortawesome/free-solid-svg-icons'; import { faTrashAlt, faCheckCircle, faTimesCircle, faRedoAlt, faSun, faMoon, faCheck, faCircleHalfStroke, faDownload, faExternalLinkAlt, faFileImport, faFileExport, faCopy, faClock, faTachometerAlt, faSortAmountDown, faSortAmountUp } from '@fortawesome/free-solid-svg-icons';
import { faGithub } from '@fortawesome/free-brands-svg-icons'; import { faGithub } from '@fortawesome/free-brands-svg-icons';
import { CookieService } from 'ngx-cookie-service'; import { CookieService } from 'ngx-cookie-service';
import { DownloadsService } from './services/downloads.service'; import { DownloadsService } from './services/downloads.service';
@@ -66,6 +66,7 @@ export class App implements AfterViewInit, OnInit {
ytDlpVersion: string | null = null; ytDlpVersion: string | null = null;
metubeVersion: string | null = null; metubeVersion: string | null = null;
isAdvancedOpen = false; isAdvancedOpen = false;
sortAscending = false;
// Download metrics // Download metrics
activeDownloads = 0; activeDownloads = 0;
@@ -100,6 +101,8 @@ export class App implements AfterViewInit, OnInit {
faGithub = faGithub; faGithub = faGithub;
faClock = faClock; faClock = faClock;
faTachometerAlt = faTachometerAlt; faTachometerAlt = faTachometerAlt;
faSortAmountDown = faSortAmountDown;
faSortAmountUp = faSortAmountUp;
subtitleFormats = [ subtitleFormats = [
{ id: 'srt', text: 'SRT' }, { id: 'srt', text: 'SRT' },
{ id: 'txt', text: 'TXT (Text only)' }, { id: 'txt', text: 'TXT (Text only)' },
@@ -178,6 +181,7 @@ export class App implements AfterViewInit, OnInit {
if (!allowedSubtitleModes.has(this.subtitleMode)) { if (!allowedSubtitleModes.has(this.subtitleMode)) {
this.subtitleMode = 'prefer_manual'; this.subtitleMode = 'prefer_manual';
} }
this.sortAscending = this.cookieService.get('metube_sort_ascending') === 'true';
this.activeTheme = this.getPreferredTheme(this.cookieService); this.activeTheme = this.getPreferredTheme(this.cookieService);
@@ -699,6 +703,25 @@ export class App implements AfterViewInit, OnInit {
this.isAdvancedOpen = !this.isAdvancedOpen; this.isAdvancedOpen = !this.isAdvancedOpen;
} }
toggleSortOrder() {
this.sortAscending = !this.sortAscending;
this.cookieService.set('metube_sort_ascending', this.sortAscending ? 'true' : 'false', { expires: 3650 });
}
sortedDone(): [string, Download][] {
const result: [string, Download][] = [];
this.downloads.done.forEach((dl, key) => {
result.push([key, dl]);
});
result.sort((a, b) => {
const tsA = (a[1] as any).timestamp || 0;
const tsB = (b[1] as any).timestamp || 0;
const cmp = tsA < tsB ? -1 : tsA > tsB ? 1 : 0;
return this.sortAscending ? cmp : -cmp;
});
return result;
}
private updateMetrics() { private updateMetrics() {
this.activeDownloads = Array.from(this.downloads.queue.values()).filter(d => d.status === 'downloading' || d.status === 'preparing').length; this.activeDownloads = Array.from(this.downloads.queue.values()).filter(d => d.status === 'downloading' || d.status === 'preparing').length;
this.queuedDownloads = Array.from(this.downloads.queue.values()).filter(d => d.status === 'pending').length; this.queuedDownloads = Array.from(this.downloads.queue.values()).filter(d => d.status === 'pending').length;