Add .ignore support in the scanner (#679)

This commit is contained in:
Zoe Roux 2025-01-01 21:25:12 +01:00 committed by GitHub
commit 4db01dd910
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 80 additions and 31 deletions

View File

@ -6,7 +6,7 @@
[`.env`](https://raw.githubusercontent.com/zoriya/Kyoo/master/.env.example) files [`.env`](https://raw.githubusercontent.com/zoriya/Kyoo/master/.env.example) files
3. Fill the `.env` file with your configuration options 3. Fill the `.env` file with your configuration options
4. Look at [Hardware Acceleration section](#Hardware-Acceleration) if you need it 4. Look at [Hardware Acceleration section](#Hardware-Acceleration) if you need it
5. Look at [Custom Volumes](#Custom-Volumes) if you need it, 5. Look at [FAQ](#FAQ) if you need it,
6. Run `docker compose up -d` and see kyoo at `http://localhost:8901` 6. Run `docker compose up -d` and see kyoo at `http://localhost:8901`
# Installing # Installing
@ -91,7 +91,9 @@ You can also add `COMPOSE_PROFILES=nvidia` to your `.env` instead of adding the
Note that most nvidia cards have an artificial limit on the number of encodes. You can confirm your card limit [here](https://developer.nvidia.com/video-encode-and-decode-gpu-support-matrix-new). Note that most nvidia cards have an artificial limit on the number of encodes. You can confirm your card limit [here](https://developer.nvidia.com/video-encode-and-decode-gpu-support-matrix-new).
This limit can also be removed by applying an [unofficial patch](https://github.com/keylase/nvidia-patch) to you driver. This limit can also be removed by applying an [unofficial patch](https://github.com/keylase/nvidia-patch) to you driver.
# Custom volumes # FAQ
## Custom volumes
To customize volumes, you can edit the `docker-compose.yml` manually. To customize volumes, you can edit the `docker-compose.yml` manually.
@ -120,6 +122,18 @@ You can also edit the volume definition to use advanced volume drivers if you ne
Don't forget to **also edit the scanner's volumes** if you edit the transcoder's volume. Don't forget to **also edit the scanner's volumes** if you edit the transcoder's volume.
## Ignoring Directories
Kyoo supports excluding specific directories from scanning and monitoring by detecting the presence of a `.ignore` file. When a directory contains a `.ignore` file, Kyoo will recursively exclude that directory and all its contents from processing.
Example:
To exclude `/media/extras/**`, add a `.ignore` file:
```bash
touch /media/extras/.ignore
```
Kyoo will skip `/media/extras` and its contents in all future scans and monitoring events.
# OpenID Connect # OpenID Connect
Kyoo supports OpenID Connect (OIDC) for authentication. Please refer to the [OIDC.md](OIDC.md) file for more information. Kyoo supports OpenID Connect (OIDC) for authentication. Please refer to the [OIDC.md](OIDC.md) file for more information.
<!-- vim: set wrap: -->

View File

@ -1,5 +1,5 @@
from logging import getLogger from logging import getLogger
from os.path import isdir from os.path import isdir, dirname, exists, join
from watchfiles import awatch, Change from watchfiles import awatch, Change
from .publisher import Publisher from .publisher import Publisher
from .scanner import scan, get_ignore_pattern from .scanner import scan, get_ignore_pattern
@ -8,16 +8,33 @@ from providers.kyoo_client import KyooClient
logger = getLogger(__name__) logger = getLogger(__name__)
def is_ignored_path(path: str) -> bool:
"""Check if the path is within a directory that contains a `.ignore` file."""
current_path = path
while current_path != "/": # Traverse up to the root directory
if exists(join(current_path, ".ignore")):
return True
current_path = dirname(current_path)
return False
async def monitor(path: str, publisher: Publisher, client: KyooClient): async def monitor(path: str, publisher: Publisher, client: KyooClient):
ignore_pattern = get_ignore_pattern() ignore_pattern = get_ignore_pattern()
async for changes in awatch(path, ignore_permission_denied=True): async for changes in awatch(path, ignore_permission_denied=True):
for event, file in changes: for event, file in changes:
# Check for ignore conditions
if is_ignored_path(file):
logger.info(
"Ignoring event %s for file %s (due to .ignore file)", event, file
)
continue
if ignore_pattern and ignore_pattern.match(file): if ignore_pattern and ignore_pattern.match(file):
logger.info( logger.info(
"Ignoring event %s for file %s (due to IGNORE_PATTERN)", event, file "Ignoring event %s for file %s (due to IGNORE_PATTERN)", event, file
) )
continue continue
logger.info("Change %s occured for file %s", event, file)
logger.info("Change %s occurred for file %s", event, file)
match event: match event:
case Change.added if isdir(file): case Change.added if isdir(file):
await scan(file, publisher, client) await scan(file, publisher, client)
@ -28,4 +45,4 @@ async def monitor(path: str, publisher: Publisher, client: KyooClient):
case Change.modified: case Change.modified:
pass pass
case _: case _:
logger.warn("Unknown file event %s (for file %s)", event, file) logger.warning("Unknown file event %s (for file %s)", event, file)

View File

@ -11,12 +11,11 @@ logger = getLogger(__name__)
def get_ignore_pattern(): def get_ignore_pattern():
"""Compile ignore pattern from environment variable."""
try: try:
pattern = os.environ.get("LIBRARY_IGNORE_PATTERN") pattern = os.environ.get("LIBRARY_IGNORE_PATTERN")
if pattern: return re.compile(pattern) if pattern else None
return re.compile(pattern) except re.error as e:
return None
except Exception as e:
logger.error(f"Invalid ignore pattern. Ignoring. Error: {e}") logger.error(f"Invalid ignore pattern. Ignoring. Error: {e}")
return None return None
@ -25,32 +24,51 @@ async def scan(
path_: Optional[str], publisher: Publisher, client: KyooClient, remove_deleted=False path_: Optional[str], publisher: Publisher, client: KyooClient, remove_deleted=False
): ):
path = path_ or os.environ.get("SCANNER_LIBRARY_ROOT", "/video") path = path_ or os.environ.get("SCANNER_LIBRARY_ROOT", "/video")
logger.info("Starting scan at %s. This may take some time...", path)
logger.info("Starting the scan. It can take some times...")
ignore_pattern = get_ignore_pattern() ignore_pattern = get_ignore_pattern()
if ignore_pattern:
logger.info(f"Applying ignore pattern: {ignore_pattern}")
registered = await client.get_registered_paths() registered = set(await client.get_registered_paths())
videos = [ videos = set()
os.path.join(dir, file) for dir, _, files in os.walk(path) for file in files
] for dirpath, dirnames, files in os.walk(path):
if ignore_pattern is not None: # Skip directories with a `.ignore` file
logger.info(f"Ignoring with pattern {ignore_pattern}") if ".ignore" in files:
videos = [p for p in videos if not ignore_pattern.match(p)] dirnames.clear() # Prevents os.walk from descending into this directory
to_register = [p for p in videos if p not in registered] continue
for file in files:
file_path = os.path.join(dirpath, file)
# Apply ignore pattern, if any
if ignore_pattern and ignore_pattern.match(file_path):
continue
videos.add(file_path)
to_register = videos - registered
to_delete = registered - videos if remove_deleted else set()
if not any(to_register) and any(to_delete) and len(to_delete) == len(registered):
logger.warning("All video files are unavailable. Check your disks.")
return
# delete stale files before creating new ones to prevent potential conflicts
if to_delete:
logger.info("Removing %d stale files.", len(to_delete))
await asyncio.gather(*[publisher.delete(path) for path in to_delete])
if to_register:
logger.info("Found %d new files to register.", len(to_register))
await asyncio.gather(*[publisher.add(path) for path in to_register])
if remove_deleted: if remove_deleted:
deleted = [x for x in registered if x not in videos] issues = set(await client.get_issues())
logger.info("Found %d stale files to remove.", len(deleted)) issues_to_delete = issues - videos
if len(deleted) != len(registered): if issues_to_delete:
await asyncio.gather(*map(publisher.delete, deleted)) logger.info("Removing %d stale issues.", len(issues_to_delete))
elif len(deleted) > 0: await asyncio.gather(
logger.warning("All video files are unavailable. Check your disks.") *[client.delete_issue(issue) for issue in issues_to_delete]
)
issues = await client.get_issues()
for x in issues:
if x not in videos:
await client.delete_issue(x)
logger.info("Found %d new files (counting non-video files)", len(to_register))
await asyncio.gather(*map(publisher.add, to_register))
logger.info("Scan finished for %s.", path) logger.info("Scan finished for %s.", path)