mirror of
https://github.com/alexta69/metube.git
synced 2026-06-17 16:20:07 +00:00
Compare commits
4 Commits
2026.06.13
...
2026.06.16
| Author | SHA1 | Date | |
|---|---|---|---|
| b73e95f405 | |||
| 64d0d62878 | |||
| 37f7af0555 | |||
| 5aa7d033e2 |
+38
-4
@@ -112,6 +112,13 @@ class Config:
|
||||
if not self.URL_PREFIX.endswith('/'):
|
||||
self.URL_PREFIX += '/'
|
||||
|
||||
# A blank PUBLIC_HOST_AUDIO_URL (e.g. set empty in a compose file) bypasses the
|
||||
# default via os.environ.get, which would leave audio links root-relative and 404.
|
||||
# Fall back to the 'audio_download/' route that serves AUDIO_DOWNLOAD_DIR. When
|
||||
# PUBLIC_HOST_URL is also blank we leave it blank to preserve serving from web root.
|
||||
if not self.PUBLIC_HOST_AUDIO_URL and self.PUBLIC_HOST_URL:
|
||||
self.PUBLIC_HOST_AUDIO_URL = self._DEFAULTS['PUBLIC_HOST_AUDIO_URL']
|
||||
|
||||
for attr in ('PUBLIC_HOST_URL', 'PUBLIC_HOST_AUDIO_URL'):
|
||||
val = getattr(self, attr)
|
||||
if val and not val.endswith('/'):
|
||||
@@ -130,6 +137,10 @@ class Config:
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
self._validate_int('MAX_CONCURRENT_DOWNLOADS', minimum=1)
|
||||
self._validate_int('PORT', minimum=1, maximum=65535)
|
||||
self._validate_int('CLEAR_COMPLETED_AFTER', minimum=0)
|
||||
|
||||
self._runtime_overrides = {}
|
||||
|
||||
success,_ = self.load_ytdl_options()
|
||||
@@ -139,6 +150,20 @@ class Config:
|
||||
if not success:
|
||||
sys.exit(1)
|
||||
|
||||
def _validate_int(self, key, *, minimum=None, maximum=None):
|
||||
raw = getattr(self, key)
|
||||
try:
|
||||
value = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
log.error('Environment variable "%s" must be an integer, got "%s"', key, raw)
|
||||
sys.exit(1)
|
||||
if minimum is not None and value < minimum:
|
||||
log.error('Environment variable "%s" must be >= %d, got "%s"', key, minimum, raw)
|
||||
sys.exit(1)
|
||||
if maximum is not None and value > maximum:
|
||||
log.error('Environment variable "%s" must be <= %d, got "%s"', key, maximum, raw)
|
||||
sys.exit(1)
|
||||
|
||||
def set_runtime_override(self, key, value):
|
||||
self._runtime_overrides[key] = value
|
||||
self.YTDL_OPTIONS[key] = value
|
||||
@@ -241,7 +266,13 @@ logging.getLogger().setLevel(parseLogLevel(str(config.LOGLEVEL)) or logging.INFO
|
||||
|
||||
class ObjectSerializer(json.JSONEncoder):
|
||||
def default(self, obj):
|
||||
# First try to use __dict__ for custom objects
|
||||
# Prefer an explicit client-facing view when the object provides one
|
||||
# (e.g. DownloadInfo / SubscriptionInfo) so server-only or bulky fields
|
||||
# are never broadcast to browser clients.
|
||||
to_public = getattr(obj, 'to_public_dict', None)
|
||||
if callable(to_public):
|
||||
return to_public()
|
||||
# Fall back to __dict__ for other custom objects
|
||||
if hasattr(obj, '__dict__'):
|
||||
return obj.__dict__
|
||||
# Convert iterables (generators, dict_items, etc.) to lists
|
||||
@@ -827,10 +858,7 @@ async def cancel_add(request):
|
||||
@routes.post(config.URL_PREFIX + 'subscribe')
|
||||
async def subscribe(request):
|
||||
post = await _read_json_request(request)
|
||||
try:
|
||||
o = parse_download_options(post)
|
||||
except web.HTTPBadRequest:
|
||||
raise
|
||||
cic = post.get('check_interval_minutes')
|
||||
if cic is None:
|
||||
cic = config.SUBSCRIPTION_DEFAULT_CHECK_INTERVAL
|
||||
@@ -964,6 +992,12 @@ async def upload_cookies(request):
|
||||
tmp_cookie_path = f"{COOKIES_PATH}.tmp"
|
||||
with open(tmp_cookie_path, 'wb') as f:
|
||||
f.write(content)
|
||||
# Cookies are sensitive auth material; restrict to owner read/write only
|
||||
# (the container's default umask would otherwise leave them group/world readable).
|
||||
try:
|
||||
os.chmod(tmp_cookie_path, 0o600)
|
||||
except OSError as exc:
|
||||
log.warning(f'Could not restrict permissions on cookies file: {exc}')
|
||||
os.replace(tmp_cookie_path, COOKIES_PATH)
|
||||
config.set_runtime_override('cookiefile', COOKIES_PATH)
|
||||
log.info(f'Cookies file uploaded ({size} bytes)')
|
||||
|
||||
@@ -312,6 +312,7 @@ class SubscriptionManager:
|
||||
self._subs: dict[str, SubscriptionInfo] = {}
|
||||
self._url_index: dict[str, str] = {} # normalized url -> id
|
||||
self._pending_urls: set[str] = set()
|
||||
self._checks_in_flight: set[str] = set() # subscription ids being checked right now
|
||||
self._lock = asyncio.Lock()
|
||||
self._loop_task: Optional[asyncio.Task] = None
|
||||
self._load_all()
|
||||
@@ -677,6 +678,22 @@ class SubscriptionManager:
|
||||
return {"status": "ok"}
|
||||
|
||||
async def _check_one_unlocked(self, sub: SubscriptionInfo) -> None:
|
||||
sid = sub.id
|
||||
# Prevent overlapping checks for the same subscription (e.g. the periodic
|
||||
# loop and a manual check-now firing together), which could double-queue
|
||||
# entries and drop seen_ids via a read-modify-write race.
|
||||
async with self._lock:
|
||||
if sid in self._checks_in_flight:
|
||||
log.info("Subscription check already in progress for %s, skipping", sub.name)
|
||||
return
|
||||
self._checks_in_flight.add(sid)
|
||||
try:
|
||||
await self._check_one_inner(sub)
|
||||
finally:
|
||||
async with self._lock:
|
||||
self._checks_in_flight.discard(sid)
|
||||
|
||||
async def _check_one_inner(self, sub: SubscriptionInfo) -> None:
|
||||
sid = sub.id
|
||||
scan = int(getattr(self.config, "SUBSCRIPTION_SCAN_PLAYLIST_END", 50))
|
||||
log.info("Checking subscription: %s", sub.name)
|
||||
|
||||
@@ -51,6 +51,19 @@ class ConfigTests(unittest.TestCase):
|
||||
self.assertEqual(c.PUBLIC_HOST_URL, "")
|
||||
self.assertEqual(c.PUBLIC_HOST_AUDIO_URL, "")
|
||||
|
||||
def test_blank_audio_host_falls_back_to_audio_download_route(self):
|
||||
# Regression: a present-but-blank PUBLIC_HOST_AUDIO_URL must not stay empty
|
||||
# (which produced root-relative, 404ing audio links). It falls back to the
|
||||
# 'audio_download/' route that serves AUDIO_DOWNLOAD_DIR.
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
_base_env(PUBLIC_HOST_URL="https://ytdl.example.com", PUBLIC_HOST_AUDIO_URL=""),
|
||||
clear=False,
|
||||
):
|
||||
c = Config()
|
||||
self.assertEqual(c.PUBLIC_HOST_URL, "https://ytdl.example.com/")
|
||||
self.assertEqual(c.PUBLIC_HOST_AUDIO_URL, "audio_download/")
|
||||
|
||||
def test_public_host_url_already_slashed_unchanged(self):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
@@ -123,6 +136,29 @@ class ConfigTests(unittest.TestCase):
|
||||
with self.assertRaises(SystemExit):
|
||||
Config()
|
||||
|
||||
def test_invalid_max_concurrent_downloads_exits(self):
|
||||
for bad in ("0", "-1", "abc"):
|
||||
with patch.dict(os.environ, _base_env(MAX_CONCURRENT_DOWNLOADS=bad), clear=False):
|
||||
with self.assertRaises(SystemExit):
|
||||
Config()
|
||||
|
||||
def test_invalid_port_exits(self):
|
||||
for bad in ("0", "70000", "notaport"):
|
||||
with patch.dict(os.environ, _base_env(PORT=bad), clear=False):
|
||||
with self.assertRaises(SystemExit):
|
||||
Config()
|
||||
|
||||
def test_invalid_clear_completed_after_exits(self):
|
||||
for bad in ("-5", "soon"):
|
||||
with patch.dict(os.environ, _base_env(CLEAR_COMPLETED_AFTER=bad), clear=False):
|
||||
with self.assertRaises(SystemExit):
|
||||
Config()
|
||||
|
||||
def test_clear_completed_after_zero_allowed(self):
|
||||
with patch.dict(os.environ, _base_env(CLEAR_COMPLETED_AFTER="0"), clear=False):
|
||||
c = Config()
|
||||
self.assertEqual(c.CLEAR_COMPLETED_AFTER, "0")
|
||||
|
||||
def test_runtime_override_roundtrip(self):
|
||||
with patch.dict(os.environ, _base_env(), clear=False):
|
||||
c = Config()
|
||||
|
||||
@@ -627,3 +627,63 @@ def test_seconds_until_next_probe_none_when_empty(dq_env):
|
||||
notifier = AsyncMock()
|
||||
dq = DownloadQueue(dq_env, notifier)
|
||||
assert dq._seconds_until_next_probe() is None
|
||||
|
||||
|
||||
def test_calc_download_path_allows_subfolder(dq_env):
|
||||
notifier = AsyncMock()
|
||||
dq = DownloadQueue(dq_env, notifier)
|
||||
path, err = dq._DownloadQueue__calc_download_path("video", "sub/dir")
|
||||
assert err is None
|
||||
assert os.path.realpath(path) == os.path.join(os.path.realpath(dq_env.DOWNLOAD_DIR), "sub", "dir")
|
||||
|
||||
|
||||
def test_calc_download_path_rejects_sibling_prefix_escape(dq_env):
|
||||
"""A folder resolving to a sibling sharing a name prefix must be rejected.
|
||||
|
||||
Regression test: ``startswith`` would have accepted ``../downloads-secret``
|
||||
when the base directory is ``.../downloads``.
|
||||
"""
|
||||
notifier = AsyncMock()
|
||||
base = os.path.realpath(dq_env.DOWNLOAD_DIR)
|
||||
sibling = base + "-secret"
|
||||
os.makedirs(sibling, exist_ok=True)
|
||||
dq = DownloadQueue(dq_env, notifier)
|
||||
escape_folder = os.path.join("..", os.path.basename(sibling), "x")
|
||||
path, err = dq._DownloadQueue__calc_download_path("video", escape_folder)
|
||||
assert path is None
|
||||
assert err is not None and err["status"] == "error"
|
||||
|
||||
|
||||
def test_calc_download_path_rejects_parent_escape(dq_env):
|
||||
notifier = AsyncMock()
|
||||
dq = DownloadQueue(dq_env, notifier)
|
||||
path, err = dq._DownloadQueue__calc_download_path("video", "../../etc")
|
||||
assert path is None
|
||||
assert err is not None and err["status"] == "error"
|
||||
|
||||
|
||||
def test_download_info_to_public_dict_excludes_server_only_fields():
|
||||
info = DownloadInfo(
|
||||
id="vid1",
|
||||
title="Test Video",
|
||||
url="https://example.com/watch?v=1",
|
||||
quality="best",
|
||||
download_type="video",
|
||||
codec="auto",
|
||||
format="any",
|
||||
folder="",
|
||||
custom_name_prefix="",
|
||||
error=None,
|
||||
entry={"id": "vid1", "huge": "x" * 100000},
|
||||
playlist_item_limit=0,
|
||||
split_by_chapters=False,
|
||||
chapter_template="",
|
||||
)
|
||||
info.subtitle_files = [{"filename": "a.srt", "size": 10}]
|
||||
public = info.to_public_dict()
|
||||
assert "entry" not in public
|
||||
assert "subtitle_files" not in public
|
||||
# Client-facing fields are still present.
|
||||
assert public["url"] == "https://example.com/watch?v=1"
|
||||
assert public["title"] == "Test Video"
|
||||
assert public["status"] == "pending"
|
||||
|
||||
+51
-8
@@ -232,6 +232,20 @@ class DownloadInfo:
|
||||
self.live_release_timestamp = live_release_timestamp
|
||||
self.subtitle_files = []
|
||||
|
||||
# Fields that are useful server-side but must not be broadcast to browser
|
||||
# clients: ``entry`` is the full yt-dlp info-dict (potentially large and
|
||||
# re-sent on every progress tick) and ``subtitle_files`` is only used
|
||||
# internally to derive the primary caption ``filename``.
|
||||
_PUBLIC_EXCLUDED_FIELDS = ("entry", "subtitle_files")
|
||||
|
||||
def to_public_dict(self) -> dict:
|
||||
"""Return the client-facing view, omitting server-only/bulky fields."""
|
||||
return {
|
||||
k: v
|
||||
for k, v in self.__dict__.items()
|
||||
if k not in self._PUBLIC_EXCLUDED_FIELDS
|
||||
}
|
||||
|
||||
def __setstate__(self, state):
|
||||
"""BACKWARD COMPATIBILITY: migrate old DownloadInfo from persistent queue files."""
|
||||
self.__dict__.update(state)
|
||||
@@ -584,7 +598,10 @@ class Download:
|
||||
self.info.filename = rel_name
|
||||
self.info.size = os.path.getsize(fileName) if os.path.exists(fileName) else None
|
||||
if getattr(self.info, 'download_type', '') == 'thumbnail':
|
||||
self.info.filename = re.sub(r'\.webm$', '.jpg', self.info.filename)
|
||||
# The thumbnail convertor always emits a .jpg, but yt-dlp may
|
||||
# report the pre-conversion media/thumbnail extension
|
||||
# (.webm/.mp4/.png/.webp/...). Normalise to .jpg regardless.
|
||||
self.info.filename = os.path.splitext(self.info.filename)[0] + '.jpg'
|
||||
|
||||
# Handle chapter files
|
||||
log.debug(f"Update status for {self.info.title}: {status}")
|
||||
@@ -647,8 +664,8 @@ class PersistentQueue:
|
||||
def __init__(self, name, path):
|
||||
self.identifier = name
|
||||
pdir = os.path.dirname(path)
|
||||
if not os.path.isdir(pdir):
|
||||
os.mkdir(pdir)
|
||||
if pdir and not os.path.isdir(pdir):
|
||||
os.makedirs(pdir, exist_ok=True)
|
||||
self.legacy_path = path
|
||||
self.path = f"{path}.json"
|
||||
self.store = AtomicJsonStore(self.path, kind=f"persistent_queue:{name}")
|
||||
@@ -1026,7 +1043,16 @@ class DownloadQueue:
|
||||
return None, {'status': 'error', 'msg': 'A folder for the download was specified but CUSTOM_DIRS is not true in the configuration.'}
|
||||
dldirectory = os.path.realpath(os.path.join(base_directory, folder))
|
||||
real_base_directory = os.path.realpath(base_directory)
|
||||
if not dldirectory.startswith(real_base_directory):
|
||||
# Use commonpath rather than startswith so that a sibling directory
|
||||
# sharing a name prefix (e.g. base "/downloads" vs "/downloads-secret")
|
||||
# cannot be reached via "../downloads-secret".
|
||||
try:
|
||||
inside_base = os.path.commonpath([real_base_directory, dldirectory]) == real_base_directory
|
||||
except ValueError:
|
||||
# Raised when paths are on different drives (Windows) or mix
|
||||
# absolute/relative; treat as outside the base directory.
|
||||
inside_base = False
|
||||
if not inside_base:
|
||||
return None, {'status': 'error', 'msg': f'Folder "{folder}" must resolve inside the base download directory "{real_base_directory}"'}
|
||||
if not os.path.isdir(dldirectory):
|
||||
if not self.config.CREATE_CUSTOM_DIRS:
|
||||
@@ -1387,11 +1413,28 @@ class DownloadQueue:
|
||||
continue
|
||||
if self.config.DELETE_FILE_ON_TRASHCAN:
|
||||
dl = self.done.get(id)
|
||||
dldirectory, calc_error = self.__calc_download_path(dl.info.download_type, dl.info.folder)
|
||||
if calc_error is not None or not dldirectory:
|
||||
log.warning(f'deleting files for download {id} skipped: could not resolve download directory')
|
||||
else:
|
||||
# Remove the primary output plus any per-chapter / per-subtitle
|
||||
# outputs. Each filename is relative to the download directory.
|
||||
rel_names = []
|
||||
if getattr(dl.info, 'filename', None):
|
||||
rel_names.append(dl.info.filename)
|
||||
for extra in (getattr(dl.info, 'chapter_files', None) or []):
|
||||
if isinstance(extra, dict) and extra.get('filename'):
|
||||
rel_names.append(extra['filename'])
|
||||
for extra in (getattr(dl.info, 'subtitle_files', None) or []):
|
||||
if isinstance(extra, dict) and extra.get('filename'):
|
||||
rel_names.append(extra['filename'])
|
||||
for rel_name in rel_names:
|
||||
try:
|
||||
dldirectory, _ = self.__calc_download_path(dl.info.download_type, dl.info.folder)
|
||||
os.remove(os.path.join(dldirectory, dl.info.filename))
|
||||
except Exception as e:
|
||||
log.warning(f'deleting file for download {id} failed with error message {e!r}')
|
||||
os.remove(os.path.join(dldirectory, rel_name))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError as e:
|
||||
log.warning(f'deleting file "{rel_name}" for download {id} failed with error message {e!r}')
|
||||
self.done.delete(id)
|
||||
await self.notifier.cleared(id)
|
||||
return {'status': 'ok'}
|
||||
|
||||
+3
-3
@@ -48,8 +48,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-eslint/builder": "21.1.0",
|
||||
"@angular/build": "^21.2.14",
|
||||
"@angular/cli": "^21.2.14",
|
||||
"@angular/build": "^21.2.15",
|
||||
"@angular/cli": "^21.2.15",
|
||||
"@angular/compiler-cli": "^21.2.17",
|
||||
"@angular/localize": "^21.2.17",
|
||||
"@eslint/js": "^9.39.4",
|
||||
@@ -58,6 +58,6 @@
|
||||
"jsdom": "^27.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "8.47.0",
|
||||
"vitest": "^4.1.8"
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+291
-291
File diff suppressed because it is too large
Load Diff
@@ -1078,3 +1078,5 @@
|
||||
}
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<app-toast-container />
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Subject, of } from 'rxjs';
|
||||
import { App } from './app';
|
||||
import { DownloadsService } from './services/downloads.service';
|
||||
import { SubscriptionsService } from './services/subscriptions.service';
|
||||
import { ToastService } from './services/toast.service';
|
||||
import { CookieService } from 'ngx-cookie-service';
|
||||
|
||||
class DownloadsServiceStub {
|
||||
@@ -263,7 +264,8 @@ describe('App', () => {
|
||||
});
|
||||
|
||||
it('blocks subscribe with invalid title regex', () => {
|
||||
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => undefined);
|
||||
const toasts = TestBed.inject(ToastService);
|
||||
const errorSpy = vi.spyOn(toasts, 'error').mockImplementation(() => undefined);
|
||||
const fixture = TestBed.createComponent(App);
|
||||
const app = fixture.componentInstance;
|
||||
const subs = TestBed.inject(SubscriptionsService) as unknown as SubscriptionsServiceStub;
|
||||
@@ -271,7 +273,7 @@ describe('App', () => {
|
||||
app.titleRegex = '[';
|
||||
app.addSubscription();
|
||||
expect(subs.subscribeCalls.length).toBe(0);
|
||||
expect(alertSpy).toHaveBeenCalledWith('Invalid subscription title filter (regex)');
|
||||
alertSpy.mockRestore();
|
||||
expect(errorSpy).toHaveBeenCalledWith('Invalid subscription title filter (regex)');
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
+63
-86
@@ -13,6 +13,8 @@ import { CookieService } from 'ngx-cookie-service';
|
||||
import { AddDownloadPayload, DownloadsService } from './services/downloads.service';
|
||||
import { MeTubeSocket } from './services/metube-socket.service';
|
||||
import { SubscriptionsService } from './services/subscriptions.service';
|
||||
import { ToastService } from './services/toast.service';
|
||||
import { BatchUrlsService, BatchUrlFilter } from './services/batch-urls.service';
|
||||
import { SubscriptionRow } from './interfaces/subscription';
|
||||
import { Themes } from './theme';
|
||||
import {
|
||||
@@ -32,7 +34,7 @@ import {
|
||||
State,
|
||||
} from './interfaces';
|
||||
import { EtaPipe, SpeedPipe, FileSizePipe } from './pipes';
|
||||
import { SelectAllCheckboxComponent, ItemCheckboxComponent } from './components/';
|
||||
import { SelectAllCheckboxComponent, ItemCheckboxComponent, ToastContainerComponent } from './components/';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
@@ -50,6 +52,7 @@ import { SelectAllCheckboxComponent, ItemCheckboxComponent } from './components/
|
||||
FileSizePipe,
|
||||
SelectAllCheckboxComponent,
|
||||
ItemCheckboxComponent,
|
||||
ToastContainerComponent,
|
||||
],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.sass',
|
||||
@@ -57,6 +60,8 @@ import { SelectAllCheckboxComponent, ItemCheckboxComponent } from './components/
|
||||
export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
downloads = inject(DownloadsService);
|
||||
subscriptionsSvc = inject(SubscriptionsService);
|
||||
private toasts = inject(ToastService);
|
||||
private batchUrls = inject(BatchUrlsService);
|
||||
private socket = inject(MeTubeSocket);
|
||||
private cookieService = inject(CookieService);
|
||||
private http = inject(HttpClient);
|
||||
@@ -415,7 +420,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
const date = new Date(data['update_time'] * 1000);
|
||||
this.ytDlpOptionsUpdateTime=date.toLocaleString();
|
||||
}else{
|
||||
alert("Error reload yt-dlp options: "+data['msg']);
|
||||
this.toasts.error("Error reloading yt-dlp options: " + data['msg']);
|
||||
}
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
@@ -490,11 +495,11 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
|
||||
alert('Custom yt-dlp options must be a JSON object');
|
||||
this.toasts.error('Custom yt-dlp options must be a JSON object');
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
alert('Custom yt-dlp options must be valid JSON');
|
||||
this.toasts.error('Custom yt-dlp options must be valid JSON');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -525,7 +530,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
this.subscriptionsSvc.refreshList().pipe(takeUntilDestroyed(this.destroyRef)).subscribe((refreshRes) => {
|
||||
const error = this.getStatusError(refreshRes);
|
||||
if (error) {
|
||||
alert(error || 'Refresh subscriptions failed');
|
||||
this.toasts.error(error || 'Refresh subscriptions failed');
|
||||
return;
|
||||
}
|
||||
this.cdr.markForCheck();
|
||||
@@ -569,7 +574,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
}
|
||||
const payload = this.buildAddPayload();
|
||||
if (!payload.url?.trim()) {
|
||||
alert('Please enter a URL');
|
||||
this.toasts.error('Please enter a URL');
|
||||
return;
|
||||
}
|
||||
const tr = (this.titleRegex || '').trim();
|
||||
@@ -577,12 +582,12 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
try {
|
||||
void RegExp(tr);
|
||||
} catch {
|
||||
alert('Invalid subscription title filter (regex)');
|
||||
this.toasts.error('Invalid subscription title filter (regex)');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (payload.splitByChapters && !payload.chapterTemplate.includes('%(section_number)')) {
|
||||
alert('Chapter template must include %(section_number)');
|
||||
this.toasts.error('Chapter template must include %(section_number)');
|
||||
return;
|
||||
}
|
||||
if (!this.validateYtdlOptionsOverrides(payload.ytdlOptionsOverrides)) {
|
||||
@@ -611,7 +616,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
next: (res) => {
|
||||
const r = res as { status?: string; msg?: string };
|
||||
if (r.status === 'error') {
|
||||
alert(r.msg || 'Subscribe failed');
|
||||
this.toasts.error(r.msg || 'Subscribe failed');
|
||||
} else {
|
||||
this.addUrl = '';
|
||||
this.titleRegex = '';
|
||||
@@ -639,14 +644,14 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
try {
|
||||
void RegExp(raw);
|
||||
} catch {
|
||||
alert('Invalid subscription title filter (regex)');
|
||||
this.toasts.error('Invalid subscription title filter (regex)');
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.subscriptionsSvc.update(id, { title_regex: raw }).subscribe((res) => {
|
||||
const error = this.getStatusError(res);
|
||||
if (error) {
|
||||
alert(error || 'Update subscription failed');
|
||||
this.toasts.error(error || 'Update subscription failed');
|
||||
return;
|
||||
}
|
||||
this.cancelEditTitleRegex();
|
||||
@@ -657,7 +662,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
this.subscriptionsSvc.delete([id]).subscribe((res) => {
|
||||
const error = this.getStatusError(res);
|
||||
if (error) {
|
||||
alert(error || 'Delete subscription failed');
|
||||
this.toasts.error(error || 'Delete subscription failed');
|
||||
return;
|
||||
}
|
||||
this.selectedSubscriptionIds.delete(id);
|
||||
@@ -673,7 +678,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
this.subscriptionsSvc.delete(ids).subscribe((res) => {
|
||||
const error = this.getStatusError(res);
|
||||
if (error) {
|
||||
alert(error || 'Delete subscriptions failed');
|
||||
this.toasts.error(error || 'Delete subscriptions failed');
|
||||
return;
|
||||
}
|
||||
this.selectedSubscriptionIds.clear();
|
||||
@@ -699,7 +704,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
.subscribe((res) => {
|
||||
const error = this.getStatusError(res);
|
||||
if (error) {
|
||||
alert(error || 'Subscription check failed');
|
||||
this.toasts.error(error || 'Subscription check failed');
|
||||
return;
|
||||
}
|
||||
this.refreshSubscriptionsWithAlert();
|
||||
@@ -746,7 +751,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
.subscribe((res) => {
|
||||
const error = this.getStatusError(res);
|
||||
if (error) {
|
||||
alert(error || 'Subscription check failed');
|
||||
this.toasts.error(error || 'Subscription check failed');
|
||||
return;
|
||||
}
|
||||
this.refreshSubscriptionsWithAlert();
|
||||
@@ -769,7 +774,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
this.subscriptionsSvc.update(row.id, { enabled: !row.enabled }).subscribe((res) => {
|
||||
const error = this.getStatusError(res);
|
||||
if (error) {
|
||||
alert(error || 'Update subscription failed');
|
||||
this.toasts.error(error || 'Update subscription failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1066,20 +1071,19 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
|
||||
// Validate chapter template if chapter splitting is enabled
|
||||
if (payload.splitByChapters && !payload.chapterTemplate.includes('%(section_number)')) {
|
||||
alert('Chapter template must include %(section_number)');
|
||||
this.toasts.error('Chapter template must include %(section_number)');
|
||||
return;
|
||||
}
|
||||
if (!this.validateYtdlOptionsOverrides(payload.ytdlOptionsOverrides)) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.debug('Downloading:', payload);
|
||||
this.addInProgress = true;
|
||||
this.cancelRequested = false;
|
||||
this.addRequestSub?.unsubscribe();
|
||||
this.addRequestSub = this.downloads.add(payload).subscribe((status: Status) => {
|
||||
if (status.status === 'error' && !this.cancelRequested) {
|
||||
alert(`Error adding URL: ${status.msg}`);
|
||||
this.toasts.error(`Error adding URL: ${status.msg}`);
|
||||
} else if (status.status !== 'error') {
|
||||
this.addUrl = '';
|
||||
}
|
||||
@@ -1185,10 +1189,22 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
downloadSelectedFiles() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
this.downloads.done.forEach((dl, _) => {
|
||||
// Chromium-based browsers silently drop programmatic downloads beyond ~10 when
|
||||
// triggered in a tight loop. Trigger in batches with a short pause in between so
|
||||
// large selections download cleanly. See issue #1008.
|
||||
private static readonly DOWNLOAD_BATCH_SIZE = 10;
|
||||
private static readonly DOWNLOAD_BATCH_DELAY_MS = 1000;
|
||||
|
||||
async downloadSelectedFiles() {
|
||||
const selected: Download[] = [];
|
||||
this.downloads.done.forEach((dl) => {
|
||||
if (dl.status === 'finished' && dl.checked) {
|
||||
selected.push(dl);
|
||||
}
|
||||
});
|
||||
|
||||
for (let i = 0; i < selected.length; i++) {
|
||||
const dl = selected[i];
|
||||
const link = document.createElement('a');
|
||||
link.href = this.buildDownloadLink(dl);
|
||||
link.setAttribute('download', dl.filename);
|
||||
@@ -1196,8 +1212,16 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
if (
|
||||
(i + 1) % App.DOWNLOAD_BATCH_SIZE === 0 &&
|
||||
i + 1 < selected.length
|
||||
) {
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, App.DOWNLOAD_BATCH_DELAY_MS),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
buildDownloadLink(download: Download) {
|
||||
@@ -1241,10 +1265,12 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
// file into memory only to have navigator.canShare reject it.
|
||||
if (download.size && download.size > App.SHARE_SIZE_WARN_BYTES) {
|
||||
const sizeMb = Math.round(download.size / 1024 / 1024);
|
||||
const proceed = window.confirm(
|
||||
const proceed = await this.toasts.confirm(
|
||||
`This file is ${sizeMb} MB. iOS' share sheet often refuses files ` +
|
||||
`larger than ~100 MB and the share will silently fail. ` +
|
||||
`Try anyway? (Use the download button instead if it fails.)`
|
||||
`Try anyway? (Use the download button instead if it fails.)`,
|
||||
'Try anyway',
|
||||
'Cancel',
|
||||
);
|
||||
if (!proceed) return;
|
||||
}
|
||||
@@ -1265,7 +1291,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
// download button right next to this one instead of staring at
|
||||
// a button that quietly did nothing.
|
||||
console.warn('navigator.canShare rejected payload for', download.filename);
|
||||
window.alert(
|
||||
this.toasts.error(
|
||||
`Your device's share sheet doesn't accept this file ` +
|
||||
`(most likely because it's too large). ` +
|
||||
`Please use the download button instead.`
|
||||
@@ -1278,7 +1304,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
// AbortError = user dismissed the share sheet → silent no-op.
|
||||
if (e.name === 'AbortError') return;
|
||||
console.error('Share failed:', err);
|
||||
window.alert(
|
||||
this.toasts.error(
|
||||
`Share failed: ${e.message || 'unknown error'}. ` +
|
||||
`Please use the download button instead.`
|
||||
);
|
||||
@@ -1370,7 +1396,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
.map(url => url.trim())
|
||||
.filter(url => url.length > 0);
|
||||
if (urls.length === 0) {
|
||||
alert('No valid URLs found.');
|
||||
this.toasts.error('No valid URLs found.');
|
||||
return;
|
||||
}
|
||||
this.importInProgress = true;
|
||||
@@ -1435,62 +1461,13 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
// Export URLs based on filter: 'pending', 'completed', 'failed', or 'all'
|
||||
exportBatchUrls(filter: 'pending' | 'completed' | 'failed' | 'all'): void {
|
||||
let urls: string[];
|
||||
if (filter === 'pending') {
|
||||
urls = Array.from(this.downloads.queue.values()).map(dl => dl.url);
|
||||
} else if (filter === 'completed') {
|
||||
// Only finished downloads in the "done" Map
|
||||
urls = Array.from(this.downloads.done.values()).filter(dl => dl.status === 'finished').map(dl => dl.url);
|
||||
} else if (filter === 'failed') {
|
||||
// Only error downloads from the "done" Map
|
||||
urls = Array.from(this.downloads.done.values()).filter(dl => dl.status === 'error').map(dl => dl.url);
|
||||
} else {
|
||||
// All: pending + both finished and error in done
|
||||
urls = [
|
||||
...Array.from(this.downloads.queue.values()).map(dl => dl.url),
|
||||
...Array.from(this.downloads.done.values()).map(dl => dl.url)
|
||||
];
|
||||
}
|
||||
if (!urls.length) {
|
||||
alert('No URLs found for the selected filter.');
|
||||
return;
|
||||
}
|
||||
const content = urls.join('\n');
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const downloadUrl = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = downloadUrl;
|
||||
a.download = 'metube_urls.txt';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(downloadUrl);
|
||||
exportBatchUrls(filter: BatchUrlFilter): void {
|
||||
this.batchUrls.export(filter);
|
||||
}
|
||||
|
||||
// Copy URLs to clipboard based on filter: 'pending', 'completed', 'failed', or 'all'
|
||||
copyBatchUrls(filter: 'pending' | 'completed' | 'failed' | 'all'): void {
|
||||
let urls: string[];
|
||||
if (filter === 'pending') {
|
||||
urls = Array.from(this.downloads.queue.values()).map(dl => dl.url);
|
||||
} else if (filter === 'completed') {
|
||||
urls = Array.from(this.downloads.done.values()).filter(dl => dl.status === 'finished').map(dl => dl.url);
|
||||
} else if (filter === 'failed') {
|
||||
urls = Array.from(this.downloads.done.values()).filter(dl => dl.status === 'error').map(dl => dl.url);
|
||||
} else {
|
||||
urls = [
|
||||
...Array.from(this.downloads.queue.values()).map(dl => dl.url),
|
||||
...Array.from(this.downloads.done.values()).map(dl => dl.url)
|
||||
];
|
||||
}
|
||||
if (!urls.length) {
|
||||
alert('No URLs found for the selected filter.');
|
||||
return;
|
||||
}
|
||||
const content = urls.join('\n');
|
||||
navigator.clipboard.writeText(content)
|
||||
.then(() => alert('URLs copied to clipboard.'))
|
||||
.catch(() => alert('Failed to copy URLs.'));
|
||||
copyBatchUrls(filter: BatchUrlFilter): void {
|
||||
this.batchUrls.copy(filter);
|
||||
}
|
||||
|
||||
fetchVersionInfo(): void {
|
||||
@@ -1550,7 +1527,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
};
|
||||
const fail = (err?: unknown) => {
|
||||
console.error('Clipboard write failed:', err);
|
||||
alert('Failed to copy to clipboard. Your browser may require HTTPS for clipboard access.');
|
||||
this.toasts.error('Failed to copy to clipboard. Your browser may require HTTPS for clipboard access.');
|
||||
};
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(text).then(done).catch(fail);
|
||||
@@ -1586,7 +1563,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
this.hasCookies = true;
|
||||
} else {
|
||||
this.refreshCookieStatus();
|
||||
alert(`Error uploading cookies: ${this.formatErrorMessage(response?.msg)}`);
|
||||
this.toasts.error(`Error uploading cookies: ${this.formatErrorMessage(response?.msg)}`);
|
||||
}
|
||||
this.cookieUploadInProgress = false;
|
||||
input.value = '';
|
||||
@@ -1595,7 +1572,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
this.refreshCookieStatus();
|
||||
this.cookieUploadInProgress = false;
|
||||
input.value = '';
|
||||
alert('Error uploading cookies.');
|
||||
this.toasts.error('Error uploading cookies.');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1629,11 +1606,11 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
this.refreshCookieStatus();
|
||||
alert(`Error deleting cookies: ${this.formatErrorMessage(response?.msg)}`);
|
||||
this.toasts.error(`Error deleting cookies: ${this.formatErrorMessage(response?.msg)}`);
|
||||
},
|
||||
error: () => {
|
||||
this.refreshCookieStatus();
|
||||
alert('Error deleting cookies.');
|
||||
this.toasts.error('Error deleting cookies.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { SelectAllCheckboxComponent } from './master-checkbox.component';
|
||||
export { ItemCheckboxComponent } from './slave-checkbox.component';
|
||||
export { ToastContainerComponent } from './toast-container.component';
|
||||
@@ -0,0 +1,58 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
|
||||
import { faCheckCircle, faTimesCircle, faInfoCircle, faXmark } from '@fortawesome/free-solid-svg-icons';
|
||||
import { ToastService } from '../services/toast.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-toast-container',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [FontAwesomeModule],
|
||||
template: `
|
||||
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1100;" aria-live="polite" aria-atomic="true">
|
||||
@for (toast of toasts.toasts(); track toast.id) {
|
||||
<div class="toast show align-items-center border-0 mb-2"
|
||||
[class.text-bg-danger]="toast.level === 'error'"
|
||||
[class.text-bg-success]="toast.level === 'success'"
|
||||
[class.text-bg-primary]="toast.level === 'info'"
|
||||
role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body d-flex align-items-start gap-2">
|
||||
@if (toast.level === 'error') {
|
||||
<fa-icon [icon]="faTimesCircle" class="mt-1" />
|
||||
} @else if (toast.level === 'success') {
|
||||
<fa-icon [icon]="faCheckCircle" class="mt-1" />
|
||||
} @else {
|
||||
<fa-icon [icon]="faInfoCircle" class="mt-1" />
|
||||
}
|
||||
<span style="white-space: pre-line;">{{ toast.message }}</span>
|
||||
</div>
|
||||
@if (!toast.actions) {
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto"
|
||||
aria-label="Close" (click)="toasts.dismiss(toast.id)"></button>
|
||||
}
|
||||
</div>
|
||||
@if (toast.actions) {
|
||||
<div class="d-flex justify-content-end gap-2 px-3 pb-2">
|
||||
@for (action of toast.actions; track action.label) {
|
||||
<button type="button"
|
||||
class="btn btn-sm"
|
||||
[class.btn-light]="!action.primary"
|
||||
[class.btn-outline-light]="action.primary"
|
||||
(click)="toasts.respond(toast.id, action.value)">
|
||||
{{ action.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class ToastContainerComponent {
|
||||
protected readonly toasts = inject(ToastService);
|
||||
protected readonly faCheckCircle = faCheckCircle;
|
||||
protected readonly faTimesCircle = faTimesCircle;
|
||||
protected readonly faInfoCircle = faInfoCircle;
|
||||
protected readonly faXmark = faXmark;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { DownloadsService } from './downloads.service';
|
||||
import { ToastService } from './toast.service';
|
||||
|
||||
export type BatchUrlFilter = 'pending' | 'completed' | 'failed' | 'all';
|
||||
|
||||
/**
|
||||
* Encapsulates collecting download URLs by status and exporting/copying them.
|
||||
* Extracted from the main app component to keep it focused on view concerns.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BatchUrlsService {
|
||||
private downloads = inject(DownloadsService);
|
||||
private toasts = inject(ToastService);
|
||||
|
||||
collect(filter: BatchUrlFilter): string[] {
|
||||
const queueUrls = () => Array.from(this.downloads.queue.values()).map((dl) => dl.url);
|
||||
const doneUrls = (status?: string) =>
|
||||
Array.from(this.downloads.done.values())
|
||||
.filter((dl) => status === undefined || dl.status === status)
|
||||
.map((dl) => dl.url);
|
||||
switch (filter) {
|
||||
case 'pending':
|
||||
return queueUrls();
|
||||
case 'completed':
|
||||
return doneUrls('finished');
|
||||
case 'failed':
|
||||
return doneUrls('error');
|
||||
default:
|
||||
return [...queueUrls(), ...doneUrls()];
|
||||
}
|
||||
}
|
||||
|
||||
export(filter: BatchUrlFilter): void {
|
||||
const urls = this.collect(filter);
|
||||
if (!urls.length) {
|
||||
this.toasts.info('No URLs found for the selected filter.');
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([urls.join('\n')], { type: 'text/plain' });
|
||||
const downloadUrl = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = downloadUrl;
|
||||
a.download = 'metube_urls.txt';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(downloadUrl);
|
||||
}
|
||||
|
||||
copy(filter: BatchUrlFilter): void {
|
||||
const urls = this.collect(filter);
|
||||
if (!urls.length) {
|
||||
this.toasts.info('No URLs found for the selected filter.');
|
||||
return;
|
||||
}
|
||||
navigator.clipboard
|
||||
.writeText(urls.join('\n'))
|
||||
.then(() => this.toasts.success('URLs copied to clipboard.'))
|
||||
.catch(() => this.toasts.error('Failed to copy URLs.'));
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
export { DownloadsService } from './downloads.service';
|
||||
export { MeTubeSocket } from './metube-socket.service';
|
||||
export { ToastService } from './toast.service';
|
||||
export { BatchUrlsService } from './batch-urls.service';
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
export type ToastLevel = 'info' | 'success' | 'error';
|
||||
|
||||
export interface ToastAction {
|
||||
label: string;
|
||||
value: boolean;
|
||||
primary?: boolean;
|
||||
}
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
level: ToastLevel;
|
||||
message: string;
|
||||
actions?: ToastAction[];
|
||||
/** Resolver for confirm() toasts; resolved when the user picks an action or dismisses. */
|
||||
_resolve?: (value: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight non-blocking notification service. Replaces the blocking
|
||||
* window.alert()/confirm() dialogs that previously littered the app component.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ToastService {
|
||||
private counter = 0;
|
||||
readonly toasts = signal<Toast[]>([]);
|
||||
|
||||
info(message: string): void {
|
||||
this.show('info', message, 4000);
|
||||
}
|
||||
|
||||
success(message: string): void {
|
||||
this.show('success', message, 4000);
|
||||
}
|
||||
|
||||
error(message: string): void {
|
||||
this.show('error', message, 8000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a confirmation toast with confirm/cancel actions. Resolves true when
|
||||
* confirmed, false when cancelled or auto-dismissed.
|
||||
*/
|
||||
confirm(message: string, confirmLabel = 'OK', cancelLabel = 'Cancel'): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const id = ++this.counter;
|
||||
this.toasts.update((list) => [
|
||||
...list,
|
||||
{
|
||||
id,
|
||||
level: 'info',
|
||||
message,
|
||||
actions: [
|
||||
{ label: cancelLabel, value: false },
|
||||
{ label: confirmLabel, value: true, primary: true },
|
||||
],
|
||||
_resolve: resolve,
|
||||
},
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
respond(id: number, value: boolean): void {
|
||||
const toast = this.toasts().find((t) => t.id === id);
|
||||
toast?._resolve?.(value);
|
||||
this.remove(id);
|
||||
}
|
||||
|
||||
dismiss(id: number): void {
|
||||
const toast = this.toasts().find((t) => t.id === id);
|
||||
// A confirm toast dismissed without an explicit choice resolves to false.
|
||||
toast?._resolve?.(false);
|
||||
this.remove(id);
|
||||
}
|
||||
|
||||
private remove(id: number): void {
|
||||
this.toasts.update((list) => list.filter((t) => t.id !== id));
|
||||
}
|
||||
|
||||
private show(level: ToastLevel, message: string, autoDismissMs: number): void {
|
||||
const id = ++this.counter;
|
||||
this.toasts.update((list) => [...list, { id, level, message }]);
|
||||
setTimeout(() => this.remove(id), autoDismissMs);
|
||||
}
|
||||
}
|
||||
@@ -106,14 +106,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.13.0"
|
||||
version = "4.14.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/b5/001890774a9552aff22502b8da382593109ce0c95314abaebbb116567545/anyio-4.14.0.tar.gz", hash = "sha256:b47c1f9ccf73e67021df785332508f99379c68fa7d0684e8e3492cb1d4b23f89", size = 253586, upload-time = "2026-06-15T22:00:49.021Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/16/9826f089383c593cdfc4a6e5aca94d9e91ae1692c57af82c3b2aa5e810f7/anyio-4.14.0-py3-none-any.whl", hash = "sha256:dd9b7a2a9799ed6552fde617b2c5df02b7fdd7d88392fc48101e51bae46164d9", size = 123506, upload-time = "2026-06-15T22:00:47.595Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -789,7 +789,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pylint"
|
||||
version = "4.0.5"
|
||||
version = "4.0.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "astroid" },
|
||||
@@ -800,14 +800,14 @@ dependencies = [
|
||||
{ name = "platformdirs" },
|
||||
{ name = "tomlkit" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/b6/74d9a8a68b8067efce8d07707fe6a236324ee1e7808d2eb3646ec8517c7d/pylint-4.0.5.tar.gz", hash = "sha256:8cd6a618df75deb013bd7eb98327a95f02a6fb839205a6bbf5456ef96afb317c", size = 1572474, upload-time = "2026-02-20T09:07:33.621Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/1d/3bb57f303701549550d74bf7ced2b07412be97125c167a0c9d216aa9f762/pylint-4.0.6.tar.gz", hash = "sha256:52f19191bee08bf103f9705ad1a0ece4aa5a0a4ef2bdcbd969375a1e6f6579d5", size = 1585588, upload-time = "2026-06-14T14:43:26.772Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/6f/9ac2548e290764781f9e7e2aaf0685b086379dabfb29ca38536985471eaf/pylint-4.0.5-py3-none-any.whl", hash = "sha256:00f51c9b14a3b3ae08cff6b2cdd43f28165c78b165b628692e428fb1f8dc2cf2", size = 536694, upload-time = "2026-02-20T09:07:31.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/da/acb2e7d4dbd2dfb792d38c0d850481f29ad7049b356d23f56c687d35203b/pylint-4.0.6-py3-none-any.whl", hash = "sha256:d11a0e1fdb7b1cd46ec5d6fc78fee8b95f28695b2d6140e5809925f61e32ea54", size = 538389, upload-time = "2026-06-14T14:43:24.873Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.3"
|
||||
version = "9.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
@@ -816,9 +816,9 @@ dependencies = [
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181, upload-time = "2026-06-13T18:52:45.983Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453, upload-time = "2026-06-13T18:52:44.045Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -861,15 +861,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "python-socketio"
|
||||
version = "5.16.2"
|
||||
version = "5.16.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "bidict" },
|
||||
{ name = "python-engineio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/07/dd/6fd4112b941f7d39b8171b6ba17902609bd8fa2059c3812a3c29dade13e7/python_socketio-5.16.2.tar.gz", hash = "sha256:ad88c228d921646efa436c0a0df217e364ef30ec072df4041484e54d49c15989", size = 128011, upload-time = "2026-05-21T22:03:44.418Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/32/2d/ffce71017c106b75099fea569df6518c63fee5d6202ce0cfe7b01e6f22c3/python_socketio-5.16.3.tar.gz", hash = "sha256:89b136f677ae65607a84cecda9b4d6c5377b40a97582c504c25df89af16d520e", size = 128095, upload-time = "2026-06-15T22:07:04.003Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/72/dc/0decaf5da92a7a969374474025787102d811d42aed1d32191fa338620e15/python_socketio-5.16.2-py3-none-any.whl", hash = "sha256:bef2da3374fd533aed4297f57b4f6512b52aa51604cb0da2165f401291c5ca20", size = 82137, upload-time = "2026-05-21T22:03:42.616Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/38/8c5e72d53ff8eb27497c4f268a7f6d9121e727a50b65248288ad79a93053/python_socketio-5.16.3-py3-none-any.whl", hash = "sha256:e7ad14202a5e6448824c7c2f86161d04e13dec05992257df5c709e6a2798c041", size = 82087, upload-time = "2026-06-15T22:07:02.498Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user