From 03cd3312cc29179f115cf12d7c8dcba01a0cfd01 Mon Sep 17 00:00:00 2001 From: arabcoders Date: Tue, 23 Sep 2025 21:34:31 +0300 Subject: [PATCH 1/3] Bundles deno runtime. Closes #426 --- .vscode/settings.json | 1 + Dockerfile | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index c80eb119..f6b0d85f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -49,6 +49,7 @@ "daterange", "defusedxml", "delenv", + "denoland", "dlfields", "dotdot", "dotenv", diff --git a/Dockerfile b/Dockerfile index 8ca4bd7d..0132a114 100644 --- a/Dockerfile +++ b/Dockerfile @@ -66,12 +66,13 @@ COPY --chown=app:app --from=python_builder /opt/python /opt/python COPY --from=ghcr.io/arabcoders/alpine-mp4box /usr/bin/mp4box /usr/bin/mp4box COPY --from=ghcr.io/arabcoders/jellyfin-ffmpeg /usr/bin/ffmpeg /usr/bin/ffmpeg COPY --from=ghcr.io/arabcoders/jellyfin-ffmpeg /usr/bin/ffprobe /usr/bin/ffprobe +COPY --from=denoland/deno:latest /usr/bin/deno /usr/bin/deno COPY --chown=app:app ./healthcheck.sh /usr/local/bin/healthcheck ENV PATH="/opt/python/bin:$PATH" RUN chown -R app:app /config /downloads && \ - chmod +x /usr/local/bin/healthcheck /usr/bin/mp4box /usr/bin/ffmpeg /usr/bin/ffprobe + chmod +x /usr/local/bin/healthcheck /usr/bin/mp4box /usr/bin/ffmpeg /usr/bin/ffprobe /usr/bin/deno VOLUME /config VOLUME /downloads From d09761bd1c3e2c602471f389782feb8e44514c06 Mon Sep 17 00:00:00 2001 From: arabcoders Date: Tue, 23 Sep 2025 22:00:26 +0300 Subject: [PATCH 2/3] Ensure a valid channel/playlist id present when validating url in youtube task handler. --- app/library/task_handlers/youtube.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/library/task_handlers/youtube.py b/app/library/task_handlers/youtube.py index 63cba520..4db7be5c 100644 --- a/app/library/task_handlers/youtube.py +++ b/app/library/task_handlers/youtube.py @@ -129,10 +129,10 @@ class YoutubeHandler(BaseHandler): None if the URL is neither. """ - if m := YoutubeHandler.CHANNEL_REGEX.match(url): + if (m := YoutubeHandler.CHANNEL_REGEX.match(url)) and m.group("id"): return {"type": "channel_id", "id": m.group("id")} - if m := YoutubeHandler.PLAYLIST_REGEX.match(url): + if (m := YoutubeHandler.PLAYLIST_REGEX.match(url)) and m.group("id"): return {"type": "playlist_id", "id": m.group("id")} return None From 4d493433af4b621025f2f33bf42cffde03af4224 Mon Sep 17 00:00:00 2001 From: arabcoders Date: Wed, 24 Sep 2025 19:23:57 +0300 Subject: [PATCH 3/3] fix nfo maker --- .vscode/settings.json | 2 + app/library/Presets.py | 16 +- app/postprocessors/__init__.py | 2 +- .../{make_nfo.py => nfo_maker.py} | 154 ++++++++++-------- ui/app/components/History.vue | 25 ++- 5 files changed, 121 insertions(+), 78 deletions(-) rename app/postprocessors/{make_nfo.py => nfo_maker.py} (83%) diff --git a/.vscode/settings.json b/.vscode/settings.json index f6b0d85f..8b85e87e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -101,6 +101,7 @@ "mccabe", "mebibytes", "MEIPASS", + "merch", "Microformat", "microformats", "mkvtoolsnix", @@ -154,6 +155,7 @@ "threadsafe", "tiktok", "timespec", + "tinyurl", "tmpfilename", "toplevel", "ungroup", diff --git a/app/library/Presets.py b/app/library/Presets.py index aed370f1..22477f74 100644 --- a/app/library/Presets.py +++ b/app/library/Presets.py @@ -54,16 +54,24 @@ DEFAULT_PRESETS: list[dict[int, dict[str, str | bool]]] = [ "id": "2ade2c28-cad4-4a06-b7eb-2439fdf46f60", "name": "Info Reader Plugin", "description": 'This preset generate specific filename format and metadata to work with yt-dlp info reader plugins for jellyfin/emby and plex and to make play state sync work for WatchState.\n\nThere is one more step you need to do via Other > Terminal if you have it enabled or directly from container shell\n\nyt-dlp -I0 --write-info-json --write-thumbnail --convert-thumbnails jpg --paths /downloads/youtube -o "%(channel|Unknown_title)s [%(channel_id|Unknown_id)s]/%(title).180B [%(channel_id|Unknown_id)s].%(ext)s" -- https://www.youtube.com/channel/UCClfFsWcT3N2I7VTXXyt84A\n\nChange the url to the channel you want to download.\n\nFor more information please visit \nhttps://github.com/arabcoders/watchstate/blob/master/FAQ.md#how-to-get-watchstate-working-with-youtube-contentlibrary', - "template": "%(channel)s [%(channel_id|Unknown_id)s]/Season %(release_date>%Y,upload_date>%Y|Unknown)s/%(release_date>%Y%m%d,upload_date>%Y%m%d)s - %(title).180B [%(extractor)s-%(id)s].%(ext)s", + "template": "%(channel)s [%(channel_id|Unknown_id)s]/Season %(release_date>%Y,upload_date>%Y|Unknown)s/%(release_date>%Y%m%d,upload_date>%Y%m%d)s - %(title).180B [%(id)s].%(ext)s", "cli": "--socket-timeout 30 --download-archive %(config_path)s/archive.log\n--windows-filenames --write-info-json --embed-info-json \n--convert-thumbnails jpg --write-thumbnail --embed-metadata", "default": True, }, { "id": "2ade2c28-cad4-4a06-b7eb-2439fdf46f61", "name": "NFO Maker TV", - "description": "This preset generate specific filename format and metadata to work with media servers like jellyfin/emby and plex.", - "template": "%(channel)s [%(channel_id|Unknown_id)s]/Season %(release_date>%Y,upload_date>%Y|Unknown)s/S%(release_date>%Y,upload_date>%Y)sE%(release_date>%m%d,upload_date>%m%d)s - %(title).100B [%(extractor)s-%(id)s].%(ext)s", - "cli": "--socket-timeout 30 --download-archive %(config_path)s/archive.log \n--windows-filenames --convert-thumbnails jpg --write-thumbnail \n--use-postprocessor NFOMakerTvPP", + "description": "This preset generate specific filename format and metadata to work with media servers like jellyfin/emby and plex for episodes.", + "template": "%(channel)s [%(channel_id|Unknown_id)s]/Season %(release_date>%Y,upload_date>%Y|Unknown)s/S%(release_date>%Y,upload_date>%Y)sE%(release_date>%m%d,upload_date>%m%d)s - %(title).100B [%(id)s].%(ext)s", + "cli": '--socket-timeout 30 --download-archive %(config_path)s/archive.log\n--use-postprocessor "NFOMakerPP:when=after_move" \n--windows-filenames --convert-thumbnails jpg --write-thumbnail', + "default": True, + }, + { + "id": "2ade2c28-cad4-4a06-b7eb-2439fdf46f61", + "name": "NFO Maker Movie", + "description": "This preset generate specific filename format and metadata to work with media servers like jellyfin/emby and plex for movies.", + "template": "%(channel)s [%(channel_id|Unknown_id)s]/Season %(release_date>%Y,upload_date>%Y|Unknown)s/S%(release_date>%Y,upload_date>%Y)sE%(release_date>%m%d,upload_date>%m%d)s - %(title).100B [%(id)s].%(ext)s", + "cli": '--socket-timeout 30 --download-archive %(config_path)s/archive.log\n--use-postprocessor "NFOMakerPP:when=after_move;mode=movie" \n--windows-filenames --convert-thumbnails jpg --write-thumbnail', "default": True, }, ] diff --git a/app/postprocessors/__init__.py b/app/postprocessors/__init__.py index 727ca673..80c2e23d 100644 --- a/app/postprocessors/__init__.py +++ b/app/postprocessors/__init__.py @@ -1,7 +1,7 @@ # flake8: noqa: F401 from yt_dlp.globals import postprocessors -from .make_nfo import NFOMakerMoviePP, NFOMakerTvPP +from .nfo_maker import NFOMakerPP _ytptube_pps = {name: value for name, value in globals().items() if name.endswith("PP")} diff --git a/app/postprocessors/make_nfo.py b/app/postprocessors/nfo_maker.py similarity index 83% rename from app/postprocessors/make_nfo.py rename to app/postprocessors/nfo_maker.py index 377aa681..70ab7ff5 100644 --- a/app/postprocessors/make_nfo.py +++ b/app/postprocessors/nfo_maker.py @@ -13,7 +13,7 @@ if TYPE_CHECKING: from collections.abc import Iterable -class NFOMaker(PostProcessor): +class NFOMakerPP(PostProcessor): """ A Post-Processor that writes metadata to an NFO file using a simple placeholder-based template engine. @@ -24,8 +24,6 @@ class NFOMaker(PostProcessor): - ("date_field", "%Y") -> year from date_field """ - _MAPPING: dict[str, Any] = {} - _TEMPLATE: str | None = None _DATE_FIELDS: tuple[str, ...] = ("upload_date", "release_date", "aired", "premiered") _URL_PAT = re.compile( @@ -43,6 +41,79 @@ class NFOMaker(PostProcessor): r"snapchat|reddit|telegram|t\.me|whatsapp|link\s+in\s+bio|bit\.ly|tinyurl|goo\.gl" r")\b" ) + _args: dict[str, Any] = {} + _MODE: dict = { + "tv": { + "mapping": { + "title": "title", + "season": ("season_number", "season", "year", ("release_date", "%Y"), ("upload_date", "%Y")), + "episode": ( + "episode_number", + "episode", + ("release_date", "%m%d"), + ("upload_date", "%m%d"), + "unique_id", + ), + "aired": ("release_date", "upload_date"), + "author": "uploader", + "plot": "description", + "id": "id", + "extractor": "extractor", + }, + "template": """ + + {title} + {season} + {episode} + {aired} + {unique_id} + {id} + {plot} + +""".strip("\n"), + }, + "movie": { + "mapping": { + "title": "title", + "plot": "description", + "runtime": "duration", + "thumb": "thumbnail", + "id": "id", + "country": "language", + "aired": ("release_date", "upload_date"), + "extractor": "extractor", + "year": ("release_year", "year"), + "author": "uploader", + "trailer": "webpage_url", + }, + "template": """ + + {title} + {plot} + {runtime} + {thumb} + {id} + {unique_id} + {id} + {country} + {aired} + {year} + {trailer} + +""".strip("\n"), + }, + } + + mode: str = "tv" + "The NFO mode to use. Either 'tv' or 'movie'. Default is 'tv'." + + prefix: bool = True + "Prefix episodes with 1 for better sorting." + + def __init__(self, downloader, mode: str = "tv", prefix: bool = True) -> None: + PostProcessor.__init__(self, downloader) + self.mode = str(mode).lower() + self.prefix = prefix in (True, "true", "1", 1) @classmethod def pp_key(cls) -> str: @@ -53,8 +124,8 @@ class NFOMaker(PostProcessor): self.to_screen("No info provided to NFO Maker.") return [], {} - if not self._TEMPLATE: - self.to_screen("NFO template not set, skipping NFO creation.") + if self.mode not in self._MODE: + self.to_screen(f"Invalid mode '{self.mode}'.") return [], info # prefer explicit final path if present, else fall back to filename @@ -103,7 +174,7 @@ class NFOMaker(PostProcessor): def _collect_nfo_data(self, info: dict) -> dict[str, Any]: data: dict[str, Any] = {} - for nfo_name, spec in self._MAPPING.items(): + for nfo_name, spec in self._MODE[self.mode].get("mapping", {}).items(): try: values = spec if isinstance(spec, tuple) else (spec,) resolved_key = None @@ -166,7 +237,7 @@ class NFOMaker(PostProcessor): self.to_screen("Invalid aired date parts, skipping NFO creation.") return False - self.to_screen(f"Creating NFO file at {nfo_file!s}") + self.to_screen(f"Creating {self.mode} NFO file at {nfo_file!s}") nfo_file.parent.mkdir(parents=True, exist_ok=True) nfo_file.touch(exist_ok=True) @@ -174,7 +245,7 @@ class NFOMaker(PostProcessor): data = dict(data) # do not mutate original data["unique_id"] = self._build_unique_id(dt, real_file) - self._write(nfo_file=nfo_file, text=self._TEMPLATE or "", repl=data) + self._write(nfo_file=nfo_file, text=self._MODE[self.mode].get("template", ""), repl=data) return True @staticmethod @@ -230,7 +301,7 @@ class NFOMaker(PostProcessor): return raw if fmt and isinstance(fmt, str): - date_s = NFOMaker._normalize_date(raw) + date_s = NFOMakerPP._normalize_date(raw) if date_s: try: dt = datetime.now(tz=UTC).strptime(date_s, "%Y-%m-%d") @@ -265,10 +336,18 @@ class NFOMaker(PostProcessor): for key, value in safe_repl.items(): if value is None: continue + + if self.prefix and key in ("episode",): + try: + value = f"1{value}" + except Exception: + pass + rendered = rendered.replace(f"{{{key}}}", str(value)) # remove any unresolved placeholder lines - unresolved_keys: Iterable[str] = set({*self._MAPPING.keys(), *safe_repl.keys()}) + mapping = self._MODE[self.mode].get("mapping", {}) + unresolved_keys: Iterable[str] = set({*mapping, *safe_repl.keys()}) pattern = re.compile(rf".*{{(?:{'|'.join(map(re.escape, unresolved_keys))})}}.*") rendered = "\n".join(line for line in rendered.splitlines() if not pattern.fullmatch(line)) @@ -336,58 +415,3 @@ class NFOMaker(PostProcessor): summary = cut[: dot + 1] if 0 < dot else cut.rstrip() return summary - - -class NFOMakerTvPP(NFOMaker): - _MAPPING = { - "title": "title", - "season": ("season_number", "season", "year", ("release_date", "%Y"), ("upload_date", "%Y")), - "episode": ("episode_number", "episode", ("release_date", "%m%d"), ("upload_date", "%m%d"), "unique_id"), - "aired": ("release_date", "upload_date"), - "author": "uploader", - "plot": "description", - "id": "id", - "extractor": "extractor", - } - _TEMPLATE = """ - - {title} - {season} - {episode} - {aired} - {unique_id} - {id} - {plot} - -""".strip("\n") - - -class NFOMakerMoviePP(NFOMaker): - _MAPPING = { - "title": "title", - "plot": "description", - "runtime": "duration", - "thumb": "thumbnail", - "id": "id", - "country": "language", - "aired": ("release_date", "upload_date"), - "extractor": "extractor", - "year": ("release_year", "year"), - "author": "uploader", - "trailer": "webpage_url", - } - _TEMPLATE = """ - - {title} - {plot} - {runtime} - {thumb} - {id} - {unique_id} - {id} - {country} - {aired} - {year} - {trailer} - -""".strip("\n") diff --git a/ui/app/components/History.vue b/ui/app/components/History.vue index 8bf45cf2..fa564054 100644 --- a/ui/app/components/History.vue +++ b/ui/app/components/History.vue @@ -764,7 +764,7 @@ const removeItem = async (item: StoreItem) => { } } -const retryItem = (item: StoreItem, re_add = false) => { +const retryItem = (item: StoreItem, re_add: boolean = false, remove_file: boolean = false) => { const item_req: Partial = { url: item.url, preset: item.preset, @@ -776,7 +776,7 @@ const retryItem = (item: StoreItem, re_add = false) => { auto_start: item.auto_start, } - socket.emit('item_delete', { id: item._id, remove_file: false }) + socket.emit('item_delete', { id: item._id, remove_file: remove_file }) if (selectedElms.value.includes(item._id || '')) { selectedElms.value = selectedElms.value.filter(i => i !== item._id) @@ -860,14 +860,18 @@ const removeFromArchiveDialog = (item: StoreItem) => { dialog_confirm.value.visible = true dialog_confirm.value.title = 'Remove from Archive' dialog_confirm.value.message = `Remove '${item.title || item.id || item.url || '??'}' from archive?` - dialog_confirm.value.options = [ - { key: 'remove_history', label: 'Also, Remove from history.' }, + const opts = [ + { key: 'remove_history', label: 'Remove from history.' }, { key: 're_add', label: 'Re-add to download form.' }, - ] + ]; + if (config.app.remove_files) { + opts.push({ key: 'dont_remove_file', label: "Don't remove associated files." }) + } + dialog_confirm.value.options = opts dialog_confirm.value.confirm = (opts: any) => removeFromArchive(item, opts) } -const removeFromArchive = async (item: StoreItem, opts?: { re_add?: boolean, remove_history?: boolean }) => { +const removeFromArchive = async (item: StoreItem, opts?: { re_add?: boolean, remove_history?: boolean, dont_remove_file?: boolean }) => { try { const req = await request(`/api/history/${item._id}/archive`, { method: 'DELETE' }) const data = await req.json() @@ -883,13 +887,18 @@ const removeFromArchive = async (item: StoreItem, opts?: { re_add?: boolean, rem dialog_confirm.value.visible = false } + let file_delete = config.app.remove_files + if (opts?.dont_remove_file) { + file_delete = false + } + if (opts?.re_add) { - retryItem(item, true) + retryItem(item, true, file_delete) return } if (opts?.remove_history) { - socket.emit('item_delete', { id: item._id, remove_file: false }) + socket.emit('item_delete', { id: item._id, remove_file: file_delete }) } }