[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:
pull_request:
@ -8,70 +8,42 @@ on:
branches:
- master
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:
runs-on: ubuntu-22.04
needs: test-build
permissions:
contents: read
steps:
- uses: actions/checkout@v3
- name: Restore Python build cache
uses: actions/cache@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
path: /opt/env
key: ${{github.sha}}-env
python-version: "3.10"
- name: Run linters
run: |
source /opt/env/bin/activate
pip install -e .[lint]
make check_lint
test-unit:
runs-on: ubuntu-22.04
needs: test-build
permissions:
contents: read
steps:
- uses: actions/checkout@v3
- name: Restore Python build cache
uses: actions/cache@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
path: /opt/env
key: ${{github.sha}}-env
python-version: "3.10"
- name: Run unit tests with coverage
run: |
pip install -e .[test]
sudo apt-get update
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
- name: Save coverage
@ -82,46 +54,42 @@ jobs:
test-soundcloud:
runs-on: ubuntu-22.04
needs: test-build
permissions:
contents: read
steps:
- uses: actions/checkout@v3
- name: Restore Python build cache
uses: actions/cache@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
path: /opt/env
key: ${{github.sha}}-env
python-version: "3.10"
- name: Run e2e soundcloud tests with coverage
run: |
pip install -e .[test]
sudo apt-get update
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
test-bandcamp:
runs-on: ubuntu-22.04
needs: test-build
permissions:
contents: read
steps:
- uses: actions/checkout@v3
- name: Restore Python build cache
uses: actions/cache@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
path: /opt/env
key: ${{github.sha}}-env
python-version: "3.10"
- name: Run e2e soundcloud tests with coverage
run: |
pip install -e .[test]
sudo apt-get update
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
- name: Save coverage
@ -132,24 +100,22 @@ jobs:
test-youtube:
runs-on: ubuntu-22.04
needs: test-build
permissions:
contents: read
steps:
- uses: actions/checkout@v3
- name: Restore Python build cache
uses: actions/cache@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
path: /opt/env
key: ${{github.sha}}-env
python-version: "3.10"
- name: Run e2e youtube tests with coverage
run: |
pip install -e .[test]
sudo apt-get update
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
- name: Save coverage
@ -160,24 +126,22 @@ jobs:
test-plugins:
runs-on: ubuntu-22.04
needs: test-build
permissions:
contents: read
steps:
- uses: actions/checkout@v3
- name: Restore Python build cache
uses: actions/cache@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
path: /opt/env
key: ${{github.sha}}-env
python-version: "3.10"
- name: Run e2e plugin tests with coverage
run: |
pip install -e .[test]
sudo apt-get update
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
- name: Save coverage

5
.gitignore vendored
View file

@ -142,4 +142,7 @@ docker/*.whl
docker/root/*.whl
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)
### 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)
### 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)
@ -285,7 +285,7 @@ docker run -d \
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.
```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
./ytdl-sub -h
```

View file

@ -1,11 +1,6 @@
import argparse
import errno
import fcntl
import gc
import os
import sys
from contextlib import contextmanager
from pathlib import Path
from typing import List
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.config.config_file import ConfigFile
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_lock import working_directory_lock
from ytdl_sub.utils.logger import Logger
logger = Logger.get()
@ -119,45 +114,6 @@ def _view_url_from_cli(
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]]:
"""
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()
transaction_logs: List[Tuple[Subscription, FileHandlerTransactionLog]] = []
with _working_directory_lock(config=config):
with working_directory_lock(config=config):
if args.subparser == "sub":
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 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.validators import LiteralDictValidator
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):
_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):
super().__init__(name, value)
@ -27,7 +37,14 @@ class ConfigOptions(StrictDictValidator):
key="dl_aliases", validator=LiteralDictValidator
)
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
@ -82,6 +99,20 @@ class ConfigOptions(StrictDictValidator):
"""
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):
_required_keys = {"configuration", "presets"}

View file

@ -7,6 +7,10 @@ from typing import Optional
from yt_dlp.utils import sanitize_filename
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.string_datetime import StringDatetimeValidator
from ytdl_sub.validators.string_formatter_validators import DictFormatterValidator
@ -221,19 +225,19 @@ class OutputOptions(StrictDictValidator):
# Output directory should resolve without any entry variables.
# This is to check the directory for any download-archives before any downloads begin
self._output_directory: OverridesStringFormatterValidator = self._validate_key(
key="output_directory", validator=OverridesStringFormatterValidator
self._output_directory = self._validate_key(
key="output_directory", validator=OverridesStringFormatterValidatorFilePathValidator
)
# file name and thumbnails however can use entry variables
self._file_name: StringFormatterValidator = self._validate_key(
key="file_name", validator=StringFormatterValidator
self._file_name = self._validate_key(
key="file_name", validator=StringFormatterFilePathValidator
)
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(
key="info_json_name", validator=StringFormatterValidator
key="info_json_name", validator=StringFormatterFilePathValidator
)
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")
]
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:
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_string
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.string_formatter_validators import DictFormatterValidator
from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator
@ -46,7 +47,7 @@ class SharedNfoTagsOptions(PluginOptions):
super().__init__(name, value)
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(
key="nfo_root", validator=StringFormatterValidator
@ -57,7 +58,7 @@ class SharedNfoTagsOptions(PluginOptions):
).value
@property
def nfo_name(self) -> StringFormatterValidator:
def nfo_name(self) -> StringFormatterFilePathValidator:
"""
The NFO file name.
"""

View file

@ -168,7 +168,10 @@ class SplitByChaptersPlugin(Plugin[SplitByChaptersOptions]):
if self.is_dry_run:
chapters = Chapters.from_entry_chapters(entry=entry)
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 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 FileMetadata
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_select_validator import StringSelectValidator
from ytdl_sub.validators.validators import BoolValidator
@ -58,7 +59,7 @@ class SubtitleOptions(PluginOptions):
def __init__(self, name, value):
super().__init__(name, value)
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(
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.utils.datetime import to_date_range
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 FileHandlerTransactionLog
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
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)
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.plugin import Plugin
from ytdl_sub.plugins.subtitles import SubtitlesPlugin
from ytdl_sub.utils.ffmpeg import FFMPEG
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
PluginT = TypeVar("PluginT", bound=Plugin)
@ -57,6 +58,7 @@ class SubscriptionYTDLOptions:
"outtmpl": str(Path(self._working_directory) / "%(id)s.%(ext)s"),
# Always write thumbnails
"writethumbnail": True,
"ffmpeg_location": FFMPEG.ffmpeg_path(),
}
return ytdl_options

View file

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

View file

@ -23,10 +23,31 @@ def _ffmpeg_metadata_escape(str_to_escape: str) -> str:
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
def _ensure_installed(cls):
try:
subprocess.check_output(["which", "ffmpeg"])
subprocess.check_output([cls.ffmpeg_path(), "-version"])
except subprocess.CalledProcessError as subprocess_error:
raise ValidationException(
"Trying to use a feature which requires ffmpeg, but it cannot be found"
@ -63,7 +84,7 @@ class FFMPEG:
"""
cls._ensure_installed()
cmd = ["ffmpeg"]
cmd = [cls.ffmpeg_path()]
cmd.extend(ffmpeg_args)
logger.debug("Running %s", " ".join(cmd))
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)
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.flush()
try:
FFMPEG.run(
[
"-i",
@ -151,6 +175,8 @@ def set_ffmpeg_metadata_chapters(
]
)
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:

View file

@ -155,6 +155,15 @@ class FileHandlerTransactionLog:
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):
self.files_created: 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_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
lines: List[str] = [file_set_title, "-" * 40]
@ -307,7 +320,9 @@ class FileHandlerTransactionLog:
)
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)

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)
# pylint: enable=R1732
# Keep track of all Loggers created
_LOGGERS: List[logging.Logger] = []
@classmethod
def debug_log_filename(cls) -> str:
"""
@ -151,6 +154,7 @@ class Logger:
if debug_file:
logger.addHandler(cls._get_debug_file_handler())
cls._LOGGERS.append(logger)
return logger
@classmethod
@ -200,6 +204,10 @@ class Logger:
delete_debug_file
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()
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
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())
try:
os.makedirs(os.path.dirname(output_thumbnail_path), exist_ok=True)
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
FileHandler.move(tmp_output_path, output_thumbnail_path)
finally:
FileHandler.delete(tmp_output_path)
FileHandler.delete(thumbnail.name)
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 json
import logging
import os
import tempfile
from pathlib import Path
from typing import Any
from typing import Callable
from typing import Dict
@ -10,17 +12,18 @@ from unittest.mock import patch
import pytest
from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.utils.logger import Logger
@pytest.fixture
def working_directory() -> str:
def working_directory() -> Path:
with tempfile.TemporaryDirectory() as temp_dir:
yield temp_dir
@pytest.fixture()
def output_directory():
def output_directory() -> Path:
with tempfile.TemporaryDirectory() as temp_dir:
yield temp_dir
@ -83,9 +86,12 @@ def preset_dict_to_subscription_yaml_generator() -> Callable:
@contextlib.contextmanager
def _preset_dict_to_subscription_yaml_generator(subscription_name: str, preset_dict: 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.flush()
try:
yield tmp_file.name
finally:
FileHandler.delete(tmp_file.name)
return _preset_dict_to_subscription_yaml_generator

View file

@ -3,6 +3,7 @@ import os
import shutil
import sys
import tempfile
from pathlib import Path
from typing import List
from typing import Tuple
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.subscriptions.subscription import Subscription
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.logger import Logger
from ytdl_sub.utils.yaml import load_yaml
@ -48,11 +50,11 @@ def working_directory() -> str:
@pytest.fixture()
def music_video_config_path():
return "examples/music_videos_config.yaml"
def music_video_config_path() -> Path:
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["configuration"]["working_directory"] = working_directory
@ -66,23 +68,26 @@ def music_video_config(music_video_config_path, working_directory) -> ConfigFile
@pytest.fixture()
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.flush()
try:
yield tmp_file.name
finally:
FileHandler.delete(tmp_file.name)
@pytest.fixture()
def channel_as_tv_show_config(working_directory) -> ConfigFile:
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()
def music_audio_config(working_directory) -> ConfigFile:
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",
]
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.seek(0)
try:
yield tmp.name
finally:
FileHandler.delete(tmp.name)
def mock_run_from_cli(args: str) -> List[Tuple[Subscription, FileHandlerTransactionLog]]:

View file

@ -1,5 +1,6 @@
import json
import os.path
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import List
@ -9,15 +10,20 @@ from resources import REGENERATE_FIXTURES
from resources import RESOURCE_PATH
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"
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] = []
for path in Path(relative_directory).rglob("*"):
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)
return relative_file_paths
@ -70,7 +76,8 @@ class ExpectedDownloads:
full_path = Path(relative_directory) / path
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
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
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
summary_lines: List[str] = summary.split("\n")

View file

@ -3,9 +3,9 @@ from pathlib import Path
REGENERATE_FIXTURES: bool = False
RESOURCE_PATH = Path("tests/resources")
_FILE_FIXTURE_PATH = RESOURCE_PATH / "file_fixtures"
RESOURCE_PATH: Path = Path("tests") / "resources"
_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)

View file

@ -3,9 +3,10 @@ import time
import pytest
from ytdl_sub.cli.main import _working_directory_lock
from ytdl_sub.config.config_file import ConfigFile
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
@ -16,17 +17,20 @@ def config() -> ConfigFile:
def test_working_directory_lock(config: ConfigFile):
if IS_WINDOWS:
return
new_pid = os.fork()
if new_pid == 0: # is child
with _working_directory_lock(config=config):
with working_directory_lock(config=config):
time.sleep(3)
return
time.sleep(1)
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(3)
with _working_directory_lock(config=config):
with working_directory_lock(config=config):
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"]
# Ensure the file cleans up too
for handler in logger.handlers:
handler.close()
Logger.cleanup(delete_debug_file=True)
assert not os.path.isfile(Logger._DEBUG_LOGGER_FILE.name)

View file

@ -1,9 +1,12 @@
import os
import re
import tempfile
import pytest
from ytdl_sub.utils.exceptions import FileNotFoundException
from ytdl_sub.utils.exceptions import InvalidYamlException
from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.utils.yaml import load_yaml
@ -21,10 +24,15 @@ def bad_yaml() -> str:
@pytest.fixture
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.flush()
try:
yield tmp_file.name
finally:
FileHandler.delete(tmp_file.name)
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):
with pytest.raises(
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)