Files
ezarr/main.py
dtoro 92aa9b4c22
Some checks failed
Check running / run (push) Has been cancelled
Allow splitting config, downloads and media paths via .env
Adds optional CONFIG_DIR, DOWNLOADS_DIR and MEDIA_DIR env variables so
users can spread configs, downloads and media across different drives
without restructuring ROOT_DIR. Each falls back to its ROOT_DIR-based
default when left empty, so existing setups keep working unchanged.

The -arr services (sonarr, radarr, lidarr, mylar3) now use a single
${DOWNLOADS_DIR}:/data parent bind with a ${MEDIA_DIR}:/data/media
overlay. This preserves TRaSH's single-/data-mount semantics inside
the container (hardlinks still work on one filesystem) while letting
the host-side media tree live wherever the user wants.

README documents the new variables, the generated folder structure,
recommended storage layouts, and the same-filesystem requirement for
hardlinks between downloads and media.
2026-04-17 10:40:44 +02:00

194 lines
6.9 KiB
Python

import os
from container_configs import ContainerConfig
from users_groups_setup import UserGroupSetup
services_classed = dict()
def take_boolean_input(default=True):
while True:
ans = input()
if ans == '':
return default
if ans == 'y' or ans == 'Y':
return True
if ans == 'n' or ans == 'N':
return False
print('Please answer with y or n.', end=' ')
def take_input(service_name, service_type, name_to_show=None):
print(f'Use {name_to_show or service_name.capitalize()}? [Y/n]', end=" ")
choice = take_boolean_input()
if choice:
services_classed[service_type].append(service_name)
else:
print('Not adding ' + service_name + ".")
def take_directory_input():
while True:
ans = input()
if ans[0] == '/':
if ans[-1] == '/':
return ans[:-1]
return ans
print('Please make sure the path is absolute, meaning it starts at the root of your filesystem and starts with "/":', end=' ')
def take_optional_directory_input(default):
"""Reads a directory override, or returns ``default`` on an empty response."""
while True:
ans = input()
if ans == '':
return default
if ans[0] == '/':
return ans[:-1] if ans[-1] == '/' else ans
print(f'Please provide an absolute path (starting with "/") or press enter to use "{default}":', end=' ')
def get_system_timezone():
tz_path = '/etc/localtime'
if os.path.exists(tz_path):
if os.path.islink(tz_path):
tz = os.readlink(tz_path)
return tz.split('zoneinfo/')[-1]
return None
def main():
print('Welcome to the EZarr CLI.')
print("This CLI will ask you which services you'd like to use and more. If you'd like more information about a "
'certain service, look in the README.')
print('\n===SERVARR===')
services_classed['servarr'] = []
take_input('sonarr', 'servarr')
take_input('radarr', 'servarr')
take_input('lidarr', 'servarr')
take_input('mylar3', 'servarr')
take_input('audiobookshelf', 'servarr')
take_input('homarr', 'servarr')
if len(services_classed['servarr']) == 0:
print('Warning: no media management services selected.')
if 'sonarr' in services_classed['servarr'] or 'radarr' in services_classed['servarr']:
take_input('bazarr', 'servarr')
print('\n===INDEXERS===')
services_classed['indexer'] = []
take_input('prowlarr', 'indexer')
take_input('jackett', 'indexer')
if len(services_classed['indexer']) == 0:
print('Warning: no indexing service selected.')
print('\n===CLOUDFLARE BYPASS===')
services_classed['bypass'] = []
take_input('flaresolverr', 'bypass')
print('\n===MEDIA SERVERS===')
services_classed['ms'] = []
take_input('plex', 'ms')
if 'plex' in services_classed['ms']:
take_input('tautulli', 'ms')
if 'sonarr' in services_classed['servarr'] or 'radarr' in services_classed['servarr']:
take_input('overseerr', 'servarr')
take_input('jellyfin', 'ms')
if ('jellyfin' in services_classed['ms']
and ('sonarr' in services_classed['servarr'] or 'radarr' in services_classed['servarr'])):
take_input('jellyseerr', 'servarr')
if len(services_classed['ms']) == 0:
print('Warning: no media servers selected.')
print('\n===BITTORRENT===')
services_classed['torrent'] = []
take_input('qbittorrent', 'torrent', 'qBittorrent')
print('\n===USENET===')
services_classed['usenet'] = []
take_input('sabnzbd', 'usenet', 'SABnzbd')
if len(services_classed['torrent']) == 0 and len(services_classed['usenet']) == 0:
print('Warning: no usenet or BitTorrent clients selected.')
services = []
for service_class in services_classed.keys():
services.extend(services_classed[service_class])
if len(services) == 0:
print('No services selected. Terminating.')
exit(1)
print('\n===CONFIGURATION===')
print('Please enter your timezone (like "Europe/Amsterdam") or press enter to use your system\'s configured timezone:', end=' ')
timezone = input()
if timezone == '':
timezone = get_system_timezone()
if len(timezone) == 0: # if user pressed enter and reading timezone from /etc/localtime failed then default to Amsterdam
timezone = 'Europe/Amsterdam'
plex_claim = ''
if 'plex' in services:
print('If you have a PleX claim token, enter it now. Otherwise, just press enter.', end=' ')
plex_claim = input()
print('Where would you like to keep your files?', end=' ')
root_dir = take_directory_input()
default_config_dir = f'{root_dir}/config'
default_downloads_dir = f'{root_dir}/data'
default_media_dir = f'{root_dir}/data/media'
print('\nBy default all configs, downloads and media go under the root directory you just picked.')
print('You can override any of these three with an absolute path, or press enter to accept the default.')
print('NOTE: hardlinks/atomic moves only work when downloads and media are on the same filesystem.')
print(f'Config directory [{default_config_dir}]:', end=' ')
config_dir = take_optional_directory_input(default_config_dir)
print(f'Downloads directory [{default_downloads_dir}]:', end=' ')
downloads_dir = take_optional_directory_input(default_downloads_dir)
print(f'Media directory [{default_media_dir}]:', end=' ')
media_dir = take_optional_directory_input(default_media_dir)
compose = open('docker-compose.yml', 'w')
compose.write(
'---\n'
'services:\n'
)
container_config = ContainerConfig(
root_dir,
timezone,
plex_claim=plex_claim,
config_dir=config_dir,
downloads_dir=downloads_dir,
media_dir=media_dir,
)
for service in services:
compose.write(getattr(container_config, service)())
compose.close()
print("Docker compose file generated successfully.")
print("Do you want to also generate the required folder structure and permissions? (this is required for first time setup) [Y/n]: ")
generate_permissions = take_boolean_input()
if generate_permissions:
permission_setup = UserGroupSetup(
root_dir=root_dir,
config_dir=config_dir,
downloads_dir=downloads_dir,
media_dir=media_dir,
)
for service in services:
try:
getattr(permission_setup, service)()
except AttributeError:
pass
else:
print("Permission and folder structure generation skipped by user.")
print('Process complete. You can now run "docker compose up -d" to start your containers.')
print('Thank you for using EZarr. If you experience any issues or have feature requests, add them to our issues.')
print('For questions, you can also use the discussions tab.')
exit(0)
if __name__ == '__main__':
main()