some fixes in cookie upload functionality

This commit is contained in:
Alex Shnitman
2026-03-06 14:20:16 +02:00
parent 7cfb0c3a1d
commit 54e25484c5
5 changed files with 203 additions and 62 deletions
+4 -12
View File
@@ -89,21 +89,13 @@ The project's Wiki contains examples of useful configurations contributed by use
In case you need to use your browser's cookies with MeTube, for example to download restricted or private videos: In case you need to use your browser's cookies with MeTube, for example to download restricted or private videos:
* Add the following to your docker-compose.yml:
```yaml
volumes:
- /path/to/cookies:/cookies
environment:
- YTDL_OPTIONS={"cookiefile":"/cookies/cookies.txt"}
```
* Install in your browser an extension to extract cookies: * Install in your browser an extension to extract cookies:
* [Firefox](https://addons.mozilla.org/en-US/firefox/addon/export-cookies-txt/) * [Firefox](https://addons.mozilla.org/en-US/firefox/addon/export-cookies-txt/)
* [Chrome](https://chrome.google.com/webstore/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc) * [Chrome](https://chrome.google.com/webstore/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc)
* Extract the cookies you need with the extension and rename the file `cookies.txt` * Extract the cookies you need with the extension and save/export them as `cookies.txt`.
* Drop the file in the folder you configured in the docker-compose.yml above * In MeTube, open **Advanced Options** and use the **Upload Cookies** button to upload the file.
* Restart the container * After upload, the cookie indicator should show as active.
* Use **Delete Cookies** in the same section to remove uploaded cookies.
## 🔌 Browser extensions ## 🔌 Browser extensions
+43 -6
View File
@@ -97,10 +97,23 @@ class Config:
if self.YTDL_OPTIONS_FILE and self.YTDL_OPTIONS_FILE.startswith('.'): if self.YTDL_OPTIONS_FILE and self.YTDL_OPTIONS_FILE.startswith('.'):
self.YTDL_OPTIONS_FILE = str(Path(self.YTDL_OPTIONS_FILE).resolve()) self.YTDL_OPTIONS_FILE = str(Path(self.YTDL_OPTIONS_FILE).resolve())
self._runtime_overrides = {}
success,_ = self.load_ytdl_options() success,_ = self.load_ytdl_options()
if not success: if not success:
sys.exit(1) sys.exit(1)
def set_runtime_override(self, key, value):
self._runtime_overrides[key] = value
self.YTDL_OPTIONS[key] = value
def remove_runtime_override(self, key):
self._runtime_overrides.pop(key, None)
self.YTDL_OPTIONS.pop(key, None)
def _apply_runtime_overrides(self):
self.YTDL_OPTIONS.update(self._runtime_overrides)
def load_ytdl_options(self) -> tuple[bool, str]: def load_ytdl_options(self) -> tuple[bool, str]:
try: try:
self.YTDL_OPTIONS = json.loads(os.environ.get('YTDL_OPTIONS', '{}')) self.YTDL_OPTIONS = json.loads(os.environ.get('YTDL_OPTIONS', '{}'))
@@ -111,6 +124,7 @@ class Config:
return (False, msg) return (False, msg)
if not self.YTDL_OPTIONS_FILE: if not self.YTDL_OPTIONS_FILE:
self._apply_runtime_overrides()
return (True, '') return (True, '')
log.info(f'Loading yt-dlp custom options from "{self.YTDL_OPTIONS_FILE}"') log.info(f'Loading yt-dlp custom options from "{self.YTDL_OPTIONS_FILE}"')
@@ -128,6 +142,7 @@ class Config:
return (False, msg) return (False, msg)
self.YTDL_OPTIONS.update(opts) self.YTDL_OPTIONS.update(opts)
self._apply_runtime_overrides()
return (True, '') return (True, '')
config = Config() config = Config()
@@ -347,21 +362,43 @@ async def upload_cookies(request):
os.remove(COOKIES_PATH) os.remove(COOKIES_PATH)
return web.Response(status=400, text=serializer.encode({'status': 'error', 'msg': 'Cookie file too large (max 1MB)'})) return web.Response(status=400, text=serializer.encode({'status': 'error', 'msg': 'Cookie file too large (max 1MB)'}))
f.write(chunk) f.write(chunk)
config.YTDL_OPTIONS['cookiefile'] = COOKIES_PATH config.set_runtime_override('cookiefile', COOKIES_PATH)
log.info(f'Cookies file uploaded ({size} bytes)') log.info(f'Cookies file uploaded ({size} bytes)')
return web.Response(text=serializer.encode({'status': 'ok', 'msg': f'Cookies uploaded ({size} bytes)'})) return web.Response(text=serializer.encode({'status': 'ok', 'msg': f'Cookies uploaded ({size} bytes)'}))
@routes.post(config.URL_PREFIX + 'delete-cookies') @routes.post(config.URL_PREFIX + 'delete-cookies')
async def delete_cookies(request): async def delete_cookies(request):
if os.path.exists(COOKIES_PATH): has_uploaded_cookies = os.path.exists(COOKIES_PATH)
os.remove(COOKIES_PATH) configured_cookiefile = config.YTDL_OPTIONS.get('cookiefile')
config.YTDL_OPTIONS.pop('cookiefile', None) has_manual_cookiefile = isinstance(configured_cookiefile, str) and configured_cookiefile and configured_cookiefile != COOKIES_PATH
if not has_uploaded_cookies:
if has_manual_cookiefile:
return web.Response(
status=400,
text=serializer.encode({
'status': 'error',
'msg': 'Cookies are configured manually via YTDL_OPTIONS (cookiefile). Remove or change that setting manually; UI delete only removes uploaded cookies.'
})
)
return web.Response(status=400, text=serializer.encode({'status': 'error', 'msg': 'No uploaded cookies to delete'}))
os.remove(COOKIES_PATH)
config.remove_runtime_override('cookiefile')
success, msg = config.load_ytdl_options()
if not success:
log.error(f'Cookies file deleted, but failed to reload YTDL_OPTIONS: {msg}')
return web.Response(status=500, text=serializer.encode({'status': 'error', 'msg': f'Cookies file deleted, but failed to reload YTDL_OPTIONS: {msg}'}))
log.info('Cookies file deleted') log.info('Cookies file deleted')
return web.Response(text=serializer.encode({'status': 'ok'})) return web.Response(text=serializer.encode({'status': 'ok'}))
@routes.get(config.URL_PREFIX + 'cookie-status') @routes.get(config.URL_PREFIX + 'cookie-status')
async def cookie_status(request): async def cookie_status(request):
exists = os.path.exists(COOKIES_PATH) configured_cookiefile = config.YTDL_OPTIONS.get('cookiefile')
has_configured_cookies = isinstance(configured_cookiefile, str) and os.path.exists(configured_cookiefile)
has_uploaded_cookies = os.path.exists(COOKIES_PATH)
exists = has_uploaded_cookies or has_configured_cookies
return web.Response(text=serializer.encode({'status': 'ok', 'has_cookies': exists})) return web.Response(text=serializer.encode({'status': 'ok', 'has_cookies': exists}))
@routes.get(config.URL_PREFIX + 'history') @routes.get(config.URL_PREFIX + 'history')
@@ -508,7 +545,7 @@ if __name__ == '__main__':
# Auto-detect cookie file on startup # Auto-detect cookie file on startup
if os.path.exists(COOKIES_PATH): if os.path.exists(COOKIES_PATH):
config.YTDL_OPTIONS['cookiefile'] = COOKIES_PATH config.set_runtime_override('cookiefile', COOKIES_PATH)
log.info(f'Cookie file detected at {COOKIES_PATH}') log.info(f'Cookie file detected at {COOKIES_PATH}')
if config.HTTPS: if config.HTTPS:
+63 -40
View File
@@ -211,24 +211,6 @@
ngbTooltip="Add a prefix to downloaded filenames"> ngbTooltip="Add a prefix to downloaded filenames">
</div> </div>
</div> </div>
<div class="col-md-6">
<div class="d-flex align-items-center mt-2">
<label class="btn btn-sm btn-outline-secondary me-2 mb-0" for="cookie-upload"
ngbTooltip="Upload a cookies.txt file for authenticated downloads">
<fa-icon [icon]="faUpload" class="me-1" />Cookies
</label>
<input type="file" id="cookie-upload" class="d-none" accept=".txt"
(change)="onCookieFileSelect($event)"
[disabled]="cookieUploadInProgress || addInProgress">
@if (hasCookies) {
<span class="badge bg-success me-2">Active</span>
<button type="button" class="btn btn-sm btn-outline-danger"
(click)="deleteCookies()" ngbTooltip="Remove uploaded cookies">
<fa-icon [icon]="faTrashAlt" />
</button>
}
</div>
</div>
<div class="col-md-6"> <div class="col-md-6">
<div class="input-group"> <div class="input-group">
<span class="input-group-text">Items Limit</span> <span class="input-group-text">Items Limit</span>
@@ -326,30 +308,71 @@
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
<hr class="my-3"> <hr class="my-3">
<div class="row g-2"> <div class="row g-3">
<div class="col-md-4"> <div class="col-md-4">
<button type="button" <div class="action-group-label">Cookies</div>
class="btn btn-secondary w-100" <input type="file" id="cookie-upload" class="d-none" accept=".txt"
(click)="openBatchImportModal()"> (change)="onCookieFileSelect($event)"
<fa-icon [icon]="faFileImport" class="me-2" /> [disabled]="cookieUploadInProgress || addInProgress">
Import URLs <div class="btn-group w-100" role="group">
</button> <label class="btn mb-0"
[class]="hasCookies ? 'btn cookie-active-btn mb-0' : 'btn cookie-btn mb-0'"
[class.disabled]="cookieUploadInProgress || addInProgress"
for="cookie-upload"
ngbTooltip="Upload a cookies.txt file for authenticated downloads">
@if (cookieUploadInProgress) {
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
} @else {
<fa-icon [icon]="faUpload" class="me-2" />
}
{{ hasCookies ? 'Replace Cookies' : 'Upload Cookies' }}
</label>
@if (hasCookies) {
<button type="button" class="btn btn-outline-danger"
(click)="deleteCookies()"
[disabled]="cookieUploadInProgress || addInProgress"
ngbTooltip="Remove uploaded cookies">
<fa-icon [icon]="faTrashAlt" />
</button>
}
</div>
<div class="cookie-status" [class.active]="hasCookies">
@if (hasCookies) {
<fa-icon [icon]="faCheckCircle" class="me-1" />
Cookies active
} @else {
No cookies configured
}
</div>
</div> </div>
<div class="col-md-4"> <div class="col-md-8">
<button type="button" <div class="action-group-label">Bulk Actions</div>
class="btn btn-secondary w-100" <div class="row g-2">
(click)="exportBatchUrls('all')"> <div class="col-4">
<fa-icon [icon]="faFileExport" class="me-2" /> <button type="button"
Export URLs class="btn btn-secondary w-100"
</button> (click)="openBatchImportModal()">
</div> <fa-icon [icon]="faFileImport" class="me-2" />
<div class="col-md-4"> Import URLs
<button type="button" </button>
class="btn btn-secondary w-100" </div>
(click)="copyBatchUrls('all')"> <div class="col-4">
<fa-icon [icon]="faCopy" class="me-2" /> <button type="button"
Copy URLs class="btn btn-secondary w-100"
</button> (click)="exportBatchUrls('all')">
<fa-icon [icon]="faFileExport" class="me-2" />
Export URLs
</button>
</div>
<div class="col-4">
<button type="button"
class="btn btn-secondary w-100"
(click)="copyBatchUrls('all')">
<fa-icon [icon]="faCopy" class="me-2" />
Copy URLs
</button>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
+45
View File
@@ -209,3 +209,48 @@ main
span span
white-space: nowrap white-space: nowrap
.cookie-btn
flex: 1 1 auto
background-color: var(--bs-secondary-bg)
border-color: var(--bs-border-color)
color: var(--bs-emphasis-color)
&:hover
background-color: var(--bs-tertiary-bg)
border-color: var(--bs-secondary)
color: var(--bs-emphasis-color)
&.disabled
opacity: 0.65
pointer-events: none
.cookie-active-btn
flex: 1 1 auto
background-color: var(--bs-success-bg-subtle)
border-color: var(--bs-success-border-subtle)
color: var(--bs-success-text-emphasis)
&:hover
background-color: var(--bs-success-bg-subtle)
border-color: var(--bs-success)
color: var(--bs-success-text-emphasis)
&.disabled
opacity: 0.65
pointer-events: none
.action-group-label
font-size: 0.7rem
text-transform: uppercase
letter-spacing: 0.05em
color: var(--bs-secondary-color)
margin-bottom: 0.4rem
.cookie-status
font-size: 0.8rem
margin-top: 0.35rem
color: var(--bs-secondary-color)
&.active
color: var(--bs-success-text-emphasis)
+48 -4
View File
@@ -793,22 +793,66 @@ export class App implements AfterViewInit, OnInit {
if (!input.files?.length) return; if (!input.files?.length) return;
this.cookieUploadInProgress = true; this.cookieUploadInProgress = true;
this.downloads.uploadCookies(input.files[0]).subscribe({ this.downloads.uploadCookies(input.files[0]).subscribe({
next: () => { next: (response) => {
this.hasCookies = true; if (response?.status === 'ok') {
this.hasCookies = true;
} else {
this.refreshCookieStatus();
alert(`Error uploading cookies: ${this.formatErrorMessage(response?.msg)}`);
}
this.cookieUploadInProgress = false; this.cookieUploadInProgress = false;
input.value = ''; input.value = '';
}, },
error: () => { error: () => {
this.refreshCookieStatus();
this.cookieUploadInProgress = false; this.cookieUploadInProgress = false;
input.value = ''; input.value = '';
alert('Error uploading cookies.');
} }
}); });
} }
private formatErrorMessage(error: unknown): string {
if (typeof error === 'string') {
return error;
}
if (error && typeof error === 'object') {
const obj = error as Record<string, unknown>;
for (const key of ['msg', 'reason', 'error', 'detail']) {
const value = obj[key];
if (typeof value === 'string' && value.trim()) {
return value;
}
}
try {
return JSON.stringify(error);
} catch {
return 'Unknown error';
}
}
return 'Unknown error';
}
deleteCookies() { deleteCookies() {
this.downloads.deleteCookies().subscribe({ this.downloads.deleteCookies().subscribe({
next: () => { this.hasCookies = false; }, next: (response) => {
error: () => {} if (response?.status === 'ok') {
this.refreshCookieStatus();
return;
}
this.refreshCookieStatus();
alert(`Error deleting cookies: ${this.formatErrorMessage(response?.msg)}`);
},
error: () => {
this.refreshCookieStatus();
alert('Error deleting cookies.');
}
});
}
private refreshCookieStatus() {
this.downloads.getCookieStatus().subscribe(data => {
this.hasCookies = data?.has_cookies || false;
}); });
} }