[BACKEND] Windows support (#476)

This commit is contained in:
Jesse Bannon 2023-02-28 17:07:03 -08:00 committed by GitHub
parent a2808fa7fe
commit 798bbbc62c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 438 additions and 156 deletions

125
.github/workflows/ci-windows.yaml vendored Normal file
View file

@ -0,0 +1,125 @@
name: ytld-sub CI (Windows)
on:
pull_request:
branches:
- master
push:
branches:
- master
jobs:
test-unit:
runs-on: windows-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.10"
- name: Run unit tests with coverage
run: |
curl.exe -L -o ffmpeg.zip https://github.com/yt-dlp/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip
tar -xf ffmpeg.zip
move "ffmpeg-master-latest-win64-gpl\bin\ffmpeg.exe" "ffmpeg.exe"
move "ffmpeg-master-latest-win64-gpl\bin\ffprobe.exe" "ffprobe.exe"
python -m pip install -e .[test]
python -m pytest tests/unit
test-soundcloud:
runs-on: windows-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.10"
- name: Run e2e soundcloud tests with coverage
run: |
curl.exe -L -o ffmpeg.zip https://github.com/yt-dlp/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip
tar -xf ffmpeg.zip
move "ffmpeg-master-latest-win64-gpl\bin\ffmpeg.exe" "ffmpeg.exe"
move "ffmpeg-master-latest-win64-gpl\bin\ffprobe.exe" "ffprobe.exe"
python -m pip install -e .[test]
python -m pytest tests/e2e/soundcloud
test-bandcamp:
runs-on: windows-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.10"
- name: Run e2e soundcloud tests with coverage
run: |
curl.exe -L -o ffmpeg.zip https://github.com/yt-dlp/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip
tar -xf ffmpeg.zip
move "ffmpeg-master-latest-win64-gpl\bin\ffmpeg.exe" "ffmpeg.exe"
move "ffmpeg-master-latest-win64-gpl\bin\ffprobe.exe" "ffprobe.exe"
python -m pip install -e .[test]
python -m pytest tests/e2e/bandcamp
test-youtube:
runs-on: windows-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.10"
- name: Run e2e youtube tests with coverage
run: |
curl.exe -L -o ffmpeg.zip https://github.com/yt-dlp/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip
tar -xf ffmpeg.zip
move "ffmpeg-master-latest-win64-gpl\bin\ffmpeg.exe" "ffmpeg.exe"
move "ffmpeg-master-latest-win64-gpl\bin\ffprobe.exe" "ffprobe.exe"
python -m pip install -e .[test]
python -m pytest tests/e2e/youtube
test-plugins:
runs-on: windows-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.10"
- name: Run e2e plugin tests with coverage
run: |
curl.exe -L -o ffmpeg.zip https://github.com/yt-dlp/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip
tar -xf ffmpeg.zip
move "ffmpeg-master-latest-win64-gpl\bin\ffmpeg.exe" "ffmpeg.exe"
move "ffmpeg-master-latest-win64-gpl\bin\ffprobe.exe" "ffprobe.exe"
python -m pip install -e .[test]
python -m pytest tests/e2e/plugins

View file

@ -1,4 +1,4 @@
name: ytld-sub CI name: ytld-sub CI (Linux)
on: on:
pull_request: pull_request:
@ -8,70 +8,42 @@ on:
branches: branches:
- master - master
jobs: jobs:
test-build:
runs-on: ubuntu-22.04
strategy:
matrix:
python-version: ["3.10"]
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m venv /opt/env
source /opt/env/bin/activate
pip install -e .[lint,test]
- name: Save Python build cache
uses: actions/cache@v3
with:
path: /opt/env
key: ${{github.sha}}-env
test-lint: test-lint:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
needs: test-build
permissions: permissions:
contents: read contents: read
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Restore Python build cache - name: Set up Python
uses: actions/cache@v3 uses: actions/setup-python@v4
with: with:
path: /opt/env python-version: "3.10"
key: ${{github.sha}}-env
- name: Run linters - name: Run linters
run: | run: |
source /opt/env/bin/activate pip install -e .[lint]
make check_lint make check_lint
test-unit: test-unit:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
needs: test-build
permissions: permissions:
contents: read contents: read
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Restore Python build cache - name: Set up Python
uses: actions/cache@v3 uses: actions/setup-python@v4
with: with:
path: /opt/env python-version: "3.10"
key: ${{github.sha}}-env
- name: Run unit tests with coverage - name: Run unit tests with coverage
run: | run: |
pip install -e .[test]
sudo apt-get update sudo apt-get update
sudo apt-get install -y ffmpeg sudo apt-get install -y ffmpeg
source /opt/env/bin/activate
coverage run -m pytest tests/unit && coverage xml -o /opt/coverage/unit/coverage.xml coverage run -m pytest tests/unit && coverage xml -o /opt/coverage/unit/coverage.xml
- name: Save coverage - name: Save coverage
@ -82,46 +54,42 @@ jobs:
test-soundcloud: test-soundcloud:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
needs: test-build
permissions: permissions:
contents: read contents: read
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Restore Python build cache - name: Set up Python
uses: actions/cache@v3 uses: actions/setup-python@v4
with: with:
path: /opt/env python-version: "3.10"
key: ${{github.sha}}-env
- name: Run e2e soundcloud tests with coverage - name: Run e2e soundcloud tests with coverage
run: | run: |
pip install -e .[test]
sudo apt-get update sudo apt-get update
sudo apt-get install -y ffmpeg sudo apt-get install -y ffmpeg
source /opt/env/bin/activate
coverage run -m pytest tests/e2e/soundcloud && coverage xml -o /opt/coverage/soundcloud/coverage.xml coverage run -m pytest tests/e2e/soundcloud && coverage xml -o /opt/coverage/soundcloud/coverage.xml
test-bandcamp: test-bandcamp:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
needs: test-build
permissions: permissions:
contents: read contents: read
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Restore Python build cache - name: Set up Python
uses: actions/cache@v3 uses: actions/setup-python@v4
with: with:
path: /opt/env python-version: "3.10"
key: ${{github.sha}}-env
- name: Run e2e soundcloud tests with coverage - name: Run e2e soundcloud tests with coverage
run: | run: |
pip install -e .[test]
sudo apt-get update sudo apt-get update
sudo apt-get install -y ffmpeg sudo apt-get install -y ffmpeg
source /opt/env/bin/activate
coverage run -m pytest tests/e2e/bandcamp && coverage xml -o /opt/coverage/bandcamp/coverage.xml coverage run -m pytest tests/e2e/bandcamp && coverage xml -o /opt/coverage/bandcamp/coverage.xml
- name: Save coverage - name: Save coverage
@ -132,24 +100,22 @@ jobs:
test-youtube: test-youtube:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
needs: test-build
permissions: permissions:
contents: read contents: read
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Restore Python build cache - name: Set up Python
uses: actions/cache@v3 uses: actions/setup-python@v4
with: with:
path: /opt/env python-version: "3.10"
key: ${{github.sha}}-env
- name: Run e2e youtube tests with coverage - name: Run e2e youtube tests with coverage
run: | run: |
pip install -e .[test]
sudo apt-get update sudo apt-get update
sudo apt-get install -y ffmpeg sudo apt-get install -y ffmpeg
source /opt/env/bin/activate
coverage run -m pytest tests/e2e/youtube && coverage xml -o /opt/coverage/youtube/coverage.xml coverage run -m pytest tests/e2e/youtube && coverage xml -o /opt/coverage/youtube/coverage.xml
- name: Save coverage - name: Save coverage
@ -160,24 +126,22 @@ jobs:
test-plugins: test-plugins:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
needs: test-build
permissions: permissions:
contents: read contents: read
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Restore Python build cache - name: Set up Python
uses: actions/cache@v3 uses: actions/setup-python@v4
with: with:
path: /opt/env python-version: "3.10"
key: ${{github.sha}}-env
- name: Run e2e plugin tests with coverage - name: Run e2e plugin tests with coverage
run: | run: |
pip install -e .[test]
sudo apt-get update sudo apt-get update
sudo apt-get install -y ffmpeg sudo apt-get install -y ffmpeg
source /opt/env/bin/activate
coverage run -m pytest tests/e2e/plugins && coverage xml -o /opt/coverage/plugins/coverage.xml coverage run -m pytest tests/e2e/plugins && coverage xml -o /opt/coverage/plugins/coverage.xml
- name: Save coverage - name: Save coverage

5
.gitignore vendored
View file

@ -142,4 +142,7 @@ docker/*.whl
docker/root/*.whl docker/root/*.whl
docker/root/defaults/examples docker/root/defaults/examples
.local/ .local/
ffmpeg.exe
ffprobe.exe

View file

@ -34,11 +34,11 @@ maximum flexibility while maintaining simplicity.
![jelly_mv](https://user-images.githubusercontent.com/10107080/182677256-43aeb029-0c3f-4648-9fd2-352b9666b262.PNG) ![jelly_mv](https://user-images.githubusercontent.com/10107080/182677256-43aeb029-0c3f-4648-9fd2-352b9666b262.PNG)
### SoundCloud Albums and Singles ### SoundCloud Albums and Singles
#### MusicBee (any file or tag-based music player) #### MusicBee (any file or tag-based music players)
![sc_mb](https://user-images.githubusercontent.com/10107080/182685415-06adf477-3dd3-475d-bbcd-53b0152b9f0a.PNG) ![sc_mb](https://user-images.githubusercontent.com/10107080/182685415-06adf477-3dd3-475d-bbcd-53b0152b9f0a.PNG)
### Bandcamp Discography ### Bandcamp Discography
#### Navidrome (any file or tag-based music server) #### Navidrome (any file or tag-based music servers)
![bc_nav](https://user-images.githubusercontent.com/10107080/212503861-1d8748e6-6f6d-4043-b543-84226cd1f662.png) ![bc_nav](https://user-images.githubusercontent.com/10107080/212503861-1d8748e6-6f6d-4043-b543-84226cd1f662.png)
@ -285,7 +285,7 @@ docker run -d \
Download and use our latest executable using the command below. For Windows users, use this method in Download and use our latest executable using the command below. For Windows users, use this method in
[WSL](https://learn.microsoft.com/en-us/windows/wsl/). FFmpeg is a required dependency. [WSL](https://learn.microsoft.com/en-us/windows/wsl/). FFmpeg is a required dependency.
```commandline ```commandline
curl -L https://github.com/jmbannon/ytdl-sub/releases/latest/download/ytdl-sub > ytdl-sub curl -L -o ytdl-sub https://github.com/jmbannon/ytdl-sub/releases/latest/download/ytdl-sub
chmod +x ytdl-sub chmod +x ytdl-sub
./ytdl-sub -h ./ytdl-sub -h
``` ```

View file

@ -1,11 +1,6 @@
import argparse import argparse
import errno
import fcntl
import gc import gc
import os
import sys import sys
from contextlib import contextmanager
from pathlib import Path
from typing import List from typing import List
from typing import Tuple from typing import Tuple
@ -13,8 +8,8 @@ from ytdl_sub.cli.download_args_parser import DownloadArgsParser
from ytdl_sub.cli.main_args_parser import parser from ytdl_sub.cli.main_args_parser import parser
from ytdl_sub.config.config_file import ConfigFile from ytdl_sub.config.config_file import ConfigFile
from ytdl_sub.subscriptions.subscription import Subscription from ytdl_sub.subscriptions.subscription import Subscription
from ytdl_sub.utils.exceptions import ValidationException
from ytdl_sub.utils.file_handler import FileHandlerTransactionLog from ytdl_sub.utils.file_handler import FileHandlerTransactionLog
from ytdl_sub.utils.file_lock import working_directory_lock
from ytdl_sub.utils.logger import Logger from ytdl_sub.utils.logger import Logger
logger = Logger.get() logger = Logger.get()
@ -119,45 +114,6 @@ def _view_url_from_cli(
return subscription, subscription.download(dry_run=True) return subscription, subscription.download(dry_run=True)
@contextmanager
def _working_directory_lock(config: ConfigFile):
"""
Create and try to lock the file /tmp/working_directory_name
Raises
------
ValidationException
Lock is acquired from another process running ytdl-sub in the same working directory
OSError
Other lock error occurred
"""
working_directory_path = Path(os.getcwd()) / config.config_options.working_directory
lock_file_path = (
Path(os.getcwd())
/ config.config_options.lock_directory
/ str(working_directory_path).replace("/", "_")
)
lock_file = open(lock_file_path, "w", encoding="utf-8")
try:
fcntl.lockf(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError as exc:
if exc.errno in (errno.EACCES, errno.EAGAIN):
raise ValidationException(
"Cannot run two instances of ytdl-sub "
"with the same working directory at the same time"
) from exc
lock_file.close()
raise exc
try:
yield
finally:
fcntl.flock(lock_file, fcntl.LOCK_UN)
lock_file.close()
def main() -> List[Tuple[Subscription, FileHandlerTransactionLog]]: def main() -> List[Tuple[Subscription, FileHandlerTransactionLog]]:
""" """
Entrypoint for ytdl-sub, without the error handling Entrypoint for ytdl-sub, without the error handling
@ -172,7 +128,7 @@ def main() -> List[Tuple[Subscription, FileHandlerTransactionLog]]:
config: ConfigFile = ConfigFile.from_file_path(args.config).initialize() config: ConfigFile = ConfigFile.from_file_path(args.config).initialize()
transaction_logs: List[Tuple[Subscription, FileHandlerTransactionLog]] = [] transaction_logs: List[Tuple[Subscription, FileHandlerTransactionLog]] = []
with _working_directory_lock(config=config): with working_directory_lock(config=config):
if args.subparser == "sub": if args.subparser == "sub":
transaction_logs = _download_subscriptions_from_yaml_files(config=config, args=args) transaction_logs = _download_subscriptions_from_yaml_files(config=config, args=args)

View file

@ -5,14 +5,24 @@ from typing import Optional
from mergedeep import mergedeep from mergedeep import mergedeep
from ytdl_sub.prebuilt_presets import PREBUILT_PRESETS from ytdl_sub.prebuilt_presets import PREBUILT_PRESETS
from ytdl_sub.utils.system import IS_WINDOWS
from ytdl_sub.validators.strict_dict_validator import StrictDictValidator from ytdl_sub.validators.strict_dict_validator import StrictDictValidator
from ytdl_sub.validators.validators import LiteralDictValidator from ytdl_sub.validators.validators import LiteralDictValidator
from ytdl_sub.validators.validators import StringValidator from ytdl_sub.validators.validators import StringValidator
if IS_WINDOWS:
_DEFAULT_LOCK_DIRECTORY = "" # Not supported in Windows
_DEFAULT_FFMPEG_PATH = ".\\ffmpeg.exe"
_DEFAULT_FFPROBE_PATH = ".\\ffprobe.exe"
else:
_DEFAULT_LOCK_DIRECTORY = "/tmp"
_DEFAULT_FFMPEG_PATH = "/usr/bin/ffmpeg"
_DEFAULT_FFPROBE_PATH = "/usr/bin/ffprobe"
class ConfigOptions(StrictDictValidator): class ConfigOptions(StrictDictValidator):
_required_keys = {"working_directory"} _required_keys = {"working_directory"}
_optional_keys = {"umask", "dl_aliases", "lock_directory"} _optional_keys = {"umask", "dl_aliases", "lock_directory", "ffmpeg_path", "ffprobe_path"}
def __init__(self, name: str, value: Any): def __init__(self, name: str, value: Any):
super().__init__(name, value) super().__init__(name, value)
@ -27,7 +37,14 @@ class ConfigOptions(StrictDictValidator):
key="dl_aliases", validator=LiteralDictValidator key="dl_aliases", validator=LiteralDictValidator
) )
self._lock_directory = self._validate_key( self._lock_directory = self._validate_key(
key="lock_directory", validator=StringValidator, default="/tmp" key="lock_directory", validator=StringValidator, default=_DEFAULT_LOCK_DIRECTORY
)
# TODO: Validate these exist
self._ffmpeg_path = self._validate_key(
key="ffmpeg_path", validator=StringValidator, default=_DEFAULT_FFMPEG_PATH
)
self._ffprobe_path = self._validate_key(
key="ffprobe_path", validator=StringValidator, default=_DEFAULT_FFPROBE_PATH
) )
@property @property
@ -82,6 +99,20 @@ class ConfigOptions(StrictDictValidator):
""" """
return self._lock_directory.value return self._lock_directory.value
@property
def ffmpeg_path(self) -> str:
"""
TODO: Fill out!
"""
return self._ffmpeg_path.value
@property
def ffprobe_path(self) -> str:
"""
TODO: Fill out!
"""
return self._ffprobe_path.value
class ConfigValidator(StrictDictValidator): class ConfigValidator(StrictDictValidator):
_required_keys = {"configuration", "presets"} _required_keys = {"configuration", "presets"}

View file

@ -7,6 +7,10 @@ from typing import Optional
from yt_dlp.utils import sanitize_filename from yt_dlp.utils import sanitize_filename
from ytdl_sub.entries.entry import Entry from ytdl_sub.entries.entry import Entry
from ytdl_sub.validators.file_path_validators import (
OverridesStringFormatterValidatorFilePathValidator,
)
from ytdl_sub.validators.file_path_validators import StringFormatterFilePathValidator
from ytdl_sub.validators.strict_dict_validator import StrictDictValidator from ytdl_sub.validators.strict_dict_validator import StrictDictValidator
from ytdl_sub.validators.string_datetime import StringDatetimeValidator from ytdl_sub.validators.string_datetime import StringDatetimeValidator
from ytdl_sub.validators.string_formatter_validators import DictFormatterValidator from ytdl_sub.validators.string_formatter_validators import DictFormatterValidator
@ -221,19 +225,19 @@ class OutputOptions(StrictDictValidator):
# Output directory should resolve without any entry variables. # Output directory should resolve without any entry variables.
# This is to check the directory for any download-archives before any downloads begin # This is to check the directory for any download-archives before any downloads begin
self._output_directory: OverridesStringFormatterValidator = self._validate_key( self._output_directory = self._validate_key(
key="output_directory", validator=OverridesStringFormatterValidator key="output_directory", validator=OverridesStringFormatterValidatorFilePathValidator
) )
# file name and thumbnails however can use entry variables # file name and thumbnails however can use entry variables
self._file_name: StringFormatterValidator = self._validate_key( self._file_name = self._validate_key(
key="file_name", validator=StringFormatterValidator key="file_name", validator=StringFormatterFilePathValidator
) )
self._thumbnail_name = self._validate_key_if_present( self._thumbnail_name = self._validate_key_if_present(
key="thumbnail_name", validator=StringFormatterValidator key="thumbnail_name", validator=StringFormatterFilePathValidator
) )
self._info_json_name = self._validate_key_if_present( self._info_json_name = self._validate_key_if_present(
key="info_json_name", validator=StringFormatterValidator key="info_json_name", validator=StringFormatterFilePathValidator
) )
self._maintain_download_archive = self._validate_key_if_present( self._maintain_download_archive = self._validate_key_if_present(

View file

@ -439,7 +439,7 @@ class Downloader(DownloadArchiver, Generic[DownloaderOptionsT], ABC):
if path.endswith(".info.json") if path.endswith(".info.json")
] ]
for info_json_file in info_json_files: for info_json_file in info_json_files:
os.remove(info_json_file) FileHandler.delete(info_json_file)
def _extract_entry_info_with_retry(self, entry: Entry) -> Entry: def _extract_entry_info_with_retry(self, entry: Entry) -> Entry:
download_entry_dict = self.extract_info_with_retry( download_entry_dict = self.extract_info_with_retry(

View file

@ -16,6 +16,7 @@ from ytdl_sub.utils.xml import XmlElement
from ytdl_sub.utils.xml import to_max_3_byte_utf8_dict from ytdl_sub.utils.xml import to_max_3_byte_utf8_dict
from ytdl_sub.utils.xml import to_max_3_byte_utf8_string from ytdl_sub.utils.xml import to_max_3_byte_utf8_string
from ytdl_sub.utils.xml import to_xml from ytdl_sub.utils.xml import to_xml
from ytdl_sub.validators.file_path_validators import StringFormatterFilePathValidator
from ytdl_sub.validators.nfo_validators import NfoTagsValidator from ytdl_sub.validators.nfo_validators import NfoTagsValidator
from ytdl_sub.validators.string_formatter_validators import DictFormatterValidator from ytdl_sub.validators.string_formatter_validators import DictFormatterValidator
from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator
@ -46,7 +47,7 @@ class SharedNfoTagsOptions(PluginOptions):
super().__init__(name, value) super().__init__(name, value)
self._nfo_name = self._validate_key_if_present( self._nfo_name = self._validate_key_if_present(
key="nfo_name", validator=StringFormatterValidator key="nfo_name", validator=StringFormatterFilePathValidator
) )
self._nfo_root = self._validate_key_if_present( self._nfo_root = self._validate_key_if_present(
key="nfo_root", validator=StringFormatterValidator key="nfo_root", validator=StringFormatterValidator
@ -57,7 +58,7 @@ class SharedNfoTagsOptions(PluginOptions):
).value ).value
@property @property
def nfo_name(self) -> StringFormatterValidator: def nfo_name(self) -> StringFormatterFilePathValidator:
""" """
The NFO file name. The NFO file name.
""" """

View file

@ -168,7 +168,10 @@ class SplitByChaptersPlugin(Plugin[SplitByChaptersOptions]):
if self.is_dry_run: if self.is_dry_run:
chapters = Chapters.from_entry_chapters(entry=entry) chapters = Chapters.from_entry_chapters(entry=entry)
else: else:
chapters = Chapters.from_embedded_chapters(file_path=entry.get_download_file_path()) chapters = Chapters.from_embedded_chapters(
ffprobe_path=FFMPEG.ffprobe_path(),
file_path=entry.get_download_file_path(),
)
# If no chapters, do not split anything # If no chapters, do not split anything
if not chapters.contains_any_chapters(): if not chapters.contains_any_chapters():

View file

@ -11,6 +11,7 @@ from ytdl_sub.plugins.plugin import PluginOptions
from ytdl_sub.utils.file_handler import FileHandler from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.utils.file_handler import FileMetadata from ytdl_sub.utils.file_handler import FileMetadata
from ytdl_sub.utils.logger import Logger from ytdl_sub.utils.logger import Logger
from ytdl_sub.validators.file_path_validators import StringFormatterFilePathValidator
from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator
from ytdl_sub.validators.string_select_validator import StringSelectValidator from ytdl_sub.validators.string_select_validator import StringSelectValidator
from ytdl_sub.validators.validators import BoolValidator from ytdl_sub.validators.validators import BoolValidator
@ -58,7 +59,7 @@ class SubtitleOptions(PluginOptions):
def __init__(self, name, value): def __init__(self, name, value):
super().__init__(name, value) super().__init__(name, value)
self._subtitles_name = self._validate_key_if_present( self._subtitles_name = self._validate_key_if_present(
key="subtitles_name", validator=StringFormatterValidator key="subtitles_name", validator=StringFormatterFilePathValidator
) )
self._subtitles_type = self._validate_key_if_present( self._subtitles_type = self._validate_key_if_present(
key="subtitles_type", validator=SubtitlesTypeValidator, default="srt" key="subtitles_type", validator=SubtitlesTypeValidator, default="srt"

View file

@ -11,6 +11,7 @@ from ytdl_sub.subscriptions.base_subscription import BaseSubscription
from ytdl_sub.subscriptions.subscription_ytdl_options import SubscriptionYTDLOptions from ytdl_sub.subscriptions.subscription_ytdl_options import SubscriptionYTDLOptions
from ytdl_sub.utils.datetime import to_date_range from ytdl_sub.utils.datetime import to_date_range
from ytdl_sub.utils.exceptions import ValidationException from ytdl_sub.utils.exceptions import ValidationException
from ytdl_sub.utils.ffmpeg import FFMPEG
from ytdl_sub.utils.file_handler import FileHandler from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.utils.file_handler import FileHandlerTransactionLog from ytdl_sub.utils.file_handler import FileHandlerTransactionLog
from ytdl_sub.utils.file_handler import FileMetadata from ytdl_sub.utils.file_handler import FileMetadata
@ -265,6 +266,12 @@ class SubscriptionDownload(BaseSubscription, ABC):
If true, do not download any video/audio files or move anything to the output If true, do not download any video/audio files or move anything to the output
directory. directory.
""" """
# Set ffmpeg paths
FFMPEG.set_paths(
ffmpeg_path=self._config_options.ffmpeg_path,
ffprobe_path=self._config_options.ffprobe_path,
)
self._enhanced_download_archive.reinitialize(dry_run=dry_run) self._enhanced_download_archive.reinitialize(dry_run=dry_run)
plugins = self._initialize_plugins() plugins = self._initialize_plugins()

View file

@ -15,6 +15,7 @@ from ytdl_sub.plugins.file_convert import FileConvertPlugin
from ytdl_sub.plugins.match_filters import MatchFiltersPlugin from ytdl_sub.plugins.match_filters import MatchFiltersPlugin
from ytdl_sub.plugins.plugin import Plugin from ytdl_sub.plugins.plugin import Plugin
from ytdl_sub.plugins.subtitles import SubtitlesPlugin from ytdl_sub.plugins.subtitles import SubtitlesPlugin
from ytdl_sub.utils.ffmpeg import FFMPEG
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
PluginT = TypeVar("PluginT", bound=Plugin) PluginT = TypeVar("PluginT", bound=Plugin)
@ -57,6 +58,7 @@ class SubscriptionYTDLOptions:
"outtmpl": str(Path(self._working_directory) / "%(id)s.%(ext)s"), "outtmpl": str(Path(self._working_directory) / "%(id)s.%(ext)s"),
# Always write thumbnails # Always write thumbnails
"writethumbnail": True, "writethumbnail": True,
"ffmpeg_location": FFMPEG.ffmpeg_path(),
} }
return ytdl_options return ytdl_options

View file

@ -221,10 +221,12 @@ class Chapters:
return Chapters(timestamps=timestamps, titles=titles) return Chapters(timestamps=timestamps, titles=titles)
@classmethod @classmethod
def from_embedded_chapters(cls, file_path: str) -> "Chapters": def from_embedded_chapters(cls, ffprobe_path: str, file_path: str) -> "Chapters":
""" """
Parameters Parameters
---------- ----------
ffprobe_path
Path to ffprobe executable
file_path file_path
File to read ffmpeg chapter metadata from File to read ffmpeg chapter metadata from
@ -234,7 +236,7 @@ class Chapters:
""" """
proc = subprocess.run( proc = subprocess.run(
[ [
"ffprobe", ffprobe_path,
"-loglevel", "-loglevel",
"quiet", "quiet",
"-print_format", "-print_format",

View file

@ -23,10 +23,31 @@ def _ffmpeg_metadata_escape(str_to_escape: str) -> str:
class FFMPEG: class FFMPEG:
_FFMPEG_PATH: str = ""
_FFPROBE_PATH: str = ""
@classmethod
def set_paths(cls, ffmpeg_path: str, ffprobe_path: str) -> None:
"""Set ffmpeg paths for usage"""
cls._FFMPEG_PATH = ffmpeg_path
cls._FFPROBE_PATH = ffprobe_path
@classmethod
def ffmpeg_path(cls) -> str:
"""Ensure the ffmpeg path has been set and return it"""
assert cls._FFMPEG_PATH, "ffmpeg has not been set"
return cls._FFMPEG_PATH
@classmethod
def ffprobe_path(cls) -> str:
"""Ensure the ffprobe path has been set and return it"""
assert cls._FFPROBE_PATH, "ffprobe has not been set"
return cls._FFPROBE_PATH
@classmethod @classmethod
def _ensure_installed(cls): def _ensure_installed(cls):
try: try:
subprocess.check_output(["which", "ffmpeg"]) subprocess.check_output([cls.ffmpeg_path(), "-version"])
except subprocess.CalledProcessError as subprocess_error: except subprocess.CalledProcessError as subprocess_error:
raise ValidationException( raise ValidationException(
"Trying to use a feature which requires ffmpeg, but it cannot be found" "Trying to use a feature which requires ffmpeg, but it cannot be found"
@ -63,7 +84,7 @@ class FFMPEG:
""" """
cls._ensure_installed() cls._ensure_installed()
cmd = ["ffmpeg"] cmd = [cls.ffmpeg_path()]
cmd.extend(ffmpeg_args) cmd.extend(ffmpeg_args)
logger.debug("Running %s", " ".join(cmd)) logger.debug("Running %s", " ".join(cmd))
with Logger.handle_external_logs(name="ffmpeg"): with Logger.handle_external_logs(name="ffmpeg"):
@ -130,10 +151,13 @@ def set_ffmpeg_metadata_chapters(
lines += _create_metadata_chapters(chapters=chapters, file_duration_sec=file_duration_sec) lines += _create_metadata_chapters(chapters=chapters, file_duration_sec=file_duration_sec)
tmp_file_path = FFMPEG.tmp_file_path(relative_file_path=file_path) tmp_file_path = FFMPEG.tmp_file_path(relative_file_path=file_path)
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", encoding="utf-8") as metadata_file: with tempfile.NamedTemporaryFile(
mode="w", suffix=".txt", encoding="utf-8", delete=False
) as metadata_file:
metadata_file.write("\n".join(lines)) metadata_file.write("\n".join(lines))
metadata_file.flush() metadata_file.flush()
try:
FFMPEG.run( FFMPEG.run(
[ [
"-i", "-i",
@ -151,6 +175,8 @@ def set_ffmpeg_metadata_chapters(
] ]
) )
FileHandler.move(tmp_file_path, file_path) FileHandler.move(tmp_file_path, file_path)
finally:
FileHandler.delete(metadata_file.name)
def add_ffmpeg_metadata_key_values(file_path: str, key_values: Dict[str, str]) -> None: def add_ffmpeg_metadata_key_values(file_path: str, key_values: Dict[str, str]) -> None:

View file

@ -155,6 +155,15 @@ class FileHandlerTransactionLog:
Tracks file 'transactions' performed by a FileHandler Tracks file 'transactions' performed by a FileHandler
""" """
@classmethod
def format_path_str(cls, path_str: Path | str) -> str:
"""
Returns
-------
str formatted to always look like a unix string
"""
return str(path_str).replace(os.sep, "/")
def __init__(self): def __init__(self):
self.files_created: Dict[str, FileMetadata] = {} self.files_created: Dict[str, FileMetadata] = {}
self.files_modified: Dict[str, FileMetadata] = {} self.files_modified: Dict[str, FileMetadata] = {}
@ -241,6 +250,10 @@ class FileHandlerTransactionLog:
file_directory = os.path.dirname(Path(output_directory) / file_path) file_directory = os.path.dirname(Path(output_directory) / file_path)
file_name = os.path.basename(Path(output_directory) / file_path) file_name = os.path.basename(Path(output_directory) / file_path)
# Format file directories/names to always look like unix
file_directory = cls.format_path_str(file_directory)
file_name = cls.format_path_str(file_name)
directory_set[file_directory][file_name] = file_metadata directory_set[file_directory][file_name] = file_metadata
lines: List[str] = [file_set_title, "-" * 40] lines: List[str] = [file_set_title, "-" * 40]
@ -307,7 +320,9 @@ class FileHandlerTransactionLog:
) )
if self.is_empty: if self.is_empty:
lines.append(f"No new, modified, or removed files in '{output_directory}'") lines.append(
f"No new, modified, or removed files in '{self.format_path_str(output_directory)}'"
)
return "\n".join(lines) return "\n".join(lines)

View file

@ -0,0 +1,64 @@
import errno
import os
from contextlib import contextmanager
from pathlib import Path
from ytdl_sub.config.config_file import ConfigFile
from ytdl_sub.utils.exceptions import ValidationException
from ytdl_sub.utils.logger import Logger
from ytdl_sub.utils.system import IS_WINDOWS
logger = Logger.get()
if IS_WINDOWS:
@contextmanager
def working_directory_lock(config: ConfigFile):
"""Windows does not support working directory lock"""
logger.info(
"Working directory lock not supported in Windows. "
"Ensure only one instance of ytdl-sub runs at once using working directory %s",
config.config_options.working_directory,
)
yield
else:
import fcntl
@contextmanager
def working_directory_lock(config: ConfigFile):
"""
Create and try to lock the file /tmp/working_directory_name
Raises
------
ValidationException
Lock is acquired from another process running ytdl-sub in the same working directory
OSError
Other lock error occurred
"""
working_directory_path = Path(os.getcwd()) / config.config_options.working_directory
lock_file_path = (
Path(os.getcwd())
/ config.config_options.lock_directory
/ str(working_directory_path).replace("/", "_")
)
lock_file = open(lock_file_path, "w", encoding="utf-8")
try:
fcntl.lockf(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError as exc:
if exc.errno in (errno.EACCES, errno.EAGAIN):
raise ValidationException(
"Cannot run two instances of ytdl-sub "
"with the same working directory at the same time"
) from exc
lock_file.close()
raise exc
try:
yield
finally:
fcntl.flock(lock_file, fcntl.LOCK_UN)
lock_file.close()

View file

@ -90,6 +90,9 @@ class Logger:
_DEBUG_LOGGER_FILE = tempfile.NamedTemporaryFile(prefix="ytdl-sub.", delete=False) _DEBUG_LOGGER_FILE = tempfile.NamedTemporaryFile(prefix="ytdl-sub.", delete=False)
# pylint: enable=R1732 # pylint: enable=R1732
# Keep track of all Loggers created
_LOGGERS: List[logging.Logger] = []
@classmethod @classmethod
def debug_log_filename(cls) -> str: def debug_log_filename(cls) -> str:
""" """
@ -151,6 +154,7 @@ class Logger:
if debug_file: if debug_file:
logger.addHandler(cls._get_debug_file_handler()) logger.addHandler(cls._get_debug_file_handler())
cls._LOGGERS.append(logger)
return logger return logger
@classmethod @classmethod
@ -200,6 +204,10 @@ class Logger:
delete_debug_file delete_debug_file
Whether to delete the debug log file. Defaults to True. Whether to delete the debug log file. Defaults to True.
""" """
for logger in cls._LOGGERS:
for handler in logger.handlers:
handler.close()
cls._DEBUG_LOGGER_FILE.close() cls._DEBUG_LOGGER_FILE.close()
if delete_debug_file: if delete_debug_file:

View file

@ -0,0 +1,3 @@
import sys
IS_WINDOWS = sys.platform.startswith("win32")

View file

@ -64,9 +64,10 @@ def convert_url_thumbnail(thumbnail_url: str, output_thumbnail_path: str) -> Opt
""" """
# timeout after 8 seconds # timeout after 8 seconds
with urlopen(thumbnail_url, timeout=1.0) as file: with urlopen(thumbnail_url, timeout=1.0) as file:
with tempfile.NamedTemporaryFile() as thumbnail: with tempfile.NamedTemporaryFile(delete=False) as thumbnail:
thumbnail.write(file.read()) thumbnail.write(file.read())
try:
os.makedirs(os.path.dirname(output_thumbnail_path), exist_ok=True) os.makedirs(os.path.dirname(output_thumbnail_path), exist_ok=True)
tmp_output_path = FFMPEG.tmp_file_path( tmp_output_path = FFMPEG.tmp_file_path(
@ -76,6 +77,8 @@ def convert_url_thumbnail(thumbnail_url: str, output_thumbnail_path: str) -> Opt
# Have FileHandler handle the move to a potential cross-device # Have FileHandler handle the move to a potential cross-device
FileHandler.move(tmp_output_path, output_thumbnail_path) FileHandler.move(tmp_output_path, output_thumbnail_path)
finally:
FileHandler.delete(tmp_output_path) FileHandler.delete(tmp_output_path)
FileHandler.delete(thumbnail.name)
return True return True

View file

@ -0,0 +1,22 @@
import os
from pathlib import Path
from typing import Dict
from ytdl_sub.validators.string_formatter_validators import OverridesStringFormatterValidator
from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator
class StringFormatterFilePathValidator(StringFormatterValidator):
_expected_value_type_name = "filepath"
def apply_formatter(self, variable_dict: Dict[str, str]) -> str:
"""Turn into a Path, then a string, to get correct directory separators"""
return str(Path(super().apply_formatter(variable_dict)))
class OverridesStringFormatterValidatorFilePathValidator(OverridesStringFormatterValidator):
_expected_value_type_name = "static filepath"
def apply_formatter(self, variable_dict: Dict[str, str]) -> str:
"""Turn into a Path, then a string, to get correct directory separators"""
return os.path.realpath(super().apply_formatter(variable_dict))

View file

@ -1,7 +1,9 @@
import contextlib import contextlib
import json import json
import logging import logging
import os
import tempfile import tempfile
from pathlib import Path
from typing import Any from typing import Any
from typing import Callable from typing import Callable
from typing import Dict from typing import Dict
@ -10,17 +12,18 @@ from unittest.mock import patch
import pytest import pytest
from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.utils.logger import Logger from ytdl_sub.utils.logger import Logger
@pytest.fixture @pytest.fixture
def working_directory() -> str: def working_directory() -> Path:
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
yield temp_dir yield temp_dir
@pytest.fixture() @pytest.fixture()
def output_directory(): def output_directory() -> Path:
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
yield temp_dir yield temp_dir
@ -83,9 +86,12 @@ def preset_dict_to_subscription_yaml_generator() -> Callable:
@contextlib.contextmanager @contextlib.contextmanager
def _preset_dict_to_subscription_yaml_generator(subscription_name: str, preset_dict: Dict): def _preset_dict_to_subscription_yaml_generator(subscription_name: str, preset_dict: Dict):
subscription_dict = {subscription_name: preset_dict} subscription_dict = {subscription_name: preset_dict}
with tempfile.NamedTemporaryFile(suffix=".yaml") as tmp_file: with tempfile.NamedTemporaryFile(suffix=".yaml", delete=False) as tmp_file:
tmp_file.write(json.dumps(subscription_dict).encode("utf-8")) tmp_file.write(json.dumps(subscription_dict).encode("utf-8"))
tmp_file.flush()
try:
yield tmp_file.name yield tmp_file.name
finally:
FileHandler.delete(tmp_file.name)
return _preset_dict_to_subscription_yaml_generator return _preset_dict_to_subscription_yaml_generator

View file

@ -3,6 +3,7 @@ import os
import shutil import shutil
import sys import sys
import tempfile import tempfile
from pathlib import Path
from typing import List from typing import List
from typing import Tuple from typing import Tuple
from unittest.mock import patch from unittest.mock import patch
@ -14,6 +15,7 @@ from ytdl_sub.cli.main import main
from ytdl_sub.config.config_file import ConfigFile from ytdl_sub.config.config_file import ConfigFile
from ytdl_sub.subscriptions.subscription import Subscription from ytdl_sub.subscriptions.subscription import Subscription
from ytdl_sub.subscriptions.subscription_download import SubscriptionDownload from ytdl_sub.subscriptions.subscription_download import SubscriptionDownload
from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.utils.file_handler import FileHandlerTransactionLog from ytdl_sub.utils.file_handler import FileHandlerTransactionLog
from ytdl_sub.utils.logger import Logger from ytdl_sub.utils.logger import Logger
from ytdl_sub.utils.yaml import load_yaml from ytdl_sub.utils.yaml import load_yaml
@ -48,11 +50,11 @@ def working_directory() -> str:
@pytest.fixture() @pytest.fixture()
def music_video_config_path(): def music_video_config_path() -> Path:
return "examples/music_videos_config.yaml" return Path("examples/music_videos_config.yaml")
def _load_config(config_path: str, working_directory: str) -> ConfigFile: def _load_config(config_path: Path, working_directory: Path) -> ConfigFile:
config_dict = load_yaml(file_path=config_path) config_dict = load_yaml(file_path=config_path)
config_dict["configuration"]["working_directory"] = working_directory config_dict["configuration"]["working_directory"] = working_directory
@ -66,23 +68,26 @@ def music_video_config(music_video_config_path, working_directory) -> ConfigFile
@pytest.fixture() @pytest.fixture()
def music_video_config_for_cli(music_video_config) -> str: def music_video_config_for_cli(music_video_config) -> str:
with tempfile.NamedTemporaryFile(suffix=".yaml") as tmp_file: with tempfile.NamedTemporaryFile(suffix=".yaml", delete=False) as tmp_file:
tmp_file.write(json.dumps(music_video_config._value).encode("utf-8")) tmp_file.write(json.dumps(music_video_config._value).encode("utf-8"))
tmp_file.flush()
try:
yield tmp_file.name yield tmp_file.name
finally:
FileHandler.delete(tmp_file.name)
@pytest.fixture() @pytest.fixture()
def channel_as_tv_show_config(working_directory) -> ConfigFile: def channel_as_tv_show_config(working_directory) -> ConfigFile:
return _load_config( return _load_config(
config_path="examples/tv_show_config.yaml", working_directory=working_directory config_path=Path("examples/tv_show_config.yaml"), working_directory=working_directory
) )
@pytest.fixture() @pytest.fixture()
def music_audio_config(working_directory) -> ConfigFile: def music_audio_config(working_directory) -> ConfigFile:
return _load_config( return _load_config(
config_path="examples/music_audio_config.yaml", working_directory=working_directory config_path=Path("examples/music_audio_config.yaml"), working_directory=working_directory
) )
@ -97,10 +102,15 @@ def timestamps_file_path():
"00:01:01 Part 5\n", "00:01:01 Part 5\n",
] ]
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", suffix=".txt") as tmp: with tempfile.NamedTemporaryFile(
mode="w", encoding="utf-8", suffix=".txt", delete=False
) as tmp:
tmp.writelines(timestamps) tmp.writelines(timestamps)
tmp.seek(0)
try:
yield tmp.name yield tmp.name
finally:
FileHandler.delete(tmp.name)
def mock_run_from_cli(args: str) -> List[Tuple[Subscription, FileHandlerTransactionLog]]: def mock_run_from_cli(args: str) -> List[Tuple[Subscription, FileHandlerTransactionLog]]:

View file

@ -1,5 +1,6 @@
import json import json
import os.path import os.path
import sys
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import List from typing import List
@ -9,15 +10,20 @@ from resources import REGENERATE_FIXTURES
from resources import RESOURCE_PATH from resources import RESOURCE_PATH
from ytdl_sub.utils.file_handler import get_file_md5_hash from ytdl_sub.utils.file_handler import get_file_md5_hash
from ytdl_sub.utils.system import IS_WINDOWS
_EXPECTED_DOWNLOADS_SUMMARY_PATH = RESOURCE_PATH / "expected_downloads_summaries" _EXPECTED_DOWNLOADS_SUMMARY_PATH = RESOURCE_PATH / "expected_downloads_summaries"
def _get_files_in_directory(relative_directory: Path | str) -> List[Path]: def _get_files_in_directory(relative_directory: Path | str) -> List[Path]:
relative_path_part_idx = 3 # Cuts /tmp/<tmp_folder>
if IS_WINDOWS:
relative_path_part_idx = 7 # Cuts C:\Users\<user>\AppData\Local\Temp\<tmp_folder>
relative_file_paths: List[Path] = [] relative_file_paths: List[Path] = []
for path in Path(relative_directory).rglob("*"): for path in Path(relative_directory).rglob("*"):
if path.is_file(): if path.is_file():
relative_path = Path(*path.parts[3:]) relative_path = Path(*path.parts[relative_path_part_idx:])
relative_file_paths.append(relative_path) relative_file_paths.append(relative_path)
return relative_file_paths return relative_file_paths
@ -70,7 +76,8 @@ class ExpectedDownloads:
full_path = Path(relative_directory) / path full_path = Path(relative_directory) / path
assert os.path.isfile(full_path), f"Expected {path} to be a file but it is not" assert os.path.isfile(full_path), f"Expected {path} to be a file but it is not"
if path in ignore_md5_hashes_for or path.endswith(".info.json"): # TODO: Implement file hash for tests in Windows
if IS_WINDOWS or path in ignore_md5_hashes_for or path.endswith(".info.json"):
continue continue
md5_hash = get_file_md5_hash(full_file_path=full_path) md5_hash = get_file_md5_hash(full_file_path=full_path)

View file

@ -43,7 +43,9 @@ def assert_transaction_log_matches(
# Read the expected summary file # Read the expected summary file
with open(transaction_log_path, "r", encoding="utf-8") as summary_file: with open(transaction_log_path, "r", encoding="utf-8") as summary_file:
expected_summary = summary_file.read().format(output_directory=output_directory) expected_summary = summary_file.read().format(
output_directory=FileHandlerTransactionLog.format_path_str(output_directory)
)
# Split, ensure there are the same number of new lines # Split, ensure there are the same number of new lines
summary_lines: List[str] = summary.split("\n") summary_lines: List[str] = summary.split("\n")

View file

@ -3,9 +3,9 @@ from pathlib import Path
REGENERATE_FIXTURES: bool = False REGENERATE_FIXTURES: bool = False
RESOURCE_PATH = Path("tests/resources") RESOURCE_PATH: Path = Path("tests") / "resources"
_FILE_FIXTURE_PATH = RESOURCE_PATH / "file_fixtures" _FILE_FIXTURE_PATH: Path = RESOURCE_PATH / "file_fixtures"
def copy_file_fixture(fixture_name: str, output_file_path: str | Path) -> None: def copy_file_fixture(fixture_name: str, output_file_path: Path) -> None:
shutil.copy(_FILE_FIXTURE_PATH / fixture_name, output_file_path) shutil.copy(_FILE_FIXTURE_PATH / fixture_name, output_file_path)

View file

@ -3,9 +3,10 @@ import time
import pytest import pytest
from ytdl_sub.cli.main import _working_directory_lock
from ytdl_sub.config.config_file import ConfigFile from ytdl_sub.config.config_file import ConfigFile
from ytdl_sub.utils.exceptions import ValidationException from ytdl_sub.utils.exceptions import ValidationException
from ytdl_sub.utils.file_lock import working_directory_lock
from ytdl_sub.utils.system import IS_WINDOWS
@pytest.fixture @pytest.fixture
@ -16,17 +17,20 @@ def config() -> ConfigFile:
def test_working_directory_lock(config: ConfigFile): def test_working_directory_lock(config: ConfigFile):
if IS_WINDOWS:
return
new_pid = os.fork() new_pid = os.fork()
if new_pid == 0: # is child if new_pid == 0: # is child
with _working_directory_lock(config=config): with working_directory_lock(config=config):
time.sleep(3) time.sleep(3)
return return
time.sleep(1) time.sleep(1)
with pytest.raises(ValidationException, match="Cannot run two instances of ytdl-sub"): with pytest.raises(ValidationException, match="Cannot run two instances of ytdl-sub"):
with _working_directory_lock(config=config): with working_directory_lock(config=config):
time.sleep(1) time.sleep(1)
time.sleep(3) time.sleep(3)
with _working_directory_lock(config=config): with working_directory_lock(config=config):
pass pass

View file

@ -95,6 +95,9 @@ class TestLogger:
assert lines == ["[ytdl-sub:name_test] info test\n", "[ytdl-sub:name_test] debug test\n"] assert lines == ["[ytdl-sub:name_test] info test\n", "[ytdl-sub:name_test] debug test\n"]
# Ensure the file cleans up too # Ensure the file cleans up too
for handler in logger.handlers:
handler.close()
Logger.cleanup(delete_debug_file=True) Logger.cleanup(delete_debug_file=True)
assert not os.path.isfile(Logger._DEBUG_LOGGER_FILE.name) assert not os.path.isfile(Logger._DEBUG_LOGGER_FILE.name)

View file

@ -1,9 +1,12 @@
import os
import re
import tempfile import tempfile
import pytest import pytest
from ytdl_sub.utils.exceptions import FileNotFoundException from ytdl_sub.utils.exceptions import FileNotFoundException
from ytdl_sub.utils.exceptions import InvalidYamlException from ytdl_sub.utils.exceptions import InvalidYamlException
from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.utils.yaml import load_yaml from ytdl_sub.utils.yaml import load_yaml
@ -21,10 +24,15 @@ def bad_yaml() -> str:
@pytest.fixture @pytest.fixture
def bad_yaml_file_path(bad_yaml) -> str: def bad_yaml_file_path(bad_yaml) -> str:
with tempfile.NamedTemporaryFile(suffix=".yaml") as tmp_file: # Do not delete the file in the context manager - for Windows compatibility
with tempfile.NamedTemporaryFile(suffix=".yaml", delete=False) as tmp_file:
tmp_file.write(bad_yaml.encode("utf-8")) tmp_file.write(bad_yaml.encode("utf-8"))
tmp_file.flush() tmp_file.flush()
try:
yield tmp_file.name yield tmp_file.name
finally:
FileHandler.delete(tmp_file.name)
def test_load_yaml_file_not_found(): def test_load_yaml_file_not_found():
@ -36,6 +44,8 @@ def test_load_yaml_file_not_found():
def test_load_yaml_invalid_syntax(bad_yaml_file_path): def test_load_yaml_invalid_syntax(bad_yaml_file_path):
with pytest.raises( with pytest.raises(
InvalidYamlException, InvalidYamlException,
match=f"'{bad_yaml_file_path}' has invalid YAML, copy-paste it into a YAML checker to find the issue.", match=re.escape(
f"'{bad_yaml_file_path}' has invalid YAML, copy-paste it into a YAML checker to find the issue."
),
): ):
load_yaml(file_path=bad_yaml_file_path) load_yaml(file_path=bad_yaml_file_path)