diff --git a/actions/minidlna b/actions/minidlna
new file mode 100755
index 000000000..544fa4c7f
--- /dev/null
+++ b/actions/minidlna
@@ -0,0 +1,106 @@
+#!/usr/bin/python3
+#
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+"""
+Configuration actions for the minidlna server.
+"""
+import argparse
+from tempfile import mkstemp
+from shutil import move
+from os import fdopen, remove
+
+import augeas
+
+from plinth.utils import grep
+
+config_path = '/etc/minidlna.conf'
+
+
+def parse_arguments():
+ """Return parsed command line arguments as dictionary."""
+ parser = argparse.ArgumentParser()
+ subparsers = parser.add_subparsers(dest='subcommand', help='Sub command')
+
+ subparsers.add_parser('setup', help='Setup SSH server')
+
+ subparsers.add_parser('get-media-dir', help='Get media directory')
+
+ set_media_dir = subparsers.add_parser('set-media-dir',
+ help='Set custom media directory')
+ set_media_dir.add_argument('--dir')
+
+ subparsers.required = True
+ return parser.parse_args()
+
+
+def subcommand_setup(arguments):
+ """
+ Increase inotify watches per folder to allow minidlna to
+ monitor changes in large media-dirs.
+ """
+ aug = augeas.Augeas(
+ flags=augeas.Augeas.NO_LOAD + augeas.Augeas.NO_MODL_AUTOLOAD)
+ aug.set('/augeas/load/Sysctl/lens', 'Sysctl.lns')
+ aug.set('/augeas/load/Sysctl/incl[last() + 1]', '/etc/sysctl.conf')
+ aug.load()
+
+ aug.set('/files/etc/sysctl.conf/fs.inotify.max_user_watches', '100000')
+ aug.save()
+
+
+def subcommand_get_media_dir(arguments):
+ """Retrieve media directory from minidlna.conf"""
+ line = grep('^media_dir=', config_path)
+
+ print(line[0].split("=")[1])
+
+
+def subcommand_set_media_dir(arguments):
+ """Set media directory in minidlna.conf"""
+ line = grep('^media_dir=', config_path)[0]
+
+ new_line = 'media_dir=%s\n' % arguments.dir
+ replace_in_config_file(config_path, line, new_line)
+
+
+def replace_in_config_file(file_path, pattern, subst):
+ """
+ Create a temporary minidlna.conf file,
+ replace the media dir config,
+ remove original one and move the temporary file.
+ """
+ temp_file, temp_file_path = mkstemp()
+ with fdopen(temp_file, 'w') as new_file:
+ with open(file_path) as old_file:
+ for line in old_file:
+ new_file.write(line.replace(pattern, subst))
+
+ remove(file_path)
+ move(temp_file_path, file_path)
+
+
+def main():
+ """Parse arguments and perform all duties."""
+ arguments = parse_arguments()
+
+ subcommand = arguments.subcommand.replace('-', '_')
+ subcommand_method = globals()['subcommand_' + subcommand]
+ subcommand_method(arguments)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/plinth/modules/minidlna/__init__.py b/plinth/modules/minidlna/__init__.py
new file mode 100644
index 000000000..48ea0bd29
--- /dev/null
+++ b/plinth/modules/minidlna/__init__.py
@@ -0,0 +1,116 @@
+#
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+"""
+FreedomBox app to configure minidlna.
+"""
+from django.utils.translation import ugettext_lazy as _
+
+from plinth import actions
+import plinth.app as app_module
+from plinth import frontpage, menu
+from plinth.modules.apache.components import Webserver
+from plinth.modules.firewall.components import Firewall
+from plinth.modules.users import register_group
+from plinth.action_utils import diagnose_url
+
+from .manifest import backup, clients # noqa
+
+version = 1
+
+name = 'minidlna'
+
+icon_name = name
+
+managed_packages = ['minidlna']
+
+short_description = _('Simple Media Server')
+
+description = [
+ _('MiniDLNA is a simple media server software, with the aim of being '
+ 'fully compliant with DLNA/UPnP-AV clients. '
+ 'The MiniDNLA daemon serves media files '
+ '(music, pictures, and video) to clients on a network. '
+ 'DNLA/UPnP is zero configuration protocol and is compliant '
+ 'with any device passing the DLNA Certification like portable '
+ 'media players, Smartphones, Televisions, and gaming systems ('
+ 'such as PS3 and Xbox 360) or applications such as totem and Kodi.')
+]
+
+clients = clients
+
+group = ('minidlna', _('Media streaming server'))
+
+app = None
+
+
+class MiniDLNAApp(app_module.App):
+ """Freedombox app managing miniDlna"""
+ app_id = 'minidlna'
+
+ def __init__(self):
+ """Initialize the app components"""
+ super().__init__()
+ menu_item = menu.Menu(
+ 'menu-minidlna',
+ name=name,
+ short_description=short_description,
+ url_name='minidlna:index',
+ parent_url_name='apps',
+ icon=icon_name,
+ )
+ firewall = Firewall('firewall-minidlna', name, ports=['minidlna'],
+ is_external=False)
+ webserver = Webserver('webserver-minidlna', 'minidlna-plinth')
+ shortcut = frontpage.Shortcut(
+ 'shortcut-minidlna',
+ name,
+ short_description=short_description,
+ description=description,
+ icon=icon_name,
+ url='/_minidlna/',
+ login_required=True,
+ )
+
+ self.add(menu_item)
+ self.add(webserver)
+ self.add(firewall)
+ self.add(shortcut)
+
+
+def init():
+ global app
+ app = MiniDLNAApp()
+ register_group(group)
+
+ setup_helper = globals()['setup_helper']
+ if setup_helper.get_state() != 'needs-setup' and app.is_enabled():
+ app.set_enabled(True)
+
+
+def setup(helper, old_version=None):
+ """Install and configure the package"""
+ helper.install(managed_packages)
+ helper.call('post', actions.superuser_run, 'minidlna', ['setup'])
+ helper.call('post', app.enable)
+
+
+def diagnose():
+ """Check if the http page listening on 8200 is accessible"""
+ results = []
+ results.append(diagnose_url('http://localhost:8200/'))
+
+ return results
diff --git a/plinth/modules/minidlna/data/etc/apache2/conf-available/minidlna-freedombox.conf b/plinth/modules/minidlna/data/etc/apache2/conf-available/minidlna-freedombox.conf
new file mode 100644
index 000000000..544e0d862
--- /dev/null
+++ b/plinth/modules/minidlna/data/etc/apache2/conf-available/minidlna-freedombox.conf
@@ -0,0 +1,3 @@
+
+ ProxyPass http://localhost:8200/
+
diff --git a/plinth/modules/minidlna/data/etc/plinth/modules-enabled/minidlna b/plinth/modules/minidlna/data/etc/plinth/modules-enabled/minidlna
new file mode 100644
index 000000000..164cbbd65
--- /dev/null
+++ b/plinth/modules/minidlna/data/etc/plinth/modules-enabled/minidlna
@@ -0,0 +1 @@
+plinth.modules.minidlna
diff --git a/plinth/modules/minidlna/forms.py b/plinth/modules/minidlna/forms.py
new file mode 100644
index 000000000..a272a8264
--- /dev/null
+++ b/plinth/modules/minidlna/forms.py
@@ -0,0 +1,39 @@
+#
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+"""
+FreedomBox configuration form for MiniDLNA server.
+"""
+
+from django import forms
+from django.utils.translation import ugettext_lazy as _
+
+from plinth.forms import AppForm
+
+
+class MiniDLNAServerForm(AppForm):
+ """MiniDLNA server configuration form."""
+ media_dir = forms.CharField(
+ label=_('Media Files Directory'),
+ help_text=_('Directory that MiniDLNA Server will read for content. All'
+ ' sub-directories of this will be also scanned for media '
+ 'files. '
+ 'If you change the default ensure that the new directory '
+ 'exists and that is readable from the "minidlna" user. '
+ 'Any user media directories ("/home/username/") will '
+ 'usually work.'),
+ required=False,
+ )
diff --git a/plinth/modules/minidlna/manifest.py b/plinth/modules/minidlna/manifest.py
new file mode 100644
index 000000000..24a0643ab
--- /dev/null
+++ b/plinth/modules/minidlna/manifest.py
@@ -0,0 +1,138 @@
+#
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+
+from django.utils.translation import ugettext_lazy as _
+
+from plinth.modules.backups.api import validate as validate_backup
+from plinth.clients import validate, store_url
+
+clients = validate([
+ {
+ 'name': _('vlc'),
+ 'platforms': [
+ {
+ 'type': 'package',
+ 'os': 'gnu-linux',
+ 'format': 'deb',
+ 'name': 'vlc',
+ },
+ {
+ 'type': 'package',
+ 'os': 'gnu-linux',
+ 'format': 'rpm',
+ 'name': 'vlc',
+ },
+ {
+ 'type': 'download',
+ 'os': 'windows',
+ 'url': 'https://www.videolan.org/vlc/download-windows.html',
+ },
+ {
+ 'type': 'download',
+ 'os': 'macos',
+ 'url': 'https://www.videolan.org/vlc/download-macosx.html',
+ },
+ {
+ 'type': 'store',
+ 'os': 'android',
+ 'store_name': 'google-play',
+ 'url': store_url('google-play', 'org.videolan.vlc')
+ },
+ {
+ 'type': 'store',
+ 'os': 'android',
+ 'store_name': 'f-droid',
+ 'url': store_url('f-droid', 'org.videolan.vlc')
+ },
+ ]
+ },
+ {
+ 'name': _('kodi'),
+ 'platforms': [
+ {
+ 'type': 'package',
+ 'os': 'gnu-linux',
+ 'format': 'deb',
+ 'name': 'kodi',
+ },
+ {
+ 'type': 'package',
+ 'os': 'gnu-linux',
+ 'format': 'rpm',
+ 'name': 'kodi',
+ },
+ {
+ 'type': 'download',
+ 'os': 'windows',
+ 'url': 'http://kodi.tv/download/',
+ },
+ {
+ 'type': 'download',
+ 'os': 'macos',
+ 'url': 'http://kodi.tv/download/',
+ },
+ {
+ 'type': 'store',
+ 'os': 'android',
+ 'store_name': 'google-play',
+ 'url': store_url('google-play', 'org.xbmc.kodi')
+ },
+ {
+ 'type': 'store',
+ 'os': 'android',
+ 'store_name': 'f-droid',
+ 'url': store_url('f-droid', 'org.xbmc.kodi')
+ },
+ ]
+ },
+ {
+ 'name': _('yaacc'),
+ 'platforms': [
+ {
+ 'type': 'store',
+ 'os': 'android',
+ 'store_name': 'f-droid',
+ 'url': store_url('f-droid', 'de.yaacc')
+ },
+ ]
+ },
+ {
+ 'name': _('totem'),
+ 'platforms': [
+ {
+ 'type': 'package',
+ 'os': 'gnu-linux',
+ 'format': 'deb',
+ 'name': 'totem',
+ },
+ {
+ 'type': 'package',
+ 'os': 'gnu-linux',
+ 'format': 'rpm',
+ 'name': 'totem',
+ },
+ ]
+ },
+])
+
+# TODO: get all media directories from config file
+# for now hard code default media folder.
+backup = validate_backup({
+ 'data': {
+ 'directories': ['/var/lib/minidlna']
+ }
+})
diff --git a/plinth/modules/minidlna/tests/__init__.py b/plinth/modules/minidlna/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/plinth/modules/minidlna/urls.py b/plinth/modules/minidlna/urls.py
new file mode 100644
index 000000000..6dd7527f2
--- /dev/null
+++ b/plinth/modules/minidlna/urls.py
@@ -0,0 +1,27 @@
+#
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+"""
+URLs for the minidlna Server module.
+"""
+
+from django.conf.urls import url
+
+from plinth.modules.minidlna.views import MiniDLNAAppView
+
+urlpatterns = [
+ url(r'^apps/minidlna/$', MiniDLNAAppView.as_view(), name='index'),
+]
diff --git a/plinth/modules/minidlna/views.py b/plinth/modules/minidlna/views.py
new file mode 100644
index 000000000..ee3926f2c
--- /dev/null
+++ b/plinth/modules/minidlna/views.py
@@ -0,0 +1,65 @@
+#
+# This file is part of FreedomBox.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+"""
+Views for the minidlna module
+"""
+import os
+
+from django.contrib import messages
+from django.utils.translation import ugettext_lazy as _
+
+from plinth import actions
+from plinth.views import AppView
+from plinth.modules import minidlna
+
+from .forms import MiniDLNAServerForm
+
+
+class MiniDLNAAppView(AppView):
+ app_id = 'minidlna'
+ name = minidlna.name
+ description = minidlna.description
+ form_class = MiniDLNAServerForm
+ diagnostics_module_name = 'minidlna'
+
+ def get_initial(self):
+ """Initial form value as found in the minidlna.conf"""
+ initial = super().get_initial()
+ initial.update({
+ 'media_dir': actions.superuser_run('minidlna', ['get-media-dir']),
+ })
+
+ return initial
+
+ def form_valid(self, form):
+ """Apply changes from the form"""
+ old_config = form.initial
+ new_config = form.cleaned_data
+
+ if old_config['media_dir'].strip() != new_config['media_dir']:
+ if os.path.isdir(new_config['media_dir']) is False:
+ messages.error(self.request,
+ _('Specified directory does not exist.'))
+ else:
+ actions.superuser_run(
+ 'minidlna',
+ ['set-media-dir', '--dir', new_config['media_dir']]
+ )
+ actions.superuser_run('service', ['restart', 'minidlna'])
+ messages.success(self.request, _('Updated media directory'))
+
+ return super().form_valid(form)
diff --git a/static/themes/default/icons/minidlna.svg b/static/themes/default/icons/minidlna.svg
new file mode 100644
index 000000000..7ae6aaed4
Binary files /dev/null and b/static/themes/default/icons/minidlna.svg differ