2023-03-24 08:58:18 -06:00
|
|
|
import contextlib
|
2024-07-18 10:04:57 -06:00
|
|
|
import dataclasses
|
2022-01-04 14:30:45 -07:00
|
|
|
import html
|
Postgres -> SQLite hard cutover: the database moves into the media directory.
The database now lives at <media>/config/wrolpi.db so a library (files,
configs, AND database) travels with the drive between WROLPi systems.
Core:
- wrolpi/db.py: lazy SQLite engine factory (WAL, busy_timeout=30s,
foreign_keys=ON); get_db_curs yields a plain sqlite3.Cursor; native
sqlite3 paramstyle everywhere (no compatibility shims); global sqlite3
adapters keep raw-SQL datetime formats identical to the ORM's.
- wrolpi/fts.py: FTS5 external-content search replacing tsvector/GIN.
Injection-proof websearch->FTS5 translator, bm25(10,4,2,1) reproducing
A/B/C/D weighting, fast(abc)/deep column filtering, snippet headlines.
Stemming contract pinned by dedicated tests (watch/watches/watched/
watching are interchangeable).
- wrolpi/schema_ddl.py: SQLite triggers shared verbatim by the alembic
baseline and test DBs; fixes the channel video_count DELETE/UPDATE
bugs and a latent total_size bug (it used the video id as channel id).
- wrolpi/db_bootstrap.py: startup auto-create/migrate; refuses databases
written by a newer WROLPi (db_version_mismatch); SQLite 3.40 floor;
network-filesystem refusal; never creates/migrates/trusts a database
beneath an unmounted production media directory (shadow-DB guard).
- Alembic: 85 Postgres-era migrations squashed into one baseline;
revision ids are dates (2026_07_10_1350) so filenames self-sort and
alembic_version says when a schema was created; render_as_batch=True.
- All raw Postgres SQL rewritten (ANY->json_each IN, array_agg @> ->
HAVING COUNT(DISTINCT), unnest->executemany/UPDATE..FROM,
generate_series histograms->strftime GROUP BY, DISTINCT ON->
row_number(), writable CTEs->two statements, jsonb_*->json_*, explicit
NULLS FIRST/LAST, LIMIT-before-OFFSET). Simple statements converted
to SQLAlchemy instead (flag upsert, download retry/summary/tag checks,
channel-by-tag estimate, Directory delete).
- Config imports count skipped entries; background dumps are blocked
after a lossy import so partial imports can never overwrite YAML
configs (they are the sole source of truth for rebuilds now).
- Search tolerates explicit JSON nulls for limit/offset (Postgres
treated OFFSET NULL as 0; SQLite errors).
- file_group.id/doc_section.id render as INTEGER PRIMARY KEY on SQLite
(BIGINT is not a rowid alias; FTS5 content_rowid requires it).
Ops teardown: docker-compose db service, CI postgres sidecars,
initialize_api_db.sh, controller postgresql service, psycopg2-binary all
removed. upgrade.sh stops/disables postgresql (data is NOT deleted;
`systemctl enable --now postgresql` restores it); systemd units no
longer Want postgresql.service.
Existing-user upgrade: first boot creates+migrates the DB, configs
import (tags recreate their FileGroups from disk), a refresh rebuilds
the library index. QA'd on the 10.0.0.8 RPi: rebuild matched the
Postgres baseline exactly; survived an uncontrolled hard reboot with
clean integrity checks.
Accepted behavior changes: FTS5 has no stopwords; bm25 length
normalization shifts some rank orders; fast-path d_headline is
un-highlighted; all-negative queries match nothing.
Deferred: Debian/trixie package lists (incl. sqlite3 CLI), Cypress
re-baseline, VACUUM INTO snapshots, exFAT/NTFS warning, auto-kicked
rebuild refresh with progress UX.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:59:20 -06:00
|
|
|
import json
|
2021-11-01 16:42:52 -06:00
|
|
|
import pathlib
|
|
|
|
|
import re
|
2024-11-02 15:14:02 -06:00
|
|
|
from dataclasses import dataclass, field
|
2024-09-17 18:04:00 -07:00
|
|
|
from datetime import datetime
|
channels.yaml imports by diffing (#872)
* channels.yaml imports by diffing
channels.yaml declares two sections: Channels keyed by directory
(comparing name, URL, Tag, file format, and the source id and flags,
which keep the database's value when missing), and the Downloads listed
under each Channel, keyed by (directory, URL); an existing Download keeps
its own frequency. An unchanged import writes nothing (it ran about a
thousand queries and committed several times).
Removed Channels are deleted with their Collection (and Downloads);
a Download removed from a Channel is deleted. A channel Collection
without a Channel is deleted unless listed (then it gets its Channel).
A Channel whose directory changed, with the same name, is moved. An
invalid entry is skipped and counted, and keeps its Channel (by name when
its directory is invalid) and that Channel's Downloads; so does an entry
without a list of Downloads. Duplicate directories still refuse the
import. Unknown Tags are dropped once tags.yaml is imported.
New Downloads are created in the import's one write (commit=False on
Channel.get_or_create_download, Download.delete and delete_with_videos).
After the import, Videos are claimed and source ids fetched only for new
and changed Channels; Downloads are linked without a query per Download,
and not while entries are skipped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* channels.yaml: moves, invalid Downloads, and saves after the write
- A moved Channel's Download diff is retargeted to its new directory:
one its new entry lists is the same Download, one it does not is
deleted in the same import (the old directory was treated as kept).
- An unreadable Download is counted as skipped, and its Channel keeps
its Downloads (it was dropped, and the Channel's Download deleted).
- A new Download without a frequency is dropped before the write and
counted (it re-opened a write on every import).
- No source id is fetched while entries are skipped (its save would be
refused, leaving the database newer than the file); a fetched one is
saved with dump_config, which records the database's version.
- Download.delete(commit=False) neither changes the skip list nor starts
config saves, which would read the database before the import commits
(and write a removed Download back into channels.yaml). The import
saves download_manager.yaml after its write.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:09:51 -06:00
|
|
|
from typing import Dict, Generator, Type, List, Set
|
2024-08-06 18:13:21 -06:00
|
|
|
from typing import Tuple, Optional
|
2021-11-01 16:42:52 -06:00
|
|
|
|
2023-10-20 18:53:26 -06:00
|
|
|
import pytz
|
channels.yaml imports by diffing (#872)
* channels.yaml imports by diffing
channels.yaml declares two sections: Channels keyed by directory
(comparing name, URL, Tag, file format, and the source id and flags,
which keep the database's value when missing), and the Downloads listed
under each Channel, keyed by (directory, URL); an existing Download keeps
its own frequency. An unchanged import writes nothing (it ran about a
thousand queries and committed several times).
Removed Channels are deleted with their Collection (and Downloads);
a Download removed from a Channel is deleted. A channel Collection
without a Channel is deleted unless listed (then it gets its Channel).
A Channel whose directory changed, with the same name, is moved. An
invalid entry is skipped and counted, and keeps its Channel (by name when
its directory is invalid) and that Channel's Downloads; so does an entry
without a list of Downloads. Duplicate directories still refuse the
import. Unknown Tags are dropped once tags.yaml is imported.
New Downloads are created in the import's one write (commit=False on
Channel.get_or_create_download, Download.delete and delete_with_videos).
After the import, Videos are claimed and source ids fetched only for new
and changed Channels; Downloads are linked without a query per Download,
and not while entries are skipped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* channels.yaml: moves, invalid Downloads, and saves after the write
- A moved Channel's Download diff is retargeted to its new directory:
one its new entry lists is the same Download, one it does not is
deleted in the same import (the old directory was treated as kept).
- An unreadable Download is counted as skipped, and its Channel keeps
its Downloads (it was dropped, and the Channel's Download deleted).
- A new Download without a frequency is dropped before the write and
counted (it re-opened a write on every import).
- No source id is fetched while entries are skipped (its save would be
refused, leaving the database newer than the file); a fetched one is
saved with dump_config, which records the database's version.
- Download.delete(commit=False) neither changes the skip list nor starts
config saves, which would read the database before the import commits
(and write a removed Download back into channels.yaml). The import
saves download_manager.yaml after its write.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:09:51 -06:00
|
|
|
from sqlalchemy.orm import Session, joinedload
|
2022-04-08 09:57:04 -06:00
|
|
|
from yt_dlp import YoutubeDL
|
2021-11-01 16:42:52 -06:00
|
|
|
|
2024-08-06 18:13:21 -06:00
|
|
|
from modules.videos.models import Video
|
2024-09-26 09:13:29 -06:00
|
|
|
from wrolpi import dates, flags
|
2023-03-24 08:58:18 -06:00
|
|
|
from wrolpi.captions import extract_captions
|
2024-11-02 15:14:02 -06:00
|
|
|
from wrolpi.common import ConfigFile, extract_domain, logger, \
|
2026-01-23 19:51:02 -07:00
|
|
|
escape_file_name, get_media_directory, background_task, Base, get_wrolpi_config, trim_file_name
|
channels.yaml imports by diffing (#872)
* channels.yaml imports by diffing
channels.yaml declares two sections: Channels keyed by directory
(comparing name, URL, Tag, file format, and the source id and flags,
which keep the database's value when missing), and the Downloads listed
under each Channel, keyed by (directory, URL); an existing Download keeps
its own frequency. An unchanged import writes nothing (it ran about a
thousand queries and committed several times).
Removed Channels are deleted with their Collection (and Downloads);
a Download removed from a Channel is deleted. A channel Collection
without a Channel is deleted unless listed (then it gets its Channel).
A Channel whose directory changed, with the same name, is moved. An
invalid entry is skipped and counted, and keeps its Channel (by name when
its directory is invalid) and that Channel's Downloads; so does an entry
without a list of Downloads. Duplicate directories still refuse the
import. Unknown Tags are dropped once tags.yaml is imported.
New Downloads are created in the import's one write (commit=False on
Channel.get_or_create_download, Download.delete and delete_with_videos).
After the import, Videos are claimed and source ids fetched only for new
and changed Channels; Downloads are linked without a query per Download,
and not while entries are skipped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* channels.yaml: moves, invalid Downloads, and saves after the write
- A moved Channel's Download diff is retargeted to its new directory:
one its new entry lists is the same Download, one it does not is
deleted in the same import (the old directory was treated as kept).
- An unreadable Download is counted as skipped, and its Channel keeps
its Downloads (it was dropped, and the Channel's Download deleted).
- A new Download without a frequency is dropped before the write and
counted (it re-opened a write on every import).
- No source id is fetched while entries are skipped (its save would be
refused, leaving the database newer than the file); a fetched one is
saved with dump_config, which records the database's version.
- Download.delete(commit=False) neither changes the skip list nor starts
config saves, which would read the database before the import commits
(and write a removed Download back into channels.yaml). The import
saves download_manager.yaml after its write.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:09:51 -06:00
|
|
|
from wrolpi.collections import Collection
|
|
|
|
|
from wrolpi.collections.models import validate_collection_directory
|
A malformed config entry is skipped and counted instead of failing the import (#875)
* A malformed config entry is skipped and counted instead of failing the import
An entry that is not a mapping (or whose key is not text, or a list that is not a list) made the whole file fail to
import in playlists.yaml, repos.yaml, download_manager.yaml and tags.yaml (tag_files/tag_zims), and a non-text name
did in domains.yaml. Each is now skipped and counted in import_skipped, so the config is not saved over it, and the
rest of the file imports. A Channel whose `downloads` is not a list keeps its Downloads.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* An unreadable entry keeps no row and does not make a section present
The key of an entry too malformed to name a row is now `Unreadable(position)`. It is still counted in
import_skipped, but `present` ignores it, so a section whose entries are all unreadable removes nothing (as an empty
section), instead of deleting every row. A playlist whose `items` is not a list keeps its items, and a Channel
Download whose url is not text is counted (its Channel keeps its Downloads) instead of failing the import.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:28:30 -06:00
|
|
|
from wrolpi.config_import import ConfigSection, Unreadable, chunks, media_relative
|
channels.yaml imports by diffing (#872)
* channels.yaml imports by diffing
channels.yaml declares two sections: Channels keyed by directory
(comparing name, URL, Tag, file format, and the source id and flags,
which keep the database's value when missing), and the Downloads listed
under each Channel, keyed by (directory, URL); an existing Download keeps
its own frequency. An unchanged import writes nothing (it ran about a
thousand queries and committed several times).
Removed Channels are deleted with their Collection (and Downloads);
a Download removed from a Channel is deleted. A channel Collection
without a Channel is deleted unless listed (then it gets its Channel).
A Channel whose directory changed, with the same name, is moved. An
invalid entry is skipped and counted, and keeps its Channel (by name when
its directory is invalid) and that Channel's Downloads; so does an entry
without a list of Downloads. Duplicate directories still refuse the
import. Unknown Tags are dropped once tags.yaml is imported.
New Downloads are created in the import's one write (commit=False on
Channel.get_or_create_download, Download.delete and delete_with_videos).
After the import, Videos are claimed and source ids fetched only for new
and changed Channels; Downloads are linked without a query per Download,
and not while entries are skipped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* channels.yaml: moves, invalid Downloads, and saves after the write
- A moved Channel's Download diff is retargeted to its new directory:
one its new entry lists is the same Download, one it does not is
deleted in the same import (the old directory was treated as kept).
- An unreadable Download is counted as skipped, and its Channel keeps
its Downloads (it was dropped, and the Channel's Download deleted).
- A new Download without a frequency is dropped before the write and
counted (it re-opened a write on every import).
- No source id is fetched while entries are skipped (its save would be
refused, leaving the database newer than the file); a fetched one is
saved with dump_config, which records the database's version.
- Download.delete(commit=False) neither changes the skip list nor starts
config saves, which would read the database before the import commits
(and write a removed Download back into channels.yaml). The import
saves download_manager.yaml after its write.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:09:51 -06:00
|
|
|
from wrolpi.config_version import register_ownership
|
2024-03-12 11:06:30 -06:00
|
|
|
from wrolpi.dates import Seconds, from_timestamp
|
db: request-path library functions use the request's connection (#774)
* db: request-path library functions use the request's connection
Library functions an API request reaches now take the request's Session (first argument) instead of
opening their own session or cursor, so a request needs one SQLite connection. Raw SQL runs through the
new `wrolpi.db.session_curs(session)`, a cursor on the Session's connection; `RequestDB.curs()` uses it.
- Search: files, videos, archives, flasher, and docs search share the request connection for the total,
the page of ids, and the page's models (3 connections -> 1).
- Zim search, including tag-filtered search and estimates (2 -> 1).
- Channel detail (`Channel.get_statistics` reads on the Channel's own Session) and the channel list.
- Video detail, edit, description, captions, comments and transcode. `get_video_for_app` and
`update_video` no longer commit; their handlers use `request.ctx.db.write()`, so editing a Video and
reading it back is one transaction.
- Tags (list, overlaps, recent), file statistics and estimates, other estimates, bulk tag preview, the
download list, retry, kill and the status summary, and docs/videos/archive statistics.
Not converted: file operations that refresh files inside the request (delete, rename, upload, channel
and collection tag/move) commit page by page in code shared with the file worker; the cached zim entry
lookup; config import.
The connection-count tests now assert one connection for every endpoint they cover (no xfails left), and
cover the videos and archive statistics endpoints, which had no API test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* downloads: startup retries in a write session of its own
start_single_tasks still called retry_downloads() without a Session after it began requiring one, so any
start after a completed refresh raised TypeError there: incomplete downloads were not reset and the
download_on_startup enable after it never ran. Startup is not a request, so
DownloadManager.retry_downloads_on_startup() opens and commits its own short write transaction.
Tested through the method, not start_single_tasks: importing main registers its startup listeners on the
shared app, and every later test in that worker then runs them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:05:20 -06:00
|
|
|
from wrolpi.db import get_db_curs, get_db_session, session_curs
|
channels.yaml imports by diffing (#872)
* channels.yaml imports by diffing
channels.yaml declares two sections: Channels keyed by directory
(comparing name, URL, Tag, file format, and the source id and flags,
which keep the database's value when missing), and the Downloads listed
under each Channel, keyed by (directory, URL); an existing Download keeps
its own frequency. An unchanged import writes nothing (it ran about a
thousand queries and committed several times).
Removed Channels are deleted with their Collection (and Downloads);
a Download removed from a Channel is deleted. A channel Collection
without a Channel is deleted unless listed (then it gets its Channel).
A Channel whose directory changed, with the same name, is moved. An
invalid entry is skipped and counted, and keeps its Channel (by name when
its directory is invalid) and that Channel's Downloads; so does an entry
without a list of Downloads. Duplicate directories still refuse the
import. Unknown Tags are dropped once tags.yaml is imported.
New Downloads are created in the import's one write (commit=False on
Channel.get_or_create_download, Download.delete and delete_with_videos).
After the import, Videos are claimed and source ids fetched only for new
and changed Channels; Downloads are linked without a query per Download,
and not while entries are skipped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* channels.yaml: moves, invalid Downloads, and saves after the write
- A moved Channel's Download diff is retargeted to its new directory:
one its new entry lists is the same Download, one it does not is
deleted in the same import (the old directory was treated as kept).
- An unreadable Download is counted as skipped, and its Channel keeps
its Downloads (it was dropped, and the Channel's Download deleted).
- A new Download without a frequency is dropped before the write and
counted (it re-opened a write on every import).
- No source id is fetched while entries are skipped (its save would be
refused, leaving the database newer than the file); a fetched one is
saved with dump_config, which records the database's version.
- Download.delete(commit=False) neither changes the skip list nor starts
config saves, which would read the database before the import commits
(and write a removed Download back into channels.yaml). The import
saves download_manager.yaml after its write.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:09:51 -06:00
|
|
|
from wrolpi.downloader import Download, download_manager, save_downloads_config
|
2022-12-06 14:28:35 -07:00
|
|
|
from wrolpi.errors import UnknownDirectory
|
2024-09-28 17:28:33 -06:00
|
|
|
from wrolpi.events import Events
|
2024-07-30 17:14:08 -06:00
|
|
|
from wrolpi.files.lib import split_path_stem_and_suffix
|
2024-09-15 15:33:07 -06:00
|
|
|
from wrolpi.switches import register_switch_handler, ActivateSwitchMethod
|
channels.yaml imports by diffing (#872)
* channels.yaml imports by diffing
channels.yaml declares two sections: Channels keyed by directory
(comparing name, URL, Tag, file format, and the source id and flags,
which keep the database's value when missing), and the Downloads listed
under each Channel, keyed by (directory, URL); an existing Download keeps
its own frequency. An unchanged import writes nothing (it ran about a
thousand queries and committed several times).
Removed Channels are deleted with their Collection (and Downloads);
a Download removed from a Channel is deleted. A channel Collection
without a Channel is deleted unless listed (then it gets its Channel).
A Channel whose directory changed, with the same name, is moved. An
invalid entry is skipped and counted, and keeps its Channel (by name when
its directory is invalid) and that Channel's Downloads; so does an entry
without a list of Downloads. Duplicate directories still refuse the
import. Unknown Tags are dropped once tags.yaml is imported.
New Downloads are created in the import's one write (commit=False on
Channel.get_or_create_download, Download.delete and delete_with_videos).
After the import, Videos are claimed and source ids fetched only for new
and changed Channels; Downloads are linked without a query per Download,
and not while entries are skipped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* channels.yaml: moves, invalid Downloads, and saves after the write
- A moved Channel's Download diff is retargeted to its new directory:
one its new entry lists is the same Download, one it does not is
deleted in the same import (the old directory was treated as kept).
- An unreadable Download is counted as skipped, and its Channel keeps
its Downloads (it was dropped, and the Channel's Download deleted).
- A new Download without a frequency is dropped before the write and
counted (it re-opened a write on every import).
- No source id is fetched while entries are skipped (its save would be
refused, leaving the database newer than the file); a fetched one is
saved with dump_config, which records the database's version.
- Download.delete(commit=False) neither changes the skip list nor starts
config saves, which would read the database before the import commits
(and write a removed Download back into channels.yaml). The import
saves download_manager.yaml after its write.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:09:51 -06:00
|
|
|
from wrolpi.tags import Tag, unknown_tag_names
|
2026-02-20 14:37:58 -07:00
|
|
|
from wrolpi.vars import YTDLP_CACHE_DIR, PYTEST
|
2022-08-06 05:22:07 +00:00
|
|
|
from .common import is_valid_poster, convert_image, \
|
2024-11-02 15:14:02 -06:00
|
|
|
generate_video_poster, ConfigError, \
|
2026-02-08 12:29:25 -07:00
|
|
|
extract_video_duration, ffprobe_json_sync, read_ffprobe_json_file, write_ffprobe_json_file
|
2026-02-20 14:37:58 -07:00
|
|
|
from .cookies import cookies_unlocked, cookies_for_download
|
2024-07-18 10:04:57 -06:00
|
|
|
from .errors import UnknownChannel
|
2024-08-06 18:13:21 -06:00
|
|
|
from .models import Channel
|
2021-11-01 16:42:52 -06:00
|
|
|
|
|
|
|
|
logger = logger.getChild(__name__)
|
|
|
|
|
|
2022-01-29 16:50:22 -07:00
|
|
|
DEFAULT_DOWNLOAD_FREQUENCY = Seconds.week
|
2021-12-20 16:08:13 -07:00
|
|
|
|
2024-09-26 09:13:29 -06:00
|
|
|
REQUIRED_OPTIONS = ['name', 'directory']
|
|
|
|
|
|
2024-07-18 10:04:57 -06:00
|
|
|
@dataclasses.dataclass
|
|
|
|
|
class VideoInfoJSON:
|
|
|
|
|
channel_source_id: str | None = None
|
|
|
|
|
channel_url: str | None = None
|
|
|
|
|
duration: int | None = None
|
2024-09-17 18:04:00 -07:00
|
|
|
epoch: int | None = None
|
2024-11-29 17:01:16 -07:00
|
|
|
timestamp: datetime | None = None
|
2024-07-18 10:04:57 -06:00
|
|
|
title: str | None = None
|
2024-09-17 18:04:00 -07:00
|
|
|
upload_date: datetime | None = None
|
2024-07-18 10:04:57 -06:00
|
|
|
url: str | None = None
|
|
|
|
|
view_count: int | None = None
|
2021-11-01 16:42:52 -06:00
|
|
|
|
|
|
|
|
|
2024-09-17 18:04:00 -07:00
|
|
|
def extract_video_info_json(video: Video) -> VideoInfoJSON:
|
2022-01-14 15:54:33 -07:00
|
|
|
"""
|
|
|
|
|
Parse the Video's info json file, return the relevant data.
|
|
|
|
|
"""
|
2022-08-06 05:22:07 +00:00
|
|
|
video_info_json = VideoInfoJSON()
|
2022-01-14 15:54:33 -07:00
|
|
|
if info_json := video.get_info_json():
|
Jobs queue, video Edit dropdown: transcode/remux, edit title & description (#711)
* Jobs queue and video transcode from the video page
Add an in-memory FIFO Job queue (wrolpi/jobs.py): functions register by
name, callers enqueue by name with JSON kwargs, and the perpetual-tasks
owner runs them one at a time. Logging emitted inside a Job and the
output of its subprocesses are captured into a per-Job log; Jobs report
progress and can be cancelled (a running Job's subprocess is killed).
Jobs are not persisted; an API restart forgets them.
Add a transcode Job and POST /api/videos/<id>/transcode. ffmpeg reports
progress via -progress pipe:1. The existing FileGroup is pointed at the
new file before the refresh so tags and history survive a container
change.
Replace the video page Refresh button with an Edit dropdown (Refresh,
Transcode). The Transcode modal pre-fills from the preferred codecs in
the Videos settings and shows the Job's progress, log tail and Cancel.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Video page: move Delete into the Edit dropdown
The Edit dropdown now holds Refresh, Transcode and Delete, so the action
row is Download / Edit / Add to Playlist. Delete keeps its confirmation
(a Confirm dialog) and the tagged-files prompt, and is disabled in WROL
Mode like the other items.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Video page: Edit > Edit to change a video's title
PUT /api/videos/<file_group_id> writes the title (and fulltitle) into the
Video's info json, creating the file when the video has none, then
validates the Video again so its title and search text follow. Files
stay the source of truth: the edit survives a refresh, and a re-download
of the metadata replaces it (the modal says so).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Video page: edit the description in Edit > Edit
PUT /api/videos/<file_group_id> now takes optional title and description
and changes only the fields given; an empty description clears it. The
modal sends only what changed and refetches the description after saving.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Transcode modal: allow a container-only remux
Keeping both streams is no longer refused: ffmpeg copies them into the
chosen container with fast start. The container select starts at the
file's own container, the hint explains what a remux does, and the button
reads Remux when nothing is re-encoded.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* WROL Mode: refuse video edits and transcodes past the API
The routes already returned 403, but a transcode Job queued before WROL
Mode was enabled still ran, update_video could be called from anywhere,
and the Edit modal's Save button stayed live. The Job now fails before
ffmpeg starts, update_video carries wrol_mode_check, and the modal
disables Save with a notice.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Transcode: verify the output before replacing the original
After ffmpeg exits, the output is probed and compared with the source
(container, presence of video/audio streams, codec per stream, duration
within 1s or 1%) and its last 30 seconds are decoded to a null output.
Any failure removes the output and keeps the original.
The tail decode replaces the download-time completeness check, which
grabbed a single frame near the end: one ffmpeg_video_complete (sync for
the downloader, async for Jobs) now serves both, and is stricter, since
every frame of the tail plus the audio must decode.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Jobs: claim atomically, cap pending, own-session subprocesses; retarget before delete
Review fixes. The worker read a Job's status and wrote RUNNING as two
steps, so a cancel landing between them was overwritten and the Job ran;
_claim_job now does both under the lock. enqueue_job refuses past
JOB_PENDING_LIMIT pending Jobs. JobContext.run_command starts the
subprocess in its own session and raises CancelledError when the command
was killed, so a handler cannot mistake it for a normal exit.
The transcode Job now retargets the FileGroup (committed) while the
original file still exists, and deletes the original afterwards
(transcode_video_file gained keep_original). A crash in between leaves
both files with the database pointing at a real one.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Video page: the Edit menu owns the transcode Job; polling survives Close and errors
Review fixes. The transcode Job's id and polling (useJob) move from the
modal to VideoEditMenu, so closing the modal no longer stops the page
from learning the Job finished (and refetching the video, whose path may
have changed). While a Job runs the menu shows Transcoding..., reopening
shows its progress instead of the form, and Delete waits. A failed poll
request is retried instead of ending the polling. getJobs checks the
response like its siblings. Design narration trimmed from comments.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Transcode: audio files, and Remove video to turn a video into an audio file
video_codec 'none' (REMOVE_VIDEO) drops the video stream (-vn, audio
required); an audio-only source is audio-only output whatever the video
target says, and a video target on it is refused. Audio output gets an
audio container (m4a/ogg/mp3) checked against the codec it will hold, and
a video container asked for audio output is replaced by the codec's
natural one. faststart only for mp4/m4a. Verification learned the
audio-only shape and ffprobe's container names (mkv reports matroska).
The modal offers 'Remove video (audio only)', switches to audio
containers and follows the audio codec, reads Extract Audio, and opens
for audio files (no video codec select).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Video page: yellow Edit button
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Transcode: remove the temp output on cancel; tell the user when a closed-modal Job fails or is lost
Review fixes. The temp output was only removed on Exception, but a
cancelled Job's run_command raises CancelledError (a BaseException), so a
cancel left the partial file for a refresh to index. Closing the modal
during a Job that then failed showed nothing; it now toasts the error
(and a cancellation). Polling gives up on a 404 or five failures in a
row (the API restarted and forgot its Jobs), warning the user and
unlocking Transcode and Delete instead of reading Transcoding... forever.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Jobs: _claim_job docstring states the invariant, not the old race
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Transcode: fragmented mp4 option for very long videos
A whole-file mp4 index grows with the frame count; for a many-hour video
it reaches tens of MB, which a phone must fetch and parse before the
first frame, and every seek walks it. `fragmented` writes the file with
-movflags +frag_keyframe+empty_moov+default_base_moof+global_sidx and
10s fragments: a ~1 KB moov plus a global seek index, so playback starts
and seeks quickly. mp4/m4a only; a remux, so it takes seconds per GB.
Measured on an 11-hour h264/aac file: moov 40 MB -> 1.4 KB, seek index
2 x 120 KB, 10k fragments, duration and codecs unchanged, remux in 25s.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Transcode modal: the Fragmented label says audio for audio output
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Video edits keep the original: custom_title/custom_description in the info json's wrolpi section
Editing a title or description no longer overwrites yt-dlp's keys. The
values go into the info json's `wrolpi` section (custom_title,
custom_description); the modeler prefers them when deriving the Video, so
a refresh keeps the edit while the original title and description stay in
the file. An empty custom description means none.
Both re-download paths carry the wrolpi section over: the metadata-only
refresh merges it into the fresh info json, and a full re-download
snapshots it before yt-dlp overwrites the file and restores it (then
re-validates) in finalize_download. FileGroup.get_wrolpi_json reads it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 19:25:23 -06:00
|
|
|
# A title the user set (Edit > Edit) is kept apart from yt-dlp's keys, and wins.
|
|
|
|
|
wrolpi_section = info_json.get('wrolpi') or dict()
|
|
|
|
|
title = wrolpi_section.get('custom_title') or info_json.get('fulltitle') or info_json.get('title')
|
2022-08-06 05:22:07 +00:00
|
|
|
video_info_json.title = html.unescape(title) if title else None
|
2022-01-14 15:54:33 -07:00
|
|
|
|
2024-09-17 18:04:00 -07:00
|
|
|
upload_date = dates.strpdate(i) if (i := info_json.get('upload_date')) else None
|
|
|
|
|
if upload_date:
|
|
|
|
|
upload_date = upload_date.astimezone(pytz.UTC)
|
2024-11-29 17:01:16 -07:00
|
|
|
timestamp = None
|
|
|
|
|
try:
|
|
|
|
|
timestamp = dates.from_timestamp(info_json['timestamp']) if info_json.get('timestamp') else None
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
2024-09-17 18:04:00 -07:00
|
|
|
|
2024-07-18 10:04:57 -06:00
|
|
|
video_info_json.channel_source_id = info_json.get('channel_id') or info_json.get('uploader_id') or None
|
|
|
|
|
video_info_json.channel_url = info_json.get('channel_url') or info_json.get('uploader_url') or None
|
2024-09-17 18:04:00 -07:00
|
|
|
video_info_json.duration = int(i) if (i := info_json.get('duration')) else None
|
2024-07-18 10:04:57 -06:00
|
|
|
video_info_json.epoch = int(i) if (i := info_json.get('epoch')) else None
|
2024-11-29 17:01:16 -07:00
|
|
|
video_info_json.timestamp = timestamp
|
2024-09-17 18:04:00 -07:00
|
|
|
video_info_json.upload_date = upload_date
|
|
|
|
|
video_info_json.url = info_json.get('webpage_url') or info_json.get('url') or None
|
|
|
|
|
video_info_json.view_count = int(i) if (i := info_json.get('view_count')) else None
|
2022-12-06 14:28:35 -07:00
|
|
|
|
2022-08-06 05:22:07 +00:00
|
|
|
return video_info_json
|
2022-01-14 15:54:33 -07:00
|
|
|
|
|
|
|
|
|
2023-03-24 08:58:18 -06:00
|
|
|
EXTRACT_SUBTITLES = False
|
|
|
|
|
|
|
|
|
|
|
2025-12-10 21:16:10 -07:00
|
|
|
def validate_video(session: Session, video: Video, channel_generate_poster: bool):
|
2022-01-14 15:54:33 -07:00
|
|
|
"""
|
2022-08-06 05:22:07 +00:00
|
|
|
Validate a single video.
|
|
|
|
|
|
|
|
|
|
A Video is validated when we have attempted to find its: title, duration, view_count, url, caption, size, and
|
|
|
|
|
channel.
|
|
|
|
|
|
|
|
|
|
A Video is also valid when it has a JPEG poster, if any. If no poster can be
|
2022-01-14 15:54:33 -07:00
|
|
|
found, it will be generated from the video file.
|
|
|
|
|
"""
|
2024-09-17 18:04:00 -07:00
|
|
|
json_published_datetime = None
|
|
|
|
|
|
|
|
|
|
if video.info_json_path:
|
2022-01-14 15:54:33 -07:00
|
|
|
# These properties can be found in the info json.
|
2024-09-17 18:04:00 -07:00
|
|
|
video_info_json = extract_video_info_json(video)
|
2023-03-24 08:58:18 -06:00
|
|
|
video.file_group.title = video_info_json.title
|
2023-10-24 10:20:06 -06:00
|
|
|
video.file_group.length = video_info_json.duration
|
|
|
|
|
video.file_group.url = video_info_json.url
|
2024-11-29 17:01:16 -07:00
|
|
|
json_published_datetime = video.file_group.published_datetime = (
|
|
|
|
|
video_info_json.timestamp # The exact second the video was published.
|
|
|
|
|
or video_info_json.upload_date # The day the video was published.
|
|
|
|
|
or video.file_group.published_datetime
|
|
|
|
|
)
|
2024-03-12 11:06:30 -06:00
|
|
|
video.file_group.download_datetime = from_timestamp(video_info_json.epoch) if video_info_json.epoch else None
|
2022-01-14 15:54:33 -07:00
|
|
|
# View count will probably be overwritten by more recent data when this Video's Channel is
|
|
|
|
|
# updated.
|
2022-08-06 05:22:07 +00:00
|
|
|
video.view_count = video.view_count or video_info_json.view_count
|
2022-01-14 15:54:33 -07:00
|
|
|
|
2024-07-18 10:04:57 -06:00
|
|
|
if video_info_json.channel_source_id or video_info_json.channel_url:
|
|
|
|
|
from modules.videos.channel.lib import get_channel
|
|
|
|
|
try:
|
|
|
|
|
if channel := get_channel(
|
2025-12-10 21:16:10 -07:00
|
|
|
session,
|
2024-07-18 10:04:57 -06:00
|
|
|
source_id=video_info_json.channel_source_id,
|
|
|
|
|
url=video_info_json.channel_url,
|
|
|
|
|
directory=video.video_path.parent,
|
|
|
|
|
return_dict=False,
|
|
|
|
|
):
|
|
|
|
|
video.channel_id = channel.id
|
|
|
|
|
except UnknownChannel:
|
|
|
|
|
pass
|
2022-01-14 15:54:33 -07:00
|
|
|
|
2022-08-06 05:22:07 +00:00
|
|
|
video_path = video.video_path
|
2023-04-01 11:52:41 -06:00
|
|
|
if not video_path:
|
|
|
|
|
logger.error(f'{video} does not have video_path!')
|
2022-05-18 17:24:50 -06:00
|
|
|
|
2023-10-24 10:20:06 -06:00
|
|
|
if video_path and (not video.file_group.title or not video.file_group.published_datetime or not video.source_id):
|
2022-01-14 15:54:33 -07:00
|
|
|
# Video is missing things that can be extracted from the video file name.
|
|
|
|
|
# These are the least trusted, so anything already on the video should be trusted.
|
2023-10-24 10:20:06 -06:00
|
|
|
_, published_date, source_id, title = parse_video_file_name(video_path)
|
|
|
|
|
published_date = published_date or video.file_group.published_datetime
|
|
|
|
|
if published_date and isinstance(published_date, str):
|
|
|
|
|
published_date = dates.strpdate(published_date)
|
|
|
|
|
if not published_date.tzinfo:
|
|
|
|
|
published_date = published_date.astimezone(pytz.UTC)
|
2023-03-24 08:58:18 -06:00
|
|
|
video.file_group.title = video.file_group.title or html.unescape(title)
|
2024-09-17 18:04:00 -07:00
|
|
|
# Trust info json upload_date before file name datetime.
|
|
|
|
|
video.file_group.published_datetime = json_published_datetime or published_date
|
2022-01-14 15:54:33 -07:00
|
|
|
video.source_id = video.source_id or source_id
|
2022-01-25 08:51:55 -07:00
|
|
|
|
2026-04-14 15:00:49 -06:00
|
|
|
is_audio = video.file_group.mimetype and video.file_group.mimetype.startswith('audio/')
|
|
|
|
|
if channel_generate_poster and not is_audio:
|
2022-01-14 15:54:33 -07:00
|
|
|
# Try to convert/generate, but keep the old poster if those fail.
|
2022-08-06 05:22:07 +00:00
|
|
|
new_poster_path, duration = convert_or_generate_poster(video) or video.poster_path
|
|
|
|
|
if new_poster_path:
|
|
|
|
|
# Poster was created/updated.
|
2023-03-24 08:58:18 -06:00
|
|
|
video.file_group.append_files(new_poster_path)
|
2022-08-06 05:22:07 +00:00
|
|
|
if duration:
|
2023-10-24 10:20:06 -06:00
|
|
|
video.file_group.length = duration
|
2022-08-06 05:22:07 +00:00
|
|
|
|
2026-02-08 12:29:25 -07:00
|
|
|
if video_path and not video.ffprobe_json:
|
|
|
|
|
# Extract ffprobe data if not already present (e.g., from .ffprobe.json cache file)
|
|
|
|
|
ffprobe_path = video.ffprobe_json_path
|
|
|
|
|
|
|
|
|
|
# Try to read from cache file first
|
|
|
|
|
if ffprobe_path and ffprobe_path.is_file():
|
|
|
|
|
video.ffprobe_json = read_ffprobe_json_file(ffprobe_path)
|
|
|
|
|
|
|
|
|
|
# If still no data, run ffprobe
|
|
|
|
|
if not video.ffprobe_json:
|
|
|
|
|
try:
|
|
|
|
|
video.ffprobe_json = ffprobe_json_sync(video_path)
|
|
|
|
|
# Write cache file if enabled
|
|
|
|
|
if ffprobe_path and get_wrolpi_config().save_ffprobe_json:
|
|
|
|
|
try:
|
|
|
|
|
write_ffprobe_json_file(ffprobe_path, video.ffprobe_json)
|
|
|
|
|
video.file_group.append_files(ffprobe_path)
|
|
|
|
|
except IOError as e:
|
|
|
|
|
logger.warning(f'Failed to write ffprobe json cache file {ffprobe_path}', exc_info=e)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning(f'Failed to extract ffprobe data for {video}', exc_info=e)
|
|
|
|
|
|
|
|
|
|
# Extract duration from ffprobe data
|
2023-10-24 10:20:06 -06:00
|
|
|
if video_path and not video.file_group.length:
|
2023-08-08 13:42:55 -06:00
|
|
|
if video.ffprobe_json:
|
|
|
|
|
if duration := video.ffprobe_json['format'].get('duration'):
|
2023-10-24 10:20:06 -06:00
|
|
|
video.file_group.length = float(duration)
|
2023-10-20 18:53:26 -06:00
|
|
|
elif (video_streams := video.get_streams_by_codec_type('video')) and 'duration' in video_streams[0]:
|
2023-10-24 10:20:06 -06:00
|
|
|
video.file_group.length = float(video_streams[0]['duration'])
|
2023-07-27 15:53:47 -06:00
|
|
|
else:
|
2026-02-08 12:29:25 -07:00
|
|
|
# Fallback if ffprobe extraction failed
|
2024-11-02 15:14:02 -06:00
|
|
|
video.file_group.length = extract_video_duration(video_path)
|
2022-01-14 15:54:33 -07:00
|
|
|
|
2023-03-24 08:58:18 -06:00
|
|
|
if video_path and not video.caption_paths and video.file_group.d_text and not EXTRACT_SUBTITLES:
|
|
|
|
|
# Caption file was deleted, clear out old captions.
|
|
|
|
|
video.file_group.d_text = None
|
|
|
|
|
elif video_path and not video.file_group.d_text and EXTRACT_SUBTITLES:
|
|
|
|
|
# Captions were not found, extract them from the video.
|
|
|
|
|
video.file_group.d_text = extract_captions(video_path)
|
|
|
|
|
elif video_path and video.caption_paths and not video.file_group.d_text:
|
|
|
|
|
video.file_group.d_text = video.get_caption_text()
|
|
|
|
|
|
2022-01-14 15:54:33 -07:00
|
|
|
|
2022-08-06 05:22:07 +00:00
|
|
|
def convert_or_generate_poster(video: Video) -> Tuple[Optional[pathlib.Path], Optional[int]]:
|
2022-01-14 15:54:33 -07:00
|
|
|
"""
|
|
|
|
|
If a Video has a poster, but the poster is invalid, convert it. If a Video has no poster, generate one from the
|
|
|
|
|
video file.
|
|
|
|
|
|
|
|
|
|
Returns None if the poster was not converted, and not generated.
|
|
|
|
|
"""
|
2022-08-06 05:22:07 +00:00
|
|
|
video_path = video.video_path
|
2022-01-14 15:54:33 -07:00
|
|
|
# Modification/generation of poster is enabled for this channel.
|
2023-03-24 08:58:18 -06:00
|
|
|
if video.poster_path:
|
2022-01-14 15:54:33 -07:00
|
|
|
# Check that the poster is a more universally supported JPEG.
|
2023-03-24 08:58:18 -06:00
|
|
|
old: pathlib.Path = video.poster_path
|
2022-01-14 15:54:33 -07:00
|
|
|
new = old.with_suffix('.jpg')
|
|
|
|
|
|
|
|
|
|
if old != new and new.exists():
|
|
|
|
|
# Destination JPEG already exists (it may have the wrong format).
|
2026-10-23 14:34:51 -06:00
|
|
|
video.file_group.unlink_tag_directory_links(old)
|
2022-01-14 15:54:33 -07:00
|
|
|
old.unlink()
|
2023-03-24 08:58:18 -06:00
|
|
|
old = new
|
2022-01-14 15:54:33 -07:00
|
|
|
|
|
|
|
|
if not is_valid_poster(old):
|
2026-10-23 14:34:51 -06:00
|
|
|
# Poster is not valid, convert it and place it in the new location. Conversion removes `old`, so its
|
|
|
|
|
# Tags Directory links are found now and removed only once the conversion succeeds.
|
|
|
|
|
old_links = video.file_group.get_tag_directory_links(old) if old != new else []
|
2022-01-14 15:54:33 -07:00
|
|
|
try:
|
|
|
|
|
convert_image(old, new)
|
2024-10-16 17:32:54 -06:00
|
|
|
if not new.is_file() or not new.stat().st_size:
|
2023-03-24 08:58:18 -06:00
|
|
|
raise FileNotFoundError(f'Failed to convert poster: {new}')
|
2026-10-23 14:34:51 -06:00
|
|
|
for link in old_links:
|
|
|
|
|
link.unlink(missing_ok=True)
|
2022-08-06 05:22:07 +00:00
|
|
|
logger.info(f'Converted invalid poster {repr(str(old))} to {repr(str(new))}')
|
|
|
|
|
return new, None
|
2022-01-14 15:54:33 -07:00
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f'Failed to convert invalid poster {old} to {new}', exc_info=e)
|
2022-08-06 05:22:07 +00:00
|
|
|
return None, None
|
2022-01-14 15:54:33 -07:00
|
|
|
|
2023-03-24 08:58:18 -06:00
|
|
|
if not video.poster_path:
|
2022-01-14 15:54:33 -07:00
|
|
|
# Video poster was not discovered, or converted. Let's generate it.
|
|
|
|
|
try:
|
2022-08-06 05:22:07 +00:00
|
|
|
poster_path, duration = generate_video_poster(video_path)
|
2022-01-14 15:54:33 -07:00
|
|
|
logger.debug(f'Generated poster for {video}')
|
2022-08-06 05:22:07 +00:00
|
|
|
return poster_path, duration
|
2022-01-14 15:54:33 -07:00
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f'Failed to generate poster for {video}', exc_info=e)
|
|
|
|
|
|
2022-08-06 05:22:07 +00:00
|
|
|
return None, None
|
2021-11-01 16:42:52 -06:00
|
|
|
|
|
|
|
|
|
2024-09-28 17:28:33 -06:00
|
|
|
@dataclass
|
|
|
|
|
class ChannelDictValidator:
|
|
|
|
|
name: str
|
|
|
|
|
directory: str
|
2025-12-06 14:47:52 -07:00
|
|
|
download_frequency: int = 604800 # Default to weekly
|
2024-09-28 17:28:33 -06:00
|
|
|
url: str = None
|
|
|
|
|
|
|
|
|
|
|
channels.yaml imports by diffing (#872)
* channels.yaml imports by diffing
channels.yaml declares two sections: Channels keyed by directory
(comparing name, URL, Tag, file format, and the source id and flags,
which keep the database's value when missing), and the Downloads listed
under each Channel, keyed by (directory, URL); an existing Download keeps
its own frequency. An unchanged import writes nothing (it ran about a
thousand queries and committed several times).
Removed Channels are deleted with their Collection (and Downloads);
a Download removed from a Channel is deleted. A channel Collection
without a Channel is deleted unless listed (then it gets its Channel).
A Channel whose directory changed, with the same name, is moved. An
invalid entry is skipped and counted, and keeps its Channel (by name when
its directory is invalid) and that Channel's Downloads; so does an entry
without a list of Downloads. Duplicate directories still refuse the
import. Unknown Tags are dropped once tags.yaml is imported.
New Downloads are created in the import's one write (commit=False on
Channel.get_or_create_download, Download.delete and delete_with_videos).
After the import, Videos are claimed and source ids fetched only for new
and changed Channels; Downloads are linked without a query per Download,
and not while entries are skipped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* channels.yaml: moves, invalid Downloads, and saves after the write
- A moved Channel's Download diff is retargeted to its new directory:
one its new entry lists is the same Download, one it does not is
deleted in the same import (the old directory was treated as kept).
- An unreadable Download is counted as skipped, and its Channel keeps
its Downloads (it was dropped, and the Channel's Download deleted).
- A new Download without a frequency is dropped before the write and
counted (it re-opened a write on every import).
- No source id is fetched while entries are skipped (its save would be
refused, leaving the database newer than the file); a fetched one is
saved with dump_config, which records the database's version.
- Download.delete(commit=False) neither changes the skip list nor starts
config saves, which would read the database before the import commits
(and write a removed Download back into channels.yaml). The import
saves download_manager.yaml after its write.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:09:51 -06:00
|
|
|
def _channel_key(data: dict) -> str:
|
|
|
|
|
"""A Channel's key: its directory, relative to the media directory."""
|
|
|
|
|
if not isinstance(data, dict) or not data.get('directory'):
|
|
|
|
|
raise ValueError('A Channel requires a directory')
|
|
|
|
|
return media_relative(validate_collection_directory(data['directory']))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# The key of a channel Collection without a Channel or a directory.
|
|
|
|
|
ORPHAN_KEY = '\0orphan:'
|
|
|
|
|
# Missing from an entry: the Channel keeps the database's value.
|
|
|
|
|
CHANNEL_KEPT_IF_MISSING = ('source_id', 'generate_posters', 'calculate_duration', 'download_missing_data')
|
|
|
|
|
CHANNEL_COMPARED = ('name', 'url', 'tag_name', 'file_format') + CHANNEL_KEPT_IF_MISSING
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _channel_row(name, url, source_id, tag_name, file_format, generate_posters, calculate_duration,
|
|
|
|
|
download_missing_data) -> dict:
|
|
|
|
|
return dict(name=name, url=url or None, source_id=source_id or None, tag_name=tag_name or None,
|
|
|
|
|
file_format=file_format or None, generate_posters=generate_posters,
|
|
|
|
|
calculate_duration=calculate_duration, download_missing_data=download_missing_data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ChannelsSection(ConfigSection):
|
|
|
|
|
"""channels.yaml's Channels (a Collection with kind 'channel' and its Channel), keyed by directory. A channel
|
|
|
|
|
Collection without a Channel is in the database's rows too: listed, it gets its Channel; not listed, it is
|
|
|
|
|
deleted."""
|
|
|
|
|
name = 'channels'
|
|
|
|
|
|
|
|
|
|
def file_rows(self, config: dict) -> Tuple[Dict[str, dict], Set[str]]:
|
|
|
|
|
rows, invalid = dict(), set()
|
|
|
|
|
for position, data in enumerate(config.get('channels') or []):
|
|
|
|
|
try:
|
|
|
|
|
key = _channel_key(data)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
channel_import_logger.error(f'Skipping invalid Channel in config: {data!r}', exc_info=e)
|
A malformed config entry is skipped and counted instead of failing the import (#875)
* A malformed config entry is skipped and counted instead of failing the import
An entry that is not a mapping (or whose key is not text, or a list that is not a list) made the whole file fail to
import in playlists.yaml, repos.yaml, download_manager.yaml and tags.yaml (tag_files/tag_zims), and a non-text name
did in domains.yaml. Each is now skipped and counted in import_skipped, so the config is not saved over it, and the
rest of the file imports. A Channel whose `downloads` is not a list keeps its Downloads.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* An unreadable entry keeps no row and does not make a section present
The key of an entry too malformed to name a row is now `Unreadable(position)`. It is still counted in
import_skipped, but `present` ignores it, so a section whose entries are all unreadable removes nothing (as an empty
section), instead of deleting every row. A playlist whose `items` is not a list keeps its items, and a Channel
Download whose url is not text is counted (its Channel keeps its Downloads) instead of failing the import.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:28:30 -06:00
|
|
|
# Counted as skipped (see `Unreadable`). The entry's name keeps its Channel (see `prepare`).
|
|
|
|
|
invalid.add(Unreadable(position))
|
channels.yaml imports by diffing (#872)
* channels.yaml imports by diffing
channels.yaml declares two sections: Channels keyed by directory
(comparing name, URL, Tag, file format, and the source id and flags,
which keep the database's value when missing), and the Downloads listed
under each Channel, keyed by (directory, URL); an existing Download keeps
its own frequency. An unchanged import writes nothing (it ran about a
thousand queries and committed several times).
Removed Channels are deleted with their Collection (and Downloads);
a Download removed from a Channel is deleted. A channel Collection
without a Channel is deleted unless listed (then it gets its Channel).
A Channel whose directory changed, with the same name, is moved. An
invalid entry is skipped and counted, and keeps its Channel (by name when
its directory is invalid) and that Channel's Downloads; so does an entry
without a list of Downloads. Duplicate directories still refuse the
import. Unknown Tags are dropped once tags.yaml is imported.
New Downloads are created in the import's one write (commit=False on
Channel.get_or_create_download, Download.delete and delete_with_videos).
After the import, Videos are claimed and source ids fetched only for new
and changed Channels; Downloads are linked without a query per Download,
and not while entries are skipped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* channels.yaml: moves, invalid Downloads, and saves after the write
- A moved Channel's Download diff is retargeted to its new directory:
one its new entry lists is the same Download, one it does not is
deleted in the same import (the old directory was treated as kept).
- An unreadable Download is counted as skipped, and its Channel keeps
its Downloads (it was dropped, and the Channel's Download deleted).
- A new Download without a frequency is dropped before the write and
counted (it re-opened a write on every import).
- No source id is fetched while entries are skipped (its save would be
refused, leaving the database newer than the file); a fetched one is
saved with dump_config, which records the database's version.
- Download.delete(commit=False) neither changes the skip list nor starts
config saves, which would read the database before the import commits
(and write a removed Download back into channels.yaml). The import
saves download_manager.yaml after its write.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:09:51 -06:00
|
|
|
continue
|
|
|
|
|
if key in rows or key in invalid:
|
|
|
|
|
raise RuntimeError('Refusing to import channels config because it contains duplicate directories!')
|
|
|
|
|
if not data.get('name'):
|
|
|
|
|
channel_import_logger.error(f'Channel {key!r} is required to have "name", skipping')
|
|
|
|
|
invalid.add(key)
|
|
|
|
|
continue
|
|
|
|
|
rows[key] = _channel_row(data['name'], data.get('url'), data.get('source_id'), data.get('tag_name'),
|
|
|
|
|
data.get('file_format'), data.get('generate_posters'),
|
|
|
|
|
data.get('calculate_duration'), data.get('download_missing_data'))
|
|
|
|
|
return rows, invalid
|
|
|
|
|
|
|
|
|
|
def db_rows(self, session: Session) -> Dict[str, dict]:
|
|
|
|
|
query = session.query(Collection.directory, Collection.name, Channel.url, Channel.source_id, Tag.name,
|
|
|
|
|
Collection.file_format, Channel.generate_posters, Channel.calculate_duration,
|
|
|
|
|
Channel.download_missing_data, Collection.id, Channel.id) \
|
|
|
|
|
.select_from(Collection) \
|
|
|
|
|
.outerjoin(Channel, Channel.collection_id == Collection.id) \
|
|
|
|
|
.outerjoin(Tag, Tag.id == Collection.tag_id) \
|
|
|
|
|
.filter(Collection.kind == 'channel')
|
|
|
|
|
rows = dict()
|
|
|
|
|
for directory, *values, collection_id, channel_id in query:
|
|
|
|
|
if directory:
|
|
|
|
|
key = media_relative(directory)
|
|
|
|
|
elif channel_id is None:
|
|
|
|
|
# An orphan without a directory: no entry can list it (`ORPHAN_KEY`).
|
|
|
|
|
key = f'{ORPHAN_KEY}{collection_id}'
|
|
|
|
|
else:
|
|
|
|
|
continue
|
|
|
|
|
rows[key] = row = _channel_row(*values)
|
|
|
|
|
if channel_id is None:
|
|
|
|
|
row['orphan'] = True
|
|
|
|
|
return rows
|
|
|
|
|
|
|
|
|
|
def same(self, file_row: dict, db_row: dict) -> bool:
|
|
|
|
|
if db_row.get('orphan') or file_row.get('directory'):
|
|
|
|
|
return False
|
|
|
|
|
return all(file_row[i] == db_row[i] for i in CHANNEL_COMPARED
|
|
|
|
|
if not (i in CHANNEL_KEPT_IF_MISSING and file_row[i] is None))
|
|
|
|
|
|
|
|
|
|
def upsert(self, session: Session, rows: Dict[str, dict]):
|
|
|
|
|
media_directory = get_media_directory()
|
|
|
|
|
collections = {media_relative(i.directory): i for i in session.query(Collection).filter(
|
|
|
|
|
Collection.kind == 'channel', Collection.directory.in_([str(media_directory / i) for i in rows]))}
|
|
|
|
|
channels = {i.collection_id: i for i in session.query(Channel).filter(
|
|
|
|
|
Channel.collection_id.in_([i.id for i in collections.values()]))}
|
|
|
|
|
tags = {i.name: i for i in
|
|
|
|
|
session.query(Tag).filter(Tag.name.in_(list({i['tag_name'] for i in rows.values()} - {None})))}
|
|
|
|
|
for key, row in rows.items():
|
|
|
|
|
if not (collection := collections.get(key)):
|
|
|
|
|
collection = Collection(kind='channel')
|
|
|
|
|
session.add(collection)
|
|
|
|
|
# A moved Channel's new directory (see `ChannelsConfig.prepare`).
|
|
|
|
|
collection.directory = media_directory / row.get('directory', key)
|
|
|
|
|
collection.name = row['name']
|
|
|
|
|
collection.tag = tags.get(row['tag_name'])
|
|
|
|
|
collection.file_format = row['file_format']
|
|
|
|
|
if not (channel := channels.get(collection.id) if collection.id else None):
|
|
|
|
|
channel = Channel(collection=collection)
|
|
|
|
|
session.add(channel)
|
|
|
|
|
channel.url = row['url']
|
|
|
|
|
for name in CHANNEL_KEPT_IF_MISSING:
|
|
|
|
|
if row[name] is not None:
|
|
|
|
|
setattr(channel, name, row[name])
|
|
|
|
|
session.flush()
|
|
|
|
|
|
|
|
|
|
def delete(self, session: Session, keys: Set[str]):
|
|
|
|
|
media_directory = get_media_directory()
|
|
|
|
|
orphan_ids = [int(i.removeprefix(ORPHAN_KEY)) for i in keys if i.startswith(ORPHAN_KEY)]
|
|
|
|
|
collections = session.query(Collection).filter(
|
|
|
|
|
Collection.kind == 'channel',
|
|
|
|
|
Collection.directory.in_([str(media_directory / i) for i in keys if not i.startswith(ORPHAN_KEY)])
|
|
|
|
|
| Collection.id.in_(orphan_ids)).all()
|
|
|
|
|
channels = {i.collection_id: i for i in session.query(Channel).filter(
|
|
|
|
|
Channel.collection_id.in_([i.id for i in collections]))}
|
|
|
|
|
for collection in collections:
|
|
|
|
|
if channel := channels.get(collection.id):
|
|
|
|
|
logger.warning(f'Deleting {channel} because it is not in the config.')
|
|
|
|
|
channel.delete_with_videos(commit=False)
|
|
|
|
|
else:
|
|
|
|
|
logger.warning(f'Deleting orphaned Collection {collection.name} (id={collection.id})')
|
|
|
|
|
session.delete(collection)
|
|
|
|
|
session.flush()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ChannelDownloadsSection(ConfigSection):
|
|
|
|
|
"""The Downloads listed under each Channel, keyed by (Channel directory, URL). An existing Download keeps its own
|
|
|
|
|
frequency (download_manager.yaml's); the file's is used to create one."""
|
|
|
|
|
name = 'downloads'
|
|
|
|
|
|
|
|
|
|
def file_rows(self, config: dict) -> Tuple[Dict[tuple, dict], Set[tuple]]:
|
|
|
|
|
rows, invalid = dict(), set()
|
|
|
|
|
for data in config.get('channels') or []:
|
|
|
|
|
try:
|
|
|
|
|
key = _channel_key(data)
|
|
|
|
|
except Exception:
|
|
|
|
|
continue
|
A malformed config entry is skipped and counted instead of failing the import (#875)
* A malformed config entry is skipped and counted instead of failing the import
An entry that is not a mapping (or whose key is not text, or a list that is not a list) made the whole file fail to
import in playlists.yaml, repos.yaml, download_manager.yaml and tags.yaml (tag_files/tag_zims), and a non-text name
did in domains.yaml. Each is now skipped and counted in import_skipped, so the config is not saved over it, and the
rest of the file imports. A Channel whose `downloads` is not a list keeps its Downloads.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* An unreadable entry keeps no row and does not make a section present
The key of an entry too malformed to name a row is now `Unreadable(position)`. It is still counted in
import_skipped, but `present` ignores it, so a section whose entries are all unreadable removes nothing (as an empty
section), instead of deleting every row. A playlist whose `items` is not a list keeps its items, and a Channel
Download whose url is not text is counted (its Channel keeps its Downloads) instead of failing the import.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:28:30 -06:00
|
|
|
downloads = data.get('downloads') or []
|
|
|
|
|
if not isinstance(downloads, list):
|
|
|
|
|
channel_import_logger.error(f'Skipping the Downloads of Channel {key!r}, they are not a list')
|
|
|
|
|
# Counted as skipped; the Channel keeps its Downloads (see `ChannelsConfig.prepare`).
|
|
|
|
|
invalid.add((key, '\0downloads'))
|
|
|
|
|
continue
|
|
|
|
|
for position, download in enumerate(downloads):
|
|
|
|
|
if isinstance(download, dict) and download.get('url') and isinstance(download['url'], str):
|
channels.yaml imports by diffing (#872)
* channels.yaml imports by diffing
channels.yaml declares two sections: Channels keyed by directory
(comparing name, URL, Tag, file format, and the source id and flags,
which keep the database's value when missing), and the Downloads listed
under each Channel, keyed by (directory, URL); an existing Download keeps
its own frequency. An unchanged import writes nothing (it ran about a
thousand queries and committed several times).
Removed Channels are deleted with their Collection (and Downloads);
a Download removed from a Channel is deleted. A channel Collection
without a Channel is deleted unless listed (then it gets its Channel).
A Channel whose directory changed, with the same name, is moved. An
invalid entry is skipped and counted, and keeps its Channel (by name when
its directory is invalid) and that Channel's Downloads; so does an entry
without a list of Downloads. Duplicate directories still refuse the
import. Unknown Tags are dropped once tags.yaml is imported.
New Downloads are created in the import's one write (commit=False on
Channel.get_or_create_download, Download.delete and delete_with_videos).
After the import, Videos are claimed and source ids fetched only for new
and changed Channels; Downloads are linked without a query per Download,
and not while entries are skipped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* channels.yaml: moves, invalid Downloads, and saves after the write
- A moved Channel's Download diff is retargeted to its new directory:
one its new entry lists is the same Download, one it does not is
deleted in the same import (the old directory was treated as kept).
- An unreadable Download is counted as skipped, and its Channel keeps
its Downloads (it was dropped, and the Channel's Download deleted).
- A new Download without a frequency is dropped before the write and
counted (it re-opened a write on every import).
- No source id is fetched while entries are skipped (its save would be
refused, leaving the database newer than the file); a fetched one is
saved with dump_config, which records the database's version.
- Download.delete(commit=False) neither changes the skip list nor starts
config saves, which would read the database before the import commits
(and write a removed Download back into channels.yaml). The import
saves download_manager.yaml after its write.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:09:51 -06:00
|
|
|
rows[(key, download['url'])] = dict(frequency=download.get('frequency'))
|
|
|
|
|
else:
|
|
|
|
|
channel_import_logger.error(f'Skipping invalid Download of Channel {key!r}: {download!r}')
|
|
|
|
|
# Counted as skipped; the Channel keeps its Downloads (see `ChannelsConfig.prepare`).
|
|
|
|
|
invalid.add((key, download if isinstance(download, str) else f'\0{position}'))
|
|
|
|
|
return rows, invalid
|
|
|
|
|
|
|
|
|
|
def present(self, config: dict, file_rows: dict, invalid_keys: set) -> bool:
|
|
|
|
|
# A Channel listed with an empty list of Downloads has none (see `ChannelsConfig.prepare` for entries without
|
|
|
|
|
# a list).
|
|
|
|
|
return bool(config.get('channels'))
|
|
|
|
|
|
|
|
|
|
def db_rows(self, session: Session) -> Dict[tuple, dict]:
|
|
|
|
|
query = session.query(Collection.directory, Download.url, Download.frequency) \
|
|
|
|
|
.join(Collection, Collection.id == Download.collection_id) \
|
|
|
|
|
.filter(Collection.kind == 'channel', Collection.directory.isnot(None))
|
|
|
|
|
return {(media_relative(directory), url): dict(frequency=frequency) for directory, url, frequency in query}
|
|
|
|
|
|
|
|
|
|
def same(self, file_row: dict, db_row: dict) -> bool:
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
def upsert(self, session: Session, rows: Dict[tuple, dict]):
|
|
|
|
|
media_directory = get_media_directory()
|
|
|
|
|
channels = {media_relative(i.directory): i for i in session.query(Channel).join(Collection).filter(
|
|
|
|
|
Collection.directory.in_([str(media_directory / i) for i, _ in rows]))}
|
|
|
|
|
for (key, url), row in rows.items():
|
|
|
|
|
if not (channel := channels.get(key)):
|
|
|
|
|
channel_import_logger.warning(f'Cannot find Channel {key!r} for Download {url}')
|
|
|
|
|
continue
|
|
|
|
|
if existing := Download.get_by_url(session, url):
|
|
|
|
|
existing.collection_id = channel.collection_id
|
|
|
|
|
elif row['frequency']:
|
|
|
|
|
channel.get_or_create_download(session, url, row['frequency'], reset_attempts=True, commit=False)
|
|
|
|
|
else:
|
|
|
|
|
logger.error(f'Refusing to create Download for Channel without frequency: {url}')
|
|
|
|
|
session.flush()
|
|
|
|
|
|
|
|
|
|
def delete(self, session: Session, keys: Set[tuple]):
|
|
|
|
|
media_directory = get_media_directory()
|
|
|
|
|
query = session.query(Download, Collection.directory) \
|
|
|
|
|
.join(Collection, Collection.id == Download.collection_id) \
|
|
|
|
|
.filter(Collection.kind == 'channel', Download.url.in_([url for _, url in keys]))
|
|
|
|
|
for download, directory in query.all():
|
|
|
|
|
if (media_relative(directory), download.url) in keys:
|
|
|
|
|
logger.warning(f'Deleting {download} because it is no longer in its Channel')
|
|
|
|
|
download.delete(add_to_skip_list=False, commit=False)
|
|
|
|
|
|
|
|
|
|
|
2024-09-28 17:28:33 -06:00
|
|
|
@dataclass
|
|
|
|
|
class ChannelsConfigValidator:
|
|
|
|
|
version: int = None
|
|
|
|
|
channels: list[ChannelDictValidator] = dataclasses.field(default_factory=list)
|
|
|
|
|
|
|
|
|
|
|
2022-02-16 21:57:27 -07:00
|
|
|
class ChannelsConfig(ConfigFile):
|
|
|
|
|
file_name = 'channels.yaml'
|
channels.yaml imports by diffing (#872)
* channels.yaml imports by diffing
channels.yaml declares two sections: Channels keyed by directory
(comparing name, URL, Tag, file format, and the source id and flags,
which keep the database's value when missing), and the Downloads listed
under each Channel, keyed by (directory, URL); an existing Download keeps
its own frequency. An unchanged import writes nothing (it ran about a
thousand queries and committed several times).
Removed Channels are deleted with their Collection (and Downloads);
a Download removed from a Channel is deleted. A channel Collection
without a Channel is deleted unless listed (then it gets its Channel).
A Channel whose directory changed, with the same name, is moved. An
invalid entry is skipped and counted, and keeps its Channel (by name when
its directory is invalid) and that Channel's Downloads; so does an entry
without a list of Downloads. Duplicate directories still refuse the
import. Unknown Tags are dropped once tags.yaml is imported.
New Downloads are created in the import's one write (commit=False on
Channel.get_or_create_download, Download.delete and delete_with_videos).
After the import, Videos are claimed and source ids fetched only for new
and changed Channels; Downloads are linked without a query per Download,
and not while entries are skipped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* channels.yaml: moves, invalid Downloads, and saves after the write
- A moved Channel's Download diff is retargeted to its new directory:
one its new entry lists is the same Download, one it does not is
deleted in the same import (the old directory was treated as kept).
- An unreadable Download is counted as skipped, and its Channel keeps
its Downloads (it was dropped, and the Channel's Download deleted).
- A new Download without a frequency is dropped before the write and
counted (it re-opened a write on every import).
- No source id is fetched while entries are skipped (its save would be
refused, leaving the database newer than the file); a fetched one is
saved with dump_config, which records the database's version.
- Download.delete(commit=False) neither changes the skip list nor starts
config saves, which would read the database before the import commits
(and write a removed Download back into channels.yaml). The import
saves download_manager.yaml after its write.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:09:51 -06:00
|
|
|
sections = (ChannelsSection(), ChannelDownloadsSection())
|
2022-02-16 21:57:27 -07:00
|
|
|
default_config = dict(
|
2024-04-17 11:25:26 -06:00
|
|
|
channels=[dict(
|
|
|
|
|
name='WROLPi',
|
|
|
|
|
url='https://www.youtube.com/channel/UC4t8bw1besFTyjW7ZBCOIrw/videos',
|
|
|
|
|
directory='videos/wrolpi',
|
|
|
|
|
download_frequency=604800,
|
|
|
|
|
)],
|
2024-09-26 09:13:29 -06:00
|
|
|
version=0,
|
2022-02-16 21:57:27 -07:00
|
|
|
)
|
2024-09-28 17:28:33 -06:00
|
|
|
validator = ChannelsConfigValidator
|
2022-02-16 21:57:27 -07:00
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def channels(self) -> dict:
|
|
|
|
|
return self._config['channels']
|
|
|
|
|
|
|
|
|
|
@channels.setter
|
|
|
|
|
def channels(self, value: dict):
|
|
|
|
|
self.update({'channels': value})
|
|
|
|
|
|
2024-09-28 17:28:33 -06:00
|
|
|
def import_config(self, file: pathlib.Path = None, send_events=False):
|
channels.yaml imports by diffing (#872)
* channels.yaml imports by diffing
channels.yaml declares two sections: Channels keyed by directory
(comparing name, URL, Tag, file format, and the source id and flags,
which keep the database's value when missing), and the Downloads listed
under each Channel, keyed by (directory, URL); an existing Download keeps
its own frequency. An unchanged import writes nothing (it ran about a
thousand queries and committed several times).
Removed Channels are deleted with their Collection (and Downloads);
a Download removed from a Channel is deleted. A channel Collection
without a Channel is deleted unless listed (then it gets its Channel).
A Channel whose directory changed, with the same name, is moved. An
invalid entry is skipped and counted, and keeps its Channel (by name when
its directory is invalid) and that Channel's Downloads; so does an entry
without a list of Downloads. Duplicate directories still refuse the
import. Unknown Tags are dropped once tags.yaml is imported.
New Downloads are created in the import's one write (commit=False on
Channel.get_or_create_download, Download.delete and delete_with_videos).
After the import, Videos are claimed and source ids fetched only for new
and changed Channels; Downloads are linked without a query per Download,
and not while entries are skipped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* channels.yaml: moves, invalid Downloads, and saves after the write
- A moved Channel's Download diff is retargeted to its new directory:
one its new entry lists is the same Download, one it does not is
deleted in the same import (the old directory was treated as kept).
- An unreadable Download is counted as skipped, and its Channel keeps
its Downloads (it was dropped, and the Channel's Download deleted).
- A new Download without a frequency is dropped before the write and
counted (it re-opened a write on every import).
- No source id is fetched while entries are skipped (its save would be
refused, leaving the database newer than the file); a fetched one is
saved with dump_config, which records the database's version.
- Download.delete(commit=False) neither changes the skip list nor starts
config saves, which would read the database before the import commits
(and write a removed Download back into channels.yaml). The import
saves download_manager.yaml after its write.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:09:51 -06:00
|
|
|
self._config_outdated = False
|
|
|
|
|
super().import_config(file, send_events)
|
2025-12-06 14:47:52 -07:00
|
|
|
|
channels.yaml imports by diffing (#872)
* channels.yaml imports by diffing
channels.yaml declares two sections: Channels keyed by directory
(comparing name, URL, Tag, file format, and the source id and flags,
which keep the database's value when missing), and the Downloads listed
under each Channel, keyed by (directory, URL); an existing Download keeps
its own frequency. An unchanged import writes nothing (it ran about a
thousand queries and committed several times).
Removed Channels are deleted with their Collection (and Downloads);
a Download removed from a Channel is deleted. A channel Collection
without a Channel is deleted unless listed (then it gets its Channel).
A Channel whose directory changed, with the same name, is moved. An
invalid entry is skipped and counted, and keeps its Channel (by name when
its directory is invalid) and that Channel's Downloads; so does an entry
without a list of Downloads. Duplicate directories still refuse the
import. Unknown Tags are dropped once tags.yaml is imported.
New Downloads are created in the import's one write (commit=False on
Channel.get_or_create_download, Download.delete and delete_with_videos).
After the import, Videos are claimed and source ids fetched only for new
and changed Channels; Downloads are linked without a query per Download,
and not while entries are skipped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* channels.yaml: moves, invalid Downloads, and saves after the write
- A moved Channel's Download diff is retargeted to its new directory:
one its new entry lists is the same Download, one it does not is
deleted in the same import (the old directory was treated as kept).
- An unreadable Download is counted as skipped, and its Channel keeps
its Downloads (it was dropped, and the Channel's Download deleted).
- A new Download without a frequency is dropped before the write and
counted (it re-opened a write on every import).
- No source id is fetched while entries are skipped (its save would be
refused, leaving the database newer than the file); a fetched one is
saved with dump_config, which records the database's version.
- Download.delete(commit=False) neither changes the skip list nor starts
config saves, which would read the database before the import commits
(and write a removed Download back into channels.yaml). The import
saves download_manager.yaml after its write.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:09:51 -06:00
|
|
|
def prepare(self, diffs: dict):
|
|
|
|
|
channels, downloads = diffs['channels'], diffs['downloads']
|
|
|
|
|
db_rows = self.import_db_rows['channels']
|
|
|
|
|
|
|
|
|
|
# A Tag that no longer exists (it was removed) is dropped, and the config is saved without it.
|
|
|
|
|
rows = [*channels.added.values(), *channels.changed.values()]
|
|
|
|
|
unknown = unknown_tag_names({i['tag_name'] for i in rows if i['tag_name']})
|
|
|
|
|
for row in rows:
|
|
|
|
|
if row['tag_name'] in unknown:
|
|
|
|
|
channel_import_logger.warning(f'Removing unknown Tag {row["tag_name"]!r} from a Channel')
|
|
|
|
|
row['tag_name'] = None
|
|
|
|
|
self._config_outdated = True
|
|
|
|
|
|
|
|
|
|
# A Channel whose directory changed (its name did not) is moved, not deleted and re-created.
|
|
|
|
|
removed_by_name = dict()
|
|
|
|
|
for key in channels.removed:
|
|
|
|
|
removed_by_name.setdefault(db_rows[key]['name'], []).append(key)
|
|
|
|
|
moves = dict()
|
|
|
|
|
for key, row in list(channels.added.items()):
|
|
|
|
|
if len(old := removed_by_name.get(row['name']) or []) == 1:
|
|
|
|
|
channels.removed.discard(old[0])
|
|
|
|
|
del channels.added[key]
|
|
|
|
|
channels.changed[old[0]] = dict(row, directory=key)
|
|
|
|
|
moves[old[0]] = key
|
|
|
|
|
|
|
|
|
|
# An invalid entry keeps its Channel (found by the entry's name when its directory is invalid), and the
|
|
|
|
|
# Channel's Downloads; so does an entry without a list of Downloads (an old config), or with an unreadable
|
|
|
|
|
# Download.
|
|
|
|
|
entries = [i for i in self._config.get('channels') or [] if isinstance(i, dict)]
|
|
|
|
|
invalid_names = {i.get('name') for i in entries if i.get('name')} - {i['name'] for i in self._channel_rows()}
|
|
|
|
|
for key in [i for i in channels.removed if db_rows[i]['name'] in invalid_names]:
|
|
|
|
|
channels.removed.discard(key)
|
|
|
|
|
kept = set(db_rows) - set(self._channel_rows_by_key()) - channels.removed - set(moves)
|
|
|
|
|
kept |= {key for key, data in self._channel_entries() if 'downloads' not in data}
|
|
|
|
|
kept |= {directory for directory, _ in ChannelDownloadsSection().file_rows(self._config)[1]}
|
|
|
|
|
|
|
|
|
|
# A moved Channel's Downloads are deleted after the move, under its new directory; one its new entry lists
|
|
|
|
|
# is the same Download.
|
|
|
|
|
for directory, url in [i for i in downloads.removed if i[0] in moves]:
|
|
|
|
|
downloads.removed.discard((directory, url))
|
|
|
|
|
if (moves[directory], url) in downloads.added:
|
|
|
|
|
del downloads.added[(moves[directory], url)]
|
|
|
|
|
else:
|
|
|
|
|
downloads.removed.add((moves[directory], url))
|
|
|
|
|
downloads.removed = {i for i in downloads.removed if i[0] not in kept}
|
|
|
|
|
|
|
|
|
|
# A new Download needs a frequency.
|
|
|
|
|
if missing := {url for (_, url), row in downloads.added.items() if not row['frequency']}:
|
|
|
|
|
with get_db_session() as session:
|
|
|
|
|
missing -= {i for i, in session.query(Download.url).filter(Download.url.in_(list(missing)))}
|
|
|
|
|
for key in [i for i in downloads.added if i[1] in missing]:
|
|
|
|
|
channel_import_logger.error(f'Refusing to create Download for Channel without frequency: {key[1]}')
|
|
|
|
|
del downloads.added[key]
|
|
|
|
|
self.import_skipped += 1
|
|
|
|
|
|
|
|
|
|
def _channel_entries(self) -> List[Tuple[str, dict]]:
|
|
|
|
|
"""The valid entries of the file, keyed by directory."""
|
|
|
|
|
entries = list()
|
|
|
|
|
for data in self._config.get('channels') or []:
|
|
|
|
|
try:
|
|
|
|
|
entries.append((_channel_key(data), data))
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
return entries
|
Database configs carry a version the database keeps too (#866)
* Benchmark every database config import (#865)
An opt-in test (WROLPI_CONFIG_BENCH=1) that builds a large library in bulk,
writes each config with its own dump_config, then times every import in
startup order: a steady pass (the database already matches) and a rebuild
(config-owned rows deleted, everything restored from the configs). It
prints seconds, SQL statements and writes per import, and asserts the
rebuild restored every row so no timed import silently skipped its work.
WROLPI_CONFIG_BENCH_SCALE multiplies the row counts.
The baseline for the config import rework.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Database configs carry a version the database keeps too
Step 1 of the config import rework. A database config (tags, downloads,
channels, domains, playlists, repos, recently viewed) and the database now
each carry an integer version, so an import can tell which side is newer
without a clock (a Raspberry Pi's resets at boot):
- config_version (new table) holds the database's version of each config.
- An after_flush hook adds one to it for every config whose rows a flush
changed, in the same transaction. Each config registers what it owns
(register_ownership): models, optionally only some columns, optionally
only some rows (a Collection's kind; a Download's Collection's kind).
Raw SQL writes to owned tables count explicitly.
- dump_config writes the database's version, read with the rows it saves;
save() no longer adds one for these configs.
- An import compares versions first: file == database or newer imports
with deletes; file older (the database changed and was not saved before a
restart) saves the database instead of undoing that change; no database
row (a config this code never imported) imports without deletes. The
import records the file's version last.
- The Import button and backup imports apply the file whatever its version
(import_forced).
Importers only gate their existing deletes on the decision here; they are
otherwise unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Config versions: keep unsaved rows after a no-delete import, count every owned write
Review fixes:
- An import without deletes (no database version yet) no longer records the
file's version, which let the next startup delete the rows it had kept.
The version stays unknown, and the database is saved into the file instead;
a successful save records the version. A save after a partial import is
refused, so the version stays unknown until an import applies the whole file.
- An import removes the database's version while it runs, so its own writes
do not count: one that fails partway no longer looks newer than the file
and gets saved over it on the next startup.
- Playlists imported without deletes keep their database items in order; the
file's other items are added after their neighbor from the file.
- A Download leaving a Channel or Repo counts for that Collection's config
(collection_id loads its previous value).
- Bulk writes count: untagging, retrying Downloads, deleting a duplicate's
Download, removing many items from a playlist. Deleting Downloads by id
counts channels.yaml/repos.yaml only for their Downloads.
- Only a playlist's items count for playlists.yaml (author and subject
Collections have items too). Collection kinds are looked up once per flush.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Config versions count only the columns each config saves
A Repo's fetch (head_sha, last_fetch), a Channel's refresh (video_count,
info_json), a Collection's counts, and a Download attempt (attempts, error)
counted as config changes, so the next import of that config saved instead of
importing: a hand edit to repos.yaml was overwritten after any fetch. Each
model now registers only the columns its config saves.
domains.yaml saves its domains' Downloads, which now count for it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:58:07 -06:00
|
|
|
|
channels.yaml imports by diffing (#872)
* channels.yaml imports by diffing
channels.yaml declares two sections: Channels keyed by directory
(comparing name, URL, Tag, file format, and the source id and flags,
which keep the database's value when missing), and the Downloads listed
under each Channel, keyed by (directory, URL); an existing Download keeps
its own frequency. An unchanged import writes nothing (it ran about a
thousand queries and committed several times).
Removed Channels are deleted with their Collection (and Downloads);
a Download removed from a Channel is deleted. A channel Collection
without a Channel is deleted unless listed (then it gets its Channel).
A Channel whose directory changed, with the same name, is moved. An
invalid entry is skipped and counted, and keeps its Channel (by name when
its directory is invalid) and that Channel's Downloads; so does an entry
without a list of Downloads. Duplicate directories still refuse the
import. Unknown Tags are dropped once tags.yaml is imported.
New Downloads are created in the import's one write (commit=False on
Channel.get_or_create_download, Download.delete and delete_with_videos).
After the import, Videos are claimed and source ids fetched only for new
and changed Channels; Downloads are linked without a query per Download,
and not while entries are skipped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* channels.yaml: moves, invalid Downloads, and saves after the write
- A moved Channel's Download diff is retargeted to its new directory:
one its new entry lists is the same Download, one it does not is
deleted in the same import (the old directory was treated as kept).
- An unreadable Download is counted as skipped, and its Channel keeps
its Downloads (it was dropped, and the Channel's Download deleted).
- A new Download without a frequency is dropped before the write and
counted (it re-opened a write on every import).
- No source id is fetched while entries are skipped (its save would be
refused, leaving the database newer than the file); a fetched one is
saved with dump_config, which records the database's version.
- Download.delete(commit=False) neither changes the skip list nor starts
config saves, which would read the database before the import commits
(and write a removed Download back into channels.yaml). The import
saves download_manager.yaml after its write.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:09:51 -06:00
|
|
|
def _channel_rows_by_key(self) -> dict:
|
|
|
|
|
return ChannelsSection().file_rows(self._config)[0]
|
Database configs carry a version the database keeps too (#866)
* Benchmark every database config import (#865)
An opt-in test (WROLPI_CONFIG_BENCH=1) that builds a large library in bulk,
writes each config with its own dump_config, then times every import in
startup order: a steady pass (the database already matches) and a rebuild
(config-owned rows deleted, everything restored from the configs). It
prints seconds, SQL statements and writes per import, and asserts the
rebuild restored every row so no timed import silently skipped its work.
WROLPI_CONFIG_BENCH_SCALE multiplies the row counts.
The baseline for the config import rework.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Database configs carry a version the database keeps too
Step 1 of the config import rework. A database config (tags, downloads,
channels, domains, playlists, repos, recently viewed) and the database now
each carry an integer version, so an import can tell which side is newer
without a clock (a Raspberry Pi's resets at boot):
- config_version (new table) holds the database's version of each config.
- An after_flush hook adds one to it for every config whose rows a flush
changed, in the same transaction. Each config registers what it owns
(register_ownership): models, optionally only some columns, optionally
only some rows (a Collection's kind; a Download's Collection's kind).
Raw SQL writes to owned tables count explicitly.
- dump_config writes the database's version, read with the rows it saves;
save() no longer adds one for these configs.
- An import compares versions first: file == database or newer imports
with deletes; file older (the database changed and was not saved before a
restart) saves the database instead of undoing that change; no database
row (a config this code never imported) imports without deletes. The
import records the file's version last.
- The Import button and backup imports apply the file whatever its version
(import_forced).
Importers only gate their existing deletes on the decision here; they are
otherwise unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Config versions: keep unsaved rows after a no-delete import, count every owned write
Review fixes:
- An import without deletes (no database version yet) no longer records the
file's version, which let the next startup delete the rows it had kept.
The version stays unknown, and the database is saved into the file instead;
a successful save records the version. A save after a partial import is
refused, so the version stays unknown until an import applies the whole file.
- An import removes the database's version while it runs, so its own writes
do not count: one that fails partway no longer looks newer than the file
and gets saved over it on the next startup.
- Playlists imported without deletes keep their database items in order; the
file's other items are added after their neighbor from the file.
- A Download leaving a Channel or Repo counts for that Collection's config
(collection_id loads its previous value).
- Bulk writes count: untagging, retrying Downloads, deleting a duplicate's
Download, removing many items from a playlist. Deleting Downloads by id
counts channels.yaml/repos.yaml only for their Downloads.
- Only a playlist's items count for playlists.yaml (author and subject
Collections have items too). Collection kinds are looked up once per flush.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Config versions count only the columns each config saves
A Repo's fetch (head_sha, last_fetch), a Channel's refresh (video_count,
info_json), a Collection's counts, and a Download attempt (attempts, error)
counted as config changes, so the next import of that config saved instead of
importing: a hand edit to repos.yaml was overwritten after any fetch. Each
model now registers only the columns its config saves.
domains.yaml saves its domains' Downloads, which now count for it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:58:07 -06:00
|
|
|
|
channels.yaml imports by diffing (#872)
* channels.yaml imports by diffing
channels.yaml declares two sections: Channels keyed by directory
(comparing name, URL, Tag, file format, and the source id and flags,
which keep the database's value when missing), and the Downloads listed
under each Channel, keyed by (directory, URL); an existing Download keeps
its own frequency. An unchanged import writes nothing (it ran about a
thousand queries and committed several times).
Removed Channels are deleted with their Collection (and Downloads);
a Download removed from a Channel is deleted. A channel Collection
without a Channel is deleted unless listed (then it gets its Channel).
A Channel whose directory changed, with the same name, is moved. An
invalid entry is skipped and counted, and keeps its Channel (by name when
its directory is invalid) and that Channel's Downloads; so does an entry
without a list of Downloads. Duplicate directories still refuse the
import. Unknown Tags are dropped once tags.yaml is imported.
New Downloads are created in the import's one write (commit=False on
Channel.get_or_create_download, Download.delete and delete_with_videos).
After the import, Videos are claimed and source ids fetched only for new
and changed Channels; Downloads are linked without a query per Download,
and not while entries are skipped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* channels.yaml: moves, invalid Downloads, and saves after the write
- A moved Channel's Download diff is retargeted to its new directory:
one its new entry lists is the same Download, one it does not is
deleted in the same import (the old directory was treated as kept).
- An unreadable Download is counted as skipped, and its Channel keeps
its Downloads (it was dropped, and the Channel's Download deleted).
- A new Download without a frequency is dropped before the write and
counted (it re-opened a write on every import).
- No source id is fetched while entries are skipped (its save would be
refused, leaving the database newer than the file); a fetched one is
saved with dump_config, which records the database's version.
- Download.delete(commit=False) neither changes the skip list nor starts
config saves, which would read the database before the import commits
(and write a removed Download back into channels.yaml). The import
saves download_manager.yaml after its write.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:09:51 -06:00
|
|
|
def _channel_rows(self) -> list:
|
|
|
|
|
return list(self._channel_rows_by_key().values())
|
2025-12-05 19:49:39 -07:00
|
|
|
|
channels.yaml imports by diffing (#872)
* channels.yaml imports by diffing
channels.yaml declares two sections: Channels keyed by directory
(comparing name, URL, Tag, file format, and the source id and flags,
which keep the database's value when missing), and the Downloads listed
under each Channel, keyed by (directory, URL); an existing Download keeps
its own frequency. An unchanged import writes nothing (it ran about a
thousand queries and committed several times).
Removed Channels are deleted with their Collection (and Downloads);
a Download removed from a Channel is deleted. A channel Collection
without a Channel is deleted unless listed (then it gets its Channel).
A Channel whose directory changed, with the same name, is moved. An
invalid entry is skipped and counted, and keeps its Channel (by name when
its directory is invalid) and that Channel's Downloads; so does an entry
without a list of Downloads. Duplicate directories still refuse the
import. Unknown Tags are dropped once tags.yaml is imported.
New Downloads are created in the import's one write (commit=False on
Channel.get_or_create_download, Download.delete and delete_with_videos).
After the import, Videos are claimed and source ids fetched only for new
and changed Channels; Downloads are linked without a query per Download,
and not while entries are skipped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* channels.yaml: moves, invalid Downloads, and saves after the write
- A moved Channel's Download diff is retargeted to its new directory:
one its new entry lists is the same Download, one it does not is
deleted in the same import (the old directory was treated as kept).
- An unreadable Download is counted as skipped, and its Channel keeps
its Downloads (it was dropped, and the Channel's Download deleted).
- A new Download without a frequency is dropped before the write and
counted (it re-opened a write on every import).
- No source id is fetched while entries are skipped (its save would be
refused, leaving the database newer than the file); a fetched one is
saved with dump_config, which records the database's version.
- Download.delete(commit=False) neither changes the skip list nor starts
config saves, which would read the database before the import commits
(and write a removed Download back into channels.yaml). The import
saves download_manager.yaml after its write.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:09:51 -06:00
|
|
|
def after_import(self, diffs: dict):
|
|
|
|
|
"""Claim the Videos of new and changed Channels, find their missing source ids, and link Downloads."""
|
|
|
|
|
changed = {row.get('directory', key) for key, row in
|
|
|
|
|
{**diffs['channels'].added, **diffs['channels'].changed}.items()}
|
|
|
|
|
if changed:
|
|
|
|
|
media_directory = get_media_directory()
|
|
|
|
|
with get_db_session() as session:
|
|
|
|
|
updated = session.query(Channel.id, Channel.url, Channel.source_id, Channel.download_missing_data) \
|
|
|
|
|
.join(Collection, Collection.id == Channel.collection_id) \
|
|
|
|
|
.filter(Collection.directory.in_([str(media_directory / i) for i in changed])).all()
|
2025-12-05 19:49:39 -07:00
|
|
|
from modules.videos import claim_videos_for_channels
|
channels.yaml imports by diffing (#872)
* channels.yaml imports by diffing
channels.yaml declares two sections: Channels keyed by directory
(comparing name, URL, Tag, file format, and the source id and flags,
which keep the database's value when missing), and the Downloads listed
under each Channel, keyed by (directory, URL); an existing Download keeps
its own frequency. An unchanged import writes nothing (it ran about a
thousand queries and committed several times).
Removed Channels are deleted with their Collection (and Downloads);
a Download removed from a Channel is deleted. A channel Collection
without a Channel is deleted unless listed (then it gets its Channel).
A Channel whose directory changed, with the same name, is moved. An
invalid entry is skipped and counted, and keeps its Channel (by name when
its directory is invalid) and that Channel's Downloads; so does an entry
without a list of Downloads. Duplicate directories still refuse the
import. Unknown Tags are dropped once tags.yaml is imported.
New Downloads are created in the import's one write (commit=False on
Channel.get_or_create_download, Download.delete and delete_with_videos).
After the import, Videos are claimed and source ids fetched only for new
and changed Channels; Downloads are linked without a query per Download,
and not while entries are skipped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* channels.yaml: moves, invalid Downloads, and saves after the write
- A moved Channel's Download diff is retargeted to its new directory:
one its new entry lists is the same Download, one it does not is
deleted in the same import (the old directory was treated as kept).
- An unreadable Download is counted as skipped, and its Channel keeps
its Downloads (it was dropped, and the Channel's Download deleted).
- A new Download without a frequency is dropped before the write and
counted (it re-opened a write on every import).
- No source id is fetched while entries are skipped (its save would be
refused, leaving the database newer than the file); a fetched one is
saved with dump_config, which records the database's version.
- Download.delete(commit=False) neither changes the skip list nor starts
config saves, which would read the database before the import commits
(and write a removed Download back into channels.yaml). The import
saves download_manager.yaml after its write.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:09:51 -06:00
|
|
|
claim_videos_for_channels([i.id for i in updated])
|
|
|
|
|
# A fetched source id changes channels.yaml, which cannot be saved while it has skipped entries.
|
|
|
|
|
for id_, url, source_id, download_missing_data in [] if self.import_skipped else updated:
|
|
|
|
|
if not source_id and url and download_missing_data and flags.have_internet.is_set() \
|
|
|
|
|
and download_manager.can_download:
|
|
|
|
|
channel_import_logger.info(f'Fetching channel source id for Channel id={id_}')
|
|
|
|
|
background_task(fetch_channel_source_id(id_))
|
|
|
|
|
|
|
|
|
|
if self.import_skipped:
|
|
|
|
|
# Linking changes channels.yaml, which cannot be saved while it has skipped entries: the database would be
|
|
|
|
|
# newer than the file, and the next startup would save over them.
|
|
|
|
|
channel_import_logger.warning('Not linking Downloads to Channels while channels.yaml has skipped entries')
|
|
|
|
|
else:
|
2024-09-26 09:13:29 -06:00
|
|
|
with get_db_session() as session:
|
channels.yaml imports by diffing (#872)
* channels.yaml imports by diffing
channels.yaml declares two sections: Channels keyed by directory
(comparing name, URL, Tag, file format, and the source id and flags,
which keep the database's value when missing), and the Downloads listed
under each Channel, keyed by (directory, URL); an existing Download keeps
its own frequency. An unchanged import writes nothing (it ran about a
thousand queries and committed several times).
Removed Channels are deleted with their Collection (and Downloads);
a Download removed from a Channel is deleted. A channel Collection
without a Channel is deleted unless listed (then it gets its Channel).
A Channel whose directory changed, with the same name, is moved. An
invalid entry is skipped and counted, and keeps its Channel (by name when
its directory is invalid) and that Channel's Downloads; so does an entry
without a list of Downloads. Duplicate directories still refuse the
import. Unknown Tags are dropped once tags.yaml is imported.
New Downloads are created in the import's one write (commit=False on
Channel.get_or_create_download, Download.delete and delete_with_videos).
After the import, Videos are claimed and source ids fetched only for new
and changed Channels; Downloads are linked without a query per Download,
and not while entries are skipped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* channels.yaml: moves, invalid Downloads, and saves after the write
- A moved Channel's Download diff is retargeted to its new directory:
one its new entry lists is the same Download, one it does not is
deleted in the same import (the old directory was treated as kept).
- An unreadable Download is counted as skipped, and its Channel keeps
its Downloads (it was dropped, and the Channel's Download deleted).
- A new Download without a frequency is dropped before the write and
counted (it re-opened a write on every import).
- No source id is fetched while entries are skipped (its save would be
refused, leaving the database newer than the file); a fetched one is
saved with dump_config, which records the database's version.
- Download.delete(commit=False) neither changes the skip list nor starts
config saves, which would read the database before the import commits
(and write a removed Download back into channels.yaml). The import
saves download_manager.yaml after its write.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:09:51 -06:00
|
|
|
links = channel_download_links(session)
|
|
|
|
|
if links:
|
|
|
|
|
with get_db_session(commit=True) as session:
|
|
|
|
|
_link_downloads(session, links)
|
|
|
|
|
self._config_outdated = True
|
|
|
|
|
|
|
|
|
|
if self._config_outdated:
|
|
|
|
|
self.background_dump.activate_switch()
|
|
|
|
|
if diffs['downloads']:
|
|
|
|
|
# The import's Downloads changed download_manager.yaml; saved after the import's write commits.
|
|
|
|
|
save_downloads_config.activate_switch()
|
2024-09-26 09:13:29 -06:00
|
|
|
|
2025-12-06 14:47:52 -07:00
|
|
|
def dump_config(self, file: pathlib.Path = None, send_events=False, overwrite=False):
|
|
|
|
|
"""Dump all channels from database to config file."""
|
|
|
|
|
from wrolpi.collections import Collection
|
|
|
|
|
channel_import_logger.info('Dumping channels to config')
|
|
|
|
|
|
|
|
|
|
with get_db_session() as session:
|
Database configs carry a version the database keeps too (#866)
* Benchmark every database config import (#865)
An opt-in test (WROLPI_CONFIG_BENCH=1) that builds a large library in bulk,
writes each config with its own dump_config, then times every import in
startup order: a steady pass (the database already matches) and a rebuild
(config-owned rows deleted, everything restored from the configs). It
prints seconds, SQL statements and writes per import, and asserts the
rebuild restored every row so no timed import silently skipped its work.
WROLPI_CONFIG_BENCH_SCALE multiplies the row counts.
The baseline for the config import rework.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Database configs carry a version the database keeps too
Step 1 of the config import rework. A database config (tags, downloads,
channels, domains, playlists, repos, recently viewed) and the database now
each carry an integer version, so an import can tell which side is newer
without a clock (a Raspberry Pi's resets at boot):
- config_version (new table) holds the database's version of each config.
- An after_flush hook adds one to it for every config whose rows a flush
changed, in the same transaction. Each config registers what it owns
(register_ownership): models, optionally only some columns, optionally
only some rows (a Collection's kind; a Download's Collection's kind).
Raw SQL writes to owned tables count explicitly.
- dump_config writes the database's version, read with the rows it saves;
save() no longer adds one for these configs.
- An import compares versions first: file == database or newer imports
with deletes; file older (the database changed and was not saved before a
restart) saves the database instead of undoing that change; no database
row (a config this code never imported) imports without deletes. The
import records the file's version last.
- The Import button and backup imports apply the file whatever its version
(import_forced).
Importers only gate their existing deletes on the decision here; they are
otherwise unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Config versions: keep unsaved rows after a no-delete import, count every owned write
Review fixes:
- An import without deletes (no database version yet) no longer records the
file's version, which let the next startup delete the rows it had kept.
The version stays unknown, and the database is saved into the file instead;
a successful save records the version. A save after a partial import is
refused, so the version stays unknown until an import applies the whole file.
- An import removes the database's version while it runs, so its own writes
do not count: one that fails partway no longer looks newer than the file
and gets saved over it on the next startup.
- Playlists imported without deletes keep their database items in order; the
file's other items are added after their neighbor from the file.
- A Download leaving a Channel or Repo counts for that Collection's config
(collection_id loads its previous value).
- Bulk writes count: untagging, retrying Downloads, deleting a duplicate's
Download, removing many items from a playlist. Deleting Downloads by id
counts channels.yaml/repos.yaml only for their Downloads.
- Only a playlist's items count for playlists.yaml (author and subject
Collections have items too). Collection kinds are looked up once per flush.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Config versions count only the columns each config saves
A Repo's fetch (head_sha, last_fetch), a Channel's refresh (video_count,
info_json), a Collection's counts, and a Download attempt (attempts, error)
counted as config changes, so the next import of that config saved instead of
importing: a hand edit to repos.yaml was overwritten after any fetch. Each
model now registers only the columns its config saves.
domains.yaml saves its domains' Downloads, which now count for it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:58:07 -06:00
|
|
|
version = self.db_version_for_dump(session)
|
2025-12-06 14:47:52 -07:00
|
|
|
# Get all channels, ordered by collection name
|
|
|
|
|
channels = session.query(Channel).join(Collection).order_by(Collection.name).all()
|
|
|
|
|
|
|
|
|
|
# Use config_view to export each channel
|
|
|
|
|
channels_data = [channel.config_view() for channel in channels]
|
|
|
|
|
|
|
|
|
|
self._config['channels'] = channels_data
|
Database configs carry a version the database keeps too (#866)
* Benchmark every database config import (#865)
An opt-in test (WROLPI_CONFIG_BENCH=1) that builds a large library in bulk,
writes each config with its own dump_config, then times every import in
startup order: a steady pass (the database already matches) and a rebuild
(config-owned rows deleted, everything restored from the configs). It
prints seconds, SQL statements and writes per import, and asserts the
rebuild restored every row so no timed import silently skipped its work.
WROLPI_CONFIG_BENCH_SCALE multiplies the row counts.
The baseline for the config import rework.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Database configs carry a version the database keeps too
Step 1 of the config import rework. A database config (tags, downloads,
channels, domains, playlists, repos, recently viewed) and the database now
each carry an integer version, so an import can tell which side is newer
without a clock (a Raspberry Pi's resets at boot):
- config_version (new table) holds the database's version of each config.
- An after_flush hook adds one to it for every config whose rows a flush
changed, in the same transaction. Each config registers what it owns
(register_ownership): models, optionally only some columns, optionally
only some rows (a Collection's kind; a Download's Collection's kind).
Raw SQL writes to owned tables count explicitly.
- dump_config writes the database's version, read with the rows it saves;
save() no longer adds one for these configs.
- An import compares versions first: file == database or newer imports
with deletes; file older (the database changed and was not saved before a
restart) saves the database instead of undoing that change; no database
row (a config this code never imported) imports without deletes. The
import records the file's version last.
- The Import button and backup imports apply the file whatever its version
(import_forced).
Importers only gate their existing deletes on the decision here; they are
otherwise unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Config versions: keep unsaved rows after a no-delete import, count every owned write
Review fixes:
- An import without deletes (no database version yet) no longer records the
file's version, which let the next startup delete the rows it had kept.
The version stays unknown, and the database is saved into the file instead;
a successful save records the version. A save after a partial import is
refused, so the version stays unknown until an import applies the whole file.
- An import removes the database's version while it runs, so its own writes
do not count: one that fails partway no longer looks newer than the file
and gets saved over it on the next startup.
- Playlists imported without deletes keep their database items in order; the
file's other items are added after their neighbor from the file.
- A Download leaving a Channel or Repo counts for that Collection's config
(collection_id loads its previous value).
- Bulk writes count: untagging, retrying Downloads, deleting a duplicate's
Download, removing many items from a playlist. Deleting Downloads by id
counts channels.yaml/repos.yaml only for their Downloads.
- Only a playlist's items count for playlists.yaml (author and subject
Collections have items too). Collection kinds are looked up once per flush.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Config versions count only the columns each config saves
A Repo's fetch (head_sha, last_fetch), a Channel's refresh (video_count,
info_json), a Collection's counts, and a Download attempt (attempts, error)
counted as config changes, so the next import of that config saved instead of
importing: a hand edit to repos.yaml was overwritten after any fetch. Each
model now registers only the columns its config saves.
domains.yaml saves its domains' Downloads, which now count for it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:58:07 -06:00
|
|
|
self._config['version'] = version
|
2025-12-06 14:47:52 -07:00
|
|
|
|
|
|
|
|
channel_import_logger.info(f'Dumping {len(channels_data)} channels to config')
|
|
|
|
|
self.save(file, send_events, overwrite)
|
|
|
|
|
|
2026-03-09 16:11:44 -06:00
|
|
|
def preview_backup_import(self, backup_date: str, mode: str) -> dict:
|
|
|
|
|
backup_file = self._get_backup_file(backup_date)
|
|
|
|
|
backup_data = self.read_config_file(backup_file)
|
|
|
|
|
current_data = self.read_config_file() if self.get_file().is_file() else dict(channels=[], version=0)
|
|
|
|
|
|
|
|
|
|
backup_channels = backup_data.get('channels', [])
|
|
|
|
|
current_channels = current_data.get('channels', [])
|
|
|
|
|
|
|
|
|
|
current_dirs = {ch.get('directory') for ch in current_channels if ch.get('directory')}
|
|
|
|
|
backup_dirs = {ch.get('directory') for ch in backup_channels if ch.get('directory')}
|
|
|
|
|
|
|
|
|
|
add = []
|
|
|
|
|
remove = []
|
|
|
|
|
unchanged = 0
|
|
|
|
|
|
|
|
|
|
for ch in backup_channels:
|
|
|
|
|
d = ch.get('directory')
|
|
|
|
|
if d and d not in current_dirs:
|
|
|
|
|
add.append(dict(type='channel', name=ch.get('name', ''), directory=d))
|
|
|
|
|
elif d:
|
|
|
|
|
unchanged += 1
|
|
|
|
|
if mode == 'overwrite':
|
|
|
|
|
backup_dir_to_name = {ch.get('directory'): ch.get('name', '') for ch in current_channels}
|
|
|
|
|
for d in sorted(current_dirs - backup_dirs):
|
|
|
|
|
remove.append(dict(type='channel', name=backup_dir_to_name.get(d, ''), directory=d))
|
|
|
|
|
|
|
|
|
|
return dict(mode=mode, add=add, remove=remove, unchanged=unchanged)
|
|
|
|
|
|
|
|
|
|
def import_backup(self, backup_date: str, mode: str, send_events: bool = False):
|
|
|
|
|
self._preserve_current_config()
|
|
|
|
|
import shutil
|
|
|
|
|
backup_file = self._get_backup_file(backup_date)
|
|
|
|
|
config_file = self.get_file()
|
|
|
|
|
|
|
|
|
|
if mode == 'overwrite':
|
|
|
|
|
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
shutil.copy2(backup_file, config_file)
|
|
|
|
|
elif mode == 'merge':
|
|
|
|
|
backup_data = self.read_config_file(backup_file)
|
|
|
|
|
current_data = self.read_config_file() if config_file.is_file() else dict(channels=[], version=0)
|
|
|
|
|
|
|
|
|
|
current_dirs = {ch.get('directory') for ch in current_data.get('channels', []) if ch.get('directory')}
|
|
|
|
|
merged_channels = list(current_data.get('channels', []))
|
|
|
|
|
for ch in backup_data.get('channels', []):
|
|
|
|
|
if ch.get('directory') and ch['directory'] not in current_dirs:
|
|
|
|
|
merged_channels.append(ch)
|
|
|
|
|
|
|
|
|
|
merged_data = dict(
|
|
|
|
|
channels=merged_channels,
|
|
|
|
|
version=current_data.get('version', 0) + 1,
|
|
|
|
|
)
|
|
|
|
|
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
self.write_config_data(merged_data, config_file)
|
|
|
|
|
|
Database configs carry a version the database keeps too (#866)
* Benchmark every database config import (#865)
An opt-in test (WROLPI_CONFIG_BENCH=1) that builds a large library in bulk,
writes each config with its own dump_config, then times every import in
startup order: a steady pass (the database already matches) and a rebuild
(config-owned rows deleted, everything restored from the configs). It
prints seconds, SQL statements and writes per import, and asserts the
rebuild restored every row so no timed import silently skipped its work.
WROLPI_CONFIG_BENCH_SCALE multiplies the row counts.
The baseline for the config import rework.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Database configs carry a version the database keeps too
Step 1 of the config import rework. A database config (tags, downloads,
channels, domains, playlists, repos, recently viewed) and the database now
each carry an integer version, so an import can tell which side is newer
without a clock (a Raspberry Pi's resets at boot):
- config_version (new table) holds the database's version of each config.
- An after_flush hook adds one to it for every config whose rows a flush
changed, in the same transaction. Each config registers what it owns
(register_ownership): models, optionally only some columns, optionally
only some rows (a Collection's kind; a Download's Collection's kind).
Raw SQL writes to owned tables count explicitly.
- dump_config writes the database's version, read with the rows it saves;
save() no longer adds one for these configs.
- An import compares versions first: file == database or newer imports
with deletes; file older (the database changed and was not saved before a
restart) saves the database instead of undoing that change; no database
row (a config this code never imported) imports without deletes. The
import records the file's version last.
- The Import button and backup imports apply the file whatever its version
(import_forced).
Importers only gate their existing deletes on the decision here; they are
otherwise unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Config versions: keep unsaved rows after a no-delete import, count every owned write
Review fixes:
- An import without deletes (no database version yet) no longer records the
file's version, which let the next startup delete the rows it had kept.
The version stays unknown, and the database is saved into the file instead;
a successful save records the version. A save after a partial import is
refused, so the version stays unknown until an import applies the whole file.
- An import removes the database's version while it runs, so its own writes
do not count: one that fails partway no longer looks newer than the file
and gets saved over it on the next startup.
- Playlists imported without deletes keep their database items in order; the
file's other items are added after their neighbor from the file.
- A Download leaving a Channel or Repo counts for that Collection's config
(collection_id loads its previous value).
- Bulk writes count: untagging, retrying Downloads, deleting a duplicate's
Download, removing many items from a playlist. Deleting Downloads by id
counts channels.yaml/repos.yaml only for their Downloads.
- Only a playlist's items count for playlists.yaml (author and subject
Collections have items too). Collection kinds are looked up once per flush.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Config versions count only the columns each config saves
A Repo's fetch (head_sha, last_fetch), a Channel's refresh (video_count,
info_json), a Collection's counts, and a Download attempt (attempts, error)
counted as config changes, so the next import of that config saved instead of
importing: a hand edit to repos.yaml was overwritten after any fetch. Each
model now registers only the columns its config saves.
domains.yaml saves its domains' Downloads, which now count for it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:58:07 -06:00
|
|
|
# Importing a backup is the user's choice: apply it whatever the versions.
|
|
|
|
|
self.import_forced(send_events=send_events)
|
2026-03-09 16:11:44 -06:00
|
|
|
|
2022-02-16 21:57:27 -07:00
|
|
|
|
2023-04-18 22:03:45 -06:00
|
|
|
CHANNELS_CONFIG: ChannelsConfig = ChannelsConfig()
|
Database configs carry a version the database keeps too (#866)
* Benchmark every database config import (#865)
An opt-in test (WROLPI_CONFIG_BENCH=1) that builds a large library in bulk,
writes each config with its own dump_config, then times every import in
startup order: a steady pass (the database already matches) and a rebuild
(config-owned rows deleted, everything restored from the configs). It
prints seconds, SQL statements and writes per import, and asserts the
rebuild restored every row so no timed import silently skipped its work.
WROLPI_CONFIG_BENCH_SCALE multiplies the row counts.
The baseline for the config import rework.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Database configs carry a version the database keeps too
Step 1 of the config import rework. A database config (tags, downloads,
channels, domains, playlists, repos, recently viewed) and the database now
each carry an integer version, so an import can tell which side is newer
without a clock (a Raspberry Pi's resets at boot):
- config_version (new table) holds the database's version of each config.
- An after_flush hook adds one to it for every config whose rows a flush
changed, in the same transaction. Each config registers what it owns
(register_ownership): models, optionally only some columns, optionally
only some rows (a Collection's kind; a Download's Collection's kind).
Raw SQL writes to owned tables count explicitly.
- dump_config writes the database's version, read with the rows it saves;
save() no longer adds one for these configs.
- An import compares versions first: file == database or newer imports
with deletes; file older (the database changed and was not saved before a
restart) saves the database instead of undoing that change; no database
row (a config this code never imported) imports without deletes. The
import records the file's version last.
- The Import button and backup imports apply the file whatever its version
(import_forced).
Importers only gate their existing deletes on the decision here; they are
otherwise unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Config versions: keep unsaved rows after a no-delete import, count every owned write
Review fixes:
- An import without deletes (no database version yet) no longer records the
file's version, which let the next startup delete the rows it had kept.
The version stays unknown, and the database is saved into the file instead;
a successful save records the version. A save after a partial import is
refused, so the version stays unknown until an import applies the whole file.
- An import removes the database's version while it runs, so its own writes
do not count: one that fails partway no longer looks newer than the file
and gets saved over it on the next startup.
- Playlists imported without deletes keep their database items in order; the
file's other items are added after their neighbor from the file.
- A Download leaving a Channel or Repo counts for that Collection's config
(collection_id loads its previous value).
- Bulk writes count: untagging, retrying Downloads, deleting a duplicate's
Download, removing many items from a playlist. Deleting Downloads by id
counts channels.yaml/repos.yaml only for their Downloads.
- Only a playlist's items count for playlists.yaml (author and subject
Collections have items too). Collection kinds are looked up once per flush.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Config versions count only the columns each config saves
A Repo's fetch (head_sha, last_fetch), a Channel's refresh (video_count,
info_json), a Collection's counts, and a Download attempt (attempts, error)
counted as config changes, so the next import of that config saved instead of
importing: a hand edit to repos.yaml was overwritten after any fetch. Each
model now registers only the columns its config saves.
domains.yaml saves its domains' Downloads, which now count for it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:58:07 -06:00
|
|
|
# Only the columns channels.yaml saves (not a refresh's counts, info_json, ...); its Collection saves the rest.
|
|
|
|
|
register_ownership(ChannelsConfig.file_name, Channel,
|
|
|
|
|
columns={'url', 'source_id', 'generate_posters', 'calculate_duration', 'download_missing_data',
|
|
|
|
|
'collection_id'})
|
2022-02-16 21:57:27 -07:00
|
|
|
TEST_CHANNELS_CONFIG: ChannelsConfig = None
|
|
|
|
|
|
|
|
|
|
|
2023-03-29 13:46:39 -06:00
|
|
|
def get_channels_config() -> ChannelsConfig:
|
2022-02-16 21:57:27 -07:00
|
|
|
global TEST_CHANNELS_CONFIG
|
2024-04-17 11:25:26 -06:00
|
|
|
if PYTEST and not TEST_CHANNELS_CONFIG:
|
|
|
|
|
logger.warning('Test did not initialize the channels config')
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if TEST_CHANNELS_CONFIG:
|
2022-02-16 21:57:27 -07:00
|
|
|
return TEST_CHANNELS_CONFIG
|
|
|
|
|
|
|
|
|
|
global CHANNELS_CONFIG
|
|
|
|
|
return CHANNELS_CONFIG
|
|
|
|
|
|
|
|
|
|
|
2023-03-24 08:58:18 -06:00
|
|
|
@contextlib.contextmanager
|
|
|
|
|
def set_test_channels_config():
|
2022-02-16 21:57:27 -07:00
|
|
|
global TEST_CHANNELS_CONFIG
|
2023-03-24 08:58:18 -06:00
|
|
|
TEST_CHANNELS_CONFIG = ChannelsConfig()
|
2024-04-17 11:25:26 -06:00
|
|
|
TEST_CHANNELS_CONFIG.initialize()
|
|
|
|
|
yield TEST_CHANNELS_CONFIG
|
2023-03-24 08:58:18 -06:00
|
|
|
TEST_CHANNELS_CONFIG = None
|
2022-02-16 21:57:27 -07:00
|
|
|
|
|
|
|
|
|
2024-09-28 17:28:33 -06:00
|
|
|
@dataclass
|
2024-11-02 15:14:02 -06:00
|
|
|
class VideoDownloaderConfigYtDlpOptionsValidator:
|
2024-09-28 17:28:33 -06:00
|
|
|
continue_dl: bool
|
|
|
|
|
file_name_format: str
|
|
|
|
|
nooverwrites: bool
|
|
|
|
|
quiet: bool
|
2024-11-29 17:01:16 -07:00
|
|
|
merge_output_format: str
|
2024-09-28 17:28:33 -06:00
|
|
|
writeautomaticsub: bool
|
|
|
|
|
writeinfojson: bool
|
|
|
|
|
writesubtitles: bool
|
|
|
|
|
writethumbnail: bool
|
|
|
|
|
youtube_include_dash_manifest: bool
|
2024-11-02 15:14:02 -06:00
|
|
|
|
2024-11-29 17:01:16 -07:00
|
|
|
def __post_init__(self):
|
|
|
|
|
from modules.videos.downloader import preview_filename
|
|
|
|
|
try:
|
|
|
|
|
preview_filename(self.file_name_format)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
raise ValueError(f'file_name_format is invalid: {str(e)}')
|
|
|
|
|
|
2024-11-02 15:14:02 -06:00
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class VideoDownloaderConfigValidator:
|
2024-11-28 13:28:23 -07:00
|
|
|
video_resolutions: List[str] = field(default_factory=lambda: ['1080p', '720p', '480p', 'maximum'])
|
2024-09-28 17:28:33 -06:00
|
|
|
version: int = None
|
2024-11-02 15:14:02 -06:00
|
|
|
yt_dlp_options: VideoDownloaderConfigYtDlpOptionsValidator = field(default_factory=dict)
|
2025-04-17 17:02:39 -06:00
|
|
|
yt_dlp_extra_args: str = ''
|
2026-02-19 16:15:18 -07:00
|
|
|
download_missing_video_comments: bool = False
|
2026-02-20 14:37:58 -07:00
|
|
|
user_agent: str = ''
|
|
|
|
|
sleep_requests: float = 0.75
|
2026-04-14 15:00:49 -06:00
|
|
|
audio_only: bool = False
|
|
|
|
|
audio_format: str = 'mp3'
|
videos: codec download preferences with transcode and strict-fail options (#701)
* videos: codec download preferences with transcode and strict-fail options
Add video_codecs/audio_codecs ordered preference lists to the video,
channel, and RSS downloaders, alongside the existing resolution
selector. Codec preference is codec-major: a preferred codec at a
lower resolution beats another codec at a higher resolution.
When 'transcode' is enabled, a downloaded file whose streams do not
match the preferences is re-encoded with ffmpeg after download (only
the mismatching stream; targets are extensible maps, currently h264
and aac/opus/mp3). When 'strict_codecs' is enabled and transcode is
not, a download with no matching format fails permanently instead of
falling back.
Empty codec lists produce a byte-identical yt-dlp command to before.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* videos: honor strict_codecs when transcode cannot produce the preference
Review fixes:
- strict_codecs is now suppressed only when transcoding can produce a
preferred codec for every non-empty preference list (new
transcode_can_satisfy_codecs); av1-only preferences with transcode
enabled fail under strict instead of silently keeping the file.
- No partial transcode: an unfixable video mismatch blocks an
audio-only conversion (and vice versa); the mismatch falls through
to strict/keep.
- fsync the transcode output before rename, and the directory after,
so power loss on a Pi cannot leave a truncated final file.
- mp3 yt-dlp pattern matches mp4a.6B as well as mp4a.6b.
- ffmpeg maps use 0:V:0 (excludes attached pictures) and 0:a:0?
(tolerates missing audio).
- Strict toggle is no longer disabled by transcode in the UI; popup
wording corrected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* videos: serialize transcodes machine-wide via shared_ctx lock
Sanic runs multiple worker processes, so an asyncio primitive cannot
serialize transcodes across download workers. A multiprocessing.Lock
in shared_ctx now guarantees only one ffmpeg transcode runs at a time
on the machine; waiting workers poll the lock asynchronously so the
event loop stays responsive while a transcode holds it for hours.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* videos: transcode-lock review fixes
- Document that a crashed lock holder requires an API restart; this is
accepted rather than adding owner-death recovery.
- Move the 'Transcoding' user notification to after the lock is
acquired, so it fires when work begins rather than before a
potentially hours-long wait.
- Soften the lock test docstring to what it measures (in-process
wait/release); trim the acquire docstring to the polling rationale.
- Give transcode_lock its own section header in contexts.py.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-07 09:11:39 -06:00
|
|
|
video_codecs: List[str] = field(default_factory=list)
|
|
|
|
|
audio_codecs: List[str] = field(default_factory=list)
|
|
|
|
|
transcode: bool = False
|
|
|
|
|
strict_codecs: bool = False
|
2024-09-28 17:28:33 -06:00
|
|
|
|
2024-11-29 17:01:16 -07:00
|
|
|
def __post_init__(self):
|
|
|
|
|
allowed_fields = {i.name for i in dataclasses.fields(VideoDownloaderConfigYtDlpOptionsValidator)}
|
|
|
|
|
yt_dlp_options = {k: v for k, v in dict(self.yt_dlp_options).items() if k in allowed_fields}
|
|
|
|
|
VideoDownloaderConfigYtDlpOptionsValidator(**yt_dlp_options)
|
|
|
|
|
self.yt_dlp_options = yt_dlp_options
|
|
|
|
|
|
2024-09-28 17:28:33 -06:00
|
|
|
|
2022-04-02 16:01:43 -06:00
|
|
|
class VideoDownloaderConfig(ConfigFile):
|
|
|
|
|
file_name = 'videos_downloader.yaml'
|
2022-02-16 21:57:27 -07:00
|
|
|
default_config = dict(
|
2024-11-28 13:28:23 -07:00
|
|
|
video_resolutions=['1080p', '720p', '480p', 'maximum'],
|
2024-09-26 09:13:29 -06:00
|
|
|
version=0,
|
2024-11-02 15:14:02 -06:00
|
|
|
yt_dlp_options=dict(
|
|
|
|
|
continue_dl=True,
|
|
|
|
|
file_name_format='%(uploader)s_%(upload_date)s_%(id)s_%(title)s.%(ext)s',
|
|
|
|
|
merge_output_format='mp4',
|
|
|
|
|
nooverwrites=True,
|
|
|
|
|
quiet=False,
|
|
|
|
|
writeautomaticsub=True,
|
|
|
|
|
writeinfojson=True,
|
|
|
|
|
writesubtitles=True,
|
|
|
|
|
writethumbnail=True,
|
|
|
|
|
youtube_include_dash_manifest=False,
|
|
|
|
|
),
|
2025-04-17 17:02:39 -06:00
|
|
|
yt_dlp_extra_args='',
|
2026-02-19 16:15:18 -07:00
|
|
|
download_missing_video_comments=False,
|
2026-02-20 14:37:58 -07:00
|
|
|
user_agent='',
|
|
|
|
|
sleep_requests=0.75,
|
2026-04-14 15:00:49 -06:00
|
|
|
audio_only=False,
|
|
|
|
|
audio_format='mp3',
|
videos: codec download preferences with transcode and strict-fail options (#701)
* videos: codec download preferences with transcode and strict-fail options
Add video_codecs/audio_codecs ordered preference lists to the video,
channel, and RSS downloaders, alongside the existing resolution
selector. Codec preference is codec-major: a preferred codec at a
lower resolution beats another codec at a higher resolution.
When 'transcode' is enabled, a downloaded file whose streams do not
match the preferences is re-encoded with ffmpeg after download (only
the mismatching stream; targets are extensible maps, currently h264
and aac/opus/mp3). When 'strict_codecs' is enabled and transcode is
not, a download with no matching format fails permanently instead of
falling back.
Empty codec lists produce a byte-identical yt-dlp command to before.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* videos: honor strict_codecs when transcode cannot produce the preference
Review fixes:
- strict_codecs is now suppressed only when transcoding can produce a
preferred codec for every non-empty preference list (new
transcode_can_satisfy_codecs); av1-only preferences with transcode
enabled fail under strict instead of silently keeping the file.
- No partial transcode: an unfixable video mismatch blocks an
audio-only conversion (and vice versa); the mismatch falls through
to strict/keep.
- fsync the transcode output before rename, and the directory after,
so power loss on a Pi cannot leave a truncated final file.
- mp3 yt-dlp pattern matches mp4a.6B as well as mp4a.6b.
- ffmpeg maps use 0:V:0 (excludes attached pictures) and 0:a:0?
(tolerates missing audio).
- Strict toggle is no longer disabled by transcode in the UI; popup
wording corrected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* videos: serialize transcodes machine-wide via shared_ctx lock
Sanic runs multiple worker processes, so an asyncio primitive cannot
serialize transcodes across download workers. A multiprocessing.Lock
in shared_ctx now guarantees only one ffmpeg transcode runs at a time
on the machine; waiting workers poll the lock asynchronously so the
event loop stays responsive while a transcode holds it for hours.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* videos: transcode-lock review fixes
- Document that a crashed lock holder requires an API restart; this is
accepted rather than adding owner-death recovery.
- Move the 'Transcoding' user notification to after the lock is
acquired, so it fires when work begins rather than before a
potentially hours-long wait.
- Soften the lock test docstring to what it measures (in-process
wait/release); trim the acquire docstring to the polling rationale.
- Give transcode_lock its own section header in contexts.py.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-07 09:11:39 -06:00
|
|
|
video_codecs=[],
|
|
|
|
|
audio_codecs=[],
|
|
|
|
|
transcode=False,
|
|
|
|
|
strict_codecs=False,
|
2022-02-16 21:57:27 -07:00
|
|
|
)
|
2024-09-28 17:28:33 -06:00
|
|
|
validator = VideoDownloaderConfigValidator
|
2022-02-16 21:57:27 -07:00
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def continue_dl(self) -> bool:
|
2024-11-02 15:14:02 -06:00
|
|
|
return self._config['yt_dlp_options']['continue_dl']
|
2022-02-16 21:57:27 -07:00
|
|
|
|
|
|
|
|
@property
|
2024-11-02 15:14:02 -06:00
|
|
|
def video_resolutions(self) -> List[str]:
|
|
|
|
|
return self._config['video_resolutions']
|
2022-02-16 21:57:27 -07:00
|
|
|
|
2024-11-02 15:14:02 -06:00
|
|
|
@video_resolutions.setter
|
|
|
|
|
def video_resolutions(self, value: List[str]):
|
|
|
|
|
self.update({'video_resolutions': value})
|
2022-02-16 21:57:27 -07:00
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def file_name_format(self) -> str:
|
2024-11-02 15:14:02 -06:00
|
|
|
return self._config['yt_dlp_options']['file_name_format']
|
2022-02-16 21:57:27 -07:00
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def nooverwrites(self) -> bool:
|
2024-11-02 15:14:02 -06:00
|
|
|
return self._config['yt_dlp_options']['nooverwrites']
|
2022-02-16 21:57:27 -07:00
|
|
|
|
2024-11-02 15:14:02 -06:00
|
|
|
@property
|
2026-03-05 16:18:03 -07:00
|
|
|
def merge_output_format(self) -> str:
|
2024-11-02 15:14:02 -06:00
|
|
|
return self._config['yt_dlp_options']['merge_output_format']
|
2022-02-16 21:57:27 -07:00
|
|
|
|
2026-03-05 16:18:03 -07:00
|
|
|
@property
|
|
|
|
|
def video_format(self) -> str:
|
|
|
|
|
"""Alias for merge_output_format, matching the name used in Download.settings."""
|
|
|
|
|
return self.merge_output_format
|
|
|
|
|
|
2022-02-16 21:57:27 -07:00
|
|
|
@property
|
|
|
|
|
def quiet(self) -> bool:
|
2024-11-02 15:14:02 -06:00
|
|
|
return self._config['yt_dlp_options']['quiet']
|
2022-02-16 21:57:27 -07:00
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def writeautomaticsub(self) -> bool:
|
2024-11-02 15:14:02 -06:00
|
|
|
return self._config['yt_dlp_options']['writeautomaticsub']
|
2022-02-16 21:57:27 -07:00
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def writeinfojson(self) -> bool:
|
2024-11-02 15:14:02 -06:00
|
|
|
return self._config['yt_dlp_options']['writeinfojson']
|
2022-02-16 21:57:27 -07:00
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def writesubtitles(self) -> bool:
|
2024-11-02 15:14:02 -06:00
|
|
|
return self._config['yt_dlp_options']['writesubtitles']
|
2022-02-16 21:57:27 -07:00
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def writethumbnail(self) -> bool:
|
2024-11-02 15:14:02 -06:00
|
|
|
return self._config['yt_dlp_options']['writethumbnail']
|
2022-02-16 21:57:27 -07:00
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def youtube_include_dash_manifest(self) -> bool:
|
2024-11-02 15:14:02 -06:00
|
|
|
return self._config['yt_dlp_options']['youtube_include_dash_manifest']
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def yt_dlp_options(self) -> dict:
|
|
|
|
|
return self._config['yt_dlp_options']
|
2022-02-16 21:57:27 -07:00
|
|
|
|
2024-11-02 15:14:02 -06:00
|
|
|
@yt_dlp_options.setter
|
|
|
|
|
def yt_dlp_options(self, value: dict):
|
|
|
|
|
self.update({'yt_dlp_options': value})
|
2022-02-16 21:57:27 -07:00
|
|
|
|
2025-04-17 17:02:39 -06:00
|
|
|
@property
|
|
|
|
|
def yt_dlp_extra_args(self) -> str:
|
|
|
|
|
return self._config['yt_dlp_extra_args']
|
|
|
|
|
|
|
|
|
|
@yt_dlp_extra_args.setter
|
|
|
|
|
def yt_dlp_extra_args(self, value: str):
|
|
|
|
|
self.update({**self._config, 'yt_dlp_extra_args': value})
|
|
|
|
|
|
|
|
|
|
@property
|
2026-02-20 14:37:58 -07:00
|
|
|
def download_missing_video_comments(self) -> bool:
|
|
|
|
|
return self._config['download_missing_video_comments']
|
2025-04-17 17:02:39 -06:00
|
|
|
|
2026-02-20 14:37:58 -07:00
|
|
|
@download_missing_video_comments.setter
|
|
|
|
|
def download_missing_video_comments(self, value: bool):
|
|
|
|
|
self.update({**self._config, 'download_missing_video_comments': value})
|
2025-04-17 17:02:39 -06:00
|
|
|
|
|
|
|
|
@property
|
2026-02-20 14:37:58 -07:00
|
|
|
def user_agent(self) -> str:
|
|
|
|
|
return self._config.get('user_agent', '')
|
2025-04-17 17:02:39 -06:00
|
|
|
|
2026-02-20 14:37:58 -07:00
|
|
|
@user_agent.setter
|
|
|
|
|
def user_agent(self, value: str):
|
|
|
|
|
self.update({**self._config, 'user_agent': value})
|
2025-04-17 17:02:39 -06:00
|
|
|
|
2026-02-19 16:15:18 -07:00
|
|
|
@property
|
2026-02-20 14:37:58 -07:00
|
|
|
def sleep_requests(self) -> float:
|
2026-05-31 12:43:56 -06:00
|
|
|
try:
|
|
|
|
|
return float(self._config.get('sleep_requests', 0.75))
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
return 0.75
|
2026-02-19 16:15:18 -07:00
|
|
|
|
2026-02-20 14:37:58 -07:00
|
|
|
@sleep_requests.setter
|
|
|
|
|
def sleep_requests(self, value: float):
|
|
|
|
|
self.update({**self._config, 'sleep_requests': value})
|
2026-02-19 16:15:18 -07:00
|
|
|
|
2026-04-14 15:00:49 -06:00
|
|
|
@property
|
|
|
|
|
def audio_only(self) -> bool:
|
|
|
|
|
return self._config.get('audio_only', False)
|
|
|
|
|
|
|
|
|
|
@audio_only.setter
|
|
|
|
|
def audio_only(self, value: bool):
|
|
|
|
|
self.update({**self._config, 'audio_only': value})
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def audio_format(self) -> str:
|
|
|
|
|
return self._config.get('audio_format', 'mp3')
|
|
|
|
|
|
|
|
|
|
@audio_format.setter
|
|
|
|
|
def audio_format(self, value: str):
|
|
|
|
|
self.update({**self._config, 'audio_format': value})
|
|
|
|
|
|
videos: codec download preferences with transcode and strict-fail options (#701)
* videos: codec download preferences with transcode and strict-fail options
Add video_codecs/audio_codecs ordered preference lists to the video,
channel, and RSS downloaders, alongside the existing resolution
selector. Codec preference is codec-major: a preferred codec at a
lower resolution beats another codec at a higher resolution.
When 'transcode' is enabled, a downloaded file whose streams do not
match the preferences is re-encoded with ffmpeg after download (only
the mismatching stream; targets are extensible maps, currently h264
and aac/opus/mp3). When 'strict_codecs' is enabled and transcode is
not, a download with no matching format fails permanently instead of
falling back.
Empty codec lists produce a byte-identical yt-dlp command to before.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* videos: honor strict_codecs when transcode cannot produce the preference
Review fixes:
- strict_codecs is now suppressed only when transcoding can produce a
preferred codec for every non-empty preference list (new
transcode_can_satisfy_codecs); av1-only preferences with transcode
enabled fail under strict instead of silently keeping the file.
- No partial transcode: an unfixable video mismatch blocks an
audio-only conversion (and vice versa); the mismatch falls through
to strict/keep.
- fsync the transcode output before rename, and the directory after,
so power loss on a Pi cannot leave a truncated final file.
- mp3 yt-dlp pattern matches mp4a.6B as well as mp4a.6b.
- ffmpeg maps use 0:V:0 (excludes attached pictures) and 0:a:0?
(tolerates missing audio).
- Strict toggle is no longer disabled by transcode in the UI; popup
wording corrected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* videos: serialize transcodes machine-wide via shared_ctx lock
Sanic runs multiple worker processes, so an asyncio primitive cannot
serialize transcodes across download workers. A multiprocessing.Lock
in shared_ctx now guarantees only one ffmpeg transcode runs at a time
on the machine; waiting workers poll the lock asynchronously so the
event loop stays responsive while a transcode holds it for hours.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* videos: transcode-lock review fixes
- Document that a crashed lock holder requires an API restart; this is
accepted rather than adding owner-death recovery.
- Move the 'Transcoding' user notification to after the lock is
acquired, so it fires when work begins rather than before a
potentially hours-long wait.
- Soften the lock test docstring to what it measures (in-process
wait/release); trim the acquire docstring to the polling rationale.
- Give transcode_lock its own section header in contexts.py.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-07 09:11:39 -06:00
|
|
|
@property
|
|
|
|
|
def video_codecs(self) -> List[str]:
|
|
|
|
|
return self._config.get('video_codecs', [])
|
|
|
|
|
|
|
|
|
|
@video_codecs.setter
|
|
|
|
|
def video_codecs(self, value: List[str]):
|
|
|
|
|
self.update({**self._config, 'video_codecs': value})
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def audio_codecs(self) -> List[str]:
|
|
|
|
|
return self._config.get('audio_codecs', [])
|
|
|
|
|
|
|
|
|
|
@audio_codecs.setter
|
|
|
|
|
def audio_codecs(self, value: List[str]):
|
|
|
|
|
self.update({**self._config, 'audio_codecs': value})
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def transcode(self) -> bool:
|
|
|
|
|
return self._config.get('transcode', False)
|
|
|
|
|
|
|
|
|
|
@transcode.setter
|
|
|
|
|
def transcode(self, value: bool):
|
|
|
|
|
self.update({**self._config, 'transcode': value})
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def strict_codecs(self) -> bool:
|
|
|
|
|
return self._config.get('strict_codecs', False)
|
|
|
|
|
|
|
|
|
|
@strict_codecs.setter
|
|
|
|
|
def strict_codecs(self, value: bool):
|
|
|
|
|
self.update({**self._config, 'strict_codecs': value})
|
|
|
|
|
|
2022-02-16 21:57:27 -07:00
|
|
|
|
2024-11-28 13:28:23 -07:00
|
|
|
VIDEOS_DOWNLOADER_CONFIG: VideoDownloaderConfig = VideoDownloaderConfig()
|
|
|
|
|
TEST_VIDEOS_DOWNLOADER_CONFIG: VideoDownloaderConfig = None
|
2022-02-16 21:57:27 -07:00
|
|
|
|
|
|
|
|
|
2024-11-28 13:28:23 -07:00
|
|
|
def get_videos_downloader_config() -> VideoDownloaderConfig:
|
|
|
|
|
global TEST_VIDEOS_DOWNLOADER_CONFIG
|
|
|
|
|
if isinstance(TEST_VIDEOS_DOWNLOADER_CONFIG, VideoDownloaderConfig):
|
|
|
|
|
return TEST_VIDEOS_DOWNLOADER_CONFIG
|
2022-02-16 21:57:27 -07:00
|
|
|
|
2024-11-28 13:28:23 -07:00
|
|
|
global VIDEOS_DOWNLOADER_CONFIG
|
|
|
|
|
return VIDEOS_DOWNLOADER_CONFIG
|
2022-02-16 21:57:27 -07:00
|
|
|
|
|
|
|
|
|
2022-02-16 23:05:43 -07:00
|
|
|
def set_test_downloader_config(enabled: bool):
|
2024-11-28 13:28:23 -07:00
|
|
|
global TEST_VIDEOS_DOWNLOADER_CONFIG
|
|
|
|
|
TEST_VIDEOS_DOWNLOADER_CONFIG = VideoDownloaderConfig() if enabled else None
|
2022-02-16 23:05:43 -07:00
|
|
|
|
|
|
|
|
|
2026-03-05 16:18:03 -07:00
|
|
|
# Names of settings that can be overridden at channel/RSS or video level.
|
|
|
|
|
# Actual default values come from VideoDownloaderConfig.
|
|
|
|
|
INHERITABLE_VIDEO_SETTINGS = {
|
|
|
|
|
'video_resolutions', 'video_format', 'writesubtitles', 'writeautomaticsub',
|
|
|
|
|
'writethumbnail', 'writeinfojson', 'yt_dlp_extra_args', 'sleep_requests',
|
2026-04-14 15:00:49 -06:00
|
|
|
'user_agent', 'continue_dl', 'nooverwrites', 'audio_only', 'audio_format',
|
videos: codec download preferences with transcode and strict-fail options (#701)
* videos: codec download preferences with transcode and strict-fail options
Add video_codecs/audio_codecs ordered preference lists to the video,
channel, and RSS downloaders, alongside the existing resolution
selector. Codec preference is codec-major: a preferred codec at a
lower resolution beats another codec at a higher resolution.
When 'transcode' is enabled, a downloaded file whose streams do not
match the preferences is re-encoded with ffmpeg after download (only
the mismatching stream; targets are extensible maps, currently h264
and aac/opus/mp3). When 'strict_codecs' is enabled and transcode is
not, a download with no matching format fails permanently instead of
falling back.
Empty codec lists produce a byte-identical yt-dlp command to before.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* videos: honor strict_codecs when transcode cannot produce the preference
Review fixes:
- strict_codecs is now suppressed only when transcoding can produce a
preferred codec for every non-empty preference list (new
transcode_can_satisfy_codecs); av1-only preferences with transcode
enabled fail under strict instead of silently keeping the file.
- No partial transcode: an unfixable video mismatch blocks an
audio-only conversion (and vice versa); the mismatch falls through
to strict/keep.
- fsync the transcode output before rename, and the directory after,
so power loss on a Pi cannot leave a truncated final file.
- mp3 yt-dlp pattern matches mp4a.6B as well as mp4a.6b.
- ffmpeg maps use 0:V:0 (excludes attached pictures) and 0:a:0?
(tolerates missing audio).
- Strict toggle is no longer disabled by transcode in the UI; popup
wording corrected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* videos: serialize transcodes machine-wide via shared_ctx lock
Sanic runs multiple worker processes, so an asyncio primitive cannot
serialize transcodes across download workers. A multiprocessing.Lock
in shared_ctx now guarantees only one ffmpeg transcode runs at a time
on the machine; waiting workers poll the lock asynchronously so the
event loop stays responsive while a transcode holds it for hours.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* videos: transcode-lock review fixes
- Document that a crashed lock holder requires an API restart; this is
accepted rather than adding owner-death recovery.
- Move the 'Transcoding' user notification to after the lock is
acquired, so it fires when work begins rather than before a
potentially hours-long wait.
- Soften the lock test docstring to what it measures (in-process
wait/release); trim the acquire docstring to the polling rationale.
- Give transcode_lock its own section header in contexts.py.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-07 09:11:39 -06:00
|
|
|
'video_codecs', 'audio_codecs', 'transcode', 'strict_codecs',
|
2026-03-05 16:18:03 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Subset of inheritable settings allowed at channel/RSS level.
|
|
|
|
|
# continue_dl and nooverwrites are global+video only.
|
|
|
|
|
CHANNEL_INHERITABLE_SETTINGS = INHERITABLE_VIDEO_SETTINGS - {'continue_dl', 'nooverwrites'}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _extract_global_inheritable(config: VideoDownloaderConfig) -> dict:
|
|
|
|
|
"""Read current global config values for all inheritable settings, flattened."""
|
|
|
|
|
return {
|
|
|
|
|
'video_resolutions': config.video_resolutions,
|
|
|
|
|
'video_format': config.merge_output_format,
|
|
|
|
|
'writesubtitles': config.writesubtitles,
|
|
|
|
|
'writeautomaticsub': config.writeautomaticsub,
|
|
|
|
|
'writethumbnail': config.writethumbnail,
|
|
|
|
|
'writeinfojson': config.writeinfojson,
|
|
|
|
|
'yt_dlp_extra_args': config.yt_dlp_extra_args,
|
|
|
|
|
'sleep_requests': config.sleep_requests,
|
|
|
|
|
'user_agent': config.user_agent,
|
|
|
|
|
'continue_dl': config.continue_dl,
|
|
|
|
|
'nooverwrites': config.nooverwrites,
|
2026-04-14 15:00:49 -06:00
|
|
|
'audio_only': config.audio_only,
|
|
|
|
|
'audio_format': config.audio_format,
|
videos: codec download preferences with transcode and strict-fail options (#701)
* videos: codec download preferences with transcode and strict-fail options
Add video_codecs/audio_codecs ordered preference lists to the video,
channel, and RSS downloaders, alongside the existing resolution
selector. Codec preference is codec-major: a preferred codec at a
lower resolution beats another codec at a higher resolution.
When 'transcode' is enabled, a downloaded file whose streams do not
match the preferences is re-encoded with ffmpeg after download (only
the mismatching stream; targets are extensible maps, currently h264
and aac/opus/mp3). When 'strict_codecs' is enabled and transcode is
not, a download with no matching format fails permanently instead of
falling back.
Empty codec lists produce a byte-identical yt-dlp command to before.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* videos: honor strict_codecs when transcode cannot produce the preference
Review fixes:
- strict_codecs is now suppressed only when transcoding can produce a
preferred codec for every non-empty preference list (new
transcode_can_satisfy_codecs); av1-only preferences with transcode
enabled fail under strict instead of silently keeping the file.
- No partial transcode: an unfixable video mismatch blocks an
audio-only conversion (and vice versa); the mismatch falls through
to strict/keep.
- fsync the transcode output before rename, and the directory after,
so power loss on a Pi cannot leave a truncated final file.
- mp3 yt-dlp pattern matches mp4a.6B as well as mp4a.6b.
- ffmpeg maps use 0:V:0 (excludes attached pictures) and 0:a:0?
(tolerates missing audio).
- Strict toggle is no longer disabled by transcode in the UI; popup
wording corrected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* videos: serialize transcodes machine-wide via shared_ctx lock
Sanic runs multiple worker processes, so an asyncio primitive cannot
serialize transcodes across download workers. A multiprocessing.Lock
in shared_ctx now guarantees only one ffmpeg transcode runs at a time
on the machine; waiting workers poll the lock asynchronously so the
event loop stays responsive while a transcode holds it for hours.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* videos: transcode-lock review fixes
- Document that a crashed lock holder requires an API restart; this is
accepted rather than adding owner-death recovery.
- Move the 'Transcoding' user notification to after the lock is
acquired, so it fires when work begins rather than before a
potentially hours-long wait.
- Soften the lock test docstring to what it measures (in-process
wait/release); trim the acquire docstring to the polling rationale.
- Give transcode_lock its own section header in contexts.py.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-07 09:11:39 -06:00
|
|
|
'video_codecs': config.video_codecs,
|
|
|
|
|
'audio_codecs': config.audio_codecs,
|
|
|
|
|
'transcode': config.transcode,
|
|
|
|
|
'strict_codecs': config.strict_codecs,
|
2026-03-05 16:18:03 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_effective_video_settings(download_settings: dict = None) -> dict:
|
|
|
|
|
"""Merge global defaults with download-level overrides.
|
|
|
|
|
|
|
|
|
|
At download time: effective = {**global, **download_settings}
|
|
|
|
|
Only inheritable keys present in download_settings override the global value.
|
|
|
|
|
"""
|
|
|
|
|
config = get_videos_downloader_config()
|
|
|
|
|
effective = _extract_global_inheritable(config)
|
|
|
|
|
if download_settings:
|
|
|
|
|
for key in INHERITABLE_VIDEO_SETTINGS:
|
|
|
|
|
if key in download_settings:
|
|
|
|
|
effective[key] = download_settings[key]
|
2026-05-31 12:43:56 -06:00
|
|
|
|
|
|
|
|
# Safety net for already-persisted downloads that may contain string values
|
|
|
|
|
# for numeric settings (from before the DownloadSettings coercion fix).
|
|
|
|
|
# This is the single chokepoint so downstream code can assume correct types.
|
|
|
|
|
if effective.get('sleep_requests') not in (None, ''):
|
|
|
|
|
try:
|
|
|
|
|
effective['sleep_requests'] = float(effective['sleep_requests'])
|
|
|
|
|
except (TypeError, ValueError):
|
2026-05-31 12:53:04 -06:00
|
|
|
# Remove corrupt value so the global config default (already populated
|
|
|
|
|
# earlier via _extract_global_inheritable) takes effect.
|
|
|
|
|
del effective['sleep_requests']
|
2026-03-05 16:18:03 -07:00
|
|
|
return effective
|
|
|
|
|
|
|
|
|
|
|
2026-02-20 14:37:58 -07:00
|
|
|
def get_yt_dlp_http_headers() -> dict:
|
|
|
|
|
"""Get HTTP headers for yt-dlp, including user-agent if configured.
|
|
|
|
|
|
|
|
|
|
This should be used when cookies are being used to ensure the user-agent
|
|
|
|
|
matches the browser that exported the cookies."""
|
|
|
|
|
config = get_videos_downloader_config()
|
|
|
|
|
if config.user_agent:
|
|
|
|
|
return {'User-Agent': config.user_agent}
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_yt_dlp_sleep_opts() -> dict:
|
|
|
|
|
"""Get sleep options for yt-dlp to avoid rate limiting.
|
|
|
|
|
|
|
|
|
|
This adds a delay between HTTP requests during metadata extraction,
|
|
|
|
|
helping avoid bot detection. Does not affect video file download speed."""
|
|
|
|
|
config = get_videos_downloader_config()
|
2026-05-31 12:43:56 -06:00
|
|
|
try:
|
|
|
|
|
if float(config.sleep_requests) > 0:
|
|
|
|
|
return {'sleep_requests': config.sleep_requests}
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
pass
|
2026-02-20 14:37:58 -07:00
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
2024-09-15 15:33:07 -06:00
|
|
|
@register_switch_handler('save_channels_config')
|
|
|
|
|
def save_channels_config():
|
2026-10-25 19:39:45 -06:00
|
|
|
"""Save the Channels in the DB to the config, with the DB's version (see `ChannelsConfig.dump_config`)."""
|
2024-09-15 15:33:07 -06:00
|
|
|
with get_db_session() as session:
|
2026-10-25 19:39:45 -06:00
|
|
|
has_channels = session.query(Channel.id).first() is not None
|
|
|
|
|
channels_config = get_channels_config()
|
2024-04-17 11:25:26 -06:00
|
|
|
|
|
|
|
|
if PYTEST and not channels_config:
|
|
|
|
|
logger.warning('Refusing to save channels config because test did not initialize a test config!')
|
|
|
|
|
return
|
|
|
|
|
|
2026-10-25 19:39:45 -06:00
|
|
|
if not has_channels and channels_config.channels:
|
2026-03-22 12:56:37 -06:00
|
|
|
logger.warning('Refusing to save empty channels config because channels exist in the config file!')
|
|
|
|
|
return
|
|
|
|
|
|
2026-10-25 19:39:45 -06:00
|
|
|
channels_config.dump_config()
|
2024-09-15 15:33:07 -06:00
|
|
|
logger.info('save_channels_config completed')
|
2021-11-01 16:42:52 -06:00
|
|
|
|
|
|
|
|
|
2024-09-15 15:33:07 -06:00
|
|
|
save_channels_config: ActivateSwitchMethod
|
|
|
|
|
|
2023-02-17 16:00:44 -07:00
|
|
|
channel_import_logger = logger.getChild('channel_import')
|
|
|
|
|
|
|
|
|
|
|
2024-08-15 19:32:01 -06:00
|
|
|
async def fetch_channel_source_id(channel_id: int):
|
|
|
|
|
# If we can download from a channel, we must have its source_id.
|
|
|
|
|
try:
|
|
|
|
|
with get_db_session() as session:
|
2025-12-10 21:16:10 -07:00
|
|
|
channel = Channel.find_by_id(session, channel_id)
|
Tests fail when a request writes outside request.ctx.db.write() (#864)
* Tests fail when a request writes outside request.ctx.db.write()
A request's transaction begins deferred. A write in it after a read fails
instantly ("database is locked") when another connection wrote in between,
and a write left for the response middleware's commit can be lost after the
response was sent. #846-#853 and #863 moved handlers' writes into
request.ctx.db.write(); this keeps it that way.
Under pytest, a listener records every INSERT/UPDATE/DELETE/REPLACE made
while a request runs (or in a task it started) outside RequestDB.write()
or get_db_session(commit=True), and the autouse no_deferred_request_writes
fixture fails the test. allow_deferred_request_writes opts a test out.
The test-only response listener now runs switches outside the request,
as production's perpetual process does.
It found three more:
- DELETE /api/zim/<ids> deleted the .zim file, returned 204, then lost the
row's delete when the middleware's commit failed under contention. It
now writes in request.ctx.db.write().
- Viewing a file recorded the view in a background task on the request's
Session, after the request ended. set_file_viewed opens its own write.
- tags.import_config (startup and config import) read, then wrote, in a
deferred get_db_session(). It now begins as a writer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Tasks don't inherit a declared write; tags import change backed out
From review of #864:
- background_task's task copied its creator's Context, including
_write_intent when started inside get_db_session(commit=True) or
RequestDB.write(), so the guard ignored the task's own writes for its
lifetime. The task now starts with no declared write. That exposes
fetch_channel_source_id (started by the channels config import): it read
the Channel, asked the remote site for its source_id, then committed in
the same deferred transaction. It now reads, fetches with no
transaction open, then writes in a short get_db_session(commit=True).
- tags.import_config: commit=True held the write lock through its directory
walk, and its exit commit saved changes the need_commit gate used to
drop. Reverted to master; splitting its reads from a short write is its
own change. The two tests that run it through a request are marked
allow_deferred_request_writes until then.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:09:22 -06:00
|
|
|
source_id, url = channel.source_id, channel.url
|
|
|
|
|
if source_id:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# Fetched outside any transaction: this asks the remote site.
|
|
|
|
|
source_id = get_channel_source_id(url)
|
|
|
|
|
if not source_id:
|
|
|
|
|
channel_import_logger.warning(f'Unable to fetch source_id for {url}')
|
|
|
|
|
return
|
2026-02-19 16:49:38 -07:00
|
|
|
|
Tests fail when a request writes outside request.ctx.db.write() (#864)
* Tests fail when a request writes outside request.ctx.db.write()
A request's transaction begins deferred. A write in it after a read fails
instantly ("database is locked") when another connection wrote in between,
and a write left for the response middleware's commit can be lost after the
response was sent. #846-#853 and #863 moved handlers' writes into
request.ctx.db.write(); this keeps it that way.
Under pytest, a listener records every INSERT/UPDATE/DELETE/REPLACE made
while a request runs (or in a task it started) outside RequestDB.write()
or get_db_session(commit=True), and the autouse no_deferred_request_writes
fixture fails the test. allow_deferred_request_writes opts a test out.
The test-only response listener now runs switches outside the request,
as production's perpetual process does.
It found three more:
- DELETE /api/zim/<ids> deleted the .zim file, returned 204, then lost the
row's delete when the middleware's commit failed under contention. It
now writes in request.ctx.db.write().
- Viewing a file recorded the view in a background task on the request's
Session, after the request ended. set_file_viewed opens its own write.
- tags.import_config (startup and config import) read, then wrote, in a
deferred get_db_session(). It now begins as a writer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Tasks don't inherit a declared write; tags import change backed out
From review of #864:
- background_task's task copied its creator's Context, including
_write_intent when started inside get_db_session(commit=True) or
RequestDB.write(), so the guard ignored the task's own writes for its
lifetime. The task now starts with no declared write. That exposes
fetch_channel_source_id (started by the channels config import): it read
the Channel, asked the remote site for its source_id, then committed in
the same deferred transaction. It now reads, fetches with no
transaction open, then writes in a short get_db_session(commit=True).
- tags.import_config: commit=True held the write lock through its directory
walk, and its exit commit saved changes the need_commit gate used to
drop. Reverted to master; splitting its reads from a short write is its
own change. The two tests that run it through a request are marked
allow_deferred_request_writes until then.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:09:22 -06:00
|
|
|
with get_db_session(commit=True) as session:
|
|
|
|
|
Channel.find_by_id(session, channel_id).source_id = source_id
|
channels.yaml imports by diffing (#872)
* channels.yaml imports by diffing
channels.yaml declares two sections: Channels keyed by directory
(comparing name, URL, Tag, file format, and the source id and flags,
which keep the database's value when missing), and the Downloads listed
under each Channel, keyed by (directory, URL); an existing Download keeps
its own frequency. An unchanged import writes nothing (it ran about a
thousand queries and committed several times).
Removed Channels are deleted with their Collection (and Downloads);
a Download removed from a Channel is deleted. A channel Collection
without a Channel is deleted unless listed (then it gets its Channel).
A Channel whose directory changed, with the same name, is moved. An
invalid entry is skipped and counted, and keeps its Channel (by name when
its directory is invalid) and that Channel's Downloads; so does an entry
without a list of Downloads. Duplicate directories still refuse the
import. Unknown Tags are dropped once tags.yaml is imported.
New Downloads are created in the import's one write (commit=False on
Channel.get_or_create_download, Download.delete and delete_with_videos).
After the import, Videos are claimed and source ids fetched only for new
and changed Channels; Downloads are linked without a query per Download,
and not while entries are skipped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* channels.yaml: moves, invalid Downloads, and saves after the write
- A moved Channel's Download diff is retargeted to its new directory:
one its new entry lists is the same Download, one it does not is
deleted in the same import (the old directory was treated as kept).
- An unreadable Download is counted as skipped, and its Channel keeps
its Downloads (it was dropped, and the Channel's Download deleted).
- A new Download without a frequency is dropped before the write and
counted (it re-opened a write on every import).
- No source id is fetched while entries are skipped (its save would be
refused, leaving the database newer than the file); a fetched one is
saved with dump_config, which records the database's version.
- Download.delete(commit=False) neither changes the skip list nor starts
config saves, which would read the database before the import commits
(and write a removed Download back into channels.yaml). The import
saves download_manager.yaml after its write.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:09:51 -06:00
|
|
|
# Dumped with the database's version (see wrolpi.config_version).
|
|
|
|
|
if config := get_channels_config():
|
|
|
|
|
config.background_dump.activate_switch()
|
2024-08-15 19:32:01 -06:00
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f'Failed to get Channel source id of id={channel_id}', exc_info=e)
|
|
|
|
|
|
|
|
|
|
|
2022-04-08 09:57:04 -06:00
|
|
|
def import_channels_config():
|
|
|
|
|
"""Import channel settings to the DB. Existing channels will be updated."""
|
2024-04-17 11:25:26 -06:00
|
|
|
if PYTEST and not get_channels_config():
|
|
|
|
|
logger.warning('Skipping import_channels_config for this test')
|
2022-10-19 09:18:53 -06:00
|
|
|
return
|
|
|
|
|
|
2024-07-22 17:15:37 -06:00
|
|
|
channel_import_logger.info('Importing channels config')
|
2024-09-26 09:13:29 -06:00
|
|
|
get_channels_config().import_config()
|
|
|
|
|
channel_import_logger.info('Importing channels config completed')
|
2022-04-08 09:57:04 -06:00
|
|
|
|
|
|
|
|
|
channels.yaml imports by diffing (#872)
* channels.yaml imports by diffing
channels.yaml declares two sections: Channels keyed by directory
(comparing name, URL, Tag, file format, and the source id and flags,
which keep the database's value when missing), and the Downloads listed
under each Channel, keyed by (directory, URL); an existing Download keeps
its own frequency. An unchanged import writes nothing (it ran about a
thousand queries and committed several times).
Removed Channels are deleted with their Collection (and Downloads);
a Download removed from a Channel is deleted. A channel Collection
without a Channel is deleted unless listed (then it gets its Channel).
A Channel whose directory changed, with the same name, is moved. An
invalid entry is skipped and counted, and keeps its Channel (by name when
its directory is invalid) and that Channel's Downloads; so does an entry
without a list of Downloads. Duplicate directories still refuse the
import. Unknown Tags are dropped once tags.yaml is imported.
New Downloads are created in the import's one write (commit=False on
Channel.get_or_create_download, Download.delete and delete_with_videos).
After the import, Videos are claimed and source ids fetched only for new
and changed Channels; Downloads are linked without a query per Download,
and not while entries are skipped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* channels.yaml: moves, invalid Downloads, and saves after the write
- A moved Channel's Download diff is retargeted to its new directory:
one its new entry lists is the same Download, one it does not is
deleted in the same import (the old directory was treated as kept).
- An unreadable Download is counted as skipped, and its Channel keeps
its Downloads (it was dropped, and the Channel's Download deleted).
- A new Download without a frequency is dropped before the write and
counted (it re-opened a write on every import).
- No source id is fetched while entries are skipped (its save would be
refused, leaving the database newer than the file); a fetched one is
saved with dump_config, which records the database's version.
- Download.delete(commit=False) neither changes the skip list nor starts
config saves, which would read the database before the import commits
(and write a removed Download back into channels.yaml). The import
saves download_manager.yaml after its write.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:09:51 -06:00
|
|
|
def channel_download_links(session: Session) -> Dict[int, int]:
|
|
|
|
|
"""The recurring Downloads without a Collection which belong to a Channel: {Download id: Collection id}.
|
2026-02-08 14:27:59 -07:00
|
|
|
|
channels.yaml imports by diffing (#872)
* channels.yaml imports by diffing
channels.yaml declares two sections: Channels keyed by directory
(comparing name, URL, Tag, file format, and the source id and flags,
which keep the database's value when missing), and the Downloads listed
under each Channel, keyed by (directory, URL); an existing Download keeps
its own frequency. An unchanged import writes nothing (it ran about a
thousand queries and committed several times).
Removed Channels are deleted with their Collection (and Downloads);
a Download removed from a Channel is deleted. A channel Collection
without a Channel is deleted unless listed (then it gets its Channel).
A Channel whose directory changed, with the same name, is moved. An
invalid entry is skipped and counted, and keeps its Channel (by name when
its directory is invalid) and that Channel's Downloads; so does an entry
without a list of Downloads. Duplicate directories still refuse the
import. Unknown Tags are dropped once tags.yaml is imported.
New Downloads are created in the import's one write (commit=False on
Channel.get_or_create_download, Download.delete and delete_with_videos).
After the import, Videos are claimed and source ids fetched only for new
and changed Channels; Downloads are linked without a query per Download,
and not while entries are skipped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* channels.yaml: moves, invalid Downloads, and saves after the write
- A moved Channel's Download diff is retargeted to its new directory:
one its new entry lists is the same Download, one it does not is
deleted in the same import (the old directory was treated as kept).
- An unreadable Download is counted as skipped, and its Channel keeps
its Downloads (it was dropped, and the Channel's Download deleted).
- A new Download without a frequency is dropped before the write and
counted (it re-opened a write on every import).
- No source id is fetched while entries are skipped (its save would be
refused, leaving the database newer than the file); a fetched one is
saved with dump_config, which records the database's version.
- Download.delete(commit=False) neither changes the skip list nor starts
config saves, which would read the database before the import commits
(and write a removed Download back into channels.yaml). The import
saves download_manager.yaml after its write.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:09:51 -06:00
|
|
|
A Download belongs to the Channel whose directory is its destination, whose URL (or RSS feed) is its URL, or whose
|
|
|
|
|
source id is the channel id of its info json."""
|
|
|
|
|
channels = session.query(Channel).options(joinedload(Channel.collection)).all()
|
|
|
|
|
by_directory = {str(i.directory): i.collection_id for i in channels if i.directory}
|
|
|
|
|
by_url = {i.url: i.collection_id for i in channels if i.url}
|
2024-07-04 19:16:33 -06:00
|
|
|
for channel in channels:
|
channels.yaml imports by diffing (#872)
* channels.yaml imports by diffing
channels.yaml declares two sections: Channels keyed by directory
(comparing name, URL, Tag, file format, and the source id and flags,
which keep the database's value when missing), and the Downloads listed
under each Channel, keyed by (directory, URL); an existing Download keeps
its own frequency. An unchanged import writes nothing (it ran about a
thousand queries and committed several times).
Removed Channels are deleted with their Collection (and Downloads);
a Download removed from a Channel is deleted. A channel Collection
without a Channel is deleted unless listed (then it gets its Channel).
A Channel whose directory changed, with the same name, is moved. An
invalid entry is skipped and counted, and keeps its Channel (by name when
its directory is invalid) and that Channel's Downloads; so does an entry
without a list of Downloads. Duplicate directories still refuse the
import. Unknown Tags are dropped once tags.yaml is imported.
New Downloads are created in the import's one write (commit=False on
Channel.get_or_create_download, Download.delete and delete_with_videos).
After the import, Videos are claimed and source ids fetched only for new
and changed Channels; Downloads are linked without a query per Download,
and not while entries are skipped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* channels.yaml: moves, invalid Downloads, and saves after the write
- A moved Channel's Download diff is retargeted to its new directory:
one its new entry lists is the same Download, one it does not is
deleted in the same import (the old directory was treated as kept).
- An unreadable Download is counted as skipped, and its Channel keeps
its Downloads (it was dropped, and the Channel's Download deleted).
- A new Download without a frequency is dropped before the write and
counted (it re-opened a write on every import).
- No source id is fetched while entries are skipped (its save would be
refused, leaving the database newer than the file); a fetched one is
saved with dump_config, which records the database's version.
- Download.delete(commit=False) neither changes the skip list nor starts
config saves, which would read the database before the import commits
(and write a removed Download back into channels.yaml). The import
saves download_manager.yaml after its write.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:09:51 -06:00
|
|
|
if rss_url := channel.get_rss_url():
|
|
|
|
|
by_url.setdefault(rss_url, channel.collection_id)
|
|
|
|
|
by_source_id = {i.source_id: i.collection_id for i in channels if i.source_id}
|
|
|
|
|
|
|
|
|
|
links = dict()
|
|
|
|
|
downloads = session.query(Download.id, Download.url, Download.destination, Download.settings, Download.info_json) \
|
|
|
|
|
.filter(Download.frequency.isnot(None), Download.collection_id.is_(None))
|
|
|
|
|
for id_, url, destination, settings, info_json in downloads:
|
|
|
|
|
destination = str(destination) if destination else (settings or dict()).get('destination')
|
|
|
|
|
collection_id = (by_directory.get(destination) if destination else None) or by_url.get(url)
|
|
|
|
|
if not collection_id:
|
|
|
|
|
info_json = info_json or dict()
|
|
|
|
|
if source_id := info_json.get('channel_id') or info_json.get('id'):
|
|
|
|
|
collection_id = by_source_id.get(source_id)
|
|
|
|
|
if collection_id:
|
|
|
|
|
links[id_] = collection_id
|
|
|
|
|
return links
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _link_downloads(session: Session, links: Dict[int, int]):
|
|
|
|
|
for ids in chunks(list(links), 500):
|
|
|
|
|
for download in session.query(Download).filter(Download.id.in_(ids)):
|
|
|
|
|
download.collection_id = links[download.id]
|
|
|
|
|
session.flush()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def link_channel_and_downloads(session: Session):
|
|
|
|
|
"""Associate any Download related to a Channel via its Collection (see `channel_download_links`)."""
|
|
|
|
|
if links := channel_download_links(session):
|
|
|
|
|
_link_downloads(session, links)
|
2024-07-04 19:16:33 -06:00
|
|
|
session.commit()
|
|
|
|
|
|
|
|
|
|
|
2024-03-12 16:52:26 -06:00
|
|
|
YDL = YoutubeDL(dict(cachedir=YTDLP_CACHE_DIR))
|
|
|
|
|
ydl_logger = YDL.params['logger'] = logger.getChild('youtube-dl')
|
2022-04-08 09:57:04 -06:00
|
|
|
YDL.add_default_info_extractors()
|
|
|
|
|
|
|
|
|
|
|
2026-02-20 14:37:58 -07:00
|
|
|
def get_channel_source_id(url: str) -> str:
|
2026-02-19 16:49:38 -07:00
|
|
|
"""Get the source ID for a channel URL."""
|
2026-02-20 14:37:58 -07:00
|
|
|
if cookies_unlocked():
|
|
|
|
|
logger.info(f'Using encrypted cookies for channel source id: {url}')
|
|
|
|
|
with cookies_for_download() as cookies_path:
|
|
|
|
|
ydl_opts = dict(
|
|
|
|
|
cachedir=YTDLP_CACHE_DIR,
|
|
|
|
|
cookiefile=str(cookies_path),
|
|
|
|
|
**get_yt_dlp_sleep_opts(),
|
|
|
|
|
)
|
|
|
|
|
http_headers = get_yt_dlp_http_headers()
|
|
|
|
|
if http_headers:
|
|
|
|
|
ydl_opts['http_headers'] = http_headers
|
|
|
|
|
ydl = YoutubeDL(ydl_opts)
|
|
|
|
|
ydl.params['logger'] = logger.getChild('youtube-dl')
|
|
|
|
|
ydl.add_default_info_extractors()
|
|
|
|
|
channel_info = ydl.extract_info(url, download=False, process=False)
|
|
|
|
|
return channel_info.get('channel_id') or channel_info['uploader_id']
|
|
|
|
|
|
|
|
|
|
logger.info(f'No cookies available for channel source id: {url}')
|
|
|
|
|
ydl_opts = dict(
|
|
|
|
|
cachedir=YTDLP_CACHE_DIR,
|
|
|
|
|
**get_yt_dlp_sleep_opts(),
|
|
|
|
|
)
|
|
|
|
|
ydl = YoutubeDL(ydl_opts)
|
|
|
|
|
ydl.params['logger'] = logger.getChild('youtube-dl')
|
|
|
|
|
ydl.add_default_info_extractors()
|
2026-02-19 16:49:38 -07:00
|
|
|
|
|
|
|
|
channel_info = ydl.extract_info(url, download=False, process=False)
|
2024-07-18 10:04:57 -06:00
|
|
|
return channel_info.get('channel_id') or channel_info['uploader_id']
|
2022-04-08 09:57:04 -06:00
|
|
|
|
|
|
|
|
|
db: request-path library functions use the request's connection (#774)
* db: request-path library functions use the request's connection
Library functions an API request reaches now take the request's Session (first argument) instead of
opening their own session or cursor, so a request needs one SQLite connection. Raw SQL runs through the
new `wrolpi.db.session_curs(session)`, a cursor on the Session's connection; `RequestDB.curs()` uses it.
- Search: files, videos, archives, flasher, and docs search share the request connection for the total,
the page of ids, and the page's models (3 connections -> 1).
- Zim search, including tag-filtered search and estimates (2 -> 1).
- Channel detail (`Channel.get_statistics` reads on the Channel's own Session) and the channel list.
- Video detail, edit, description, captions, comments and transcode. `get_video_for_app` and
`update_video` no longer commit; their handlers use `request.ctx.db.write()`, so editing a Video and
reading it back is one transaction.
- Tags (list, overlaps, recent), file statistics and estimates, other estimates, bulk tag preview, the
download list, retry, kill and the status summary, and docs/videos/archive statistics.
Not converted: file operations that refresh files inside the request (delete, rename, upload, channel
and collection tag/move) commit page by page in code shared with the file worker; the cached zim entry
lookup; config import.
The connection-count tests now assert one connection for every endpoint they cover (no xfails left), and
cover the videos and archive statistics endpoints, which had no API test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* downloads: startup retries in a write session of its own
start_single_tasks still called retry_downloads() without a Session after it began requiring one, so any
start after a completed refresh raised TypeError there: incomplete downloads were not reset and the
download_on_startup enable after it never ran. Startup is not a request, so
DownloadManager.retry_downloads_on_startup() opens and commits its own short write transaction.
Tested through the method, not start_single_tasks: importing main registers its startup listeners on the
shared app, and every later test in that worker then runs them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:05:20 -06:00
|
|
|
async def get_statistics(session: Session):
|
|
|
|
|
with session_curs(session) as curs:
|
2021-11-01 16:42:52 -06:00
|
|
|
curs.execute('''
|
2025-04-17 17:02:39 -06:00
|
|
|
SELECT
|
|
|
|
|
-- total videos
|
|
|
|
|
COUNT(v.id) AS "videos",
|
|
|
|
|
-- total videos downloaded over the past week/month/year
|
Postgres -> SQLite hard cutover: the database moves into the media directory.
The database now lives at <media>/config/wrolpi.db so a library (files,
configs, AND database) travels with the drive between WROLPi systems.
Core:
- wrolpi/db.py: lazy SQLite engine factory (WAL, busy_timeout=30s,
foreign_keys=ON); get_db_curs yields a plain sqlite3.Cursor; native
sqlite3 paramstyle everywhere (no compatibility shims); global sqlite3
adapters keep raw-SQL datetime formats identical to the ORM's.
- wrolpi/fts.py: FTS5 external-content search replacing tsvector/GIN.
Injection-proof websearch->FTS5 translator, bm25(10,4,2,1) reproducing
A/B/C/D weighting, fast(abc)/deep column filtering, snippet headlines.
Stemming contract pinned by dedicated tests (watch/watches/watched/
watching are interchangeable).
- wrolpi/schema_ddl.py: SQLite triggers shared verbatim by the alembic
baseline and test DBs; fixes the channel video_count DELETE/UPDATE
bugs and a latent total_size bug (it used the video id as channel id).
- wrolpi/db_bootstrap.py: startup auto-create/migrate; refuses databases
written by a newer WROLPi (db_version_mismatch); SQLite 3.40 floor;
network-filesystem refusal; never creates/migrates/trusts a database
beneath an unmounted production media directory (shadow-DB guard).
- Alembic: 85 Postgres-era migrations squashed into one baseline;
revision ids are dates (2026_07_10_1350) so filenames self-sort and
alembic_version says when a schema was created; render_as_batch=True.
- All raw Postgres SQL rewritten (ANY->json_each IN, array_agg @> ->
HAVING COUNT(DISTINCT), unnest->executemany/UPDATE..FROM,
generate_series histograms->strftime GROUP BY, DISTINCT ON->
row_number(), writable CTEs->two statements, jsonb_*->json_*, explicit
NULLS FIRST/LAST, LIMIT-before-OFFSET). Simple statements converted
to SQLAlchemy instead (flag upsert, download retry/summary/tag checks,
channel-by-tag estimate, Directory delete).
- Config imports count skipped entries; background dumps are blocked
after a lossy import so partial imports can never overwrite YAML
configs (they are the sole source of truth for rebuilds now).
- Search tolerates explicit JSON nulls for limit/offset (Postgres
treated OFFSET NULL as 0; SQLite errors).
- file_group.id/doc_section.id render as INTEGER PRIMARY KEY on SQLite
(BIGINT is not a rowid alias; FTS5 content_rowid requires it).
Ops teardown: docker-compose db service, CI postgres sidecars,
initialize_api_db.sh, controller postgresql service, psycopg2-binary all
removed. upgrade.sh stops/disables postgresql (data is NOT deleted;
`systemctl enable --now postgresql` restores it); systemd units no
longer Want postgresql.service.
Existing-user upgrade: first boot creates+migrates the DB, configs
import (tags recreate their FileGroups from disk), a refresh rebuilds
the library index. QA'd on the 10.0.0.8 RPi: rebuild matched the
Postgres baseline exactly; survived an uncontrolled hard reboot with
clean integrity checks.
Accepted behavior changes: FTS5 has no stopwords; bm25 length
normalization shifts some rank orders; fast-path d_headline is
un-highlighted; all-negative queries match nothing.
Deferred: Debian/trixie package lists (incl. sqlite3 CLI), Cypress
re-baseline, VACUUM INTO snapshots, exFAT/NTFS warning, auto-kicked
rebuild refresh with progress UX.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:59:20 -06:00
|
|
|
COUNT(v.id) FILTER (WHERE published_datetime >= datetime('now', '-7 days')) AS "week",
|
|
|
|
|
COUNT(v.id) FILTER (WHERE published_datetime >= datetime('now', '-1 month')) AS "month",
|
|
|
|
|
COUNT(v.id) FILTER (WHERE published_datetime >= datetime('now', '-1 year')) AS "year",
|
2025-04-17 17:02:39 -06:00
|
|
|
-- sum of all video lengths in seconds
|
Postgres -> SQLite hard cutover: the database moves into the media directory.
The database now lives at <media>/config/wrolpi.db so a library (files,
configs, AND database) travels with the drive between WROLPi systems.
Core:
- wrolpi/db.py: lazy SQLite engine factory (WAL, busy_timeout=30s,
foreign_keys=ON); get_db_curs yields a plain sqlite3.Cursor; native
sqlite3 paramstyle everywhere (no compatibility shims); global sqlite3
adapters keep raw-SQL datetime formats identical to the ORM's.
- wrolpi/fts.py: FTS5 external-content search replacing tsvector/GIN.
Injection-proof websearch->FTS5 translator, bm25(10,4,2,1) reproducing
A/B/C/D weighting, fast(abc)/deep column filtering, snippet headlines.
Stemming contract pinned by dedicated tests (watch/watches/watched/
watching are interchangeable).
- wrolpi/schema_ddl.py: SQLite triggers shared verbatim by the alembic
baseline and test DBs; fixes the channel video_count DELETE/UPDATE
bugs and a latent total_size bug (it used the video id as channel id).
- wrolpi/db_bootstrap.py: startup auto-create/migrate; refuses databases
written by a newer WROLPi (db_version_mismatch); SQLite 3.40 floor;
network-filesystem refusal; never creates/migrates/trusts a database
beneath an unmounted production media directory (shadow-DB guard).
- Alembic: 85 Postgres-era migrations squashed into one baseline;
revision ids are dates (2026_07_10_1350) so filenames self-sort and
alembic_version says when a schema was created; render_as_batch=True.
- All raw Postgres SQL rewritten (ANY->json_each IN, array_agg @> ->
HAVING COUNT(DISTINCT), unnest->executemany/UPDATE..FROM,
generate_series histograms->strftime GROUP BY, DISTINCT ON->
row_number(), writable CTEs->two statements, jsonb_*->json_*, explicit
NULLS FIRST/LAST, LIMIT-before-OFFSET). Simple statements converted
to SQLAlchemy instead (flag upsert, download retry/summary/tag checks,
channel-by-tag estimate, Directory delete).
- Config imports count skipped entries; background dumps are blocked
after a lossy import so partial imports can never overwrite YAML
configs (they are the sole source of truth for rebuilds now).
- Search tolerates explicit JSON nulls for limit/offset (Postgres
treated OFFSET NULL as 0; SQLite errors).
- file_group.id/doc_section.id render as INTEGER PRIMARY KEY on SQLite
(BIGINT is not a rowid alias; FTS5 content_rowid requires it).
Ops teardown: docker-compose db service, CI postgres sidecars,
initialize_api_db.sh, controller postgresql service, psycopg2-binary all
removed. upgrade.sh stops/disables postgresql (data is NOT deleted;
`systemctl enable --now postgresql` restores it); systemd units no
longer Want postgresql.service.
Existing-user upgrade: first boot creates+migrates the DB, configs
import (tags recreate their FileGroups from disk), a refresh rebuilds
the library index. QA'd on the 10.0.0.8 RPi: rebuild matched the
Postgres baseline exactly; survived an uncontrolled hard reboot with
clean integrity checks.
Accepted behavior changes: FTS5 has no stopwords; bm25 length
normalization shifts some rank orders; fast-path d_headline is
un-highlighted; all-negative queries match nothing.
Deferred: Debian/trixie package lists (incl. sqlite3 CLI), Cypress
re-baseline, VACUUM INTO snapshots, exFAT/NTFS warning, auto-kicked
rebuild refresh with progress UX.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:59:20 -06:00
|
|
|
COALESCE(SUM(fg.length), 0) AS "sum_duration",
|
2025-04-17 17:02:39 -06:00
|
|
|
-- sum of all video file sizes
|
Postgres -> SQLite hard cutover: the database moves into the media directory.
The database now lives at <media>/config/wrolpi.db so a library (files,
configs, AND database) travels with the drive between WROLPi systems.
Core:
- wrolpi/db.py: lazy SQLite engine factory (WAL, busy_timeout=30s,
foreign_keys=ON); get_db_curs yields a plain sqlite3.Cursor; native
sqlite3 paramstyle everywhere (no compatibility shims); global sqlite3
adapters keep raw-SQL datetime formats identical to the ORM's.
- wrolpi/fts.py: FTS5 external-content search replacing tsvector/GIN.
Injection-proof websearch->FTS5 translator, bm25(10,4,2,1) reproducing
A/B/C/D weighting, fast(abc)/deep column filtering, snippet headlines.
Stemming contract pinned by dedicated tests (watch/watches/watched/
watching are interchangeable).
- wrolpi/schema_ddl.py: SQLite triggers shared verbatim by the alembic
baseline and test DBs; fixes the channel video_count DELETE/UPDATE
bugs and a latent total_size bug (it used the video id as channel id).
- wrolpi/db_bootstrap.py: startup auto-create/migrate; refuses databases
written by a newer WROLPi (db_version_mismatch); SQLite 3.40 floor;
network-filesystem refusal; never creates/migrates/trusts a database
beneath an unmounted production media directory (shadow-DB guard).
- Alembic: 85 Postgres-era migrations squashed into one baseline;
revision ids are dates (2026_07_10_1350) so filenames self-sort and
alembic_version says when a schema was created; render_as_batch=True.
- All raw Postgres SQL rewritten (ANY->json_each IN, array_agg @> ->
HAVING COUNT(DISTINCT), unnest->executemany/UPDATE..FROM,
generate_series histograms->strftime GROUP BY, DISTINCT ON->
row_number(), writable CTEs->two statements, jsonb_*->json_*, explicit
NULLS FIRST/LAST, LIMIT-before-OFFSET). Simple statements converted
to SQLAlchemy instead (flag upsert, download retry/summary/tag checks,
channel-by-tag estimate, Directory delete).
- Config imports count skipped entries; background dumps are blocked
after a lossy import so partial imports can never overwrite YAML
configs (they are the sole source of truth for rebuilds now).
- Search tolerates explicit JSON nulls for limit/offset (Postgres
treated OFFSET NULL as 0; SQLite errors).
- file_group.id/doc_section.id render as INTEGER PRIMARY KEY on SQLite
(BIGINT is not a rowid alias; FTS5 content_rowid requires it).
Ops teardown: docker-compose db service, CI postgres sidecars,
initialize_api_db.sh, controller postgresql service, psycopg2-binary all
removed. upgrade.sh stops/disables postgresql (data is NOT deleted;
`systemctl enable --now postgresql` restores it); systemd units no
longer Want postgresql.service.
Existing-user upgrade: first boot creates+migrates the DB, configs
import (tags recreate their FileGroups from disk), a refresh rebuilds
the library index. QA'd on the 10.0.0.8 RPi: rebuild matched the
Postgres baseline exactly; survived an uncontrolled hard reboot with
clean integrity checks.
Accepted behavior changes: FTS5 has no stopwords; bm25 length
normalization shifts some rank orders; fast-path d_headline is
un-highlighted; all-negative queries match nothing.
Deferred: Debian/trixie package lists (incl. sqlite3 CLI), Cypress
re-baseline, VACUUM INTO snapshots, exFAT/NTFS warning, auto-kicked
rebuild refresh with progress UX.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:59:20 -06:00
|
|
|
COALESCE(SUM(fg.size), 0) AS "sum_size",
|
2025-04-17 17:02:39 -06:00
|
|
|
-- largest video
|
|
|
|
|
COALESCE(MAX(fg.size), 0) AS "max_size",
|
|
|
|
|
-- Videos may or may not have comments.
|
|
|
|
|
COUNT(v.id) FILTER ( WHERE v.have_comments = TRUE ) AS "have_comments",
|
|
|
|
|
COUNT(v.id) FILTER ( WHERE v.have_comments = FALSE AND v.comments_failed = FALSE
|
|
|
|
|
and fg.censored = false and
|
|
|
|
|
fg.url is not null) AS "missing_comments",
|
|
|
|
|
COUNT(v.id) FILTER ( WHERE v.comments_failed = TRUE ) AS "failed_comments",
|
|
|
|
|
COUNT(v.id) FILTER ( WHERE fg.censored = TRUE ) as "censored_videos"
|
|
|
|
|
FROM video v
|
|
|
|
|
LEFT JOIN file_group fg on v.file_group_id = fg.id
|
|
|
|
|
''')
|
2021-11-01 16:42:52 -06:00
|
|
|
video_stats = dict(curs.fetchone())
|
|
|
|
|
|
|
|
|
|
# Get the total videos downloaded every month for the past two years.
|
Postgres -> SQLite hard cutover: the database moves into the media directory.
The database now lives at <media>/config/wrolpi.db so a library (files,
configs, AND database) travels with the drive between WROLPi systems.
Core:
- wrolpi/db.py: lazy SQLite engine factory (WAL, busy_timeout=30s,
foreign_keys=ON); get_db_curs yields a plain sqlite3.Cursor; native
sqlite3 paramstyle everywhere (no compatibility shims); global sqlite3
adapters keep raw-SQL datetime formats identical to the ORM's.
- wrolpi/fts.py: FTS5 external-content search replacing tsvector/GIN.
Injection-proof websearch->FTS5 translator, bm25(10,4,2,1) reproducing
A/B/C/D weighting, fast(abc)/deep column filtering, snippet headlines.
Stemming contract pinned by dedicated tests (watch/watches/watched/
watching are interchangeable).
- wrolpi/schema_ddl.py: SQLite triggers shared verbatim by the alembic
baseline and test DBs; fixes the channel video_count DELETE/UPDATE
bugs and a latent total_size bug (it used the video id as channel id).
- wrolpi/db_bootstrap.py: startup auto-create/migrate; refuses databases
written by a newer WROLPi (db_version_mismatch); SQLite 3.40 floor;
network-filesystem refusal; never creates/migrates/trusts a database
beneath an unmounted production media directory (shadow-DB guard).
- Alembic: 85 Postgres-era migrations squashed into one baseline;
revision ids are dates (2026_07_10_1350) so filenames self-sort and
alembic_version says when a schema was created; render_as_batch=True.
- All raw Postgres SQL rewritten (ANY->json_each IN, array_agg @> ->
HAVING COUNT(DISTINCT), unnest->executemany/UPDATE..FROM,
generate_series histograms->strftime GROUP BY, DISTINCT ON->
row_number(), writable CTEs->two statements, jsonb_*->json_*, explicit
NULLS FIRST/LAST, LIMIT-before-OFFSET). Simple statements converted
to SQLAlchemy instead (flag upsert, download retry/summary/tag checks,
channel-by-tag estimate, Directory delete).
- Config imports count skipped entries; background dumps are blocked
after a lossy import so partial imports can never overwrite YAML
configs (they are the sole source of truth for rebuilds now).
- Search tolerates explicit JSON nulls for limit/offset (Postgres
treated OFFSET NULL as 0; SQLite errors).
- file_group.id/doc_section.id render as INTEGER PRIMARY KEY on SQLite
(BIGINT is not a rowid alias; FTS5 content_rowid requires it).
Ops teardown: docker-compose db service, CI postgres sidecars,
initialize_api_db.sh, controller postgresql service, psycopg2-binary all
removed. upgrade.sh stops/disables postgresql (data is NOT deleted;
`systemctl enable --now postgresql` restores it); systemd units no
longer Want postgresql.service.
Existing-user upgrade: first boot creates+migrates the DB, configs
import (tags recreate their FileGroups from disk), a refresh rebuilds
the library index. QA'd on the 10.0.0.8 RPi: rebuild matched the
Postgres baseline exactly; survived an uncontrolled hard reboot with
clean integrity checks.
Accepted behavior changes: FTS5 has no stopwords; bm25 length
normalization shifts some rank orders; fast-path d_headline is
un-highlighted; all-negative queries match nothing.
Deferred: Debian/trixie package lists (incl. sqlite3 CLI), Cypress
re-baseline, VACUUM INTO snapshots, exFAT/NTFS warning, auto-kicked
rebuild refresh with progress UX.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:59:20 -06:00
|
|
|
# (Not parameterized, so `%` needs no escaping.) Like the old Postgres query, months
|
|
|
|
|
# without any videos are absent from the results.
|
2021-11-01 16:42:52 -06:00
|
|
|
curs.execute('''
|
Postgres -> SQLite hard cutover: the database moves into the media directory.
The database now lives at <media>/config/wrolpi.db so a library (files,
configs, AND database) travels with the drive between WROLPi systems.
Core:
- wrolpi/db.py: lazy SQLite engine factory (WAL, busy_timeout=30s,
foreign_keys=ON); get_db_curs yields a plain sqlite3.Cursor; native
sqlite3 paramstyle everywhere (no compatibility shims); global sqlite3
adapters keep raw-SQL datetime formats identical to the ORM's.
- wrolpi/fts.py: FTS5 external-content search replacing tsvector/GIN.
Injection-proof websearch->FTS5 translator, bm25(10,4,2,1) reproducing
A/B/C/D weighting, fast(abc)/deep column filtering, snippet headlines.
Stemming contract pinned by dedicated tests (watch/watches/watched/
watching are interchangeable).
- wrolpi/schema_ddl.py: SQLite triggers shared verbatim by the alembic
baseline and test DBs; fixes the channel video_count DELETE/UPDATE
bugs and a latent total_size bug (it used the video id as channel id).
- wrolpi/db_bootstrap.py: startup auto-create/migrate; refuses databases
written by a newer WROLPi (db_version_mismatch); SQLite 3.40 floor;
network-filesystem refusal; never creates/migrates/trusts a database
beneath an unmounted production media directory (shadow-DB guard).
- Alembic: 85 Postgres-era migrations squashed into one baseline;
revision ids are dates (2026_07_10_1350) so filenames self-sort and
alembic_version says when a schema was created; render_as_batch=True.
- All raw Postgres SQL rewritten (ANY->json_each IN, array_agg @> ->
HAVING COUNT(DISTINCT), unnest->executemany/UPDATE..FROM,
generate_series histograms->strftime GROUP BY, DISTINCT ON->
row_number(), writable CTEs->two statements, jsonb_*->json_*, explicit
NULLS FIRST/LAST, LIMIT-before-OFFSET). Simple statements converted
to SQLAlchemy instead (flag upsert, download retry/summary/tag checks,
channel-by-tag estimate, Directory delete).
- Config imports count skipped entries; background dumps are blocked
after a lossy import so partial imports can never overwrite YAML
configs (they are the sole source of truth for rebuilds now).
- Search tolerates explicit JSON nulls for limit/offset (Postgres
treated OFFSET NULL as 0; SQLite errors).
- file_group.id/doc_section.id render as INTEGER PRIMARY KEY on SQLite
(BIGINT is not a rowid alias; FTS5 content_rowid requires it).
Ops teardown: docker-compose db service, CI postgres sidecars,
initialize_api_db.sh, controller postgresql service, psycopg2-binary all
removed. upgrade.sh stops/disables postgresql (data is NOT deleted;
`systemctl enable --now postgresql` restores it); systemd units no
longer Want postgresql.service.
Existing-user upgrade: first boot creates+migrates the DB, configs
import (tags recreate their FileGroups from disk), a refresh rebuilds
the library index. QA'd on the 10.0.0.8 RPi: rebuild matched the
Postgres baseline exactly; survived an uncontrolled hard reboot with
clean integrity checks.
Accepted behavior changes: FTS5 has no stopwords; bm25 length
normalization shifts some rank orders; fast-path d_headline is
un-highlighted; all-negative queries match nothing.
Deferred: Debian/trixie package lists (incl. sqlite3 CLI), Cypress
re-baseline, VACUUM INTO snapshots, exFAT/NTFS warning, auto-kicked
rebuild refresh with progress UX.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:59:20 -06:00
|
|
|
SELECT strftime('%Y-%m-01 00:00:00', fg.published_datetime) AS "month",
|
|
|
|
|
COUNT(video.id) AS "count",
|
|
|
|
|
SUM(fg.size) AS "size"
|
|
|
|
|
FROM video
|
2025-04-17 17:02:39 -06:00
|
|
|
LEFT JOIN file_group fg on video.file_group_id = fg.id
|
Postgres -> SQLite hard cutover: the database moves into the media directory.
The database now lives at <media>/config/wrolpi.db so a library (files,
configs, AND database) travels with the drive between WROLPi systems.
Core:
- wrolpi/db.py: lazy SQLite engine factory (WAL, busy_timeout=30s,
foreign_keys=ON); get_db_curs yields a plain sqlite3.Cursor; native
sqlite3 paramstyle everywhere (no compatibility shims); global sqlite3
adapters keep raw-SQL datetime formats identical to the ORM's.
- wrolpi/fts.py: FTS5 external-content search replacing tsvector/GIN.
Injection-proof websearch->FTS5 translator, bm25(10,4,2,1) reproducing
A/B/C/D weighting, fast(abc)/deep column filtering, snippet headlines.
Stemming contract pinned by dedicated tests (watch/watches/watched/
watching are interchangeable).
- wrolpi/schema_ddl.py: SQLite triggers shared verbatim by the alembic
baseline and test DBs; fixes the channel video_count DELETE/UPDATE
bugs and a latent total_size bug (it used the video id as channel id).
- wrolpi/db_bootstrap.py: startup auto-create/migrate; refuses databases
written by a newer WROLPi (db_version_mismatch); SQLite 3.40 floor;
network-filesystem refusal; never creates/migrates/trusts a database
beneath an unmounted production media directory (shadow-DB guard).
- Alembic: 85 Postgres-era migrations squashed into one baseline;
revision ids are dates (2026_07_10_1350) so filenames self-sort and
alembic_version says when a schema was created; render_as_batch=True.
- All raw Postgres SQL rewritten (ANY->json_each IN, array_agg @> ->
HAVING COUNT(DISTINCT), unnest->executemany/UPDATE..FROM,
generate_series histograms->strftime GROUP BY, DISTINCT ON->
row_number(), writable CTEs->two statements, jsonb_*->json_*, explicit
NULLS FIRST/LAST, LIMIT-before-OFFSET). Simple statements converted
to SQLAlchemy instead (flag upsert, download retry/summary/tag checks,
channel-by-tag estimate, Directory delete).
- Config imports count skipped entries; background dumps are blocked
after a lossy import so partial imports can never overwrite YAML
configs (they are the sole source of truth for rebuilds now).
- Search tolerates explicit JSON nulls for limit/offset (Postgres
treated OFFSET NULL as 0; SQLite errors).
- file_group.id/doc_section.id render as INTEGER PRIMARY KEY on SQLite
(BIGINT is not a rowid alias; FTS5 content_rowid requires it).
Ops teardown: docker-compose db service, CI postgres sidecars,
initialize_api_db.sh, controller postgresql service, psycopg2-binary all
removed. upgrade.sh stops/disables postgresql (data is NOT deleted;
`systemctl enable --now postgresql` restores it); systemd units no
longer Want postgresql.service.
Existing-user upgrade: first boot creates+migrates the DB, configs
import (tags recreate their FileGroups from disk), a refresh rebuilds
the library index. QA'd on the 10.0.0.8 RPi: rebuild matched the
Postgres baseline exactly; survived an uncontrolled hard reboot with
clean integrity checks.
Accepted behavior changes: FTS5 has no stopwords; bm25 length
normalization shifts some rank orders; fast-path d_headline is
un-highlighted; all-negative queries match nothing.
Deferred: Debian/trixie package lists (incl. sqlite3 CLI), Cypress
re-baseline, VACUUM INTO snapshots, exFAT/NTFS warning, auto-kicked
rebuild refresh with progress UX.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:59:20 -06:00
|
|
|
WHERE fg.published_datetime IS NOT NULL
|
|
|
|
|
AND fg.published_datetime >= datetime('now', 'start of month', '-24 months')
|
|
|
|
|
AND fg.published_datetime < datetime('now', 'start of month')
|
2025-04-17 17:02:39 -06:00
|
|
|
GROUP BY 1
|
|
|
|
|
ORDER BY 1
|
|
|
|
|
''')
|
2021-11-01 16:42:52 -06:00
|
|
|
monthly_videos = [dict(i) for i in curs.fetchall()]
|
Postgres -> SQLite hard cutover: the database moves into the media directory.
The database now lives at <media>/config/wrolpi.db so a library (files,
configs, AND database) travels with the drive between WROLPi systems.
Core:
- wrolpi/db.py: lazy SQLite engine factory (WAL, busy_timeout=30s,
foreign_keys=ON); get_db_curs yields a plain sqlite3.Cursor; native
sqlite3 paramstyle everywhere (no compatibility shims); global sqlite3
adapters keep raw-SQL datetime formats identical to the ORM's.
- wrolpi/fts.py: FTS5 external-content search replacing tsvector/GIN.
Injection-proof websearch->FTS5 translator, bm25(10,4,2,1) reproducing
A/B/C/D weighting, fast(abc)/deep column filtering, snippet headlines.
Stemming contract pinned by dedicated tests (watch/watches/watched/
watching are interchangeable).
- wrolpi/schema_ddl.py: SQLite triggers shared verbatim by the alembic
baseline and test DBs; fixes the channel video_count DELETE/UPDATE
bugs and a latent total_size bug (it used the video id as channel id).
- wrolpi/db_bootstrap.py: startup auto-create/migrate; refuses databases
written by a newer WROLPi (db_version_mismatch); SQLite 3.40 floor;
network-filesystem refusal; never creates/migrates/trusts a database
beneath an unmounted production media directory (shadow-DB guard).
- Alembic: 85 Postgres-era migrations squashed into one baseline;
revision ids are dates (2026_07_10_1350) so filenames self-sort and
alembic_version says when a schema was created; render_as_batch=True.
- All raw Postgres SQL rewritten (ANY->json_each IN, array_agg @> ->
HAVING COUNT(DISTINCT), unnest->executemany/UPDATE..FROM,
generate_series histograms->strftime GROUP BY, DISTINCT ON->
row_number(), writable CTEs->two statements, jsonb_*->json_*, explicit
NULLS FIRST/LAST, LIMIT-before-OFFSET). Simple statements converted
to SQLAlchemy instead (flag upsert, download retry/summary/tag checks,
channel-by-tag estimate, Directory delete).
- Config imports count skipped entries; background dumps are blocked
after a lossy import so partial imports can never overwrite YAML
configs (they are the sole source of truth for rebuilds now).
- Search tolerates explicit JSON nulls for limit/offset (Postgres
treated OFFSET NULL as 0; SQLite errors).
- file_group.id/doc_section.id render as INTEGER PRIMARY KEY on SQLite
(BIGINT is not a rowid alias; FTS5 content_rowid requires it).
Ops teardown: docker-compose db service, CI postgres sidecars,
initialize_api_db.sh, controller postgresql service, psycopg2-binary all
removed. upgrade.sh stops/disables postgresql (data is NOT deleted;
`systemctl enable --now postgresql` restores it); systemd units no
longer Want postgresql.service.
Existing-user upgrade: first boot creates+migrates the DB, configs
import (tags recreate their FileGroups from disk), a refresh rebuilds
the library index. QA'd on the 10.0.0.8 RPi: rebuild matched the
Postgres baseline exactly; survived an uncontrolled hard reboot with
clean integrity checks.
Accepted behavior changes: FTS5 has no stopwords; bm25 length
normalization shifts some rank orders; fast-path d_headline is
un-highlighted; all-negative queries match nothing.
Deferred: Debian/trixie package lists (incl. sqlite3 CLI), Cypress
re-baseline, VACUUM INTO snapshots, exFAT/NTFS warning, auto-kicked
rebuild refresh with progress UX.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:59:20 -06:00
|
|
|
for monthly_video in monthly_videos:
|
|
|
|
|
# The frontend expects a datetime, not the TEXT that SQLite stores.
|
|
|
|
|
monthly_video['month'] = datetime.fromisoformat(monthly_video['month'])
|
2021-11-01 16:42:52 -06:00
|
|
|
|
|
|
|
|
historical_stats = dict(monthly_videos=monthly_videos)
|
|
|
|
|
historical_stats['average_count'] = (sum(i['count'] for i in monthly_videos) // len(monthly_videos)) \
|
|
|
|
|
if monthly_videos else 0
|
|
|
|
|
historical_stats['average_size'] = (sum(i['size'] for i in monthly_videos) // len(monthly_videos)) \
|
|
|
|
|
if monthly_videos else 0
|
|
|
|
|
|
|
|
|
|
curs.execute('''
|
2025-10-29 22:16:33 -06:00
|
|
|
SELECT COUNT(c.id) AS "channels",
|
|
|
|
|
COUNT(c.id) FILTER ( WHERE col.tag_id IS NOT NULL ) AS "tagged_channels"
|
2025-04-17 17:02:39 -06:00
|
|
|
FROM channel c
|
2025-10-29 22:16:33 -06:00
|
|
|
LEFT JOIN collection col on col.id = c.collection_id
|
Postgres -> SQLite hard cutover: the database moves into the media directory.
The database now lives at <media>/config/wrolpi.db so a library (files,
configs, AND database) travels with the drive between WROLPi systems.
Core:
- wrolpi/db.py: lazy SQLite engine factory (WAL, busy_timeout=30s,
foreign_keys=ON); get_db_curs yields a plain sqlite3.Cursor; native
sqlite3 paramstyle everywhere (no compatibility shims); global sqlite3
adapters keep raw-SQL datetime formats identical to the ORM's.
- wrolpi/fts.py: FTS5 external-content search replacing tsvector/GIN.
Injection-proof websearch->FTS5 translator, bm25(10,4,2,1) reproducing
A/B/C/D weighting, fast(abc)/deep column filtering, snippet headlines.
Stemming contract pinned by dedicated tests (watch/watches/watched/
watching are interchangeable).
- wrolpi/schema_ddl.py: SQLite triggers shared verbatim by the alembic
baseline and test DBs; fixes the channel video_count DELETE/UPDATE
bugs and a latent total_size bug (it used the video id as channel id).
- wrolpi/db_bootstrap.py: startup auto-create/migrate; refuses databases
written by a newer WROLPi (db_version_mismatch); SQLite 3.40 floor;
network-filesystem refusal; never creates/migrates/trusts a database
beneath an unmounted production media directory (shadow-DB guard).
- Alembic: 85 Postgres-era migrations squashed into one baseline;
revision ids are dates (2026_07_10_1350) so filenames self-sort and
alembic_version says when a schema was created; render_as_batch=True.
- All raw Postgres SQL rewritten (ANY->json_each IN, array_agg @> ->
HAVING COUNT(DISTINCT), unnest->executemany/UPDATE..FROM,
generate_series histograms->strftime GROUP BY, DISTINCT ON->
row_number(), writable CTEs->two statements, jsonb_*->json_*, explicit
NULLS FIRST/LAST, LIMIT-before-OFFSET). Simple statements converted
to SQLAlchemy instead (flag upsert, download retry/summary/tag checks,
channel-by-tag estimate, Directory delete).
- Config imports count skipped entries; background dumps are blocked
after a lossy import so partial imports can never overwrite YAML
configs (they are the sole source of truth for rebuilds now).
- Search tolerates explicit JSON nulls for limit/offset (Postgres
treated OFFSET NULL as 0; SQLite errors).
- file_group.id/doc_section.id render as INTEGER PRIMARY KEY on SQLite
(BIGINT is not a rowid alias; FTS5 content_rowid requires it).
Ops teardown: docker-compose db service, CI postgres sidecars,
initialize_api_db.sh, controller postgresql service, psycopg2-binary all
removed. upgrade.sh stops/disables postgresql (data is NOT deleted;
`systemctl enable --now postgresql` restores it); systemd units no
longer Want postgresql.service.
Existing-user upgrade: first boot creates+migrates the DB, configs
import (tags recreate their FileGroups from disk), a refresh rebuilds
the library index. QA'd on the 10.0.0.8 RPi: rebuild matched the
Postgres baseline exactly; survived an uncontrolled hard reboot with
clean integrity checks.
Accepted behavior changes: FTS5 has no stopwords; bm25 length
normalization shifts some rank orders; fast-path d_headline is
un-highlighted; all-negative queries match nothing.
Deferred: Debian/trixie package lists (incl. sqlite3 CLI), Cypress
re-baseline, VACUUM INTO snapshots, exFAT/NTFS warning, auto-kicked
rebuild refresh with progress UX.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:59:20 -06:00
|
|
|
LEFT JOIN tag t on t.id = col.tag_id
|
2025-04-17 17:02:39 -06:00
|
|
|
''')
|
2021-11-01 16:42:52 -06:00
|
|
|
channel_stats = dict(curs.fetchone())
|
|
|
|
|
ret = dict(statistics=dict(
|
|
|
|
|
videos=video_stats,
|
|
|
|
|
channels=channel_stats,
|
|
|
|
|
historical=historical_stats,
|
|
|
|
|
))
|
|
|
|
|
return ret
|
|
|
|
|
|
|
|
|
|
|
2025-02-07 11:44:17 -07:00
|
|
|
NAME_PARSER = re.compile(
|
|
|
|
|
r'(?:(.*?)_)?((?:\d+?)|(?:NA))_(?:(.{5,25})_)?(.*)'
|
2026-08-24 12:13:52 -06:00
|
|
|
# The file suffix. Every media suffix a Video FileGroup can lead with belongs here:
|
|
|
|
|
# audio is Channel content too, and a suffix missing from this list leaves the file
|
|
|
|
|
# without a published date, sorted to the end of its Channel.
|
|
|
|
|
r'\.(jpg|webp|flv|mp4|m4v|mkv|webm|mp3|flac|opus|ogg|m4a|wav|aac|wma'
|
|
|
|
|
r'|part|info\.json|description|..\.srt|..\.vtt)',
|
2025-02-07 11:44:17 -07:00
|
|
|
re.IGNORECASE)
|
2021-11-01 16:42:52 -06:00
|
|
|
|
|
|
|
|
|
2022-01-14 15:54:33 -07:00
|
|
|
def parse_video_file_name(video_path: pathlib.Path) -> \
|
|
|
|
|
Tuple[Optional[str], Optional[str], Optional[str], Optional[str]]:
|
|
|
|
|
"""
|
|
|
|
|
A Video's file name can have data in it, this attempts to extract what may be there.
|
|
|
|
|
|
2025-02-07 11:44:17 -07:00
|
|
|
{published_date} is assumed to always be %Y%m%d
|
|
|
|
|
|
|
|
|
|
Examples:
|
|
|
|
|
NA_{published_date}_{source_id}_{title}{ext}
|
|
|
|
|
{channel_name}_NA_{source_id}_{title}{ext}
|
|
|
|
|
{channel_name}_{published_date}_{source_id}_{title}{ext} # Typical name format from WROLPi.
|
|
|
|
|
{published_date}_{title}{ext}
|
|
|
|
|
{title}{ext}
|
2022-01-14 15:54:33 -07:00
|
|
|
"""
|
|
|
|
|
video_str = str(video_path)
|
|
|
|
|
if match := NAME_PARSER.match(video_str):
|
|
|
|
|
channel, date, source_id, title, _ = match.groups()
|
|
|
|
|
|
|
|
|
|
channel = None if channel == 'NA' else channel
|
|
|
|
|
date = None if date == 'NA' else date
|
|
|
|
|
|
|
|
|
|
title = title.strip()
|
|
|
|
|
if date == 'NA':
|
|
|
|
|
return channel, None, source_id, title
|
|
|
|
|
if date is None or (len(date) == 8 and date.isdigit()):
|
|
|
|
|
return channel, date, source_id, title
|
|
|
|
|
|
|
|
|
|
# Return the stem as a last resort
|
2025-02-07 11:44:17 -07:00
|
|
|
title, _ = split_path_stem_and_suffix(video_path)
|
|
|
|
|
return None, None, None, title.strip()
|
2022-12-06 14:28:35 -07:00
|
|
|
|
|
|
|
|
|
2026-02-05 08:52:29 -07:00
|
|
|
def get_video_metadata(video) -> dict:
|
|
|
|
|
"""Extract video metadata with fallback chain.
|
|
|
|
|
|
|
|
|
|
Fallback strategy:
|
2026-02-05 11:48:25 -07:00
|
|
|
1. Primary: video.channel.name for uploader (when video belongs to a channel)
|
|
|
|
|
2. Secondary: info_json + database fields (video.source_id, file_group.published_datetime)
|
|
|
|
|
3. Tertiary: parse_video_file_name() - ALL-OR-NOTHING, only if it provides channel AND date AND source_id
|
2026-02-05 08:52:29 -07:00
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
dict with keys: upload_date (datetime|None), uploader (str), source_id (str), title (str)
|
|
|
|
|
"""
|
|
|
|
|
file_group = video.file_group
|
|
|
|
|
info_json = video.get_info_json() or {}
|
|
|
|
|
|
|
|
|
|
# Primary sources
|
|
|
|
|
upload_date = file_group.published_datetime
|
|
|
|
|
if not upload_date and info_json.get('upload_date'):
|
|
|
|
|
upload_date = dates.strpdate(info_json['upload_date'])
|
|
|
|
|
|
2026-02-05 11:48:25 -07:00
|
|
|
# Prioritize WROLPi channel name when video belongs to a channel
|
|
|
|
|
# This ensures reorganized files use the user's channel name, not the YouTube uploader name
|
|
|
|
|
if video.channel and video.channel.name:
|
|
|
|
|
uploader = video.channel.name
|
|
|
|
|
else:
|
|
|
|
|
uploader = info_json.get('uploader') or info_json.get('channel') or ''
|
|
|
|
|
|
2026-02-05 08:52:29 -07:00
|
|
|
source_id = video.source_id or info_json.get('id') or ''
|
|
|
|
|
title = file_group.title or info_json.get('title') or info_json.get('fulltitle') or 'untitled'
|
|
|
|
|
|
|
|
|
|
# Fallback: Parse filename - ALL-OR-NOTHING
|
|
|
|
|
# Only use if it provides ALL three fields: channel, date, source_id
|
|
|
|
|
if file_group.primary_path and not (upload_date and source_id and uploader):
|
|
|
|
|
parsed_channel, parsed_date, parsed_source_id, _ = parse_video_file_name(file_group.primary_path)
|
|
|
|
|
|
|
|
|
|
if parsed_channel and parsed_date and parsed_source_id:
|
|
|
|
|
if not upload_date:
|
|
|
|
|
upload_date = dates.strpdate(parsed_date)
|
|
|
|
|
if not source_id:
|
|
|
|
|
source_id = parsed_source_id
|
|
|
|
|
if not uploader:
|
|
|
|
|
uploader = parsed_channel
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
'upload_date': upload_date,
|
|
|
|
|
'uploader': uploader,
|
|
|
|
|
'source_id': source_id,
|
|
|
|
|
'title': title,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2022-12-06 14:28:35 -07:00
|
|
|
def find_orphaned_video_files(directory: pathlib.Path) -> Generator[pathlib.Path, None, None]:
|
|
|
|
|
"""Finds all files which should be associated with a video file, but a video file does not match their stem.
|
|
|
|
|
|
|
|
|
|
Example:
|
|
|
|
|
video1.mp4, video1.info.json # Video file with info json.
|
|
|
|
|
video2.info.json # Orphaned video json.
|
|
|
|
|
"""
|
|
|
|
|
with get_db_curs() as curs:
|
|
|
|
|
if not directory.is_dir():
|
|
|
|
|
raise UnknownDirectory()
|
|
|
|
|
|
2025-12-23 21:02:36 -07:00
|
|
|
directory_str = str(directory).rstrip('/')
|
2022-12-06 14:28:35 -07:00
|
|
|
|
2025-12-23 21:02:36 -07:00
|
|
|
# Query returns directory and files - files may contain relative or absolute paths
|
|
|
|
|
# Use indexed directory column for efficient lookup
|
Postgres -> SQLite hard cutover: the database moves into the media directory.
The database now lives at <media>/config/wrolpi.db so a library (files,
configs, AND database) travels with the drive between WROLPi systems.
Core:
- wrolpi/db.py: lazy SQLite engine factory (WAL, busy_timeout=30s,
foreign_keys=ON); get_db_curs yields a plain sqlite3.Cursor; native
sqlite3 paramstyle everywhere (no compatibility shims); global sqlite3
adapters keep raw-SQL datetime formats identical to the ORM's.
- wrolpi/fts.py: FTS5 external-content search replacing tsvector/GIN.
Injection-proof websearch->FTS5 translator, bm25(10,4,2,1) reproducing
A/B/C/D weighting, fast(abc)/deep column filtering, snippet headlines.
Stemming contract pinned by dedicated tests (watch/watches/watched/
watching are interchangeable).
- wrolpi/schema_ddl.py: SQLite triggers shared verbatim by the alembic
baseline and test DBs; fixes the channel video_count DELETE/UPDATE
bugs and a latent total_size bug (it used the video id as channel id).
- wrolpi/db_bootstrap.py: startup auto-create/migrate; refuses databases
written by a newer WROLPi (db_version_mismatch); SQLite 3.40 floor;
network-filesystem refusal; never creates/migrates/trusts a database
beneath an unmounted production media directory (shadow-DB guard).
- Alembic: 85 Postgres-era migrations squashed into one baseline;
revision ids are dates (2026_07_10_1350) so filenames self-sort and
alembic_version says when a schema was created; render_as_batch=True.
- All raw Postgres SQL rewritten (ANY->json_each IN, array_agg @> ->
HAVING COUNT(DISTINCT), unnest->executemany/UPDATE..FROM,
generate_series histograms->strftime GROUP BY, DISTINCT ON->
row_number(), writable CTEs->two statements, jsonb_*->json_*, explicit
NULLS FIRST/LAST, LIMIT-before-OFFSET). Simple statements converted
to SQLAlchemy instead (flag upsert, download retry/summary/tag checks,
channel-by-tag estimate, Directory delete).
- Config imports count skipped entries; background dumps are blocked
after a lossy import so partial imports can never overwrite YAML
configs (they are the sole source of truth for rebuilds now).
- Search tolerates explicit JSON nulls for limit/offset (Postgres
treated OFFSET NULL as 0; SQLite errors).
- file_group.id/doc_section.id render as INTEGER PRIMARY KEY on SQLite
(BIGINT is not a rowid alias; FTS5 content_rowid requires it).
Ops teardown: docker-compose db service, CI postgres sidecars,
initialize_api_db.sh, controller postgresql service, psycopg2-binary all
removed. upgrade.sh stops/disables postgresql (data is NOT deleted;
`systemctl enable --now postgresql` restores it); systemd units no
longer Want postgresql.service.
Existing-user upgrade: first boot creates+migrates the DB, configs
import (tags recreate their FileGroups from disk), a refresh rebuilds
the library index. QA'd on the 10.0.0.8 RPi: rebuild matched the
Postgres baseline exactly; survived an uncontrolled hard reboot with
clean integrity checks.
Accepted behavior changes: FTS5 has no stopwords; bm25 length
normalization shifts some rank orders; fast-path d_headline is
un-highlighted; all-negative queries match nothing.
Deferred: Debian/trixie package lists (incl. sqlite3 CLI), Cypress
re-baseline, VACUUM INTO snapshots, exFAT/NTFS warning, auto-kicked
rebuild refresh with progress UX.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:59:20 -06:00
|
|
|
# (Not parameterized, so `%` needs no escaping.)
|
2022-12-06 14:28:35 -07:00
|
|
|
curs.execute(f'''
|
2025-12-23 21:02:36 -07:00
|
|
|
SELECT directory, files
|
2023-03-24 08:58:18 -06:00
|
|
|
FROM file_group
|
|
|
|
|
WHERE
|
Postgres -> SQLite hard cutover: the database moves into the media directory.
The database now lives at <media>/config/wrolpi.db so a library (files,
configs, AND database) travels with the drive between WROLPi systems.
Core:
- wrolpi/db.py: lazy SQLite engine factory (WAL, busy_timeout=30s,
foreign_keys=ON); get_db_curs yields a plain sqlite3.Cursor; native
sqlite3 paramstyle everywhere (no compatibility shims); global sqlite3
adapters keep raw-SQL datetime formats identical to the ORM's.
- wrolpi/fts.py: FTS5 external-content search replacing tsvector/GIN.
Injection-proof websearch->FTS5 translator, bm25(10,4,2,1) reproducing
A/B/C/D weighting, fast(abc)/deep column filtering, snippet headlines.
Stemming contract pinned by dedicated tests (watch/watches/watched/
watching are interchangeable).
- wrolpi/schema_ddl.py: SQLite triggers shared verbatim by the alembic
baseline and test DBs; fixes the channel video_count DELETE/UPDATE
bugs and a latent total_size bug (it used the video id as channel id).
- wrolpi/db_bootstrap.py: startup auto-create/migrate; refuses databases
written by a newer WROLPi (db_version_mismatch); SQLite 3.40 floor;
network-filesystem refusal; never creates/migrates/trusts a database
beneath an unmounted production media directory (shadow-DB guard).
- Alembic: 85 Postgres-era migrations squashed into one baseline;
revision ids are dates (2026_07_10_1350) so filenames self-sort and
alembic_version says when a schema was created; render_as_batch=True.
- All raw Postgres SQL rewritten (ANY->json_each IN, array_agg @> ->
HAVING COUNT(DISTINCT), unnest->executemany/UPDATE..FROM,
generate_series histograms->strftime GROUP BY, DISTINCT ON->
row_number(), writable CTEs->two statements, jsonb_*->json_*, explicit
NULLS FIRST/LAST, LIMIT-before-OFFSET). Simple statements converted
to SQLAlchemy instead (flag upsert, download retry/summary/tag checks,
channel-by-tag estimate, Directory delete).
- Config imports count skipped entries; background dumps are blocked
after a lossy import so partial imports can never overwrite YAML
configs (they are the sole source of truth for rebuilds now).
- Search tolerates explicit JSON nulls for limit/offset (Postgres
treated OFFSET NULL as 0; SQLite errors).
- file_group.id/doc_section.id render as INTEGER PRIMARY KEY on SQLite
(BIGINT is not a rowid alias; FTS5 content_rowid requires it).
Ops teardown: docker-compose db service, CI postgres sidecars,
initialize_api_db.sh, controller postgresql service, psycopg2-binary all
removed. upgrade.sh stops/disables postgresql (data is NOT deleted;
`systemctl enable --now postgresql` restores it); systemd units no
longer Want postgresql.service.
Existing-user upgrade: first boot creates+migrates the DB, configs
import (tags recreate their FileGroups from disk), a refresh rebuilds
the library index. QA'd on the 10.0.0.8 RPi: rebuild matched the
Postgres baseline exactly; survived an uncontrolled hard reboot with
clean integrity checks.
Accepted behavior changes: FTS5 has no stopwords; bm25 length
normalization shifts some rank orders; fast-path d_headline is
un-highlighted; all-negative queries match nothing.
Deferred: Debian/trixie package lists (incl. sqlite3 CLI), Cypress
re-baseline, VACUUM INTO snapshots, exFAT/NTFS warning, auto-kicked
rebuild refresh with progress UX.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:59:20 -06:00
|
|
|
mimetype NOT LIKE 'video%'
|
|
|
|
|
AND (directory = '{directory_str}' OR directory LIKE '{directory_str}/%')
|
2022-12-06 14:28:35 -07:00
|
|
|
''')
|
2025-12-23 21:02:36 -07:00
|
|
|
for row in curs.fetchall():
|
|
|
|
|
fg_directory = pathlib.Path(row[0]) if row[0] else None
|
Postgres -> SQLite hard cutover: the database moves into the media directory.
The database now lives at <media>/config/wrolpi.db so a library (files,
configs, AND database) travels with the drive between WROLPi systems.
Core:
- wrolpi/db.py: lazy SQLite engine factory (WAL, busy_timeout=30s,
foreign_keys=ON); get_db_curs yields a plain sqlite3.Cursor; native
sqlite3 paramstyle everywhere (no compatibility shims); global sqlite3
adapters keep raw-SQL datetime formats identical to the ORM's.
- wrolpi/fts.py: FTS5 external-content search replacing tsvector/GIN.
Injection-proof websearch->FTS5 translator, bm25(10,4,2,1) reproducing
A/B/C/D weighting, fast(abc)/deep column filtering, snippet headlines.
Stemming contract pinned by dedicated tests (watch/watches/watched/
watching are interchangeable).
- wrolpi/schema_ddl.py: SQLite triggers shared verbatim by the alembic
baseline and test DBs; fixes the channel video_count DELETE/UPDATE
bugs and a latent total_size bug (it used the video id as channel id).
- wrolpi/db_bootstrap.py: startup auto-create/migrate; refuses databases
written by a newer WROLPi (db_version_mismatch); SQLite 3.40 floor;
network-filesystem refusal; never creates/migrates/trusts a database
beneath an unmounted production media directory (shadow-DB guard).
- Alembic: 85 Postgres-era migrations squashed into one baseline;
revision ids are dates (2026_07_10_1350) so filenames self-sort and
alembic_version says when a schema was created; render_as_batch=True.
- All raw Postgres SQL rewritten (ANY->json_each IN, array_agg @> ->
HAVING COUNT(DISTINCT), unnest->executemany/UPDATE..FROM,
generate_series histograms->strftime GROUP BY, DISTINCT ON->
row_number(), writable CTEs->two statements, jsonb_*->json_*, explicit
NULLS FIRST/LAST, LIMIT-before-OFFSET). Simple statements converted
to SQLAlchemy instead (flag upsert, download retry/summary/tag checks,
channel-by-tag estimate, Directory delete).
- Config imports count skipped entries; background dumps are blocked
after a lossy import so partial imports can never overwrite YAML
configs (they are the sole source of truth for rebuilds now).
- Search tolerates explicit JSON nulls for limit/offset (Postgres
treated OFFSET NULL as 0; SQLite errors).
- file_group.id/doc_section.id render as INTEGER PRIMARY KEY on SQLite
(BIGINT is not a rowid alias; FTS5 content_rowid requires it).
Ops teardown: docker-compose db service, CI postgres sidecars,
initialize_api_db.sh, controller postgresql service, psycopg2-binary all
removed. upgrade.sh stops/disables postgresql (data is NOT deleted;
`systemctl enable --now postgresql` restores it); systemd units no
longer Want postgresql.service.
Existing-user upgrade: first boot creates+migrates the DB, configs
import (tags recreate their FileGroups from disk), a refresh rebuilds
the library index. QA'd on the 10.0.0.8 RPi: rebuild matched the
Postgres baseline exactly; survived an uncontrolled hard reboot with
clean integrity checks.
Accepted behavior changes: FTS5 has no stopwords; bm25 length
normalization shifts some rank orders; fast-path d_headline is
un-highlighted; all-negative queries match nothing.
Deferred: Debian/trixie package lists (incl. sqlite3 CLI), Cypress
re-baseline, VACUUM INTO snapshots, exFAT/NTFS warning, auto-kicked
rebuild refresh with progress UX.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:59:20 -06:00
|
|
|
files = row[1]
|
|
|
|
|
if isinstance(files, str):
|
|
|
|
|
# Raw cursor reads return the JSON column as TEXT.
|
|
|
|
|
files = json.loads(files)
|
|
|
|
|
for file_info in files:
|
2025-12-23 21:02:36 -07:00
|
|
|
path = pathlib.Path(file_info['path'])
|
|
|
|
|
if not path.is_absolute() and fg_directory:
|
|
|
|
|
# Resolve relative path using directory
|
|
|
|
|
path = fg_directory / path
|
|
|
|
|
yield path
|
2024-08-06 18:13:21 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def format_videos_destination(channel_name: str = None, channel_tag: str = None, channel_url: str = None) \
|
|
|
|
|
-> pathlib.Path:
|
|
|
|
|
"""Return the directory where Videos should be downloaded according to the WROLPi Config.
|
|
|
|
|
|
2025-12-22 17:56:35 -07:00
|
|
|
Note: When you have a Collection object, prefer using Collection.format_destination()
|
|
|
|
|
or Collection.get_or_set_directory() instead, as they provide unified behavior
|
|
|
|
|
across both channel and domain collections.
|
|
|
|
|
|
2024-08-06 18:13:21 -06:00
|
|
|
@warning: Directory may or may not exist."""
|
|
|
|
|
videos_destination = get_wrolpi_config().videos_destination
|
|
|
|
|
|
|
|
|
|
channel_domain = ''
|
|
|
|
|
if channel_url:
|
|
|
|
|
try:
|
|
|
|
|
channel_domain = extract_domain(channel_url)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f'Failed to extract domain from Channel URL: {channel_url}', exc_info=e)
|
|
|
|
|
if PYTEST:
|
|
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
if channel_name and not isinstance(channel_name, str):
|
|
|
|
|
raise RuntimeError('channel name must be string')
|
|
|
|
|
if channel_tag and not isinstance(channel_tag, str):
|
|
|
|
|
raise RuntimeError('channel tag must be string')
|
|
|
|
|
|
|
|
|
|
name = escape_file_name(channel_name) if channel_name else ''
|
|
|
|
|
variables = dict(
|
|
|
|
|
channel_name=name,
|
|
|
|
|
channel_tag=channel_tag or '',
|
|
|
|
|
channel_domain=channel_domain,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
videos_destination = videos_destination % variables
|
|
|
|
|
except KeyError as e:
|
2024-11-02 15:14:02 -06:00
|
|
|
msg = f'Cannot download to the "videos_destination" from Settings: {videos_destination}'
|
|
|
|
|
raise FileNotFoundError(msg) from e
|
2024-08-06 18:13:21 -06:00
|
|
|
|
|
|
|
|
videos_destination = get_media_directory() / videos_destination.lstrip('/')
|
|
|
|
|
|
|
|
|
|
return videos_destination
|
2025-04-17 17:02:39 -06:00
|
|
|
|
|
|
|
|
|
2026-01-23 19:51:02 -07:00
|
|
|
def format_video_filename(
|
|
|
|
|
video: Video,
|
|
|
|
|
file_name_format: str = None,
|
|
|
|
|
) -> str:
|
|
|
|
|
"""Single source of truth for video filenames.
|
|
|
|
|
|
|
|
|
|
Formats a video filename using the configured file_name_format template.
|
|
|
|
|
Used by both the video downloader (initial download) and collection reorganization.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
video: Video model with metadata (upload_date, uploader, title, source_id)
|
|
|
|
|
file_name_format: Optional override, defaults to config value
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Formatted filename (may include subdirectory path like "2025/01/video.mp4")
|
|
|
|
|
|
|
|
|
|
Example:
|
|
|
|
|
With format '%(upload_year)s/%(upload_month)s/%(uploader)s_%(upload_date)s_%(id)s_%(title)s.%(ext)s':
|
|
|
|
|
Returns '2025/01/WROLPi_20250115_abc123_My Video Title.mp4'
|
|
|
|
|
"""
|
|
|
|
|
from modules.videos.downloader import convert_wrolpi_filename_format
|
|
|
|
|
|
|
|
|
|
config = get_videos_downloader_config()
|
|
|
|
|
template = file_name_format or config.file_name_format
|
|
|
|
|
|
2026-02-05 08:52:29 -07:00
|
|
|
# Get metadata with fallback chain
|
|
|
|
|
metadata = get_video_metadata(video)
|
|
|
|
|
upload_date = metadata['upload_date']
|
|
|
|
|
uploader = metadata['uploader']
|
|
|
|
|
source_id = metadata['source_id']
|
|
|
|
|
title = metadata['title']
|
2026-01-23 19:51:02 -07:00
|
|
|
|
|
|
|
|
# Get video file extension
|
|
|
|
|
video_path = video.video_path
|
|
|
|
|
ext = video_path.suffix.lstrip('.') if video_path else config.merge_output_format or 'mp4'
|
|
|
|
|
|
|
|
|
|
variables = dict(
|
|
|
|
|
uploader=escape_file_name(uploader) if uploader else '',
|
|
|
|
|
channel=escape_file_name(uploader) if uploader else '', # Alias
|
|
|
|
|
upload_date=upload_date.strftime('%Y%m%d') if upload_date else '',
|
|
|
|
|
id=source_id,
|
|
|
|
|
title=escape_file_name(title) if title else 'untitled',
|
|
|
|
|
ext=ext,
|
|
|
|
|
# WROLPi-specific variables for subdirectory organization
|
|
|
|
|
upload_year=str(upload_date.year) if upload_date else '',
|
|
|
|
|
upload_month=f'{upload_date.month:02d}' if upload_date else '',
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Convert WROLPi-specific variables to yt-dlp syntax then apply
|
|
|
|
|
# (This handles %(upload_year)s -> %(upload_date>%Y)s conversion)
|
|
|
|
|
converted_template = convert_wrolpi_filename_format(template)
|
|
|
|
|
|
|
|
|
|
# For the converted variables (like %(upload_date>%Y)s), we need to add them too
|
|
|
|
|
variables['upload_date>%Y'] = str(upload_date.year) if upload_date else ''
|
|
|
|
|
variables['upload_date>%m'] = f'{upload_date.month:02d}' if upload_date else ''
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
result = converted_template % variables
|
|
|
|
|
# Trim the filename portion if it's too long (may include subdirectory paths)
|
|
|
|
|
if '/' in result:
|
|
|
|
|
# Has subdirectories - only trim the filename part
|
|
|
|
|
parts = result.rsplit('/', 1)
|
|
|
|
|
subdir = parts[0]
|
|
|
|
|
filename = trim_file_name(parts[1])
|
|
|
|
|
return f'{subdir}/{filename}'
|
|
|
|
|
else:
|
|
|
|
|
return trim_file_name(result)
|
|
|
|
|
except KeyError as e:
|
|
|
|
|
logger.error(f'Invalid variable in video file_name_format: {e}')
|
|
|
|
|
# Fallback to a simple format
|
|
|
|
|
fallback_title = escape_file_name(title) if title else 'untitled'
|
|
|
|
|
return trim_file_name(f'{fallback_title}.{ext}')
|