feat:[v1.7]增加重复url通知
This commit is contained in:
parent
b042805d46
commit
f5024383bf
|
|
@ -1,5 +1,5 @@
|
||||||
docker build -t 192.168.2.212:3000/tigeren/metube:1.6 .
|
docker build -t 192.168.2.212:3000/tigeren/metube:1.7 .
|
||||||
|
|
||||||
docker push 192.168.2.212:3000/tigeren/metube:1.6
|
docker push 192.168.2.212:3000/tigeren/metube:1.7
|
||||||
|
|
||||||
docker compose up -d --build --force-recreate
|
docker compose up -d --build --force-recreate
|
||||||
14
app/main.py
14
app/main.py
|
|
@ -156,6 +156,10 @@ class Notifier(DownloadQueueNotifier):
|
||||||
async def cleared(self, id):
|
async def cleared(self, id):
|
||||||
log.info(f"Notifier: Download cleared - {id}")
|
log.info(f"Notifier: Download cleared - {id}")
|
||||||
await sio.emit('cleared', serializer.encode(id))
|
await sio.emit('cleared', serializer.encode(id))
|
||||||
|
|
||||||
|
async def event(self, event):
|
||||||
|
log.info(f"Notifier: Event - {event['type']}")
|
||||||
|
await sio.emit('event', serializer.encode(event))
|
||||||
|
|
||||||
dqueue = DownloadQueue(config, Notifier())
|
dqueue = DownloadQueue(config, Notifier())
|
||||||
app.on_startup.append(lambda app: dqueue.initialize())
|
app.on_startup.append(lambda app: dqueue.initialize())
|
||||||
|
|
@ -433,6 +437,16 @@ def version(request):
|
||||||
"version": os.getenv("METUBE_VERSION", "dev")
|
"version": os.getenv("METUBE_VERSION", "dev")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@routes.get(config.URL_PREFIX + 'events')
|
||||||
|
def get_events(request):
|
||||||
|
events = dqueue.get_events()
|
||||||
|
return web.Response(text=serializer.encode(events))
|
||||||
|
|
||||||
|
@routes.post(config.URL_PREFIX + 'events/clear')
|
||||||
|
async def clear_events(request):
|
||||||
|
dqueue.clear_events()
|
||||||
|
return web.Response(text=serializer.encode({'status': 'ok'}))
|
||||||
|
|
||||||
if config.URL_PREFIX != '/':
|
if config.URL_PREFIX != '/':
|
||||||
@routes.get('/')
|
@routes.get('/')
|
||||||
def index_redirect_root(request):
|
def index_redirect_root(request):
|
||||||
|
|
|
||||||
32
app/ytdl.py
32
app/ytdl.py
|
|
@ -32,6 +32,9 @@ class DownloadQueueNotifier:
|
||||||
|
|
||||||
async def cleared(self, id):
|
async def cleared(self, id):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def event(self, event):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
class DownloadInfo:
|
class DownloadInfo:
|
||||||
def __init__(self, id, title, url, quality, format, folder, custom_name_prefix, error, entry, playlist_item_limit):
|
def __init__(self, id, title, url, quality, format, folder, custom_name_prefix, error, entry, playlist_item_limit):
|
||||||
|
|
@ -369,6 +372,10 @@ class DownloadQueue:
|
||||||
self.reserved_filenames = set() # Track filenames being processed
|
self.reserved_filenames = set() # Track filenames being processed
|
||||||
self.precheck_in_progress = {} # Track URL -> DownloadInfo for items in precheck queue
|
self.precheck_in_progress = {} # Track URL -> DownloadInfo for items in precheck queue
|
||||||
|
|
||||||
|
# Event notifications (keep last 5 in memory)
|
||||||
|
self.events = [] # List of {type, message, timestamp, url}
|
||||||
|
self.max_events = 5
|
||||||
|
|
||||||
self.done.load()
|
self.done.load()
|
||||||
|
|
||||||
async def __import_queue(self):
|
async def __import_queue(self):
|
||||||
|
|
@ -621,6 +628,8 @@ class DownloadQueue:
|
||||||
self.pending.exists(dl.url) or
|
self.pending.exists(dl.url) or
|
||||||
self.done.exists(dl.url)):
|
self.done.exists(dl.url)):
|
||||||
log.info(f"[PreCheck] URL already queued/processing/downloaded, skipping: {dl.url}")
|
log.info(f"[PreCheck] URL already queued/processing/downloaded, skipping: {dl.url}")
|
||||||
|
# Add event notification
|
||||||
|
self._add_event('duplicate_skipped', 'URL already in queue or downloaded', dl.url)
|
||||||
return {'status': 'ok', 'msg': 'Download already exists'}
|
return {'status': 'ok', 'msg': 'Download already exists'}
|
||||||
|
|
||||||
dldirectory, error_message = self.__calc_download_path(dl.quality, dl.format, dl.folder)
|
dldirectory, error_message = self.__calc_download_path(dl.quality, dl.format, dl.folder)
|
||||||
|
|
@ -878,3 +887,26 @@ class DownloadQueue:
|
||||||
list((k, v.info) for k, v in self.queue.items()) +
|
list((k, v.info) for k, v in self.queue.items()) +
|
||||||
list((k, v.info) for k, v in self.pending.items()),
|
list((k, v.info) for k, v in self.pending.items()),
|
||||||
list((k, v.info) for k, v in self.done.items()))
|
list((k, v.info) for k, v in self.done.items()))
|
||||||
|
|
||||||
|
def _add_event(self, event_type, message, url=None):
|
||||||
|
"""Add an event to the events list (keep only last 5)."""
|
||||||
|
event = {
|
||||||
|
'type': event_type,
|
||||||
|
'message': message,
|
||||||
|
'timestamp': int(time.time()),
|
||||||
|
'url': url
|
||||||
|
}
|
||||||
|
self.events.append(event)
|
||||||
|
# Keep only last 5 events
|
||||||
|
if len(self.events) > self.max_events:
|
||||||
|
self.events = self.events[-self.max_events:]
|
||||||
|
# Notify frontend via WebSocket
|
||||||
|
asyncio.create_task(self.notifier.event(event))
|
||||||
|
|
||||||
|
def get_events(self):
|
||||||
|
"""Get all events (last 5)."""
|
||||||
|
return self.events
|
||||||
|
|
||||||
|
def clear_events(self):
|
||||||
|
"""Clear all events."""
|
||||||
|
self.events = []
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,41 @@
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main role="main" class="container container-xl">
|
<main role="main" class="container container-xl">
|
||||||
|
<!-- Events Display Area -->
|
||||||
|
<div *ngIf="events.length > 0" class="events-container mb-3">
|
||||||
|
<div class="events-header">
|
||||||
|
<div class="d-flex align-items-center justify-content-between">
|
||||||
|
<h6 class="mb-0">
|
||||||
|
<fa-icon [icon]="faClock" class="me-2"></fa-icon>
|
||||||
|
Recent Events
|
||||||
|
</h6>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-sm btn-outline-secondary"
|
||||||
|
(click)="clearEvents()"
|
||||||
|
title="Clear all events">
|
||||||
|
<fa-icon [icon]="faTrashAlt" class="me-1"></fa-icon>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="events-list">
|
||||||
|
<div *ngFor="let event of events" class="event-item" [ngClass]="'event-' + event.type">
|
||||||
|
<div class="event-icon">
|
||||||
|
<fa-icon [icon]="faTimesCircle"></fa-icon>
|
||||||
|
</div>
|
||||||
|
<div class="event-content">
|
||||||
|
<div class="event-message">{{ event.message }}</div>
|
||||||
|
<div class="event-url" *ngIf="event.url" [title]="event.url">
|
||||||
|
{{ event.url }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="event-time">
|
||||||
|
{{ getRelativeTime(event.timestamp) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form #f="ngForm">
|
<form #f="ngForm">
|
||||||
<div class="container add-url-box">
|
<div class="container add-url-box">
|
||||||
<!-- Main URL Input with Download Button -->
|
<!-- Main URL Input with Download Button -->
|
||||||
|
|
|
||||||
|
|
@ -236,3 +236,86 @@ main
|
||||||
|
|
||||||
fa-icon
|
fa-icon
|
||||||
vertical-align: middle
|
vertical-align: middle
|
||||||
|
|
||||||
|
// Events display area
|
||||||
|
.events-container
|
||||||
|
background: var(--bs-body-bg)
|
||||||
|
border: 1px solid var(--bs-border-color)
|
||||||
|
border-radius: 0.5rem
|
||||||
|
overflow: hidden
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1)
|
||||||
|
margin-top: 1rem
|
||||||
|
|
||||||
|
.events-header
|
||||||
|
padding: 0.75rem 1rem
|
||||||
|
background: var(--bs-secondary-bg)
|
||||||
|
border-bottom: 1px solid var(--bs-border-color)
|
||||||
|
|
||||||
|
h6
|
||||||
|
color: var(--bs-secondary-color)
|
||||||
|
font-weight: 600
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
|
||||||
|
fa-icon
|
||||||
|
opacity: 0.7
|
||||||
|
|
||||||
|
.events-list
|
||||||
|
max-height: 300px
|
||||||
|
overflow-y: auto
|
||||||
|
|
||||||
|
.event-item
|
||||||
|
display: flex
|
||||||
|
align-items: flex-start
|
||||||
|
padding: 0.875rem 1rem
|
||||||
|
border-bottom: 1px solid var(--bs-border-color)
|
||||||
|
transition: background-color 0.2s
|
||||||
|
|
||||||
|
&:last-child
|
||||||
|
border-bottom: none
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
background: var(--bs-tertiary-bg)
|
||||||
|
|
||||||
|
.event-icon
|
||||||
|
flex-shrink: 0
|
||||||
|
width: 32px
|
||||||
|
height: 32px
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
justify-content: center
|
||||||
|
border-radius: 50%
|
||||||
|
margin-right: 0.75rem
|
||||||
|
|
||||||
|
fa-icon
|
||||||
|
font-size: 1.1rem
|
||||||
|
|
||||||
|
&.event-duplicate_skipped
|
||||||
|
.event-icon
|
||||||
|
background: rgba(255, 193, 7, 0.15)
|
||||||
|
color: #ffc107
|
||||||
|
|
||||||
|
.event-content
|
||||||
|
flex: 1
|
||||||
|
min-width: 0
|
||||||
|
|
||||||
|
.event-message
|
||||||
|
font-size: 0.9375rem
|
||||||
|
color: var(--bs-body-color)
|
||||||
|
margin-bottom: 0.25rem
|
||||||
|
font-weight: 500
|
||||||
|
|
||||||
|
.event-url
|
||||||
|
font-size: 0.8125rem
|
||||||
|
color: var(--bs-secondary-color)
|
||||||
|
white-space: nowrap
|
||||||
|
overflow: hidden
|
||||||
|
text-overflow: ellipsis
|
||||||
|
font-family: 'Courier New', monospace
|
||||||
|
|
||||||
|
.event-time
|
||||||
|
flex-shrink: 0
|
||||||
|
font-size: 0.75rem
|
||||||
|
color: var(--bs-secondary-color)
|
||||||
|
margin-left: 0.75rem
|
||||||
|
whitespace: nowrap
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Component, ViewChild, ElementRef, AfterViewInit } from '@angular/core';
|
import { Component, ViewChild, ElementRef, AfterViewInit, OnInit } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { faTrashAlt, faCheckCircle, faTimesCircle, IconDefinition } from '@fortawesome/free-regular-svg-icons';
|
import { faTrashAlt, faCheckCircle, faTimesCircle, IconDefinition } from '@fortawesome/free-regular-svg-icons';
|
||||||
import { faRedoAlt, faSun, faMoon, faCircleHalfStroke, faCheck, faExternalLinkAlt, faDownload, faFileImport, faFileExport, faCopy, faClock, faTachometerAlt } from '@fortawesome/free-solid-svg-icons';
|
import { faRedoAlt, faSun, faMoon, faCircleHalfStroke, faCheck, faExternalLinkAlt, faDownload, faFileImport, faFileExport, faCopy, faClock, faTachometerAlt } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
|
@ -18,7 +18,7 @@ import {KeyValue} from "@angular/common";
|
||||||
styleUrls: ['./app.component.sass'],
|
styleUrls: ['./app.component.sass'],
|
||||||
standalone: false
|
standalone: false
|
||||||
})
|
})
|
||||||
export class AppComponent implements AfterViewInit {
|
export class AppComponent implements OnInit, AfterViewInit {
|
||||||
addUrl: string;
|
addUrl: string;
|
||||||
formats: Format[] = Formats;
|
formats: Format[] = Formats;
|
||||||
qualities: Quality[];
|
qualities: Quality[];
|
||||||
|
|
@ -78,6 +78,9 @@ export class AppComponent implements AfterViewInit {
|
||||||
faClock = faClock;
|
faClock = faClock;
|
||||||
faTachometerAlt = faTachometerAlt;
|
faTachometerAlt = faTachometerAlt;
|
||||||
|
|
||||||
|
// Events from backend (last 5 events)
|
||||||
|
events: Array<{type: string, message: string, timestamp: number, url: string}> = [];
|
||||||
|
|
||||||
constructor(public downloads: DownloadsService, private cookieService: CookieService, private http: HttpClient) {
|
constructor(public downloads: DownloadsService, private cookieService: CookieService, private http: HttpClient) {
|
||||||
this.format = cookieService.get('metube_format') || 'any';
|
this.format = cookieService.get('metube_format') || 'any';
|
||||||
// Needs to be set or qualities won't automatically be set
|
// Needs to be set or qualities won't automatically be set
|
||||||
|
|
@ -98,6 +101,15 @@ export class AppComponent implements AfterViewInit {
|
||||||
this.downloads.updated.subscribe(() => {
|
this.downloads.updated.subscribe(() => {
|
||||||
this.updateMetrics();
|
this.updateMetrics();
|
||||||
});
|
});
|
||||||
|
// Subscribe to events
|
||||||
|
this.downloads.eventReceived.subscribe((event: any) => {
|
||||||
|
console.debug('Event received in component:', event);
|
||||||
|
// Add to events array (keep last 5)
|
||||||
|
this.events.push(event);
|
||||||
|
if (this.events.length > 5) {
|
||||||
|
this.events = this.events.slice(-5);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
|
|
@ -106,6 +118,9 @@ export class AppComponent implements AfterViewInit {
|
||||||
this.customDirs$ = this.getMatchingCustomDir();
|
this.customDirs$ = this.getMatchingCustomDir();
|
||||||
this.setTheme(this.activeTheme);
|
this.setTheme(this.activeTheme);
|
||||||
|
|
||||||
|
// Load events from backend
|
||||||
|
this.loadEvents();
|
||||||
|
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||||
if (this.activeTheme.id === 'auto') {
|
if (this.activeTheme.id === 'auto') {
|
||||||
this.setTheme(this.activeTheme);
|
this.setTheme(this.activeTheme);
|
||||||
|
|
@ -269,11 +284,38 @@ export class AppComponent implements AfterViewInit {
|
||||||
alert(`Error adding URL: ${status.msg}`);
|
alert(`Error adding URL: ${status.msg}`);
|
||||||
} else {
|
} else {
|
||||||
this.addUrl = '';
|
this.addUrl = '';
|
||||||
|
// Reload events after adding
|
||||||
|
this.loadEvents();
|
||||||
}
|
}
|
||||||
this.addInProgress = false;
|
this.addInProgress = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load events from backend
|
||||||
|
loadEvents() {
|
||||||
|
this.http.get('/events').subscribe((events: any[]) => {
|
||||||
|
this.events = events || [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear all events
|
||||||
|
clearEvents() {
|
||||||
|
this.http.post('/events/clear', {}).subscribe(() => {
|
||||||
|
this.events = [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format timestamp to relative time
|
||||||
|
getRelativeTime(timestamp: number): string {
|
||||||
|
const now = Date.now() / 1000;
|
||||||
|
const diff = Math.floor(now - timestamp);
|
||||||
|
|
||||||
|
if (diff < 60) return 'just now';
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||||
|
return `${Math.floor(diff / 86400)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
downloadItemByKey(id: string) {
|
downloadItemByKey(id: string) {
|
||||||
this.downloads.startById([id]).subscribe();
|
this.downloads.startById([id]).subscribe();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ export class DownloadsService {
|
||||||
ytdlOptionsChanged = new Subject();
|
ytdlOptionsChanged = new Subject();
|
||||||
configurationChanged = new Subject();
|
configurationChanged = new Subject();
|
||||||
updated = new Subject();
|
updated = new Subject();
|
||||||
|
eventReceived = new Subject(); // New subject for events
|
||||||
|
|
||||||
configuration = {};
|
configuration = {};
|
||||||
customDirs = {};
|
customDirs = {};
|
||||||
|
|
@ -112,6 +113,11 @@ export class DownloadsService {
|
||||||
let data = JSON.parse(strdata);
|
let data = JSON.parse(strdata);
|
||||||
this.ytdlOptionsChanged.next(data);
|
this.ytdlOptionsChanged.next(data);
|
||||||
});
|
});
|
||||||
|
socket.fromEvent('event').subscribe((strdata: string) => {
|
||||||
|
let event = JSON.parse(strdata);
|
||||||
|
console.debug('Received event:', event);
|
||||||
|
this.eventReceived.next(event);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
handleHTTPError(error: HttpErrorResponse) {
|
handleHTTPError(error: HttpErrorResponse) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue