├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE.md ├── LICENSE ├── README.md ├── VERSION ├── arm ├── __init__.py ├── config │ └── config.py ├── migrations │ ├── README │ ├── alembic.ini │ ├── env.py │ ├── script.py.mako │ └── versions │ │ └── c3a3fa694636_.py ├── models │ └── models.py ├── ripper │ ├── __init__.py │ ├── getkeys.py │ ├── handbrake.py │ ├── identify.py │ ├── logger.py │ ├── main.py │ ├── makemkv.py │ └── utils.py ├── runui.py └── ui │ ├── __init__.py │ ├── forms.py │ ├── routes.py │ ├── static │ ├── css │ │ ├── bootstrap.css │ │ ├── bootstrap.orig.css │ │ └── bootstrap.spacelab.css │ ├── img │ │ ├── arm40nw.png │ │ ├── arm80.png │ │ ├── arm80nw.png │ │ ├── disc.svg │ │ ├── favicon.png │ │ └── file-text.svg │ └── js │ │ ├── bootstrap.js │ │ ├── jquery-3.3.1.min.js │ │ └── jquery.tablesorter.js │ ├── templates │ ├── activerips.html │ ├── base.html │ ├── changeparams.html │ ├── customTitle.html │ ├── history.html │ ├── index.html │ ├── jobdetail.html │ ├── list_titles.html │ ├── logfiles.html │ ├── logview.html │ ├── nav.html │ ├── showtitle.html │ └── titlesearch.html │ └── utils.py ├── docs ├── README-OMDBAPI.txt └── arm.yaml.sample ├── requirements.txt ├── scripts └── arm_wrapper.sh ├── setup.cfg └── setup ├── .abcde.conf └── 51-automedia.rules /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/settings.json 2 | arm.conf.yaml 3 | logs/* 4 | *.log 5 | test.sh 6 | archive/ 7 | temp/ 8 | test.py 9 | *.sh~ 10 | Branch_Scope_Sanatization.txt 11 | __pycache* 12 | removed/* 13 | arm.conf 14 | *.pyc 15 | .gitignore 16 | arm.yaml 17 | *.db 18 | *.log 19 | *.json 20 | .abdce.conf 21 | *.old 22 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.5" 4 | install: "pip install -r requirements.txt" 5 | script: 6 | - flake8 arm 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v2.1.0 4 | - Added new package (armui) for web user interface 5 | - Basic web framework (Flask, Bootstrap) 6 | - Retitle functionality 7 | - View or download logs of active and past rips 8 | - sqlite db 9 | 10 | ## v2.0.1 11 | - Fixed crash inserting bluray when bdmt_eng.xml file is not present 12 | - Fixed error when deleting non-existent raw files 13 | - Fixed file extension config parameter not being honored when RIPMETHOD='mkv' 14 | - Fixed media not being moved when skip_transcode=True 15 | - Added logic for when skip_trancode=True to make it consistant with standard processing 16 | - Removed systemd and reimplemented arm_wrapper.sh (see Readme for upgrade instructions) 17 | 18 | ## v2.0.0 19 | - Rewritten completely in Python 20 | - Run as non-root 21 | - Seperate HandBrake arguments and profiles for DVD's and Bluray's 22 | - Set video type or automatically identify 23 | - Better logging 24 | - Auto download latest keys_hashed.txt and KEYDB.cfg 25 | 26 | ## v1.3.0 27 | - Get Title for DVD and Blu-Rays so that media servesr can identify them easily. 28 | - Determine if video is Movie or TV-Show from OMDB API query so that different actions can be taken (TV shows usually require manual episode identification) 29 | - Option for MakeMKV to rip using backup method. 30 | - Option to rip only main feature if so desired. 31 | 32 | ## v1.2.0 33 | - Distinguish UDF data from UDF video discs 34 | 35 | ## v1.1.1 36 | 37 | - Added devname to abcde command 38 | - Added logging stats (timers). "grep STAT" to see parse them out. 39 | 40 | ## v1.1.0 41 | 42 | - Added ability to rip from multiple drives at the same time 43 | - Added a config file for parameters 44 | - Changed logging 45 | - Log name is based on ID_FS_LABEL (dvd name) variable set by udev in order to isolate logging from multiple process running simultaneously 46 | - Log file name and path set in config file 47 | - Log file cleanup based on parameter set in config file 48 | - Added phone notification options for Pushbullet and IFTTT 49 | - Remove MakeMKV destination directory after HandBrake finishes transcoding 50 | - Misc stuff 51 | 52 | ## v1.0.1 53 | 54 | - Fix ripping "Audio CDs" in ISO9660 format like LOTR. 55 | 56 | ## v1.0.0 57 | 58 | - Initial Release -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guide 2 | ## Introduction 3 | Thank you for contributing to the Automatic Ripping Machine. 4 | 5 | ## Issues, Bugs, and Feature Requests 6 | If you find a bug, please delete the existing log for that rip, change the log level to DEBUG in your arm.yaml file and then run the rip again to get a clean log for analysis. You can drag and drop the log onto an issue comment to attach it to the issue. 7 | 8 | Also, since ARM relies on software such a HandBrake and MakeMKV try running those programs manually to see if it's an issue there. If you run ARM in DEBUG mode you should 9 | be able to see the exact call out to each program. 10 | 11 | When submitting a bug, enhancement, or feature request please indicate if you are able/willing to make the changes yourself in a pull request. 12 | 13 | ## Pull Requests 14 | Please submit pull request for bug fixes against the v2_fixes branch and features against the v2.x_dev branch. 15 | 16 | To make a pull request fork this project into your own github repository and after making changes create a PR. Read https://help.github.com/articles/creating-a-pull-request/ 17 | 18 | Test your changes locally to the best of your ability to make sure nothing broke. 19 | 20 | If you are making multiple changes, please create separate pull requests so they can be evaluated and approved individually (obviously if changes are trivial, or multiple changes are dependent on each other then one PR is fine). 21 | 22 | Update the README file in your PR if your changes require them. 23 | 24 | After submitting your PR check that the Travis CI build passes, if it doesn't you can fix those issues with additional commits. 25 | 26 | ## Hardware/OS Documentation 27 | The installation guide is for Ubuntu18.04 and the devs run it in VMware, however, many are running ARM in different environments. If you have successfully set ARM up in a different environment and would like to assist others, please submit a howto to the [wiki](https://github.com/automatic-ripping-machine/automatic-ripping-machine/wiki). 28 | 29 | ## Testing, Quality, etc. 30 | If you are interested in helping out with testing, quality, etc. please let us know. 31 | 32 | 33 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Description 2 | [Description of Bug, Feature, Question, etc.] 3 | 4 | ### Environment 5 | [OS Distribution and version (run "cat /etc/lsb-release")] 6 | [ARM Release Version or if cloning from git branch/commit (run "git branch" and "git log -1" to get this)] 7 | 8 | ### Log file 9 | [Run the rip in DEBUG and drag and drop the log file onto this comment box] 10 | 11 | [>>>>> DRAG AND DROP LOG FILE HERE <<<<<] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Benjamin Bryan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Automatic Ripping Machine (ARM) 2 | 3 | [![Build Status](https://travis-ci.org/automatic-ripping-machine/automatic-ripping-machine.svg?branch=v2_master)](https://travis-ci.org/automatic-ripping-machine/automatic-ripping-machine) 4 | 5 | ## Upgrading from v2_master to v2.2_dev 6 | 7 | If you wish to upgrade from v2_master to v2.2_dev instead of a clean install, these directions should get you there. 8 | 9 | ```bash 10 | cd /opt/arm 11 | sudo git checkout v2.2_dev 12 | sudo pip3 install -r requirements.txt 13 | ``` 14 | Backup config file and replace it with the updated config 15 | ```bash 16 | mv arm.yaml arm.yaml.old 17 | cp docs/arm.yaml.sample arm.yaml 18 | ``` 19 | 20 | There are new config parameters so review the new arm.yaml file 21 | 22 | Make sure the 'arm' user has write permissions to the db directory (see your arm.yaml file for locaton). is writeable by the arm user. A db will be created when you first run ARM. 23 | 24 | Make sure that your rules file is properly **copied** instead of linked: 25 | ``` 26 | sudo rm /usr/lib/udev/rules.d/51-automedia.rules 27 | sudo cp /opt/arm/setup/51-automedia.rules /etc/udev/rules.d/ 28 | ``` 29 | Otherwise you may not get the auto-launching of ARM when a disc is inserted behavior 30 | on Ubuntu 20.04. 31 | 32 | Please log any issues you find. Don't forget to run in DEBUG mode if you need to submit an issue (and log files). Also, please note that you are running 2.2_dev in your issue. 33 | 34 | 35 | ## Overview 36 | 37 | Insert an optical disc (Blu-Ray, DVD, CD) and checks to see if it's audio, video (Movie or TV), or data, then rips it. 38 | 39 | See: https://b3n.org/automatic-ripping-machine 40 | 41 | 42 | ## Features 43 | 44 | - Detects insertion of disc using udev 45 | - Auto downloads keys_hashed.txt and KEYDB.cfg using robobrowser and tinydownloader 46 | - Determines disc type... 47 | - If video (Blu-Ray or DVD) 48 | - Retrieve title from disc or Windows Media MetaServices API to name the folder "movie title (year)" so that Plex or Emby can pick it up 49 | - Determine if video is Movie or TV using OMDb API 50 | - Rip using MakeMKV or HandBrake (can rip all features or main feature) 51 | - Eject disc and queue up Handbrake transcoding when done 52 | - Transcoding jobs are asynchronusly batched from ripping 53 | - Send notification when done via IFTTT or Pushbullet 54 | - If audio (CD) - rip using abcde 55 | - If data (Blu-Ray, DVD, or CD) - make an ISO backup 56 | - Headless, designed to be run from a server 57 | - Can rip from multiple-optical drives in parallel 58 | - HTML UI to interact with ripping jobs, view logs, etc 59 | 60 | 61 | ## Requirements 62 | 63 | - Ubuntu Server 18.04 (should work with other Linux distros) - Needs Multiverse and Universe repositories 64 | - One or more optical drives to rip Blu-Rays, DVDs, and CDs 65 | - Lots of drive space (I suggest using a NAS like FreeNAS) to store your movies 66 | 67 | ## Pre-Install (only if necessary) 68 | 69 | If you have a new DVD drive that you haven't used before, some require setting the region before they can play anything. Be aware most DVD players only let you change the region a handful (4 or 5?) of times then lockout any further changes. If your region is already set or you have a region free DVD drive you can skip this step. 70 | 71 | ```bash 72 | sudo apt-get install regionset 73 | sudo regionset /dev/sr0 74 | ``` 75 | 76 | ## Install 77 | 78 | **Setup 'arm' user and ubuntu basics:** 79 | 80 | Sets up graphics drivers, does Ubuntu update & Upgrade, gets Ubuntu to auto set up driver, and finally installs and setups up avahi-daemon 81 | ```bash 82 | sudo apt upgrade -y && sudo apt update -y 83 | ***optional (was not required for me): sudo add-apt-repository ppa:graphics-drivers/ppa 84 | sudo apt install avahi-daemon -y && sudo systemctl restart avahi-daemon 85 | sudo apt install ubuntu-drivers-common -y && sudo ubuntu-drivers install 86 | sudo reboot 87 | # Installation of drivers seems to install a full gnome desktop, and it seems to set up hibernation modes. 88 | # It is optional to run the below line (Hibernation may be something you want.) 89 | sudo systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.target 90 | sudo groupadd arm 91 | sudo useradd -m arm -g arm -G cdrom 92 | sudo passwd arm 93 | 94 | ``` 95 | 96 | **Set up repos and install dependencies** 97 | 98 | ```bash 99 | sudo apt-get install git -y 100 | sudo add-apt-repository ppa:heyarje/makemkv-beta 101 | sudo add-apt-repository ppa:stebbins/handbrake-releases 102 | 103 | NumOnly=$(cut -f2 <<< `lsb_release -r`) && case $NumOnly in "16.04" ) sudo add-apt-repository ppa:mc3man/xerus-media;; "18.04" ) sudo add-apt-repository ppa:mc3man/bionic-prop;; "20.04" ) sudo add-apt-repository ppa:mc3man/focal6;; *) echo "error in finding release version";; esac 104 | 105 | sudo apt update -y && \ 106 | sudo apt install makemkv-bin makemkv-oss -y && \ 107 | sudo apt install handbrake-cli libavcodec-extra -y && \ 108 | sudo apt install abcde flac imagemagick glyrc cdparanoia -y && \ 109 | sudo apt install at -y && \ 110 | sudo apt install python3 python3-pip -y && \ 111 | sudo apt-get install libcurl4-openssl-dev libssl-dev -y && \ 112 | sudo apt-get install libdvd-pkg -y && \ 113 | sudo dpkg-reconfigure libdvd-pkg && \ 114 | sudo apt install default-jre-headless -y 115 | ``` 116 | 117 | **Install and setup ARM** 118 | 119 | ```bash 120 | cd /opt 121 | sudo mkdir arm 122 | sudo chown arm:arm arm 123 | sudo chmod 775 arm 124 | sudo git clone https://github.com/automatic-ripping-machine/automatic-ripping-machine.git arm 125 | sudo chown -R arm:arm arm 126 | cd arm 127 | sudo pip3 install -r requirements.txt 128 | sudo cp /opt/arm/setup/51-automedia.rules /etc/udev/rules.d/ 129 | sudo ln -s /opt/arm/setup/.abcde.conf /home/arm/ 130 | sudo cp docs/arm.yaml.sample arm.yaml 131 | sudo mkdir /etc/arm/ 132 | sudo ln -s /opt/arm/arm.yaml /etc/arm/ 133 | ``` 134 | 135 | **Set up drives** 136 | 137 | Create mount point for each dvd drive. 138 | If you don't know the device name try running `dmesg | grep -i -E '\b(dvd|cd)\b'`. The mountpoint needs to be /mnt/dev/. 139 | So if your device name is `sr0`, set the mountpoint with this command: 140 | ```bash 141 | sudo mkdir -p /mnt/dev/sr0 142 | ``` 143 | Repeat this for each device you plan on using with ARM. 144 | 145 | Create entries in /etc/fstab to allow non-root to mount dvd-roms 146 | Example (create for each optical drive you plan on using for ARM): 147 | ``` 148 | /dev/sr0 /mnt/dev/sr0 udf,iso9660 user,noauto,exec,utf8 0 0 149 | ``` 150 | 151 | **Configure ARM** 152 | 153 | - Edit your "config" file (located at /opt/arm/arm.yaml) to determine what options you'd like to use. Pay special attention to the 'directory setup' section and make sure the 'arm' user has write access to wherever you define these directories. 154 | 155 | - Edit the music config file (located at /home/arm/.abcde.conf) 156 | 157 | - To rip Blu-Rays after the MakeMKV trial is up you will need to purchase a license key or while MakeMKV is in BETA you can get a free key (which you will need to update from time to time) here: https://www.makemkv.com/forum2/viewtopic.php?f=5&t=1053 and create /home/arm/.MakeMKV/settings.conf with the contents: 158 | 159 | app_Key = "insertlicensekeyhere" 160 | 161 | - For ARM to identify movie/tv titles register for an API key at OMDb API: http://www.omdbapi.com/apikey.aspx and set the OMDB_API_KEY parameter in the config file. 162 | 163 | After setup is complete reboot... 164 | 165 | reboot 166 | 167 | Optionally if you want something more stable than master you can download the latest release from the releases page. 168 | 169 | **Email notifcations** 170 | 171 | A lot of random problems are found in the sysmail, email alerting is a most effective method for debugging and monitoring. 172 | 173 | I recommend you install postfix from here:http://mhawthorne.net/posts/2011-postfix-configuring-gmail-as-relay/ 174 | 175 | Then configure /etc/aliases 176 | e.g.: 177 | 178 | ``` 179 | root: my_email@gmail.com 180 | arm: my_email@gmail.com 181 | userAccount: my_email@gmail.com 182 | ``` 183 | 184 | Run below to pick up the aliases 185 | 186 | ``` 187 | sudo newaliases 188 | ``` 189 | 190 | ## Usage 191 | 192 | - Insert disc 193 | - Wait for disc to eject 194 | - Repeat 195 | 196 | ## Troubleshooting 197 | 198 | When a disc is inserted, udev rules should launch a script (scripts/arm_wrapper.sh) that will launch ARM. Here are some basic troubleshooting steps: 199 | - Look for empty.log. 200 | - Everytime you eject the cdrom, an entry should be entered in empty.log like: 201 | ``` 202 | [2018-08-05 11:39:45] INFO ARM: main. Drive appears to be empty or is not ready. Exiting ARM. 203 | ``` 204 | - Empty.log should be in your logs directory as defined in your arm.yaml file. If there is no empty.log file, or entries are not being entered when you eject the cdrom drive, then udev is not launching ARM correctly. Check the instructions and make sure the symlink to 51-automedia.rules is set up right. I've you've changed the link or the file contents you need to reload your udev rules with: 205 | ``` 206 | sudo udevadm control --reload_rules 207 | ``` 208 | 209 | - Check ARM log files 210 | - The default location is /home/arm/logs/ (unless this is changed in your arm.yaml file) and is named after the dvd. These are very verbose. You can filter them a little by piping the log through grep. Something like 211 | ``` 212 | cat | grep ARM: 213 | ``` 214 | This will filter out the MakeMKV and HandBrake entries and only output the ARM log entries. 215 | - You can change the verbosity in the arm.yaml file. DEBUG will give you more information about what ARM is trying to do. Note: please run a rip in DEBUG mode if you want to post to an issue for assistance. 216 | - Ideally, if you are going to post a log for help, please delete the log file, and re-run the disc in DEBUG mode. This ensures we get the most information possible and don't have to parse the file for multiple rips. 217 | 218 | If you need any help feel free to open an issue. Please see the above note about posting a log. 219 | 220 | ## Contributing 221 | 222 | Pull requests are welcome. Please see the [Contributing Guide](./CONTRIBUTING.md) 223 | 224 | If you set ARM up in a different environment (harware/OS/virtual/etc), please consider submitting a howto to the [wiki](https://github.com/automatic-ripping-machine/automatic-ripping-machine/wiki). 225 | 226 | ## License 227 | 228 | [MIT License](LICENSE) 229 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 2.1.0-dev -------------------------------------------------------------------------------- /arm/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flammableliquids/automatic-ripping-machine/f7450fb96730a631190c30e012b0bc469e5a890b/arm/__init__.py -------------------------------------------------------------------------------- /arm/config/config.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import os 4 | # import re 5 | import yaml 6 | 7 | yamlfile = os.path.join(os.path.dirname(os.path.abspath(__file__)), "../..", "arm.yaml") 8 | 9 | with open(yamlfile, "r") as f: 10 | cfg = yaml.load(f) 11 | -------------------------------------------------------------------------------- /arm/migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /arm/migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | 11 | 12 | # Logging configuration 13 | [loggers] 14 | keys = root,sqlalchemy,alembic 15 | 16 | [handlers] 17 | keys = console 18 | 19 | [formatters] 20 | keys = generic 21 | 22 | [logger_root] 23 | level = WARN 24 | handlers = console 25 | qualname = 26 | 27 | [logger_sqlalchemy] 28 | level = WARN 29 | handlers = 30 | qualname = sqlalchemy.engine 31 | 32 | [logger_alembic] 33 | level = INFO 34 | handlers = 35 | qualname = alembic 36 | 37 | [handler_console] 38 | class = StreamHandler 39 | args = (sys.stderr,) 40 | level = NOTSET 41 | formatter = generic 42 | 43 | [formatter_generic] 44 | format = %(levelname)-5.5s [%(name)s] %(message)s 45 | datefmt = %H:%M:%S 46 | -------------------------------------------------------------------------------- /arm/migrations/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | from alembic import context 3 | from sqlalchemy import engine_from_config, pool 4 | # from logging.config import fileConfig 5 | # import logging 6 | 7 | # this is the Alembic Config object, which provides 8 | # access to the values within the .ini file in use. 9 | config = context.config 10 | 11 | # Interpret the config file for Python logging. 12 | # This line sets up loggers basically. 13 | # fileConfig(config.config_file_name) 14 | # logger = logging.getLogger('alembic.env') 15 | 16 | # add your model's MetaData object here 17 | # for 'autogenerate' support 18 | # from myapp import mymodel 19 | # target_metadata = mymodel.Base.metadata 20 | from flask import current_app 21 | config.set_main_option('sqlalchemy.url', 22 | current_app.config.get('SQLALCHEMY_DATABASE_URI')) 23 | target_metadata = current_app.extensions['migrate'].db.metadata 24 | 25 | # other values from the config, defined by the needs of env.py, 26 | # can be acquired: 27 | # my_important_option = config.get_main_option("my_important_option") 28 | # ... etc. 29 | 30 | # flake8: noqa: W291,E122,E128 31 | 32 | def run_migrations_offline(): 33 | """Run migrations in 'offline' mode. 34 | 35 | This configures the context with just a URL 36 | and not an Engine, though an Engine is acceptable 37 | here as well. By skipping the Engine creation 38 | we don't even need a DBAPI to be available. 39 | 40 | Calls to context.execute() here emit the given string to the 41 | script output. 42 | 43 | """ 44 | url = config.get_main_option("sqlalchemy.url") 45 | context.configure(url=url) 46 | 47 | with context.begin_transaction(): 48 | context.run_migrations() 49 | 50 | 51 | def run_migrations_online(): 52 | """Run migrations in 'online' mode. 53 | 54 | In this scenario we need to create an Engine 55 | and associate a connection with the context. 56 | 57 | """ 58 | 59 | # this callback is used to prevent an auto-migration from being generated 60 | # when there are no changes to the schema 61 | # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html 62 | def process_revision_directives(context, revision, directives): 63 | if getattr(config.cmd_opts, 'autogenerate', False): 64 | script = directives[0] 65 | if script.upgrade_ops.is_empty(): 66 | directives[:] = [] 67 | logger.info('No changes in schema detected.') 68 | 69 | engine = engine_from_config(config.get_section(config.config_ini_section), 70 | prefix='sqlalchemy.', 71 | poolclass=pool.NullPool) 72 | 73 | connection = engine.connect() 74 | context.configure(connection=connection, 75 | target_metadata=target_metadata, 76 | process_revision_directives=process_revision_directives, 77 | **current_app.extensions['migrate'].configure_args) 78 | 79 | try: 80 | with context.begin_transaction(): 81 | context.run_migrations() 82 | finally: 83 | connection.close() 84 | 85 | if context.is_offline_mode(): 86 | run_migrations_offline() 87 | else: 88 | run_migrations_online() 89 | -------------------------------------------------------------------------------- /arm/migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /arm/migrations/versions/c3a3fa694636_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: c3a3fa694636 4 | Revises: 5 | Create Date: 2019-02-09 19:06:50.363700 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'c3a3fa694636' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('job', 22 | sa.Column('job_id', sa.Integer(), nullable=False), 23 | sa.Column('arm_version', sa.String(length=20), nullable=True), 24 | sa.Column('crc_id', sa.String(length=63), nullable=True), 25 | sa.Column('logfile', sa.String(length=256), nullable=True), 26 | sa.Column('start_time', sa.DateTime(), nullable=True), 27 | sa.Column('stop_time', sa.DateTime(), nullable=True), 28 | sa.Column('job_length', sa.String(length=12), nullable=True), 29 | sa.Column('status', sa.String(length=32), nullable=True), 30 | sa.Column('no_of_titles', sa.Integer(), nullable=True), 31 | sa.Column('title', sa.String(length=256), nullable=True), 32 | sa.Column('title_auto', sa.String(length=256), nullable=True), 33 | sa.Column('title_manual', sa.String(length=256), nullable=True), 34 | sa.Column('year', sa.String(length=4), nullable=True), 35 | sa.Column('year_auto', sa.String(length=4), nullable=True), 36 | sa.Column('year_manual', sa.String(length=4), nullable=True), 37 | sa.Column('video_type', sa.String(length=20), nullable=True), 38 | sa.Column('video_type_auto', sa.String(length=20), nullable=True), 39 | sa.Column('video_type_manual', sa.String(length=20), nullable=True), 40 | sa.Column('imdb_id', sa.String(length=15), nullable=True), 41 | sa.Column('imdb_id_auto', sa.String(length=15), nullable=True), 42 | sa.Column('imdb_id_manual', sa.String(length=15), nullable=True), 43 | sa.Column('poster_url', sa.String(length=256), nullable=True), 44 | sa.Column('poster_url_auto', sa.String(length=256), nullable=True), 45 | sa.Column('poster_url_manual', sa.String(length=256), nullable=True), 46 | sa.Column('devpath', sa.String(length=15), nullable=True), 47 | sa.Column('mountpoint', sa.String(length=20), nullable=True), 48 | sa.Column('hasnicetitle', sa.Boolean(), nullable=True), 49 | sa.Column('errors', sa.Text(), nullable=True), 50 | sa.Column('disctype', sa.String(length=20), nullable=True), 51 | sa.Column('label', sa.String(length=256), nullable=True), 52 | sa.Column('ejected', sa.Boolean(), nullable=True), 53 | sa.Column('updated', sa.Boolean(), nullable=True), 54 | sa.Column('pid', sa.Integer(), nullable=True), 55 | sa.Column('pid_hash', sa.Integer(), nullable=True), 56 | sa.PrimaryKeyConstraint('job_id') 57 | ) 58 | op.create_table('config', 59 | sa.Column('CONFIG_ID', sa.Integer(), nullable=False), 60 | sa.Column('job_id', sa.Integer(), nullable=True), 61 | sa.Column('ARM_CHECK_UDF', sa.Boolean(), nullable=True), 62 | sa.Column('GET_VIDEO_TITLE', sa.Boolean(), nullable=True), 63 | sa.Column('SKIP_TRANSCODE', sa.Boolean(), nullable=True), 64 | sa.Column('VIDEOTYPE', sa.String(length=25), nullable=True), 65 | sa.Column('MINLENGTH', sa.String(length=6), nullable=True), 66 | sa.Column('MAXLENGTH', sa.String(length=6), nullable=True), 67 | sa.Column('MANUAL_WAIT', sa.Boolean(), nullable=True), 68 | sa.Column('MANUAL_WAIT_TIME', sa.Integer(), nullable=True), 69 | sa.Column('ARMPATH', sa.String(length=255), nullable=True), 70 | sa.Column('RAWPATH', sa.String(length=255), nullable=True), 71 | sa.Column('MEDIA_DIR', sa.String(length=255), nullable=True), 72 | sa.Column('EXTRAS_SUB', sa.String(length=255), nullable=True), 73 | sa.Column('INSTALLPATH', sa.String(length=255), nullable=True), 74 | sa.Column('LOGPATH', sa.String(length=255), nullable=True), 75 | sa.Column('LOGLEVEL', sa.String(length=255), nullable=True), 76 | sa.Column('LOGLIFE', sa.Integer(), nullable=True), 77 | sa.Column('DBFILE', sa.String(length=255), nullable=True), 78 | sa.Column('WEBSERVER_IP', sa.String(length=25), nullable=True), 79 | sa.Column('WEBSERVER_PORT', sa.Integer(), nullable=True), 80 | sa.Column('SET_MEDIA_PERMISSIONS', sa.Boolean(), nullable=True), 81 | sa.Column('CHMOD_VALUE', sa.Integer(), nullable=True), 82 | sa.Column('SET_MEDIA_OWNER', sa.Boolean(), nullable=True), 83 | sa.Column('CHOWN_USER', sa.String(length=50), nullable=True), 84 | sa.Column('CHOWN_GROUP', sa.String(length=50), nullable=True), 85 | sa.Column('RIPMETHOD', sa.String(length=25), nullable=True), 86 | sa.Column('MKV_ARGS', sa.String(length=25), nullable=True), 87 | sa.Column('DELRAWFILES', sa.Boolean(), nullable=True), 88 | sa.Column('HASHEDKEYS', sa.Boolean(), nullable=True), 89 | sa.Column('HB_PRESET_DVD', sa.String(length=256), nullable=True), 90 | sa.Column('HB_PRESET_BD', sa.String(length=256), nullable=True), 91 | sa.Column('DEST_EXT', sa.String(length=10), nullable=True), 92 | sa.Column('HANDBRAKE_CLI', sa.String(length=25), nullable=True), 93 | sa.Column('MAINFEATURE', sa.Boolean(), nullable=True), 94 | sa.Column('HB_ARGS_DVD', sa.String(length=256), nullable=True), 95 | sa.Column('HB_ARGS_BD', sa.String(length=256), nullable=True), 96 | sa.Column('EMBY_REFRESH', sa.Boolean(), nullable=True), 97 | sa.Column('EMBY_SERVER', sa.String(length=25), nullable=True), 98 | sa.Column('EMBY_PORT', sa.String(length=6), nullable=True), 99 | sa.Column('EMBY_CLIENT', sa.String(length=25), nullable=True), 100 | sa.Column('EMBY_DEVICE', sa.String(length=50), nullable=True), 101 | sa.Column('EMBY_DEVICEID', sa.String(length=128), nullable=True), 102 | sa.Column('EMBY_USERNAME', sa.String(length=50), nullable=True), 103 | sa.Column('EMBY_USERID', sa.String(length=128), nullable=True), 104 | sa.Column('EMBY_PASSWORD', sa.String(length=128), nullable=True), 105 | sa.Column('EMBY_API_KEY', sa.String(length=64), nullable=True), 106 | sa.Column('NOTIFY_RIP', sa.Boolean(), nullable=True), 107 | sa.Column('NOTIFY_TRANSCODE', sa.Boolean(), nullable=True), 108 | sa.Column('PB_KEY', sa.String(length=64), nullable=True), 109 | sa.Column('IFTTT_KEY', sa.String(length=64), nullable=True), 110 | sa.Column('IFTTT_EVENT', sa.String(length=25), nullable=True), 111 | sa.Column('PO_USER_KEY', sa.String(length=64), nullable=True), 112 | sa.Column('PO_APP_KEY', sa.String(length=64), nullable=True), 113 | sa.Column('OMDB_API_KEY', sa.String(length=64), nullable=True), 114 | sa.ForeignKeyConstraint(['job_id'], ['job.job_id'], ), 115 | sa.PrimaryKeyConstraint('CONFIG_ID') 116 | ) 117 | op.create_table('track', 118 | sa.Column('track_id', sa.Integer(), nullable=False), 119 | sa.Column('job_id', sa.Integer(), nullable=True), 120 | sa.Column('track_number', sa.String(length=4), nullable=True), 121 | sa.Column('length', sa.Integer(), nullable=True), 122 | sa.Column('aspect_ratio', sa.String(length=20), nullable=True), 123 | sa.Column('fps', sa.Float(), nullable=True), 124 | sa.Column('main_feature', sa.Boolean(), nullable=True), 125 | sa.Column('basename', sa.String(length=256), nullable=True), 126 | sa.Column('filename', sa.String(length=256), nullable=True), 127 | sa.Column('orig_filename', sa.String(length=256), nullable=True), 128 | sa.Column('new_filename', sa.String(length=256), nullable=True), 129 | sa.Column('ripped', sa.Boolean(), nullable=True), 130 | sa.Column('status', sa.String(length=32), nullable=True), 131 | sa.Column('error', sa.Text(), nullable=True), 132 | sa.Column('source', sa.String(length=32), nullable=True), 133 | sa.ForeignKeyConstraint(['job_id'], ['job.job_id'], ), 134 | sa.PrimaryKeyConstraint('track_id') 135 | ) 136 | # ### end Alembic commands ### 137 | 138 | 139 | def downgrade(): 140 | # ### commands auto generated by Alembic - please adjust! ### 141 | op.drop_table('track') 142 | op.drop_table('config') 143 | op.drop_table('job') 144 | # ### end Alembic commands ### 145 | -------------------------------------------------------------------------------- /arm/models/models.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pyudev 3 | import psutil 4 | from arm.ui import db 5 | from arm.config.config import cfg # noqa: E402 6 | 7 | 8 | class Job(db.Model): 9 | job_id = db.Column(db.Integer, primary_key=True) 10 | arm_version = db.Column(db.String(20)) 11 | crc_id = db.Column(db.String(63)) 12 | logfile = db.Column(db.String(256)) 13 | # disc = db.Column(db.String(63)) 14 | start_time = db.Column(db.DateTime) 15 | stop_time = db.Column(db.DateTime) 16 | job_length = db.Column(db.String(12)) 17 | status = db.Column(db.String(32)) 18 | no_of_titles = db.Column(db.Integer) 19 | title = db.Column(db.String(256)) 20 | title_auto = db.Column(db.String(256)) 21 | title_manual = db.Column(db.String(256)) 22 | year = db.Column(db.String(4)) 23 | year_auto = db.Column(db.String(4)) 24 | year_manual = db.Column(db.String(4)) 25 | video_type = db.Column(db.String(20)) 26 | video_type_auto = db.Column(db.String(20)) 27 | video_type_manual = db.Column(db.String(20)) 28 | imdb_id = db.Column(db.String(15)) 29 | imdb_id_auto = db.Column(db.String(15)) 30 | imdb_id_manual = db.Column(db.String(15)) 31 | poster_url = db.Column(db.String(256)) 32 | poster_url_auto = db.Column(db.String(256)) 33 | poster_url_manual = db.Column(db.String(256)) 34 | devpath = db.Column(db.String(15)) 35 | mountpoint = db.Column(db.String(20)) 36 | hasnicetitle = db.Column(db.Boolean) 37 | errors = db.Column(db.Text) 38 | disctype = db.Column(db.String(20)) # dvd/bluray/data/music/unknown 39 | label = db.Column(db.String(256)) 40 | ejected = db.Column(db.Boolean) 41 | updated = db.Column(db.Boolean) 42 | pid = db.Column(db.Integer) 43 | pid_hash = db.Column(db.Integer) 44 | tracks = db.relationship('Track', backref='job', lazy='dynamic') 45 | config = db.relationship('Config', uselist=False, backref="job") 46 | 47 | def __init__(self, devpath): 48 | """Return a disc object""" 49 | self.devpath = devpath 50 | self.mountpoint = "/mnt" + devpath 51 | self.hasnicetitle = False 52 | self.video_type = "unknown" 53 | self.ejected = False 54 | self.updated = False 55 | if cfg['VIDEOTYPE'] != "auto": 56 | self.video_type = cfg['VIDEOTYPE'] 57 | 58 | self.parse_udev() 59 | self.get_pid() 60 | 61 | def parse_udev(self): 62 | """Parse udev for properties of current disc""" 63 | 64 | # print("Entering disc") 65 | context = pyudev.Context() 66 | device = pyudev.Devices.from_device_file(context, self.devpath) 67 | self.disctype = "unknown" 68 | for key, value in device.items(): 69 | if key == "ID_FS_LABEL": 70 | self.label = value 71 | if value == "iso9660": 72 | self.disctype = "data" 73 | elif key == "ID_CDROM_MEDIA_BD": 74 | self.disctype = "bluray" 75 | elif key == "ID_CDROM_MEDIA_DVD": 76 | self.disctype = "dvd" 77 | elif key == "ID_CDROM_MEDIA_TRACK_COUNT_AUDIO": 78 | self.disctype = "music" 79 | else: 80 | pass 81 | 82 | def get_pid(self): 83 | pid = os.getpid() 84 | p = psutil.Process(pid) 85 | self.pid = pid 86 | self.pid_hash = hash(p) 87 | 88 | def __str__(self): 89 | """Returns a string of the object""" 90 | 91 | s = self.__class__.__name__ + ": " 92 | for attr, value in self.__dict__.items(): 93 | s = s + "(" + str(attr) + "=" + str(value) + ") " 94 | 95 | return s 96 | 97 | def __repr__(self): 98 | return ''.format(self.label) 99 | 100 | def eject(self): 101 | """Eject disc if it hasn't previously been ejected""" 102 | 103 | # print("Value is " + str(self.ejected)) 104 | if not self.ejected: 105 | os.system("eject " + self.devpath) 106 | self.ejected = True 107 | 108 | 109 | class Track(db.Model): 110 | track_id = db.Column(db.Integer, primary_key=True) 111 | job_id = db.Column(db.Integer, db.ForeignKey('job.job_id')) 112 | track_number = db.Column(db.String(4)) 113 | length = db.Column(db.Integer) 114 | aspect_ratio = db.Column(db.String(20)) 115 | # blocks = db.Column(db.Integer) 116 | fps = db.Column(db.Float) 117 | main_feature = db.Column(db.Boolean) 118 | basename = db.Column(db.String(256)) 119 | filename = db.Column(db.String(256)) 120 | orig_filename = db.Column(db.String(256)) 121 | new_filename = db.Column(db.String(256)) 122 | ripped = db.Column(db.Boolean) 123 | status = db.Column(db.String(32)) 124 | error = db.Column(db.Text) 125 | source = db.Column(db.String(32)) 126 | 127 | def __init__(self, job_id, track_number, length, aspect_ratio, fps, main_feature, source, basename, filename): 128 | """Return a track object""" 129 | self.job_id = job_id 130 | self.track_number = track_number 131 | self.length = length 132 | self.aspect_ratio = aspect_ratio 133 | # self.blocks = blocks 134 | self.fps = fps 135 | self.main_feature = main_feature 136 | self.source = source 137 | self.basename = basename 138 | self.filename = filename 139 | self.ripped = False 140 | 141 | def __repr__(self): 142 | return ''.format(self.track_number) 143 | 144 | 145 | class Config(db.Model): 146 | CONFIG_ID = db.Column(db.Integer, primary_key=True) 147 | job_id = db.Column(db.Integer, db.ForeignKey('job.job_id')) 148 | ARM_CHECK_UDF = db.Column(db.Boolean) 149 | GET_VIDEO_TITLE = db.Column(db.Boolean) 150 | SKIP_TRANSCODE = db.Column(db.Boolean) 151 | VIDEOTYPE = db.Column(db.String(25)) 152 | MINLENGTH = db.Column(db.String(6)) 153 | MAXLENGTH = db.Column(db.String(6)) 154 | MANUAL_WAIT = db.Column(db.Boolean) 155 | MANUAL_WAIT_TIME = db.Column(db.Integer) 156 | ARMPATH = db.Column(db.String(255)) 157 | RAWPATH = db.Column(db.String(255)) 158 | MEDIA_DIR = db.Column(db.String(255)) 159 | EXTRAS_SUB = db.Column(db.String(255)) 160 | INSTALLPATH = db.Column(db.String(255)) 161 | LOGPATH = db.Column(db.String(255)) 162 | LOGLEVEL = db.Column(db.String(255)) 163 | LOGLIFE = db.Column(db.Integer) 164 | DBFILE = db.Column(db.String(255)) 165 | WEBSERVER_IP = db.Column(db.String(25)) 166 | WEBSERVER_PORT = db.Column(db.Integer) 167 | SET_MEDIA_PERMISSIONS = db.Column(db.Boolean) 168 | CHMOD_VALUE = db.Column(db.Integer) 169 | SET_MEDIA_OWNER = db.Column(db.Boolean) 170 | CHOWN_USER = db.Column(db.String(50)) 171 | CHOWN_GROUP = db.Column(db.String(50)) 172 | RIPMETHOD = db.Column(db.String(25)) 173 | MKV_ARGS = db.Column(db.String(25)) 174 | DELRAWFILES = db.Column(db.Boolean) 175 | HASHEDKEYS = db.Column(db.Boolean) 176 | HB_PRESET_DVD = db.Column(db.String(256)) 177 | HB_PRESET_BD = db.Column(db.String(256)) 178 | DEST_EXT = db.Column(db.String(10)) 179 | HANDBRAKE_CLI = db.Column(db.String(25)) 180 | MAINFEATURE = db.Column(db.Boolean) 181 | HB_ARGS_DVD = db.Column(db.String(256)) 182 | HB_ARGS_BD = db.Column(db.String(256)) 183 | EMBY_REFRESH = db.Column(db.Boolean) 184 | EMBY_SERVER = db.Column(db.String(25)) 185 | EMBY_PORT = db.Column(db.String(6)) 186 | EMBY_CLIENT = db.Column(db.String(25)) 187 | EMBY_DEVICE = db.Column(db.String(50)) 188 | EMBY_DEVICEID = db.Column(db.String(128)) 189 | EMBY_USERNAME = db.Column(db.String(50)) 190 | EMBY_USERID = db.Column(db.String(128)) 191 | EMBY_PASSWORD = db.Column(db.String(128)) 192 | EMBY_API_KEY = db.Column(db.String(64)) 193 | NOTIFY_RIP = db.Column(db.Boolean) 194 | NOTIFY_TRANSCODE = db.Column(db.Boolean) 195 | PB_KEY = db.Column(db.String(64)) 196 | IFTTT_KEY = db.Column(db.String(64)) 197 | IFTTT_EVENT = db.Column(db.String(25)) 198 | PO_USER_KEY = db.Column(db.String(64)) 199 | PO_APP_KEY = db.Column(db.String(64)) 200 | OMDB_API_KEY = db.Column(db.String(64)) 201 | # job = db.relationship("Job", backref="config") 202 | 203 | def __init__(self, c, job_id): 204 | self.__dict__.update(c) 205 | self.job_id = job_id 206 | 207 | def list_params(self): 208 | """Returns a string of the object""" 209 | 210 | s = self.__class__.__name__ + ": " 211 | for attr, value in self.__dict__.items(): 212 | if s: 213 | s = s + "\n" 214 | if str(attr) in ("OMDB_API_KEY", "EMBY_USERID", "EMBY_PASSWORD", "EMBY_API_KEY", "PB_KEY", "IFTTT_KEY", "PO_KEY", 215 | "PO_USER_KEY", "PO_APP_KEY") and value: 216 | value = "" 217 | s = s + str(attr) + ":" + str(value) 218 | 219 | return s 220 | 221 | def __str__(self): 222 | """Returns a string of the object""" 223 | 224 | s = self.__class__.__name__ + ": " 225 | for attr, value in self.__dict__.items(): 226 | s = s + "(" + str(attr) + "=" + str(value) + ") " 227 | 228 | return s 229 | -------------------------------------------------------------------------------- /arm/ripper/__init__.py: -------------------------------------------------------------------------------- 1 | from arm.ripper import logger, utils, makemkv, handbrake, identify, getkeys # noqa F401 2 | -------------------------------------------------------------------------------- /arm/ripper/getkeys.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import werkzeug 4 | werkzeug.cached_property = werkzeug.utils.cached_property 5 | 6 | from robobrowser import RoboBrowser 7 | 8 | 9 | def grabkeys(): 10 | br = RoboBrowser() 11 | br.open('http://makemkv.com/forum2/viewtopic.php?f=12&t=16959') 12 | pageStr = str(br.parsed()) 13 | i = 1 14 | 15 | def get_key_link(base_link): 16 | global i, pageStr 17 | beg = pageStr.find(base_link) 18 | strLength = len(base_link) 19 | 20 | while True: 21 | link = pageStr[beg:beg + strLength + i] 22 | print(link) 23 | 24 | if pageStr[beg + strLength:beg + strLength + i].isnumeric() is False: 25 | return link[:-1] 26 | i = i + 1 27 | 28 | # print(get_key_link()) 29 | os.system('tinydownload -o keys_hashed.txt ' + get_key_link('http://s000.tinyupload.com/index.php?file_id=')) 30 | br.open('https://forum.doom9.org/showthread.php?t=175194') 31 | pageStr = str(br.parsed()) 32 | i = 1 33 | os.system('tinydownload -o KEYDB.cfg ' + get_key_link('http://s000.tinyupload.com/index.php?file_id=')) 34 | os.system('mv -u -t /home/arm/.MakeMKV keys_hashed.txt KEYDB.cfg') 35 | -------------------------------------------------------------------------------- /arm/ripper/handbrake.py: -------------------------------------------------------------------------------- 1 | # Handbrake processing of dvd/bluray 2 | 3 | import sys 4 | import os 5 | import logging 6 | import subprocess 7 | import re 8 | import shlex 9 | import time 10 | import datetime 11 | import psutil 12 | 13 | from arm.ripper import utils 14 | # from arm.config.config import cfg 15 | from arm.models.models import Track # noqa: E402 16 | from arm.ui import app, db # noqa E402 17 | 18 | # flake8: noqa: W605 19 | 20 | 21 | def handbrake_mainfeature(srcpath, basepath, logfile, job): 22 | """process dvd with mainfeature enabled.\n 23 | srcpath = Path to source for HB (dvd or files)\n 24 | basepath = Path where HB will save trancoded files\n 25 | logfile = Logfile for HB to redirect output to\n 26 | job = Job object\n 27 | 28 | Returns nothing 29 | """ 30 | logging.info("Starting DVD Movie Mainfeature processing") 31 | logging.debug("Handbrake starting: " + str(job)) 32 | utils.SleepCheckProcess("HandBrakeCLI",int(job.config.MAX_CONCURRENT_TRANSCODES)) 33 | logging.debug("Setting job status to 'transcoding'") 34 | job.status = "transcoding" 35 | 36 | 37 | filename = os.path.join(basepath, job.title + "." + job.config.DEST_EXT) 38 | filepathname = os.path.join(basepath, filename) 39 | logging.info("Ripping title Mainfeature to " + shlex.quote(filepathname)) 40 | 41 | get_track_info(srcpath, job) 42 | 43 | track = job.tracks.filter_by(main_feature=True).first() 44 | 45 | 46 | if track is None: 47 | msg = "No main feature found by Handbrake. Turn MAINFEATURE to false in arm.yml and try again." 48 | logging.error(msg) 49 | raise RuntimeError(msg) 50 | 51 | track.filename = track.orig_filename = filename 52 | db.session.commit() 53 | 54 | if job.disctype == "dvd": 55 | hb_args = job.config.HB_ARGS_DVD 56 | hb_preset = job.config.HB_PRESET_DVD 57 | elif job.disctype == "bluray": 58 | hb_args = job.config.HB_ARGS_BD 59 | hb_preset = job.config.HB_PRESET_BD 60 | 61 | cmd = 'nice {0} -i {1} -o {2} --main-feature --preset "{3}" {4} >> {5} 2>&1'.format( 62 | job.config.HANDBRAKE_CLI, 63 | shlex.quote(srcpath), 64 | shlex.quote(filepathname), 65 | hb_preset, 66 | hb_args, 67 | logfile 68 | ) 69 | 70 | logging.debug("Sending command: %s", (cmd)) 71 | 72 | try: 73 | subprocess.check_output( 74 | cmd, 75 | shell=True 76 | ).decode("utf-8") 77 | logging.info("Handbrake call successful") 78 | track.status = "success" 79 | except subprocess.CalledProcessError as hb_error: 80 | err = "Call to handbrake failed with code: " + str(hb_error.returncode) + "(" + str(hb_error.output) + ")" 81 | logging.error(err) 82 | track.status = "fail" 83 | track.error = err 84 | sys.exit(err) 85 | 86 | logging.info("Handbrake processing complete") 87 | logging.debug(str(job)) 88 | 89 | track.ripped = True 90 | db.session.commit() 91 | 92 | return 93 | 94 | 95 | def handbrake_all(srcpath, basepath, logfile, job): 96 | """Process all titles on the dvd\n 97 | srcpath = Path to source for HB (dvd or files)\n 98 | basepath = Path where HB will save trancoded files\n 99 | logfile = Logfile for HB to redirect output to\n 100 | job = Disc object\n 101 | 102 | Returns nothing 103 | """ 104 | # Wait until there is a spot to transcode 105 | job.status = "waiting_transcode" 106 | utils.SleepCheckProcess("HandBrakeCLI",int(job.config.MAX_CONCURRENT_TRANSCODES)) 107 | job.status = "transcoding" 108 | 109 | logging.info("Starting BluRay/DVD transcoding - All titles") 110 | 111 | if job.disctype == "dvd": 112 | hb_args = job.config.HB_ARGS_DVD 113 | hb_preset = job.config.HB_PRESET_DVD 114 | elif job.disctype == "bluray": 115 | hb_args = job.config.HB_ARGS_BD 116 | hb_preset = job.config.HB_PRESET_BD 117 | 118 | get_track_info(srcpath, job) 119 | 120 | logging.debug("Total number of tracks is " + str(job.no_of_titles)) 121 | 122 | for track in job.tracks: 123 | 124 | if track.length < int(job.config.MINLENGTH): 125 | # too short 126 | logging.info("Track #" + str(track.track_number) + " of " + str(job.no_of_titles) + ". Length (" + str(track.length) + 127 | ") is less than minimum length (" + job.config.MINLENGTH + "). Skipping") 128 | elif track.length > int(job.config.MAXLENGTH): 129 | # too long 130 | logging.info("Track #" + str(track.track_number) + " of " + str(job.no_of_titles) + ". Length (" + str(track.length) + 131 | ") is greater than maximum length (" + job.config.MAXLENGTH + "). Skipping") 132 | else: 133 | # just right 134 | logging.info("Processing track #" + str(track.track_number) + " of " + str(job.no_of_titles) + ". Length is " + str(track.length) + " seconds.") 135 | 136 | filename = "title_" + str.zfill(str(track.track_number), 2) + "." + job.config.DEST_EXT 137 | filepathname = os.path.join(basepath, filename) 138 | 139 | logging.info("Transcoding title " + str(track.track_number) + " to " + shlex.quote(filepathname)) 140 | 141 | track.filename = track.orig_filename = filename 142 | db.session.commit() 143 | 144 | cmd = 'nice {0} -i {1} -o {2} --preset "{3}" -t {4} {5}>> {6} 2>&1'.format( 145 | job.config.HANDBRAKE_CLI, 146 | shlex.quote(srcpath), 147 | shlex.quote(filepathname), 148 | hb_preset, 149 | str(track.track_number), 150 | hb_args, 151 | logfile 152 | ) 153 | 154 | logging.debug("Sending command: %s", (cmd)) 155 | 156 | try: 157 | hb = subprocess.check_output( 158 | cmd, 159 | shell=True 160 | ).decode("utf-8") 161 | logging.debug("Handbrake exit code: " + hb) 162 | track.status = "success" 163 | except subprocess.CalledProcessError as hb_error: 164 | err = "Handbrake encoding of title " + str(track.track_number) + " failed with code: " + str(hb_error.returncode) + "(" + str(hb_error.output) + ")" # noqa E501 165 | logging.error(err) 166 | track.status = "fail" 167 | track.error = err 168 | # return 169 | # sys.exit(err) 170 | 171 | track.ripped = True 172 | db.session.commit() 173 | 174 | logging.info("Handbrake processing complete") 175 | logging.debug(str(job)) 176 | 177 | return 178 | 179 | 180 | def handbrake_mkv(srcpath, basepath, logfile, job): 181 | """process all mkv files in a directory.\n 182 | srcpath = Path to source for HB (dvd or files)\n 183 | basepath = Path where HB will save trancoded files\n 184 | logfile = Logfile for HB to redirect output to\n 185 | job = Disc object\n 186 | 187 | Returns nothing 188 | """ 189 | 190 | job.status = "waiting_transcode" 191 | utils.SleepCheckProcess("HandBrakeCLI",int(job.config.MAX_CONCURRENT_TRANSCODES)) 192 | job.status = "transcoding" 193 | 194 | if job.disctype == "dvd": 195 | hb_args = job.config.HB_ARGS_DVD 196 | hb_preset = job.config.HB_PRESET_DVD 197 | elif job.disctype == "bluray": 198 | hb_args = job.config.HB_ARGS_BD 199 | hb_preset = job.config.HB_PRESET_BD 200 | 201 | for f in os.listdir(srcpath): 202 | srcpathname = os.path.join(srcpath, f) 203 | destfile = os.path.splitext(f)[0] 204 | filename = os.path.join(basepath, destfile + "." + job.config.DEST_EXT) 205 | filepathname = os.path.join(basepath, filename) 206 | 207 | logging.info("Transcoding file " + shlex.quote(f) + " to " + shlex.quote(filepathname)) 208 | 209 | cmd = 'nice {0} -i {1} -o {2} --preset "{3}" {4}>> {5} 2>&1'.format( 210 | job.config.HANDBRAKE_CLI, 211 | shlex.quote(srcpathname), 212 | shlex.quote(filepathname), 213 | hb_preset, 214 | hb_args, 215 | logfile 216 | ) 217 | 218 | logging.debug("Sending command: %s", (cmd)) 219 | 220 | try: 221 | hb = subprocess.check_output( 222 | cmd, 223 | shell=True 224 | ).decode("utf-8") 225 | logging.debug("Handbrake exit code: " + hb) 226 | except subprocess.CalledProcessError as hb_error: 227 | err = "Handbrake encoding of file " + shlex.quote(f) + " failed with code: " + str(hb_error.returncode) + "(" + str(hb_error.output) + ")" 228 | logging.error(err) 229 | # job.errors.append(f) 230 | 231 | logging.info("Handbrake processing complete") 232 | logging.debug(str(job)) 233 | 234 | return 235 | 236 | 237 | def get_track_info(srcpath, job): 238 | """Use HandBrake to get track info and updatte Track class\n 239 | 240 | srcpath = Path to disc\n 241 | job = Job instance\n 242 | """ 243 | 244 | logging.info("Using HandBrake to get information on all the tracks on the disc. This will take a few minutes...") 245 | 246 | cmd = '{0} -i {1} -t 0 --scan'.format( 247 | job.config.HANDBRAKE_CLI, 248 | shlex.quote(srcpath) 249 | ) 250 | 251 | logging.debug("Sending command: %s", (cmd)) 252 | 253 | try: 254 | hb = subprocess.check_output( 255 | cmd, 256 | stderr=subprocess.STDOUT, 257 | shell=True 258 | ).decode('cp437').splitlines() 259 | except subprocess.CalledProcessError as hb_error: 260 | logging.error("Couldn't find a valid track. Try running the command manually to see more specific errors.") 261 | logging.error("Specifid error is: " + str(hb_error.returncode) + "(" + str(hb_error.output) + ")") 262 | return(-1) 263 | # sys.exit(err) 264 | 265 | t_pattern = re.compile(r'.*\+ title *') 266 | pattern = re.compile(r'.*duration\:.*') 267 | seconds = 0 268 | t_no = 0 269 | fps = float(0) 270 | aspect = 0 271 | result = None 272 | mainfeature = False 273 | for line in hb: 274 | 275 | # get number of titles 276 | if result is None: 277 | if job.disctype == "bluray": 278 | result = re.search('scan: BD has (.*) title\(s\)', line) 279 | else: 280 | result = re.search('scan: DVD has (.*) title\(s\)', line) 281 | 282 | if result: 283 | titles = result.group(1) 284 | titles = titles.strip() 285 | logging.debug("Line found is: " + line) 286 | logging.info("Found " + titles + " titles") 287 | job.no_of_titles = titles 288 | db.session.commit() 289 | 290 | if(re.search(t_pattern, line)) is not None: 291 | if t_no == 0: 292 | pass 293 | else: 294 | utils.put_track(job, t_no, seconds, aspect, fps, mainfeature, "handbrake") 295 | 296 | mainfeature = False 297 | t_no = line.rsplit(' ', 1)[-1] 298 | t_no = t_no.replace(":", "") 299 | 300 | if(re.search(pattern, line)) is not None: 301 | t = line.split() 302 | h, m, s = t[2].split(':') 303 | seconds = int(h) * 3600 + int(m) * 60 + int(s) 304 | 305 | if(re.search("Main Feature", line)) is not None: 306 | mainfeature = True 307 | 308 | if(re.search(" fps", line)) is not None: 309 | fps = line.rsplit(' ', 2)[-2] 310 | aspect = line.rsplit(' ', 3)[-3] 311 | aspect = str(aspect).replace(",", "") 312 | 313 | utils.put_track(job, t_no, seconds, aspect, fps, mainfeature, "handbrake") 314 | 315 | -------------------------------------------------------------------------------- /arm/ripper/identify.py: -------------------------------------------------------------------------------- 1 | # Identification of dvd/bluray 2 | 3 | import os 4 | import sys # noqa # pylint: disable=unused-import 5 | import logging 6 | import urllib 7 | import re 8 | import datetime 9 | import pydvdid 10 | import unicodedata 11 | import xmltodict 12 | import json 13 | 14 | from arm.ripper import utils 15 | from arm.ui import db 16 | # from arm.config.config import cfg 17 | 18 | # flake8: noqa: W605 19 | 20 | 21 | def identify(job, logfile): 22 | """Identify disc attributes""" 23 | 24 | logging.debug("Identification starting: " + str(job)) 25 | 26 | logging.info("Mounting disc to: " + str(job.mountpoint)) 27 | 28 | if not os.path.exists(str(job.mountpoint)): 29 | os.makedirs(str(job.mountpoint)) 30 | 31 | os.system("mount " + job.devpath) 32 | 33 | # Check to make sure it's not a data disc 34 | if job.disctype == "music": 35 | logging.debug("Disc is music. Skipping identification") 36 | elif os.path.isdir(job.mountpoint + "/VIDEO_TS"): 37 | logging.debug("Found: " + job.mountpoint + "/VIDEO_TS") 38 | job.disctype = "dvd" 39 | elif os.path.isdir(job.mountpoint + "/video_ts"): 40 | logging.debug("Found: " + job.mountpoint + "/video_ts") 41 | job.disctype = "dvd" 42 | elif os.path.isdir(job.mountpoint + "/BDMV"): 43 | logging.debug("Found: " + job.mountpoint + "/BDMV") 44 | job.disctype = "bluray" 45 | elif os.path.isdir(job.mountpoint + "/HVDVD_TS"): 46 | logging.debug("Found: " + job.mountpoint + "/HVDVD_TS") 47 | # do something here 48 | elif utils.find_file("HVDVD_TS", job.mountpoint): 49 | logging.debug("Found file: HVDVD_TS") 50 | # do something here too 51 | else: 52 | logging.debug("Did not find valid dvd/bd files. Changing disctype to 'data'") 53 | job.disctype = "data" 54 | 55 | if job.disctype in ["dvd", "bluray"]: 56 | 57 | logging.info("Disc identified as video") 58 | 59 | if job.config.GET_VIDEO_TITLE: 60 | 61 | # get crc_id (dvd only), title, year 62 | if job.disctype == "dvd": 63 | res = identify_dvd(job) 64 | if job.disctype == "bluray": 65 | res = identify_bluray(job) 66 | 67 | if res and not job.year == "": 68 | get_video_details(job) 69 | else: 70 | job.hasnicetitle = False 71 | db.session.commit() 72 | 73 | logging.info("Disc title: " + str(job.title) + " : " + str(job.year) + " : " + str(job.video_type)) 74 | logging.debug("Identification complete: " + str(job)) 75 | 76 | os.system("umount " + job.devpath) 77 | 78 | 79 | def clean_for_filename(string): 80 | """ Cleans up string for use in filename """ 81 | string = re.sub('\[(.*?)\]', '', string) 82 | string = re.sub('\s+', ' ', string) 83 | string = string.replace(' : ', ' - ') 84 | string = string.replace(':', '-') 85 | string = string.replace('&', 'and') 86 | string = string.replace("\\", " - ") 87 | string = string.strip() 88 | # testing why the return function isn't cleaning 89 | return re.sub('[^\w\-_\.\(\) ]', '', string) 90 | #return string 91 | 92 | 93 | def identify_dvd(job): 94 | """ Calculates CRC64 for the DVD and calls Windows Media 95 | Metaservices and returns the Title and year of DVD """ 96 | """ Manipulates the DVD title and calls OMDB to try and 97 | lookup the title """ 98 | logging.debug(str(job)) 99 | 100 | try: 101 | crc64 = pydvdid.compute(str(job.mountpoint)) 102 | except pydvdid.exceptions.PydvdidException as e: 103 | logging.error("Pydvdid failed with the error: " + str(e)) 104 | return False 105 | 106 | logging.info("DVD CRC64 hash is: " + str(crc64)) 107 | job.crc_id = str(crc64) 108 | fallback_title = "{0}_{1}".format(str(job.label), str(crc64)) 109 | logging.info("Fallback title is: " + str(fallback_title)) 110 | urlstring = "http://metaservices.windowsmedia.com/pas_dvd_B/template/GetMDRDVDByCRC.xml?CRC={0}".format(str(crc64)) 111 | logging.debug(urlstring) 112 | 113 | try: 114 | dvd_info_xml = urllib.request.urlopen(urlstring).read() 115 | except OSError as e: 116 | dvd_info_xml = False 117 | dvd_title = str(fallback_title) 118 | dvd_release_date = "" 119 | logging.error("Failed to reach windowsmedia web service. Error number is: " + str(e.errno)) 120 | # return False 121 | 122 | # Some older DVDs aren't actually labelled 123 | if not job.label: 124 | job.label = "not identified" 125 | 126 | try: 127 | if not dvd_info_xml: 128 | pass 129 | else: 130 | doc = xmltodict.parse(dvd_info_xml) 131 | dvd_title = doc['METADATA']['MDR-DVD']['dvdTitle'] 132 | dvd_release_date = doc['METADATA']['MDR-DVD']['releaseDate'] 133 | dvd_title = dvd_title.strip() 134 | dvd_title = clean_for_filename(dvd_title) 135 | if dvd_release_date is not None: 136 | dvd_release_date = dvd_release_date.split()[0] 137 | else: 138 | dvd_release_date = "" 139 | except KeyError: 140 | dvd_title = str(fallback_title) 141 | dvd_release_date = "" 142 | logging.error("Windows Media request returned no result.Probably because the service is discontinued.") 143 | # return False 144 | 145 | # TODO: split this out to another file/function and loop depending how many replacements 146 | # need to be done 147 | dvd_title = job.label.replace("_", " ").replace("16x9", "") 148 | dvd_release_date = "" 149 | 150 | job.title = job.title_auto = dvd_title 151 | job.year = job.year_auto = dvd_release_date 152 | db.session.commit() 153 | 154 | return True 155 | 156 | 157 | def identify_bluray(job): 158 | """ Get's Blu-Ray title by parsing XML in bdmt_eng.xml """ 159 | 160 | try: 161 | with open(job.mountpoint + '/BDMV/META/DL/bdmt_eng.xml', "rb") as xml_file: 162 | doc = xmltodict.parse(xml_file.read()) 163 | except OSError as e: 164 | logging.error("Disc is a bluray, but bdmt_eng.xml could not be found. Disc cannot be identified. Error number is: " + str(e.errno)) 165 | return False 166 | 167 | try: 168 | bluray_title = doc['disclib']['di:discinfo']['di:title']['di:name'] 169 | except KeyError: 170 | bluray_title = str(fallback_title) 171 | bluray_year = "" 172 | logging.error("Could not parse title from bdmt_eng.xml file. Disc cannot be identified.") 173 | # return False 174 | 175 | bluray_modified_timestamp = os.path.getmtime(job.mountpoint + '/BDMV/META/DL/bdmt_eng.xml') 176 | bluray_year = (datetime.datetime.fromtimestamp(bluray_modified_timestamp).strftime('%Y')) 177 | 178 | bluray_title = unicodedata.normalize('NFKD', bluray_title).encode('ascii', 'ignore').decode() 179 | 180 | bluray_title = bluray_title.replace(' - Blu-rayTM', '') 181 | bluray_title = bluray_title.replace(' Blu-rayTM', '') 182 | bluray_title = bluray_title.replace(' - BLU-RAYTM', '') 183 | bluray_title = bluray_title.replace(' - BLU-RAY', '') 184 | bluray_title = bluray_title.replace(' - Blu-ray', '') 185 | 186 | bluray_title = clean_for_filename(bluray_title) 187 | 188 | job.title = job.title_auto = bluray_title 189 | job.year = job.year_auto = bluray_year 190 | db.session.commit() 191 | 192 | return True 193 | 194 | 195 | def get_video_details(job): 196 | """ Clean up title and year. Get video_type, imdb_id, poster_url from 197 | omdbapi.com webservice.\n 198 | 199 | job = Instance of Job class\n 200 | """ 201 | 202 | title = job.title 203 | 204 | if title == "not identified": 205 | return 206 | 207 | year = job.year 208 | if year is None: 209 | year = "" 210 | 211 | # needs_new_year = False 212 | omdb_api_key = job.config.OMDB_API_KEY 213 | 214 | logging.debug("Title: " + title + " | Year: " + year) 215 | 216 | # dvd_title_clean = cleanupstring(dvd_title) 217 | title = title.strip() 218 | title = re.sub('[_ ]', "+", title) 219 | 220 | logging.debug("Calling webservice with title: " + title + " and year: " + year) 221 | response = callwebservice(job, omdb_api_key, title, year) 222 | logging.debug("response: " + response) 223 | 224 | # handle failures 225 | # this is a little kludgy, but it kind of works... 226 | if (response == "fail"): 227 | 228 | if year: 229 | # first try subtracting one year. This accounts for when 230 | # the dvd release date is the year following the movie release date 231 | logging.debug("Subtracting 1 year...") 232 | response = callwebservice(job, omdb_api_key, title, str(int(year) - 1)) 233 | logging.debug("response: " + response) 234 | 235 | # try submitting without the year 236 | if response == "fail": 237 | # year needs to be changed 238 | logging.debug("Removing year...") 239 | response = callwebservice(job, omdb_api_key, title, "") 240 | logging.debug("response: " + response) 241 | 242 | # if response != "fail": 243 | # # that means the year is wrong. 244 | # needs_new_year = True 245 | # logging.debug("Setting needs_new_year = True.") 246 | 247 | if response == "fail": 248 | # see if there is a hyphen and split it 249 | # if title.find("-") > -1: 250 | while response == "fail" and title.find("-") > 0: 251 | # dvd_title_slice = title[:title.find("-")] 252 | title = title.rsplit('-', 1)[0] 253 | # dvd_title_slice = cleanupstring(dvd_title_slice) 254 | logging.debug("Trying title: " + title) 255 | response = callwebservice(job, omdb_api_key, title, year) 256 | logging.debug("response: " + response) 257 | 258 | # if still fail, then try slicing off the last word in a loop 259 | while response == "fail" and title.count('+') > 0: 260 | title = title.rsplit('+', 1)[0] 261 | logging.debug("Trying title: " + title) 262 | response = callwebservice(job, omdb_api_key, title, year) 263 | logging.debug("response: " + response) 264 | if response == "fail": 265 | logging.debug("Removing year...") 266 | response = callwebservice(job, omdb_api_key, title, "") 267 | 268 | 269 | def callwebservice(job, omdb_api_key, dvd_title, year=""): 270 | """ Queries OMDbapi.org for title information and parses type, imdb, and poster info 271 | """ 272 | 273 | if job.config.VIDEOTYPE == "auto": 274 | strurl = "http://www.omdbapi.com/?t={1}&y={2}&plot=short&r=json&apikey={0}".format(omdb_api_key, dvd_title, year) 275 | logging.debug("http://www.omdbapi.com/?t={1}&y={2}&plot=short&r=json&apikey={0}".format("key_hidden", dvd_title, year)) 276 | else: 277 | strurl = "http://www.omdbapi.com/?t={1}&y={2}&type={3}&plot=short&r=json&apikey={0}".format(omdb_api_key, dvd_title, year, job.config.VIDEOTYPE) 278 | logging.debug("http://www.omdbapi.com/?t={1}&y={2}&type={3}&plot=short&r=json&apikey={0}".format("key_hidden", dvd_title, year, job.config.VIDEOTYPE)) 279 | 280 | logging.debug("***Calling webservice with Title: " + dvd_title + " and Year: " + year) 281 | try: 282 | # strurl = "http://www.omdbapi.com/?t={1}&y={2}&plot=short&r=json&apikey={0}".format(omdb_api_key, dvd_title, year) 283 | # logging.debug("http://www.omdbapi.com/?t={1}&y={2}&plot=short&r=json&apikey={0}".format("key_hidden", dvd_title, year)) 284 | dvd_title_info_json = urllib.request.urlopen(strurl).read() 285 | except Exception: 286 | logging.debug("Webservice failed") 287 | return "fail" 288 | else: 289 | doc = json.loads(dvd_title_info_json.decode()) 290 | if doc['Response'] == "False": 291 | logging.debug("Webservice failed with error: " + doc['Error']) 292 | return "fail" 293 | else: 294 | # global new_year 295 | new_year = doc['Year'] 296 | title = clean_for_filename(doc['Title']) 297 | logging.debug("Webservice successful. New title is " + title + ". New Year is: " + new_year) 298 | job.year_auto = str(new_year) 299 | job.year = str(new_year) 300 | job.title_auto = title 301 | job.title = title 302 | job.video_type_auto = doc['Type'] 303 | job.video_type = doc['Type'] 304 | job.imdb_id_auto = doc['imdbID'] 305 | job.imdb_id = doc['imdbID'] 306 | job.poster_url_auto = doc['Poster'] 307 | job.poster_url = doc['Poster'] 308 | job.hasnicetitle = True 309 | db.session.commit() 310 | return doc['Response'] 311 | -------------------------------------------------------------------------------- /arm/ripper/logger.py: -------------------------------------------------------------------------------- 1 | # set up logging 2 | 3 | import os 4 | import logging 5 | import time 6 | import os.path 7 | 8 | from os import path 9 | from arm.config.config import cfg 10 | 11 | 12 | def setuplogging(job): 13 | """Setup logging and return the path to the logfile for 14 | redirection of external calls""" 15 | 16 | if not os.path.exists(cfg['LOGPATH']): 17 | os.makedirs(cfg['LOGPATH']) 18 | 19 | if job.label == "" or job.label is None: 20 | if job.disctype == "music": 21 | logfile = "music_cd.log" 22 | else: 23 | logfile = "empty.log" 24 | else: 25 | logfile = job.label + ".log" 26 | 27 | if cfg['LOGPATH'][-1:] == "/": 28 | #Check to see if file already exists, if so, create a new file 29 | TmpLogFull = cfg['LOGPATH'] + logfile 30 | if os.path.isfile(TmpLogFull): 31 | logfile = str(job.label) + "_" + str(round(time.time() * 100)) + ".log" 32 | logfull = cfg['LOGPATH'] + logfile 33 | else: 34 | logfull = cfg['LOGPATH'] + logfile 35 | else: 36 | #Check to see if file already exists, if so, create a new file 37 | TmpLogFull = cfg['LOGPATH'] + "/" + logfile 38 | if os.path.isfile(TmpLogFull): 39 | #CTime = round(time.time() * 100) 40 | logfile = str(job.label) + "_" + str(round(time.time() * 100)) + ".log" 41 | logfull = cfg['LOGPATH'] + "/" + logfile 42 | else: 43 | logfull = cfg['LOGPATH'] + "/" + logfile 44 | 45 | if cfg['LOGLEVEL'] == "DEBUG": 46 | logging.basicConfig(filename=logfull, format='[%(asctime)s] %(levelname)s ARM: %(module)s.%(funcName)s %(message)s', 47 | datefmt='%Y-%m-%d %H:%M:%S', level=cfg['LOGLEVEL']) 48 | else: 49 | logging.basicConfig(filename=logfull, format='[%(asctime)s] %(levelname)s ARM: %(message)s', 50 | datefmt='%Y-%m-%d %H:%M:%S', level=cfg['LOGLEVEL']) 51 | 52 | job.logfile = logfile 53 | 54 | return logfull 55 | 56 | 57 | def cleanuplogs(logpath, loglife): 58 | """Delete all log files older than x days\n 59 | logpath = path of log files\n 60 | loglife = days to let logs live\n 61 | 62 | """ 63 | 64 | now = time.time() 65 | logging.info("Looking for log files older than " + str(loglife) + " days old.") 66 | 67 | for filename in os.listdir(logpath): 68 | fullname = os.path.join(logpath, filename) 69 | if fullname.endswith(".log"): 70 | if os.stat(fullname).st_mtime < now - loglife * 86400: 71 | logging.info("Deleting log file: " + filename) 72 | os.remove(fullname) 73 | -------------------------------------------------------------------------------- /arm/ripper/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import sys 4 | sys.path.append("/opt/arm") 5 | 6 | import argparse # noqa: E402 7 | import os # noqa: E402 8 | import logging # noqa: E402 9 | import time # noqa: E402 10 | import datetime # noqa: E402 11 | import re # noqa: E402 12 | import shutil # noqa: E402 13 | import pyudev # noqa: E402 14 | import getpass # noqa E402 15 | import psutil # noqa E402 16 | 17 | from arm.ripper import logger, utils, makemkv, handbrake, identify # noqa: E402 18 | from arm.config.config import cfg # noqa: E402 19 | 20 | from arm.ripper.getkeys import grabkeys # noqa: E402 21 | from arm.models.models import Job, Config # noqa: E402 22 | from arm.ui import app, db # noqa E402 23 | 24 | 25 | def entry(): 26 | """ Entry to program, parses arguments""" 27 | parser = argparse.ArgumentParser(description='Process disc using ARM') 28 | parser.add_argument('-d', '--devpath', help='Devpath', required=True) 29 | 30 | return parser.parse_args() 31 | 32 | 33 | def log_udev_params(): 34 | """log all udev paramaters""" 35 | 36 | logging.debug("**** Logging udev attributes ****") 37 | # logging.info("**** Start udev attributes ****") 38 | context = pyudev.Context() 39 | device = pyudev.Devices.from_device_file(context, '/dev/sr0') 40 | for key, value in device.items(): 41 | logging.debug(key + ":" + value) 42 | logging.debug("**** End udev attributes ****") 43 | 44 | 45 | def log_arm_params(job): 46 | """log all entry parameters""" 47 | 48 | # log arm parameters 49 | logging.info("**** Logging ARM variables ****") 50 | logging.info("devpath: " + str(job.devpath)) 51 | logging.info("mountpoint: " + str(job.mountpoint)) 52 | logging.info("title: " + str(job.title)) 53 | logging.info("year: " + str(job.year)) 54 | logging.info("video_type: " + str(job.video_type)) 55 | logging.info("hasnicetitle: " + str(job.hasnicetitle)) 56 | logging.info("label: " + str(job.label)) 57 | logging.info("disctype: " + str(job.disctype)) 58 | logging.info("**** End of ARM variables ****") 59 | logging.info("**** Logging config parameters ****") 60 | logging.info("skip_transcode: " + str(job.config.SKIP_TRANSCODE)) 61 | logging.info("mainfeature: " + str(job.config.MAINFEATURE)) 62 | logging.info("minlength: " + job.config.MINLENGTH) 63 | logging.info("maxlength: " + job.config.MAXLENGTH) 64 | logging.info("videotype: " + job.config.VIDEOTYPE) 65 | logging.info("manual_wait: " + str(job.config.MANUAL_WAIT)) 66 | logging.info("wait_time: " + str(job.config.MANUAL_WAIT_TIME)) 67 | logging.info("ripmethod: " + job.config.RIPMETHOD) 68 | logging.info("mkv_args: " + job.config.MKV_ARGS) 69 | logging.info("delrawfile: " + str(job.config.DELRAWFILES)) 70 | logging.info("hb_preset_dvd: " + job.config.HB_PRESET_DVD) 71 | logging.info("hb_preset_bd: " + job.config.HB_PRESET_BD) 72 | logging.info("hb_args_dvd: " + job.config.HB_ARGS_DVD) 73 | logging.info("hb_args_bd: " + job.config.HB_ARGS_BD) 74 | logging.info("logfile: " + logfile) 75 | logging.info("armpath: " + job.config.ARMPATH) 76 | logging.info("rawpath: " + job.config.RAWPATH) 77 | logging.info("media_dir: " + job.config.MEDIA_DIR) 78 | logging.info("extras_sub: " + job.config.EXTRAS_SUB) 79 | logging.info("emby_refresh: " + str(job.config.EMBY_REFRESH)) 80 | logging.info("emby_server: " + job.config.EMBY_SERVER) 81 | logging.info("emby_port: " + job.config.EMBY_PORT) 82 | logging.info("notify_rip: " + str(job.config.NOTIFY_RIP)) 83 | logging.info("notify_transcode " + str(job.config.NOTIFY_TRANSCODE)) 84 | logging.info("max_concurrent_transcodes " + str(job.config.MAX_CONCURRENT_TRANSCODES)) 85 | logging.info("**** End of config parameters ****") 86 | 87 | 88 | def check_fstab(): 89 | logging.info("Checking for fstab entry.") 90 | with open('/etc/fstab', 'r') as f: 91 | lines = f.readlines() 92 | for line in lines: 93 | if re.search(job.devpath, line): 94 | logging.info("fstab entry is: " + line.rstrip()) 95 | return 96 | logging.error("No fstab entry found. ARM will likely fail.") 97 | 98 | 99 | def main(logfile, job): 100 | 101 | """main dvd processing function""" 102 | logging.info("Starting Disc identification") 103 | 104 | identify.identify(job, logfile) 105 | 106 | if job.disctype in ["dvd", "bluray"]: 107 | utils.notify(job, "ARM notification", "Found disc: " + str(job.title) + ". Video type is " 108 | + str(job.video_type) + ". Main Feature is " + str(job.config.MAINFEATURE) 109 | + ". Edit entry here: http://" + str(job.config.WEBSERVER_IP) + ":" + str(job.config.WEBSERVER_PORT) + "/jobdetail?job_id=" + str(job.job_id)) 110 | #+ ". Edit entry here: http://" + job.config.WEBSERVER_IP + ":" + str(job.config.WEBSERVER_PORT)) 111 | elif job.disctype == "music": 112 | #Fixed bug next line 113 | utils.notify(job, "ARM notification", "Found music CD: " + str(job.label) + ". Ripping all tracks") 114 | elif job.disctype == "data": 115 | utils.notify(job, "ARM notification", "Found data disc. Copying data.") 116 | else: 117 | utils.notify(job, "ARM Notification", "Could not identify disc. Exiting.") 118 | sys.exit() 119 | 120 | if job.config.MANUAL_WAIT: 121 | logging.info("Waiting " + str(job.config.MANUAL_WAIT_TIME) + " seconds for manual override.") 122 | job.status = "waiting" 123 | db.session.commit() 124 | time.sleep(job.config.MANUAL_WAIT_TIME) 125 | db.session.refresh(job) 126 | db.session.refresh(config) 127 | job.status = "active" 128 | db.session.commit() 129 | 130 | if job.title_manual: 131 | logging.info("Manual override found. Overriding auto identification values.") 132 | job.updated = True 133 | job.hasnicetitle = True 134 | else: 135 | logging.info("No manual override found.") 136 | 137 | log_arm_params(job) 138 | 139 | check_fstab() 140 | 141 | if job.config.HASHEDKEYS: 142 | logging.info("Getting MakeMKV hashed keys for UHD rips") 143 | grabkeys() 144 | 145 | if job.disctype in ["dvd", "bluray"]: 146 | # get filesystem in order 147 | hboutpath = os.path.join(job.config.ARMPATH, str(job.title)) 148 | 149 | if (utils.make_dir(hboutpath)) is False: 150 | ts = round(time.time() * 100) 151 | hboutpath = os.path.join(job.config.ARMPATH, str(job.title) + "_" + str(ts)) 152 | if(utils.make_dir(hboutpath)) is False: 153 | logging.info("Failed to create base directory. Exiting ARM.") 154 | sys.exit() 155 | 156 | logging.info("Processing files to: " + hboutpath) 157 | 158 | # Do the work! 159 | hbinpath = str(job.devpath) 160 | if job.disctype == "bluray" or (not job.config.MAINFEATURE and job.config.RIPMETHOD == "mkv"): 161 | # send to makemkv for ripping 162 | # run MakeMKV and get path to ouput 163 | job.status = "ripping" 164 | db.session.commit() 165 | try: 166 | mkvoutpath = makemkv.makemkv(logfile, job) 167 | except: # noqa: E772 168 | raise 169 | 170 | if mkvoutpath is None: 171 | logging.error("MakeMKV did not complete successfully. Exiting ARM!") 172 | sys.exit() 173 | if job.config.NOTIFY_RIP: 174 | # Fixed bug line below 175 | utils.notify(job, "ARM notification", str(job.title) + " rip complete. Starting transcode.") 176 | # point HB to the path MakeMKV ripped to 177 | hbinpath = mkvoutpath 178 | 179 | if job.config.SKIP_TRANSCODE and job.config.RIPMETHOD == "mkv": 180 | logging.info("SKIP_TRANSCODE is true. Moving raw mkv files.") 181 | logging.info("NOTE: Identified main feature may not be actual main feature") 182 | files = os.listdir(mkvoutpath) 183 | final_directory = hboutpath 184 | if job.video_type == "movie": 185 | logging.debug("Videotype: " + job.video_type) 186 | # if videotype is movie, then move biggest title to media_dir 187 | # move the rest of the files to the extras folder 188 | 189 | # find largest filesize 190 | logging.debug("Finding largest file") 191 | largest_file_name = "" 192 | for f in files: 193 | # initialize largest_file_name 194 | if largest_file_name == "": 195 | largest_file_name = f 196 | temp_path_f = os.path.join(hbinpath, f) 197 | temp_path_largest = os.path.join(hbinpath, largest_file_name) 198 | # os.path.join(cfg['MEDIA_DIR'] + videotitle) 199 | # if cur file size > largest_file size 200 | if(os.stat(temp_path_f).st_size > os.stat(temp_path_largest).st_size): 201 | largest_file_name = f 202 | # largest_file should be largest file 203 | logging.debug("Largest file is: " + largest_file_name) 204 | temp_path = os.path.join(hbinpath, largest_file_name) 205 | if(os.stat(temp_path).st_size > 0): # sanity check for filesize 206 | for f in files: 207 | # move main into media_dir 208 | # move others into extras folder 209 | if(f == largest_file_name): 210 | # largest movie 211 | # Encorporating Rajlaud's fix #349 212 | utils.move_files(hbinpath, f, job, True) 213 | else: 214 | # other extras 215 | if not str(job.config.EXTRAS_SUB).lower() == "none": 216 | utils.move_files(hbinpath, f, job, False) 217 | else: 218 | logging.info("Not moving extra: " + f) 219 | # Change final path (used to set permissions) 220 | final_directory = os.path.join(job.config.MEDIA_DIR, job.title + " (" + str(job.year) + ")") 221 | # Clean up 222 | logging.debug("Attempting to remove extra folder in ARMPATH: " + hboutpath) 223 | try: 224 | shutil.rmtree(hboutpath) 225 | logging.debug("Removed sucessfully: " + hboutpath) 226 | except Exception: 227 | logging.debug("Failed to remove: " + hboutpath) 228 | else: 229 | # if videotype is not movie, then move everything 230 | # into 'Unidentified' folder 231 | logging.debug("Videotype: " + job.video_type) 232 | 233 | for f in files: 234 | mkvoutfile = os.path.join(mkvoutpath, f) 235 | logging.debug("Moving file: " + mkvoutfile + " to: " + mkvoutpath + f) 236 | shutil.move(mkvoutfile, hboutpath) 237 | # remove raw files, if specified in config 238 | if job.config.DELRAWFILES: 239 | logging.info("Removing raw files") 240 | shutil.rmtree(mkvoutpath) 241 | # set file to default permissions '777' 242 | if job.config.SET_MEDIA_PERMISSIONS: 243 | perm_result = utils.set_permissions(job, final_directory) 244 | logging.info("Permissions set successfully: " + str(perm_result)) 245 | utils.notify(job, "ARM notification", str(job.title) + " processing complete.") 246 | logging.info("ARM processing complete") 247 | # exit 248 | sys.exit() 249 | 250 | job.status = "transcoding" 251 | db.session.commit() 252 | if job.disctype == "bluray" and job.config.RIPMETHOD == "mkv": 253 | handbrake.handbrake_mkv(hbinpath, hboutpath, logfile, job) 254 | elif job.disctype == "dvd" and (not job.config.MAINFEATURE and job.config.RIPMETHOD == "mkv"): 255 | handbrake.handbrake_mkv(hbinpath, hboutpath, logfile, job) 256 | elif job.video_type == "movie" and job.config.MAINFEATURE and job.hasnicetitle: 257 | handbrake.handbrake_mainfeature(hbinpath, hboutpath, logfile, job) 258 | job.eject() 259 | else: 260 | handbrake.handbrake_all(hbinpath, hboutpath, logfile, job) 261 | job.eject() 262 | 263 | # get rid of this 264 | # if not titles_in_out: 265 | # pass 266 | 267 | # check if there is a new title and change all filenames 268 | # time.sleep(60) 269 | db.session.refresh(job) 270 | logging.debug("New Title is " + str(job.title_manual)) 271 | if job.title_manual and not job.updated: 272 | newpath = utils.rename_files(hboutpath, job) 273 | p = newpath 274 | else: 275 | p = hboutpath 276 | 277 | # move to media directory 278 | if job.video_type == "movie" and job.hasnicetitle: 279 | # tracks = job.tracks.all() 280 | tracks = job.tracks.filter_by(ripped=True) 281 | for track in tracks: 282 | logging.info("Moving Movie " + str(track.filename) + " to " + str(p)) 283 | utils.move_files(p, track.filename, job, track.main_feature) 284 | 285 | 286 | # move to media directory 287 | elif job.video_type == "series" and job.hasnicetitle: 288 | # tracks = job.tracks.all() 289 | tracks = job.tracks.filter_by(ripped=True) 290 | for track in tracks: 291 | logging.info("Moving Series " + str(track.filename) + " to " + str(p)) 292 | utils.move_files(p, track.filename, job, False) 293 | else: 294 | logging.info("job type is " + str(job.video_type) + "not movie or series, not moving.") 295 | utils.scan_emby(job) 296 | 297 | # remove empty directories 298 | try: 299 | os.rmdir(hboutpath) 300 | except OSError: 301 | logging.info(hboutpath + " directory is not empty. Skipping removal.") 302 | pass 303 | 304 | try: 305 | newpath 306 | except NameError: 307 | logging.debug("'newpath' directory not found") 308 | else: 309 | logging.info("Found path " + newpath + ". Attempting to remove it.") 310 | try: 311 | os.rmdir(p) 312 | except OSError: 313 | logging.info(newpath + " directory is not empty. Skipping removal.") 314 | pass 315 | 316 | # Clean up bluray backup 317 | # if job.disctype == "bluray" and cfg["DELRAWFILES"]: 318 | if job.config.DELRAWFILES: 319 | try: 320 | shutil.rmtree(mkvoutpath) 321 | except UnboundLocalError: 322 | logging.debug("No raw files found to delete.") 323 | except OSError: 324 | logging.debug("No raw files found to delete.") 325 | 326 | # report errors if any 327 | if job.errors: 328 | errlist = ', '.join(job.errors) 329 | if job.config.NOTIFY_TRANSCODE: 330 | utils.notify(job, "ARM notification", str(job.title) + " processing completed with errors. Title(s) " + errlist + " failed to complete.") 331 | logging.info("Transcoding completed with errors. Title(s) " + str(errlist) + " failed to complete.") 332 | else: 333 | if job.config.NOTIFY_TRANSCODE: 334 | utils.notify(job, "ARM notification", str(job.title) + " processing complete.") 335 | logging.info("ARM processing complete") 336 | 337 | elif job.disctype == "music": 338 | if utils.rip_music(job, logfile): 339 | utils.notify(job, "ARM notification", "Music CD: " + str(job.label) + " processing complete.") 340 | utils.scan_emby(job) 341 | else: 342 | logging.info("Music rip failed. See previous errors. Exiting.") 343 | 344 | elif job.disctype == "data": 345 | # get filesystem in order 346 | datapath = os.path.join(job.config.ARMPATH, str(job.label)) 347 | if (utils.make_dir(datapath)) is False: 348 | ts = str(round(time.time() * 100)) 349 | datapath = os.path.join(job.config.ARMPATH, str(job.label) + "_" + ts) 350 | 351 | if(utils.make_dir(datapath)) is False: 352 | logging.info("Could not create data directory: " + str(datapath) + ". Exiting ARM.") 353 | sys.exit() 354 | 355 | if utils.rip_data(job, datapath, logfile): 356 | utils.notify(job, "ARM notification", "Data disc: " + str(job.label)+ " copying complete.") 357 | job.eject() 358 | else: 359 | logging.info("Data rip failed. See previous errors. Exiting.") 360 | job.eject() 361 | 362 | else: 363 | logging.info("Couldn't identify the disc type. Exiting without any action.") 364 | 365 | # job.status = "success" 366 | # job.stop_time = datetime.datetime.now() 367 | # joblength = job.stop_time - job.start_time 368 | # minutes, seconds = divmod(joblength.seconds + joblength.days * 86400, 60) 369 | # hours, minutes = divmod(minutes, 60) 370 | # len = '{:d}:{:02d}:{:02d}'.format(hours, minutes, seconds) 371 | # job.job_length = len 372 | # db.session.commit() 373 | 374 | 375 | if __name__ == "__main__": 376 | args = entry() 377 | 378 | devpath = "/dev/" + args.devpath 379 | print(devpath) 380 | 381 | job = Job(devpath) 382 | 383 | logfile = logger.setuplogging(job) 384 | print("Log: " + logfile) 385 | 386 | if utils.get_cdrom_status(devpath) != 4: 387 | logging.info("Drive appears to be empty or is not ready. Exiting ARM.") 388 | sys.exit() 389 | 390 | logging.info("Starting ARM processing at " + str(datetime.datetime.now())) 391 | 392 | utils.check_db_version(cfg['INSTALLPATH'], cfg['DBFILE']) 393 | 394 | # put in db 395 | job.status = "active" 396 | job.start_time = datetime.datetime.now() 397 | db.session.add(job) 398 | db.session.commit() 399 | config = Config(cfg, job_id=job.job_id) 400 | db.session.add(config) 401 | db.session.commit() 402 | 403 | # Log version number 404 | with open(os.path.join(job.config.INSTALLPATH, 'VERSION')) as version_file: 405 | version = version_file.read().strip() 406 | logging.info("ARM version: " + version) 407 | job.arm_version = version 408 | logging.info(("Python version: " + sys.version).replace('\n', "")) 409 | logging.info("User is: " + getpass.getuser()) 410 | 411 | logger.cleanuplogs(job.config.LOGPATH, job.config.LOGLIFE) 412 | 413 | logging.info("Job: " + str(job.label)) 414 | 415 | # a_jobs = Job.query.filter_by(status="active") 416 | a_jobs = db.session.query(Job).filter(Job.status.notin_(['fail', 'success'])).all() 417 | 418 | # Clean up abandoned jobs 419 | for j in a_jobs: 420 | if psutil.pid_exists(j.pid): 421 | p = psutil.Process(j.pid) 422 | if j.pid_hash == hash(p): 423 | logging.info("Job #" + str(j.job_id) + " with PID " + str(j.pid) + " is currently running.") 424 | else: 425 | logging.info("Job #" + str(j.job_id) + " with PID " + str(j.pid) + " has been abandoned. Updating job status to fail.") 426 | j.status = "fail" 427 | db.session.commit() 428 | 429 | log_udev_params() 430 | 431 | try: 432 | main(logfile, job) 433 | except Exception: 434 | logging.exception("A fatal error has occured and ARM is exiting. See traceback below for details.") 435 | utils.notify(job, "ARM notification", "ARM encountered a fatal error processing " + str(job.title) + ". Check the logs for more details") 436 | job.status = "fail" 437 | job.eject() 438 | # job.stop_time = datetime.datetime.now() 439 | # job.job_length = job.stop_time - job.start_time 440 | # job.errors = "ARM encountered a fatal error processing " + str(job.title) + ". Check the logs for more details" 441 | # # db.session.add(job) 442 | # db.session.commit() 443 | else: 444 | job.status = "success" 445 | finally: 446 | job.stop_time = datetime.datetime.now() 447 | joblength = job.stop_time - job.start_time 448 | minutes, seconds = divmod(joblength.seconds + joblength.days * 86400, 60) 449 | hours, minutes = divmod(minutes, 60) 450 | len = '{:d}:{:02d}:{:02d}'.format(hours, minutes, seconds) 451 | job.job_length = len 452 | db.session.commit() 453 | -------------------------------------------------------------------------------- /arm/ripper/makemkv.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import logging 4 | import subprocess 5 | import time 6 | import shlex 7 | 8 | # from arm.config.config import cfg 9 | from arm.ripper import utils # noqa: E402 10 | from arm.ui import db 11 | 12 | 13 | def makemkv(logfile, job): 14 | """ 15 | Rip Blurays with MakeMKV\n 16 | logfile = Location of logfile to redirect MakeMKV logs to\n 17 | job = job object\n 18 | 19 | Returns path to ripped files. 20 | """ 21 | 22 | logging.info("Starting MakeMKV rip. Method is " + job.config.RIPMETHOD) 23 | 24 | # get MakeMKV disc number 25 | logging.debug("Getting MakeMKV disc number") 26 | cmd = 'makemkvcon -r info disc:9999 |grep {0} |grep -oP \'(?<=:).*?(?=,)\''.format( 27 | job.devpath 28 | ) 29 | 30 | try: 31 | mdisc = subprocess.check_output( 32 | cmd, 33 | shell=True 34 | ).decode("utf-8") 35 | logging.info("MakeMKV disc number: " + mdisc.strip()) 36 | # print("mdisc is: " + mdisc) 37 | except subprocess.CalledProcessError as mdisc_error: 38 | err = "Call to makemkv failed with code: " + str(mdisc_error.returncode) + "(" + str(mdisc_error.output) + ")" 39 | logging.error(err) 40 | raise RuntimeError(err) 41 | 42 | # get filesystem in order 43 | rawpath = os.path.join(job.config.RAWPATH, job.title) 44 | logging.info("Destination is " + rawpath) 45 | 46 | if not os.path.exists(rawpath): 47 | try: 48 | os.makedirs(rawpath) 49 | except OSError: 50 | # logging.error("Couldn't create the base file path: " + rawpath + " Probably a permissions error") 51 | err = "Couldn't create the base file path: " + rawpath + " Probably a permissions error" 52 | else: 53 | logging.info(rawpath + " exists. Adding timestamp.") 54 | ts = round(time.time() * 100) 55 | rawpath = os.path.join(job.config.RAWPATH, job.title + "_" + str(ts)) 56 | logging.info("rawpath is " + rawpath) 57 | try: 58 | os.makedirs(rawpath) 59 | except OSError: 60 | # logging.error("Couldn't create the base file path: " + rawpath + " Probably a permissions error") 61 | err = "Couldn't create the base file path: " + rawpath + " Probably a permissions error" 62 | sys.exit(err) 63 | 64 | # rip bluray 65 | if job.config.RIPMETHOD == "backup" and job.disctype == "bluray": 66 | # backup method 67 | cmd = 'makemkvcon backup --decrypt {0} -r disc:{1} {2}>> {3}'.format( 68 | job.config.MKV_ARGS, 69 | mdisc.strip(), 70 | shlex.quote(rawpath), 71 | logfile 72 | ) 73 | logging.info("Backup up disc") 74 | logging.debug("Backing up with the following command: " + cmd) 75 | 76 | try: 77 | mkv = subprocess.run( 78 | cmd, 79 | shell=True 80 | ) 81 | # ).decode("utf-8") 82 | # print("mkv is: " + mkv) 83 | logging.debug("The exit code for MakeMKV is: " + str(mkv.returncode)) 84 | if mkv.returncode == 253: 85 | # Makemkv is out of date 86 | err = "MakeMKV version is too old. Upgrade and try again. MakeMKV returncode is '253'." 87 | logging.error(err) 88 | raise RuntimeError(err) 89 | except subprocess.CalledProcessError as mdisc_error: 90 | err = "Call to MakeMKV failed with code: " + str(mdisc_error.returncode) + "(" + str(mdisc_error.output) + ")" 91 | logging.error(err) 92 | # print("Error: " + mkv) 93 | return None 94 | 95 | elif job.config.RIPMETHOD == "mkv" or job.disctype == "dvd": 96 | # mkv method 97 | get_track_info(mdisc, job) 98 | 99 | # if no maximum length, process the whole disc in one command 100 | if int(job.config.MAXLENGTH) > 99998: 101 | cmd = 'makemkvcon mkv {0} -r dev:{1} {2} {3} --minlength={4}>> {5}'.format( 102 | job.config.MKV_ARGS, 103 | job.devpath, 104 | "all", 105 | shlex.quote(rawpath), 106 | job.config.MINLENGTH, 107 | logfile 108 | ) 109 | logging.debug("Ripping with the following command: " + cmd) 110 | 111 | try: 112 | mkv = subprocess.run( 113 | cmd, 114 | shell=True 115 | ) 116 | # ).decode("utf-8") 117 | # print("mkv is: " + mkv) 118 | logging.debug("The exit code for MakeMKV is: " + str(mkv.returncode)) 119 | if mkv.returncode == 253: 120 | # Makemkv is out of date 121 | err = "MakeMKV version is too old. Upgrade and try again. MakeMKV returncode is '253'." 122 | logging.error(err) 123 | raise RuntimeError(err) 124 | except subprocess.CalledProcessError as mdisc_error: 125 | err = "Call to MakeMKV failed with code: " + str(mdisc_error.returncode) + "(" + str(mdisc_error.output) + ")" 126 | logging.error(err) 127 | # print("Error: " + mkv) 128 | return None 129 | else: 130 | # process one track at a time based on track length 131 | for track in job.tracks: 132 | if track.length < int(job.config.MINLENGTH): 133 | # too short 134 | logging.info("Track #" + str(track.track_number) + " of " + str(job.no_of_titles) + ". Length (" + str(track.length) + 135 | ") is less than minimum length (" + job.config.MINLENGTH + "). Skipping") 136 | elif track.length > int(job.config.MAXLENGTH): 137 | # too long 138 | logging.info("Track #" + str(track.track_number) + " of " + str(job.no_of_titles) + ". Length (" + str(track.length) + 139 | ") is greater than maximum length (" + job.config.MAXLENGTH + "). Skipping") 140 | else: 141 | # just right 142 | logging.info("Processing track #" + str(track.track_number) + " of " + str(job.no_of_titles - 1) + ". Length is " + 143 | str(track.length) + " seconds.") 144 | 145 | # filename = "title_" + str.zfill(str(track.track_number), 2) + "." + cfg['DEST_EXT'] 146 | # filename = track.filename 147 | filepathname = os.path.join(rawpath, track.filename) 148 | 149 | logging.info("Ripping title " + str(track.track_number) + " to " + shlex.quote(filepathname)) 150 | 151 | # track.filename = track.orig_filename = filename 152 | # db.session.commit() 153 | 154 | cmd = 'makemkvcon mkv {0} -r dev:{1} {2} {3} --minlength={4}>> {5}'.format( 155 | job.config.MKV_ARGS, 156 | job.devpath, 157 | str(track.track_number), 158 | shlex.quote(rawpath), 159 | job.config.MINLENGTH, 160 | logfile 161 | ) 162 | logging.debug("Ripping with the following command: " + cmd) 163 | 164 | try: 165 | mkv = subprocess.run( 166 | cmd, 167 | shell=True 168 | ) 169 | # ).decode("utf-8") 170 | # print("mkv is: " + mkv) 171 | logging.debug("The exit code for MakeMKV is: " + str(mkv.returncode)) 172 | if mkv.returncode == 253: 173 | # Makemkv is out of date 174 | err = "MakeMKV version is too old. Upgrade and try again. MakeMKV returncode is '253'." 175 | logging.error(err) 176 | raise RuntimeError(err) 177 | except subprocess.CalledProcessError as mdisc_error: 178 | err = "Call to MakeMKV failed with code: " + str(mdisc_error.returncode) + "(" + str(mdisc_error.output) + ")" 179 | logging.error(err) 180 | # print("Error: " + mkv) 181 | return None 182 | 183 | else: 184 | logging.info("I'm confused what to do.... Passing on MakeMKV") 185 | 186 | job.eject() 187 | 188 | logging.info("Exiting MakeMKV processing with return value of: " + rawpath) 189 | return(rawpath) 190 | 191 | 192 | def get_track_info(mdisc, job): 193 | """Use MakeMKV to get track info and updatte Track class\n 194 | 195 | mdisc = MakeMKV disc number\n 196 | job = Job instance\n 197 | """ 198 | 199 | logging.info("Using MakeMKV to get information on all the tracks on the disc. This will take a few minutes...") 200 | 201 | cmd = 'makemkvcon -r --cache=1 info disc:{0}'.format( 202 | mdisc 203 | ) 204 | 205 | logging.debug("Sending command: %s", (cmd)) 206 | 207 | try: 208 | mkv = subprocess.check_output( 209 | cmd, 210 | stderr=subprocess.STDOUT, 211 | shell=True 212 | ).decode("utf-8").splitlines() 213 | except subprocess.CalledProcessError as mdisc_error: 214 | err = "Call to MakeMKV failed with code: " + str(mdisc_error.returncode) + "(" + str(mdisc_error.output) + ")" 215 | logging.error(err) 216 | return None 217 | 218 | track = 0 219 | fps = float(0) 220 | aspect = "" 221 | seconds = 0 222 | filename = "" 223 | 224 | for line in mkv: 225 | if line.split(":")[0] in ("MSG", "TCOUNT", "CINFO", "TINFO", "SINFO"): 226 | # print(line.rstrip()) 227 | line_split = line.split(":", 1) 228 | msg_type = line_split[0] 229 | msg = line_split[1].split(",") 230 | line_track = int(msg[0]) 231 | 232 | if msg_type == "MSG": 233 | if msg[0] == "5055": 234 | job.errors = "MakeMKV evaluation period has expired. DVD processing will continus. Bluray processing will exit." 235 | if job.disctype == "bluray": 236 | err = "MakeMKV evaluation period has expired. Disc is a Bluray so ARM is exiting" 237 | logging.error(err) 238 | raise ValueError(err, "makemkv") 239 | else: 240 | logging.error("MakeMKV evaluation perios has ecpires. Disc is dvd so ARM will continue") 241 | db.session.commit() 242 | 243 | if msg_type == "TCOUNT": 244 | titles = int(line_split[1].strip()) 245 | logging.info("Found " + str(titles) + " titles") 246 | job.no_of_titles = titles 247 | db.session.commit() 248 | 249 | if msg_type == "TINFO": 250 | if track != line_track: 251 | if line_track == int(0): 252 | pass 253 | else: 254 | utils.put_track(job, track, seconds, aspect, fps, False, "makemkv", filename) 255 | track = line_track 256 | 257 | if msg[1] == "27": 258 | filename = msg[3].replace('"', '').strip() 259 | 260 | if msg_type == "TINFO" and msg[1] == "9": 261 | len_hms = msg[3].replace('"', '').strip() 262 | h, m, s = len_hms.split(':') 263 | seconds = int(h) * 3600 + int(m) * 60 + int(s) 264 | 265 | if msg_type == "SINFO" and msg[1] == "0": 266 | if msg[2] == "20": 267 | aspect = msg[4].replace('"', '').strip() 268 | elif msg[2] == "21": 269 | fps = msg[4].split()[0] 270 | fps = fps.replace('"', '').strip() 271 | fps = float(fps) 272 | 273 | utils.put_track(job, track, seconds, aspect, fps, False, "makemkv", filename) 274 | -------------------------------------------------------------------------------- /arm/ripper/utils.py: -------------------------------------------------------------------------------- 1 | # Collection of utility functions 2 | 3 | import os 4 | import sys 5 | import logging 6 | import fcntl 7 | import subprocess 8 | import shutil 9 | import requests 10 | import time 11 | import datetime # noqa: E402 12 | import psutil # noqa E402 13 | 14 | # from arm.config.config import cfg 15 | from arm.ui import app, db # noqa E402 16 | from arm.models.models import Track # noqa: E402 17 | 18 | 19 | def notify(job, title, body): 20 | # Send notificaions 21 | # title = title for notification 22 | # body = body of the notification 23 | 24 | if job.config.PB_KEY != "": 25 | try: 26 | from pushbullet import Pushbullet 27 | pb = Pushbullet(job.config.PB_KEY) 28 | pb.push_note(title, body) 29 | except: # noqa: E722 30 | logging.error("Failed sending PushBullet notification. Continueing processing...") 31 | 32 | if job.config.IFTTT_KEY != "": 33 | try: 34 | import pyfttt as pyfttt 35 | event = job.config.IFTTT_EVENT 36 | pyfttt.send_event(job.config.IFTTT_KEY, event, title, body) 37 | except: # noqa: E722 38 | logging.error("Failed sending IFTTT notification. Continueing processing...") 39 | 40 | if job.config.PO_USER_KEY != "": 41 | try: 42 | from pushover import init, Client 43 | init(job.config.PO_APP_KEY) 44 | Client(job.config.PO_USER_KEY).send_message(body, title=title) 45 | except: # noqa: E722 46 | logging.error("Failed sending PushOver notification. Continueing processing...") 47 | 48 | 49 | def scan_emby(job): 50 | """Trigger a media scan on Emby""" 51 | 52 | if job.config.EMBY_REFRESH: 53 | logging.info("Sending Emby library scan request") 54 | url = "http://" + job.config.EMBY_SERVER + ":" + job.config.EMBY_PORT + "/Library/Refresh?api_key=" + job.config.EMBY_API_KEY 55 | try: 56 | req = requests.post(url) 57 | if req.status_code > 299: 58 | req.raise_for_status() 59 | logging.info("Emby Library Scan request successful") 60 | except requests.exceptions.HTTPError: 61 | logging.error("Emby Library Scan request failed with status code: " + str(req.status_code)) 62 | else: 63 | logging.info("EMBY_REFRESH config parameter is false. Skipping emby scan.") 64 | 65 | def SleepCheckProcess(ProcessStr, Proc_Count): 66 | 67 | if Proc_Count != 0: 68 | Loop_Count = Proc_Count + 1 69 | logging.debug("Loop_Count " + str(Loop_Count)) 70 | logging.info("Starting A sleep check of " + str(ProcessStr)) 71 | while Loop_Count >= Proc_Count: 72 | Loop_Count = sum(1 for proc in psutil.process_iter() if proc.name() == ProcessStr) 73 | logging.debug("Number of Processes running is:" + str(Loop_Count) + " going to waiting 12 seconds.") 74 | time.sleep(10) 75 | else: 76 | logging.info("Number of processes to count is: " + str(Proc_Count)) 77 | 78 | def move_files(basepath, filename, job, ismainfeature=False): 79 | """Move files into final media directory\n 80 | basepath = path to source directory\n 81 | filename = name of file to be moved\n 82 | job = instance of Job class\n 83 | ismainfeature = True/False""" 84 | 85 | logging.debug("Moving files: " + str(job)) 86 | 87 | if job.title_manual: 88 | # logging.info("Found new title: " + job.new_title + " (" + str(job.new_year) + ")") 89 | # videotitle = job.new_title + " (" + str(job.new_year) + ")" 90 | hasnicetitle = True 91 | else: 92 | hasnicetitle = job.hasnicetitle 93 | 94 | videotitle = job.title + " (" + str(job.year) + ")" 95 | 96 | logging.debug("Arguments: " + basepath + " : " + filename + " : " + str(hasnicetitle) + " : " + videotitle + " : " + str(ismainfeature)) 97 | 98 | if hasnicetitle: 99 | m_path = os.path.join(job.config.MEDIA_DIR + videotitle) 100 | 101 | if not os.path.exists(m_path): 102 | logging.info("Creating base title directory: " + m_path) 103 | os.makedirs(m_path) 104 | 105 | if ismainfeature is True: 106 | logging.info("Track is the Main Title. Moving '" + filename + "' to " + m_path) 107 | 108 | m_file = os.path.join(m_path, videotitle + "." + job.config.DEST_EXT) 109 | if not os.path.isfile(m_file): 110 | try: 111 | shutil.move(os.path.join(basepath, filename), m_file) 112 | except shutil.Error: 113 | logging.error("Unable to move '" + filename + "' to " + m_path) 114 | else: 115 | logging.info("File: " + m_file + " already exists. Not moving.") 116 | else: 117 | e_path = os.path.join(m_path, job.config.EXTRAS_SUB) 118 | 119 | if not os.path.exists(e_path): 120 | logging.info("Creating extras directory " + e_path) 121 | os.makedirs(e_path) 122 | 123 | logging.info("Moving '" + filename + "' to " + e_path) 124 | 125 | e_file = os.path.join(e_path, videotitle + "." + job.config.DEST_EXT) 126 | if not os.path.isfile(e_file): 127 | try: 128 | shutil.move(os.path.join(basepath, filename), os.path.join(e_path, filename)) 129 | except shutil.Error: 130 | logging.error("Unable to move '" + filename + "' to " + e_path) 131 | else: 132 | logging.info("File: " + e_file + " already exists. Not moving.") 133 | 134 | else: 135 | logging.info("hasnicetitle is false. Not moving files.") 136 | 137 | 138 | def rename_files(oldpath, job): 139 | """ 140 | Rename a directory and its contents based on job class details\n 141 | oldpath = Path to existing directory\n 142 | job = An instance of the Job class\n 143 | 144 | returns new path if successful 145 | """ 146 | 147 | newpath = os.path.join(job.config.ARMPATH, job.title + " (" + str(job.year) + ")") 148 | logging.debug("oldpath: " + oldpath + " newpath: " + newpath) 149 | logging.info("Changing directory name from " + oldpath + " to " + newpath) 150 | 151 | # Sometimes a job fails, after the rip, but before move of the tracks into the folder, at which point the below command 152 | # will move the newly ripped folder inside the old correctly named folder. 153 | # This can be a problem as the job when it tries to move the files, won't find them. 154 | # other than putting in an error message, I'm not sure how to perminently fix this problem. 155 | # Maybe I could add a configurable option for deletion of crashed job files? 156 | 157 | if os.path.isdir(newpath): 158 | logging.info("Error: The 'new' directory already exists, ARM will probably copy the newly ripped folder into the old-new folder.") 159 | 160 | try: 161 | shutil.move(oldpath, newpath) 162 | logging.debug("Directory name change successful") 163 | except shutil.Error: 164 | logging.info("Error change directory from " + oldpath + " to " + newpath + ". Likely the path already exists.") 165 | raise OSError(2, 'No such file or directory', newpath) 166 | 167 | # try: 168 | # shutil.rmtree(oldpath) 169 | # logging.debug("oldpath deleted successfully.") 170 | # except shutil.Error: 171 | # logging.info("Error change directory from " + oldpath + " to " + newpath + ". Likely the path already exists.") 172 | # raise OSError(2, 'No such file or directory', newpath) 173 | 174 | # tracks = Track.query.get(job.job_id) 175 | # tracks = job.tracks.all() 176 | # for track in tracks: 177 | # if track.main_feature: 178 | # newfilename = job.title + " (" + str(job.year) + ")" + "." + cfg["DEST_EXT"] 179 | # else: 180 | # newfilename = job.title + " (" + str(job.year) + ")" + track.track_number + "." + cfg["DEST_EXT"] 181 | 182 | # track.new_filename = newfilename 183 | 184 | # # newfullpath = os.path.join(newpath, job.new_title + " (" + str(job.new_year) + ")" + track.track_number + "." + cfg["DEST_EXT"]) 185 | # logging.info("Changing filename '" + os.path.join(newpath, track.filename) + "' to '" + os.path.join(newpath, newfilename) + "'") 186 | # try: 187 | # shutil.move(os.path.join(newpath, track.filename), os.path.join(newpath, newfilename)) 188 | # logging.debug("Filename change successful") 189 | # except shutil.Error: 190 | # logging.error("Unable to change '" + track.filename + "' to '" + newfilename + "'") 191 | # raise OSError(3, 'Unable to change file', newfilename) 192 | 193 | # track.filename = newfilename 194 | # db.session.commit() 195 | 196 | return newpath 197 | 198 | 199 | def make_dir(path): 200 | """ 201 | Make a directory\n 202 | path = Path to directory\n 203 | 204 | returns success True if successful 205 | false if the directory already exists 206 | """ 207 | if not os.path.exists(path): 208 | logging.debug("Creating directory: " + path) 209 | try: 210 | os.makedirs(path) 211 | return True 212 | except OSError: 213 | err = "Couldn't create a directory at path: " + path + " Probably a permissions error. Exiting" 214 | logging.error(err) 215 | sys.exit(err) 216 | # return False 217 | else: 218 | return False 219 | 220 | 221 | def get_cdrom_status(devpath): 222 | """get the status of the cdrom drive\n 223 | devpath = path to cdrom\n 224 | 225 | returns int 226 | CDS_NO_INFO 0\n 227 | CDS_NO_DISC 1\n 228 | CDS_TRAY_OPEN 2\n 229 | CDS_DRIVE_NOT_READY 3\n 230 | CDS_DISC_OK 4\n 231 | 232 | see linux/cdrom.h for specifics\n 233 | """ 234 | 235 | try: 236 | fd = os.open(devpath, os.O_RDONLY | os.O_NONBLOCK) 237 | except Exception: 238 | logging.info("Failed to open device " + devpath + " to check status.") 239 | exit(2) 240 | result = fcntl.ioctl(fd, 0x5326, 0) 241 | 242 | return result 243 | 244 | 245 | def find_file(filename, search_path): 246 | """ 247 | Check to see if file exists by searching a directory recursively\n 248 | filename = filename to look for\n 249 | search_path = path to search recursively\n 250 | 251 | returns True or False 252 | """ 253 | 254 | for dirpath, dirnames, filenames in os.walk(search_path): 255 | if filename in filenames: 256 | return True 257 | return False 258 | 259 | 260 | def rip_music(job, logfile): 261 | """ 262 | Rip music CD using abcde using abcde config\n 263 | job = job object\n 264 | logfile = location of logfile\n 265 | 266 | returns True/False for success/fail 267 | """ 268 | 269 | if job.disctype == "music": 270 | logging.info("Disc identified as music") 271 | cmd = 'abcde -d "{0}" >> "{1}" 2>&1'.format( 272 | job.devpath, 273 | logfile 274 | ) 275 | 276 | logging.debug("Sending command: " + cmd) 277 | 278 | try: 279 | subprocess.check_output( 280 | cmd, 281 | shell=True 282 | ).decode("utf-8") 283 | logging.info("abcde call successful") 284 | return True 285 | except subprocess.CalledProcessError as ab_error: 286 | err = "Call to abcde failed with code: " + str(ab_error.returncode) + "(" + str(ab_error.output) + ")" 287 | logging.error(err) 288 | # sys.exit(err) 289 | 290 | return False 291 | 292 | 293 | def rip_data(job, datapath, logfile): 294 | """ 295 | Rip data disc using dd on the command line\n 296 | job = job object\n 297 | datapath = path to copy data to\n 298 | logfile = location of logfile\n 299 | 300 | returns True/False for success/fail 301 | """ 302 | 303 | if job.disctype == "data": 304 | logging.info("Disc identified as data") 305 | 306 | if (job.label) == "": 307 | job.label = "datadisc" 308 | 309 | filename = os.path.join(datapath, job.label + ".iso") 310 | 311 | logging.info("Ripping data disc to: " + filename) 312 | 313 | cmd = 'dd if="{0}" of="{1}" {2} 2>> {3}'.format( 314 | job.devpath, 315 | filename, 316 | cfg["DATA_RIP_PARAMETERS"], 317 | logfile 318 | ) 319 | 320 | logging.debug("Sending command: " + cmd) 321 | 322 | try: 323 | subprocess.check_output( 324 | cmd, 325 | shell=True 326 | ).decode("utf-8") 327 | logging.info("Data rip call successful") 328 | return True 329 | except subprocess.CalledProcessError as dd_error: 330 | err = "Data rip failed with code: " + str(dd_error.returncode) + "(" + str(dd_error.output) + ")" 331 | logging.error(err) 332 | # sys.exit(err) 333 | 334 | return False 335 | 336 | 337 | def set_permissions(job, directory_to_traverse): 338 | try: 339 | corrected_chmod_value = int(str(job.config.CHMOD_VALUE), 8) 340 | logging.info("Setting permissions to: " + str(job.config.CHMOD_VALUE) + " on: " + directory_to_traverse) 341 | os.chmod(directory_to_traverse, corrected_chmod_value) 342 | 343 | for dirpath, l_directories, l_files in os.walk(directory_to_traverse): 344 | for cur_dir in l_directories: 345 | logging.debug("Setting path: " + cur_dir + " to permissions value: " + str(job.config.CHMOD_VALUE)) 346 | os.chmod(os.path.join(dirpath, cur_dir), corrected_chmod_value) 347 | for cur_file in l_files: 348 | logging.debug("Setting file: " + cur_file + " to permissions value: " + str(job.config.CHMOD_VALUE)) 349 | os.chmod(os.path.join(dirpath, cur_file), corrected_chmod_value) 350 | return True 351 | except Exception as e: 352 | err = "Permissions setting failed as: " + str(e) 353 | logging.error(err) 354 | return False 355 | 356 | 357 | def check_db_version(install_path, db_file): 358 | """ 359 | Check if db exists and is up to date. 360 | If it doesn't exist create it. If it's out of date update it. 361 | """ 362 | from alembic.script import ScriptDirectory 363 | from alembic.config import Config 364 | import sqlite3 365 | import flask_migrate 366 | 367 | # db_file = job.config.DBFILE 368 | mig_dir = os.path.join(install_path, "arm/migrations") 369 | 370 | config = Config() 371 | config.set_main_option("script_location", mig_dir) 372 | script = ScriptDirectory.from_config(config) 373 | 374 | # create db file if it doesn't exist 375 | if not os.path.isfile(db_file): 376 | logging.info("No database found. Initializing arm.db...") 377 | make_dir(os.path.dirname(db_file)) 378 | with app.app_context(): 379 | flask_migrate.upgrade(mig_dir) 380 | 381 | if not os.path.isfile(db_file): 382 | logging.error("Can't create database file. This could be a permissions issue. Exiting...") 383 | sys.exit() 384 | 385 | # check to see if db is at current revision 386 | head_revision = script.get_current_head() 387 | logging.debug("Head is: " + head_revision) 388 | 389 | conn = sqlite3.connect(db_file) 390 | c = conn.cursor() 391 | 392 | c.execute("SELECT {cn} FROM {tn}".format(cn="version_num", tn="alembic_version")) 393 | db_version = c.fetchone()[0] 394 | logging.debug("Database version is: " + db_version) 395 | if head_revision == db_version: 396 | logging.info("Database is up to date") 397 | else: 398 | logging.info("Database out of date. Head is " + head_revision + " and database is " + db_version + ". Upgrading database...") 399 | with app.app_context(): 400 | ts = round(time.time() * 100) 401 | logging.info("Backuping up database '" + db_file + "' to '" + db_file + str(ts) + "'.") 402 | shutil.copy(db_file, db_file + "_" + str(ts)) 403 | flask_migrate.upgrade(mig_dir) 404 | logging.info("Upgrade complete. Validating version level...") 405 | 406 | c.execute("SELECT {cn} FROM {tn}".format(tn="alembic_version", cn="version_num")) 407 | db_version = c.fetchone()[0] 408 | logging.debug("Database version is: " + db_version) 409 | if head_revision == db_version: 410 | logging.info("Database is now up to date") 411 | else: 412 | logging.error("Database is still out of date. Head is " + head_revision + " and database is " + db_version + ". Exiting arm.") 413 | sys.exit() 414 | 415 | 416 | def put_track(job, t_no, seconds, aspect, fps, mainfeature, source, filename=""): 417 | """ 418 | Put data into a track instance\n 419 | 420 | 421 | job = job ID\n 422 | t_no = track number\n 423 | seconds = lenght of track in seconds\n 424 | aspect = aspect ratio (ie '16:9')\n 425 | fps = frames per second (float)\n 426 | mainfeature = True/False\n 427 | source = Source of information\n 428 | filename = filename of track\n 429 | """ 430 | 431 | logging.debug("Track #" + str(t_no) + " Length: " + str(seconds) + " fps: " + str(fps) + " aspect: " + str(aspect) + " Mainfeature: " + 432 | str(mainfeature) + " Source: " + source) 433 | 434 | t = Track( 435 | job_id=job.job_id, 436 | track_number=t_no, 437 | length=seconds, 438 | aspect_ratio=aspect, 439 | # blocks=b, 440 | fps=fps, 441 | main_feature=mainfeature, 442 | source=source, 443 | basename=job.title, 444 | filename=filename 445 | ) 446 | db.session.add(t) 447 | db.session.commit() 448 | -------------------------------------------------------------------------------- /arm/runui.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) 4 | 5 | from arm.ui import app # noqa E402 6 | from arm.config.config import cfg # noqa E402 7 | import arm.ui.routes # noqa E402 8 | 9 | host = cfg['WEBSERVER_IP'] 10 | if host == 'x.x.x.x': 11 | # autodetect host IP address 12 | from netifaces import interfaces, ifaddresses, AF_INET 13 | ip_list = [] 14 | for interface in interfaces(): 15 | inet_links = ifaddresses(interface).get(AF_INET, []) 16 | for link in inet_links: 17 | ip = link['addr'] 18 | if ip != '127.0.0.1': 19 | ip_list.append(ip) 20 | if len(ip_list) > 0: 21 | host = ip_list[0] 22 | else: 23 | host = '127.0.0.1' 24 | 25 | if __name__ == '__main__': 26 | app.run(host=host, port=cfg['WEBSERVER_PORT'], debug=True) 27 | # app.run(debug=True) 28 | -------------------------------------------------------------------------------- /arm/ui/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | from flask_sqlalchemy import SQLAlchemy 3 | from flask_migrate import Migrate 4 | from arm.config.config import cfg 5 | # import omdb 6 | 7 | 8 | sqlitefile = 'sqlite:///' + cfg['DBFILE'] 9 | 10 | app = Flask(__name__) 11 | app.config['SQLALCHEMY_DATABASE_URI'] = sqlitefile 12 | app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False 13 | app.config['SECRET_KEY'] = "Big secret key" 14 | db = SQLAlchemy(app) 15 | migrate = Migrate(app, db) 16 | 17 | # omdb.set_default('apikey', cfg['OMDB_API_KEY']) 18 | 19 | # import arm.ui.routes # noqa: E402,F401 20 | # import models.models # noqa: E402 21 | # import ui.config # noqa: E402 22 | # import ui.utils # noqa: E402,F401 23 | -------------------------------------------------------------------------------- /arm/ui/forms.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import FlaskForm 2 | from wtforms import StringField, SubmitField, SelectField, IntegerField, BooleanField 3 | from wtforms.validators import DataRequired 4 | 5 | 6 | class TitleSearchForm(FlaskForm): 7 | title = StringField('Title', validators=[DataRequired()]) 8 | year = StringField('Year') 9 | submit = SubmitField('Submit') 10 | 11 | class CustomTitleForm(FlaskForm): 12 | title = StringField('Title', validators=[DataRequired()]) 13 | year = StringField('Year') 14 | submit = SubmitField('Submit') 15 | 16 | 17 | class ChangeParamsForm(FlaskForm): 18 | RIPMETHOD = SelectField('Rip Method', choices=[('mkv', 'mkv'), ('backup', 'backup')]) 19 | #MAINFEATURE = BooleanField('Main Feature', validators=[DataRequired()]) 20 | MAINFEATURE = BooleanField('Main Feature') 21 | MINLENGTH = IntegerField('Minimum Length') 22 | MAXLENGTH = IntegerField('Maximum Length') 23 | submit = SubmitField('Submit') -------------------------------------------------------------------------------- /arm/ui/routes.py: -------------------------------------------------------------------------------- 1 | import os 2 | from time import sleep 3 | from flask import render_template, abort, request, send_file, flash, redirect, url_for 4 | import psutil 5 | from arm.ui import app, db 6 | from arm.models.models import Job, Config 7 | from arm.config.config import cfg 8 | from arm.ui.utils import convert_log, get_info, call_omdb_api, clean_for_filename 9 | from arm.ui.forms import TitleSearchForm, ChangeParamsForm, CustomTitleForm 10 | 11 | 12 | @app.route('/logreader') 13 | def logreader(): 14 | 15 | logpath = cfg['LOGPATH'] 16 | mode = request.args['mode'] 17 | logfile = request.args['logfile'] 18 | 19 | # Assemble full path 20 | fullpath = os.path.join(logpath, logfile) 21 | 22 | if mode == "armcat": 23 | def generate(): 24 | f = open(fullpath) 25 | while True: 26 | new = f.readline() 27 | if new: 28 | if "ARM:" in new: 29 | yield new 30 | else: 31 | sleep(1) 32 | elif mode == "full": 33 | def generate(): 34 | with open(fullpath) as f: 35 | while True: 36 | yield f.read() 37 | sleep(1) 38 | elif mode == "download": 39 | clogfile = convert_log(logfile) 40 | return send_file(clogfile, as_attachment=True) 41 | else: 42 | # do nothing 43 | exit() 44 | 45 | return app.response_class(generate(), mimetype='text/plain') 46 | 47 | 48 | @app.route('/activerips') 49 | def rips(): 50 | return render_template('activerips.html', jobs=Job.query.filter_by(status="active")) 51 | 52 | 53 | @app.route('/history') 54 | def history(): 55 | if os.path.isfile(cfg['DBFILE']): 56 | # jobs = Job.query.filter_by(status="active") 57 | jobs = Job.query.filter_by() 58 | else: 59 | jobs = {} 60 | 61 | return render_template('history.html', jobs=jobs) 62 | 63 | 64 | @app.route('/jobdetail', methods=['GET', 'POST']) 65 | def jobdetail(): 66 | job_id = request.args.get('job_id') 67 | jobs = Job.query.get(job_id) 68 | tracks = jobs.tracks.all() 69 | 70 | return render_template('jobdetail.html', jobs=jobs, tracks=tracks) 71 | 72 | 73 | @app.route('/titlesearch', methods=['GET', 'POST']) 74 | def submitrip(): 75 | job_id = request.args.get('job_id') 76 | job = Job.query.get(job_id) 77 | form = TitleSearchForm(obj=job) 78 | if form.validate_on_submit(): 79 | form.populate_obj(job) 80 | flash('Search for {}, year={}'.format(form.title.data, form.year.data), category='success') 81 | # dvd_info = call_omdb_api(form.title.data, form.year.data) 82 | return redirect(url_for('list_titles', title=form.title.data, year=form.year.data, job_id=job_id)) 83 | # return render_template('list_titles.html', results=dvd_info, job_id=job_id) 84 | # return redirect('/gettitle', title=form.title.data, year=form.year.data) 85 | return render_template('titlesearch.html', title='Update Title', form=form) 86 | 87 | 88 | @app.route('/changeparams', methods=['GET', 'POST']) 89 | def changeparams(): 90 | config_id = request.args.get('config_id') 91 | config = Config.query.get(config_id) 92 | form = ChangeParamsForm(obj=config) 93 | if form.validate_on_submit(): 94 | config.MINLENGTH = format(form.MINLENGTH.data) 95 | config.MAXLENGTH = format(form.MAXLENGTH.data) 96 | config.RIPMETHOD = format(form.RIPMETHOD.data) 97 | #config.MAINFEATURE = format(form.MAINFEATURE.data) 98 | db.session.commit() 99 | flash('Parameters changed. Rip Method={}, Main Feature={}, Minimum Length={}, Maximum Length={}'.format(form.RIPMETHOD.data, form.MAINFEATURE.data, form.MINLENGTH.data, form.MAXLENGTH.data)) 100 | return redirect(url_for('home')) 101 | return render_template('changeparams.html', title='Change Parameters', form=form) 102 | 103 | @app.route('/customTitle', methods=['GET', 'POST']) 104 | def customtitle(): 105 | job_id = request.args.get('job_id') 106 | job = Job.query.get(job_id) 107 | form = CustomTitleForm(obj=job) 108 | if form.validate_on_submit(): 109 | form.populate_obj(job) 110 | job.title = format(form.title.data) 111 | job.year = format(form.year.data) 112 | db.session.commit() 113 | flash('custom title changed. Title={}, Year={}, '.format(form.title, form.year)) 114 | return redirect(url_for('home')) 115 | return render_template('customTitle.html', title='Change Title', form=form) 116 | 117 | 118 | @app.route('/list_titles') 119 | def list_titles(): 120 | title = request.args.get('title').strip() 121 | year = request.args.get('year').strip() 122 | job_id = request.args.get('job_id') 123 | dvd_info = call_omdb_api(title, year) 124 | return render_template('list_titles.html', results=dvd_info, job_id=job_id) 125 | 126 | 127 | @app.route('/gettitle', methods=['GET', 'POST']) 128 | def gettitle(): 129 | imdbID = request.args.get('imdbID') 130 | job_id = request.args.get('job_id') 131 | dvd_info = call_omdb_api(None, None, imdbID, "full") 132 | return render_template('showtitle.html', results=dvd_info, job_id=job_id) 133 | 134 | 135 | @app.route('/updatetitle', methods=['GET', 'POST']) 136 | def updatetitle(): 137 | new_title = request.args.get('title') 138 | new_year = request.args.get('year') 139 | video_type = request.args.get('type') 140 | imdbID = request.args.get('imdbID') 141 | poster_url = request.args.get('poster') 142 | job_id = request.args.get('job_id') 143 | print("New imdbID=" + imdbID) 144 | job = Job.query.get(job_id) 145 | job.title = clean_for_filename(new_title) 146 | job.title_manual = clean_for_filename(new_title) 147 | job.year = new_year 148 | job.year_manual = new_year 149 | job.video_type_manual = video_type 150 | job.video_type = video_type 151 | job.imdb_id_manual = imdbID 152 | job.imdb_id = imdbID 153 | job.poster_url_manual = poster_url 154 | job.poster_url = poster_url 155 | job.hasnicetitle = True 156 | db.session.add(job) 157 | db.session.commit() 158 | flash('Title: {} ({}) was updated to {} ({})'.format(job.title_auto, job.year_auto, new_title, new_year), category='success') 159 | return redirect(url_for('home')) 160 | 161 | 162 | @app.route('/logs') 163 | def logs(): 164 | mode = request.args['mode'] 165 | logfile = request.args['logfile'] 166 | 167 | return render_template('logview.html', file=logfile, mode=mode) 168 | 169 | 170 | @app.route('/listlogs', defaults={'path': ''}) 171 | def listlogs(path): 172 | 173 | basepath = cfg['LOGPATH'] 174 | fullpath = os.path.join(basepath, path) 175 | 176 | # Deal with bad data 177 | if not os.path.exists(fullpath): 178 | return abort(404) 179 | 180 | # Get all files in directory 181 | files = get_info(fullpath) 182 | return render_template('logfiles.html', files=files) 183 | 184 | 185 | @app.route('/') 186 | @app.route('/index.html') 187 | def home(): 188 | # freegb = getsize(cfg['RAWPATH']) 189 | freegb = psutil.disk_usage(cfg['ARMPATH']).free 190 | freegb = round(freegb/1073741824, 1) 191 | mfreegb = psutil.disk_usage(cfg['MEDIA_DIR']).free 192 | mfreegb = round(mfreegb/1073741824, 1) 193 | if os.path.isfile(cfg['DBFILE']): 194 | # jobs = Job.query.filter_by(status="active") 195 | jobs = db.session.query(Job).filter(Job.status.notin_(['fail', 'success'])).all() 196 | else: 197 | jobs = {} 198 | 199 | return render_template('index.html', freegb=freegb, mfreegb=mfreegb, jobs=jobs) 200 | -------------------------------------------------------------------------------- /arm/ui/static/img/arm40nw.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flammableliquids/automatic-ripping-machine/f7450fb96730a631190c30e012b0bc469e5a890b/arm/ui/static/img/arm40nw.png -------------------------------------------------------------------------------- /arm/ui/static/img/arm80.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flammableliquids/automatic-ripping-machine/f7450fb96730a631190c30e012b0bc469e5a890b/arm/ui/static/img/arm80.png -------------------------------------------------------------------------------- /arm/ui/static/img/arm80nw.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flammableliquids/automatic-ripping-machine/f7450fb96730a631190c30e012b0bc469e5a890b/arm/ui/static/img/arm80nw.png -------------------------------------------------------------------------------- /arm/ui/static/img/disc.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /arm/ui/static/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flammableliquids/automatic-ripping-machine/f7450fb96730a631190c30e012b0bc469e5a890b/arm/ui/static/img/favicon.png -------------------------------------------------------------------------------- /arm/ui/static/img/file-text.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /arm/ui/templates/activerips.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}Logs{% endblock %} 3 | 4 | {% block nav %}{{ super() }}{% endblock %} 5 | 6 | {% block content %} 7 |
8 |
9 |
10 |
11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | {% for job in jobs %} 22 | 23 | 24 | 25 | 26 | {% endfor %} 27 | 28 | 29 |
TitleStart TimeStatus
{{ job.title }}{{ job.start_time }}{{ job.status }}
30 |
31 |
32 |
33 |
34 |
35 | 36 | {% endblock %} 37 | {% block footer %}{{ super() }}{% endblock %} 38 | {% block js %}{{ super() }}{% endblock %} 39 | -------------------------------------------------------------------------------- /arm/ui/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% block head %} 6 | 7 | 8 | 9 | {% block title %}{% endblock %} - ARM 10 | {% endblock %} 11 | 12 | 13 | {% block nav %} 14 |
15 | 36 |
37 | {% with messages = get_flashed_messages(with_categories=true) %} 38 | 39 | {% if messages %} 40 | {% for category, message in messages %} 41 | 45 | {% endfor %} 46 | {% endif %} 47 | {% endwith %} 48 | {% endblock %} 49 |
{% block content %}{% endblock %}
50 | 62 | {% block js %} 63 | 64 | 65 | {% endblock %} 66 | 67 | -------------------------------------------------------------------------------- /arm/ui/templates/changeparams.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}Parameter Changer{% endblock %} 3 | 4 | {% block nav %}{{ super() }}{% endblock %} 5 | 6 | {% block content %} 7 |
8 |
9 |
10 |
11 |
12 | {{ form.hidden_tag() }} 13 |

14 | 15 | {{ form.RIPMETHOD.label }} 16 | {{ form.RIPMETHOD }} 17 | {% for error in form.RIPMETHOD.errors %} 18 | [{{ error }}] 19 | {% endfor %} 20 |

21 |

22 |

I don't know flask well enough to fix the error, but tick the box below if you want this form to execute. I disabled it in the db update.

23 | {{ form.MAINFEATURE.label }} 24 | {{ form.MAINFEATURE }} 25 | {% for error in form.MAINFEATURE.errors %} 26 | [{{ error }}] 27 | {% endfor %} 28 |

29 |

30 | {{ form.MINLENGTH.label }} 31 | {{ form.MINLENGTH(size=5) }} 32 | {% for error in form.MINLENGTH.errors %} 33 | [{{ error }}] 34 | {% endfor %} 35 |

36 |

37 | {{ form.MAXLENGTH.label }} 38 | {{ form.MAXLENGTH(size=5) }} 39 | {% for error in form.MAXLENGTH.errors %} 40 | [{{ error }}] 41 | {% endfor %} 42 |

43 |

{{ form.submit() }}

44 | {% if form.errors %} 45 | {{ form.errors }} 46 | {% endif %} 47 |
48 |
49 |
50 |
51 |
52 | 53 | 54 | 55 | {% endblock %} 56 | -------------------------------------------------------------------------------- /arm/ui/templates/customTitle.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}Customize Title{% endblock %} 3 | 4 | {% block nav %}{{ super() }}{% endblock %} 5 | 6 | {% block content %} 7 |
8 |
9 |
10 |
11 |

This for was inteneded for DVD's that have more than one disk

12 |

This for was inteneded or for TV Serie's that have more than one disk

13 |
14 | {{ form.hidden_tag() }} 15 |

16 | {{ form.title.label }}
17 | {{ form.title(size=32) }} 18 |

19 |

20 | {{ form.year.label }}
21 | {{ form.year(size=4) }} 22 |

23 |

{{ form.submit() }}

24 | {% if form.errors %} 25 | {{ form.errors }} 26 | {% endif %} 27 |
28 |
29 |
30 |
31 |
32 | 33 | 34 | 35 | {% endblock %} 36 | -------------------------------------------------------------------------------- /arm/ui/templates/history.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}Logs{% endblock %} 3 | 4 | {% block nav %}{{ super() }}{% endblock %} 5 | 6 | {% block content %} 7 |
8 |
9 |
10 |
11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | {% for job in jobs %} 23 | 24 | 25 | 26 | 27 | 28 | {% endfor %} 29 | 30 | 31 |
TitleStart TimeDurationStatus
{{ job.title }}{{ job.start_time.strftime("%Y-%m-%d %H:%M:%S") }}{{ job.job_length }}{{ job.status }}
32 |
33 |
34 |
35 |
36 |
37 | 38 | {% endblock %} 39 | {% block footer %}{{ super() }}{% endblock %} 40 | {% block js %} 41 | {{ super() }} 42 | 43 | 44 | 53 | {% endblock %} 54 | -------------------------------------------------------------------------------- /arm/ui/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}Logs{% endblock %} 3 | 4 | {% block nav %}{{ super() }}{% endblock %} 5 | 6 | {% block content %} 7 |
8 |
9 | 10 |
11 | 12 |

Welcome to your Automatic Ripping Machine
13 | 14 |

15 |
16 | 17 |
18 | 19 |
20 |
21 |

22 |

Active Rips
23 |

24 |
25 |
26 | 27 |
28 | 29 |
30 |
31 | {% for job in jobs %} 32 |
33 |
34 |
35 |
36 | 39 |
40 |
41 |
42 | {% if not job.title_manual %} 43 | {{ job["title"] }}
44 | {% else %} 45 | {{ job["title_auto"] }}
46 | {{ job["title"] }}
47 | {% endif %} 48 | Type: {{ job.video_type }}
49 | {% if not job.title_manual %} 50 | Year: ({{ job["year"] }})
51 | {% else %} 52 | Year: 53 | {{ job["year_auto"] }}
54 | Year: {{ job["year"] }}
55 | {% endif %} 56 | Device: {{ job.devpath }}
57 | Status: {{ job.status }}
58 | Title Search 60 | 61 |
62 |
63 |
64 |
65 | Rip Method: {{ job.config.RIPMETHOD }} 66 |
67 | Main Feature: 68 | {{ job.config.MAINFEATURE }}
69 | Min Length: 70 | {{ job.config.MINLENGTH }}
71 | Max Length: 72 | {{ job.config.MAXLENGTH }}
73 | Change 75 | Settings 76 |
77 |
78 | Custom Title 80 |
81 |
82 | 83 |
84 |
85 |
86 | {% endfor %} 87 |
88 |
89 |
90 | 91 | 92 |
93 |
94 |
95 |
96 | 97 |
98 |
99 |
100 | Server Specs 101 |
102 |
    103 |
  • Free Space: {{ freegb }} GB
  • 104 |
105 |
106 |
107 | 113 |
114 |
115 | 116 | 117 | {% endblock %} 118 | {% block js %}{{ super() }}{% endblock %} -------------------------------------------------------------------------------- /arm/ui/templates/jobdetail.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}Logs{% endblock %} 3 | 4 | {% block nav %}{{ super() }}{% endblock %} 5 | 6 | {% block content %} 7 |
8 |
9 | 10 |
11 |
12 |
13 |
14 | 15 |
{{ jobs.title }} - {{ jobs.video_type.capitalize() }} ({{ jobs.year }})
16 | Title Search 17 | Custom Title 18 |
19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 |
FieldValue
job_id{{ jobs.job_id }}
arm_version{{ jobs.arm_version }}
crc_id{{ jobs.crc_id }}
logfile{{ jobs.logfile }}
disc{{ jobs.disc }}
start_time{{ jobs.start_time }}
stop_time{{ jobs.stop_time }}
job_length{{ jobs.job_length }}
status{{ jobs.status }}
video_type{{ jobs.video_type }}
video_type_auto{{ jobs.video_type_auto }}
video_type_manual{{ jobs.video_type_manual }}
title{{ jobs.title }}
title_auto{{ jobs.title_auto }}
title_manual{{ jobs.title_manual }}
year{{ jobs.year }}
year_auto{{ jobs.year_auto }}
year_manual{{ jobs.year_manual }}
imdb_id{{ jobs.imdb_id }}
imdb_id_auto{{ jobs.imdb_id_auto }}
imdb_id_manual{{ jobs.imdb_id_manual }}
poster_url
poster_url_auto
poster_url_manual{{ jobs.poster_url_manual }}
devpath{{ jobs.devpath }}
mountpoint{{ jobs.mountpoint }}
hasnicetitle{{ jobs.hasnicetitle }}
errors{{ jobs.errors }}
disctype{{ jobs.disctype }}
label{{ jobs.label }}
ejected{{ jobs.ejected }}
pid{{ jobs.pid }}
pid hash{{ jobs.pid_hash }}
Config ID {{ jobs.config.CONFIG_ID }}
HB_PRESET_DVD {{ jobs.config.HB_PRESET_DVD }}
HB_ARGS_DVD {{ jobs.config.HB_ARGS_DVD }}
HB_PRESET_BD{{ jobs.config.HB_PRESET_BD }}
HB_ARGS_BD{{ jobs.config.HB_ARGS_BD }}
69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | {% for track in tracks %} 82 | 83 | 84 | {% endfor %} 85 | 86 | 87 |
Track #Length (sec)FPSAspect RatioMain FeatureRipped
{{ track.track_number }}{{ track.length }}{{ track.fps }}{{ track.aspect_ratio }}{{ track.main_feature }}{{ track.ripped }}
88 |
89 | 90 |
91 |
92 | 93 |
94 |
95 |
96 | 97 | {% endblock %} 98 | {% block footer %}{{ super() }}{% endblock %} 99 | {% block js %} 100 | {{ super() }} 101 | 102 | {% endblock %} 103 | -------------------------------------------------------------------------------- /arm/ui/templates/list_titles.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}Logs{% endblock %} 3 | 4 | {% block nav %}{{ super() }}{% endblock %} 5 | 6 | {% block content %} 7 |
8 |
9 | {% for res in results["Search"] %} 10 | {% if res["Type"].lower() != "game" %} 11 |
12 |
13 |
14 |
{{ res["Title"] }} - {{ res["Type"].capitalize() }} ({{ res["Year"] }})
15 |
16 |
17 | {% endif %} 18 | {% endfor %} 19 | 20 |
21 |
22 | 23 | 24 | 25 | {% endblock %} -------------------------------------------------------------------------------- /arm/ui/templates/logfiles.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}Logs{% endblock %} 3 | 4 | {% block nav %}{{ super() }}{% endblock %} 5 | 6 | {% block content %} 7 |
8 | 9 |
10 |
11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | {% for file in files %} 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 34 | 35 | {% endfor %} 36 | 37 |
Log fileCreated TimeSize(kb)View mode
{{ file[0] }}{{ file[2] }}{{ file[3] }}tailarmfulldownload
38 |
39 | 40 | 41 | 44 | 45 | 46 | 47 | 48 | 49 |
42 |
View Modes:
43 |
tail: Output to browser in real time. Similar to 'tail -f'
arm: Static output of just the ARM log entries
full: Static output of all of the log including MakeMKV and HandBrake
download: Download the full log file
50 |
51 |
52 |
53 | {% endblock %} 54 | {% block footer %}{{ super() }}{% endblock %} 55 | {% block js %} 56 | {{ super() }} 57 | 58 | 59 | 68 | {% endblock %} 69 | -------------------------------------------------------------------------------- /arm/ui/templates/logview.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}ARM{% endblock %} 3 | 4 | {% block nav %}{{ super() }}{% endblock %} 5 | 6 | {% block content %} 7 |

Loading log: {{ file }}...

8 |
9 | 10 |
11 |

12 |   
13 |
14 | {% endblock %} 15 | {% block footer %}{{ super() }}{% endblock %} 16 | {% block js %} 17 | {{ super() }} 18 | 43 | {% endblock %} 44 | 45 | -------------------------------------------------------------------------------- /arm/ui/templates/nav.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /arm/ui/templates/showtitle.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}Logs{% endblock %} 3 | 4 | {% block nav %}{{ super() }}{% endblock %} 5 | 6 | {% block content %} 7 |
8 |
9 |
10 |
11 |
12 |
13 |
{{ results["Title"] }} - {{ results["Type"].capitalize() }} ({{ results["Year"] }})
14 |
Full Plot: {{ results["Plot"] }}
15 | 16 |
17 |
18 |
19 |
20 |
21 | 22 | 23 | 24 | {% endblock %} -------------------------------------------------------------------------------- /arm/ui/templates/titlesearch.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}Logs{% endblock %} 3 | 4 | {% block nav %}{{ super() }}{% endblock %} 5 | 6 | {% block content %} 7 |
8 |
9 |
10 |
11 |
12 | {{ form.hidden_tag() }} 13 |

14 | {{ form.title.label }}
15 | {{ form.title(size=32) }} 16 |

17 |

18 | {{ form.year.label }}
19 | {{ form.year(size=4) }} 20 |

21 |

{{ form.submit() }}

22 |
23 |
24 |
25 |
26 |
27 | 28 | 29 | 30 | {% endblock %} -------------------------------------------------------------------------------- /arm/ui/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | from time import strftime, localtime 3 | import urllib 4 | import json 5 | import re 6 | #import logging 7 | # import omdb 8 | from arm.config.config import cfg 9 | 10 | 11 | def get_info(directory): 12 | file_list = [] 13 | for i in os.listdir(directory): 14 | if os.path.isfile(os.path.join(directory, i)): 15 | a = os.stat(os.path.join(directory, i)) 16 | fsize = os.path.getsize(os.path.join(directory, i)) 17 | fsize = round((fsize / 1024), 1) 18 | fsize = "{0:,.1f}".format(fsize) 19 | create_time = strftime('%Y-%m-%d %H:%M:%S', localtime(a.st_ctime)) 20 | access_time = strftime('%Y-%m-%d %H:%M:%S', localtime(a.st_atime)) 21 | file_list.append([i, access_time, create_time, fsize]) # [file,most_recent_access,created] 22 | return file_list 23 | 24 | def clean_for_filename(string): 25 | """ Cleans up string for use in filename """ 26 | string = re.sub('\[(.*?)\]', '', string) 27 | string = re.sub('\s+', ' ', string) 28 | string = string.replace(' : ', ' - ') 29 | string = string.replace(':', '-') 30 | string = string.replace('&', 'and') 31 | string = string.replace("\\", " - ") 32 | string = string.strip() 33 | #return re.sub('[^\w\-_\.\(\) ]', '', string) 34 | return string 35 | 36 | def getsize(path): 37 | st = os.statvfs(path) 38 | free = (st.f_bavail * st.f_frsize) 39 | freegb = free/1073741824 40 | return freegb 41 | 42 | 43 | def convert_log(logfile): 44 | logpath = cfg['LOGPATH'] 45 | fullpath = os.path.join(logpath, logfile) 46 | 47 | output_log = os.path.join('static/tmp/', logfile) 48 | 49 | with open(fullpath) as infile, open(output_log, 'w') as outfile: 50 | txt = infile.read() 51 | txt = txt.replace('\n', '\r\n') 52 | outfile.write(txt) 53 | return(output_log) 54 | 55 | 56 | def call_omdb_api(title=None, year=None, imdbID=None, plot="short"): 57 | """ Queries OMDbapi.org for title information and parses if it's a movie 58 | or a tv series """ 59 | omdb_api_key = cfg['OMDB_API_KEY'] 60 | 61 | if imdbID: 62 | strurl = "http://www.omdbapi.com/?i={1}&plot={2}&r=json&apikey={0}".format(omdb_api_key, imdbID, plot) 63 | elif title: 64 | # try: 65 | title = urllib.parse.quote(title) 66 | year = urllib.parse.quote(year) 67 | strurl = "http://www.omdbapi.com/?s={1}&y={2}&plot={3}&r=json&apikey={0}".format(omdb_api_key, title, year, plot) 68 | else: 69 | print("no params") 70 | return(None) 71 | 72 | # strurl = urllib.parse.quote(strurl) 73 | #logging.info("OMDB string query"+str(strurl)) 74 | print(strurl) 75 | title_info_json = urllib.request.urlopen(strurl).read() 76 | title_info = json.loads(title_info_json.decode()) 77 | print(title_info) 78 | #logging.info("Response from Title Info command"+str(title_info)) 79 | # d = {'year': '1977'} 80 | # dvd_info = omdb.get(title=title, year=year) 81 | print("call was successful") 82 | return(title_info) 83 | # except Exception: 84 | # print("call failed") 85 | # return(None) 86 | -------------------------------------------------------------------------------- /docs/README-OMDBAPI.txt: -------------------------------------------------------------------------------- 1 | README-OMDBAPI 2 | 3 | BACKGROUND: 4 | 5 | The ARM uses a call to the omdbapi (Open Movie Database API) web site to determine whether a video disc 6 | is a movie or a TV series. It also uses the database to determine the correct year for a movie, since 7 | older movies frequently report the date of issue on DVD as opposed to the date of the actual movie. An 8 | example of this is "The Enforcer" which was originally released in 1976, but released on DVD in 2008. 9 | 10 | Give the two primary functions OMDBAPI is used for in ARM, it's fair to say that having it not work is 11 | nothing more than a bit of a headache in re-titling and categorizing your final product. Years may be 12 | wrong, and as the ARM is currently designed, your movies will be placed in the Unknown directory as 13 | opposed to the "Movies" directory. I can also see plenty of future uses for this functionality, so it 14 | seemed prudent to repair it and make it work again. And I like shell scripts, and I wanted to 15 | contribute something that might truly be useful to an already amazing project. 16 | 17 | HISTORY: 18 | 19 | Full writeup is here: https://www.patreon.com/posts/api-is-going-10743518 20 | 21 | As of May 5, 2017, the developer/maintainer of the omdbapi.com website was forced to take the API 22 | project private due to unforeseen request charges. Specifically, it appears (to me) that they were 23 | generating significantly more requests to the API than they anticipated, and the costs were either 24 | about to, or did, catch up with them. In short, they can no longer provide the API access for free 25 | in the current term. I am under the impression that they may open the API back up for casual and 26 | limited use once the financial bleeding stops. In the meantime, a patronage program is available for 27 | $1.00/month on a 12-month commitment which allows for 100,000 queries per day. It is this threshold 28 | that leaves me with the hope that the API may go public again on a limited basis at some point in 29 | the future, and it is upon this hope that I went ahead and made the minor code updates to support 30 | the requirement for an API key. 31 | 32 | HOW TO GET YOUR OWN OMDBAPI KEY: 33 | 34 | Start by visiting http://www.omdbapi.com/ As of this writing (2017-06-08), you will find a 35 | link labeled "Become a Patron." That link will take you to the Patreon site, where you can sign up. 36 | Select the $1.00/month level and complete the process (or donate more if you feel so compelled -- it 37 | seems to be a very useful project.) Once you have completed your donation, go back to the omdbapi 38 | home page, look to the top menu bar, click on "Patrons --> API Key." Enter the email address under 39 | which you registered, and your API key will be emailed to you. Reports are that it may take a few hours 40 | but mine came within a minute or two. 41 | 42 | TO USE YOUR NEW OMDBAPI KEY: 43 | 44 | Open the config file (usually /opt/arm/config) with your favorite text editor and navigate to the 45 | section 'OMDB_API_KEY=""', put your new API key between the double quotes, and save the file. 46 | That's it! The API key will be propagated throughout the ARM tool as appropriate, and any future 47 | links or tools using the API will be made aware. 48 | 49 | DEVELOPER'S NOTES: 50 | 51 | Since not everyone interested in testing this update prior to pulling it into the master codebase 52 | may want to purchase an API key, and since each API key gives access to 100,000 queries per day, 53 | I am willing to share my key for testing purposes provided you don't go over, oh let's say, 25,000 54 | queries in a day (LOL). Contact me at my primary email cbunt1@yahoo.com or my github account cbunt1. 55 | 56 | A special thank you to Aaron Helton (aargonian) for your help with the Python update...I'm a 57 | Shell scripter, not a Python scripter...:-) -------------------------------------------------------------------------------- /docs/arm.yaml.sample: -------------------------------------------------------------------------------- 1 | # ARM (Automatic Ripping Machine) config file 2 | 3 | ################# 4 | ## ARM Options ## 5 | ################# 6 | 7 | # Distinguish UDF video discs from UDF data discs. Requires mounting disc so adds a few seconds to the identify script. 8 | ARM_CHECK_UDF: true 9 | 10 | # When enabled if the disc is a DVD use dvdid to calculate a crc64 and query Windows Media Meta Services for the Movie Title. 11 | # For BluRays attempts to extract the title from an XML file on the disc 12 | GET_VIDEO_TITLE: true 13 | 14 | # Skip transcoding if you want the original MakeMKV files as your final output 15 | # This will produce the highest quality videos (and use the most storage) 16 | # Note: RIPMETHOD must be set to "mkv" for this feature to work 17 | # Note: The largest file will be considered to be the "main feature" but there are cases when this is not true 18 | # to avoid losing a desired track, use this feature with an EXTRAS_SUB value that is not "None" 19 | SKIP_TRANSCODE: false 20 | 21 | # Video type identification. Options are "auto", "series", "movie". 22 | # If "auto" then ARM will get the video type when quering the movie webservice. This is default. 23 | # If the disc is not clearly a movie or series, or if ARM is having difficulty getting the right video type 24 | # you can override the automatic identification with "series" or "movie" 25 | VIDEOTYPE: "auto" 26 | 27 | # Minimum length of track for ARM rip (in seconds) 28 | MINLENGTH: "600" 29 | 30 | # Maximum length of track for ARM rip (in seconds) 31 | # Use "99999" to indicate no maximum length 32 | MAXLENGTH: "99999" 33 | 34 | # Wait for manual identification 35 | MANUAL_WAIT: true 36 | 37 | # Wait tie for manual identification (in seconds) 38 | MANUAL_WAIT_TIME: 600 39 | 40 | # Number of Transcodes that runs at the same time. 41 | # Certain Video cards are limited to how many encodes they can run at the same time. 42 | # Also useful for deminishing returns on CPU based encodes. 43 | MAX_CONCURRENT_TRANSCODES: 3 44 | 45 | # Additional parameters for dd. e.g. "conv=noerror,sync" for ignoring read errors 46 | DATA_RIP_PARAMETERS: "" 47 | 48 | ##################### 49 | ## Directory setup ## 50 | ##################### 51 | 52 | # Final directory of transcoded files 53 | # Ripped and transcoded files end up here 54 | ARMPATH: "/home/arm/media/completed/" 55 | 56 | # Path to raw MakeMKV directory 57 | # Destination for MakeMKV and source for HandBrake 58 | RAWPATH: "/home/arm/media/raw/" 59 | 60 | # Path to final media directory 61 | # Destination for final file. Only used for movies that are positively identified 62 | MEDIA_DIR: "/home/arm/media/movies/" 63 | 64 | # Movie subdirectory name for "extras" 65 | # Valid names are dependent on your media server 66 | # For Emby see https://github.com/MediaBrowser/Wiki/wiki/Movie%20naming#user-content-movie-extras 67 | # For Plex see https://support.plex.tv/hc/en-us/articles/200220677 68 | EXTRAS_SUB: "extras" 69 | 70 | # Path to installation of ARM 71 | INSTALLPATH: "/opt/arm/" 72 | 73 | # Path to directory to hold log files 74 | # Make sure to include trailing / 75 | LOGPATH: "/home/arm/logs/" 76 | 77 | # Log level. DEBUG, INFO, WARNING, ERROR, CRITICAL 78 | # The default is INFO 79 | LOGLEVEL: "INFO" 80 | 81 | # How long to let log files live before deleting (in days) 82 | LOGLIFE: 1 83 | 84 | # Path to ARM database file 85 | DBFILE: "/home/arm/db/arm.db" 86 | 87 | ################## 88 | ## Web Server ## 89 | ################## 90 | 91 | # IP address of web server (this machine) 92 | # Use x.x.x.x to autodetect the IP address to use 93 | WEBSERVER_IP: x.x.x.x 94 | 95 | # Port for web server 96 | WEBSERVER_PORT: 8080 97 | 98 | ######################## 99 | ## File Permissions ## 100 | ######################## 101 | 102 | # Enabling this seting will allow you to adjust the default file permissions of the outputted files 103 | # The default value is set to 777 for read/write/execute for all users, but can be changed below using the "CHMOD_VALUE" setting 104 | # This setting is helpful when storing the data locally on the system 105 | SET_MEDIA_PERMISSIONS: false 106 | CHMOD_VALUE: 777 107 | SET_MEDIA_OWNER: false 108 | CHOWN_USER: 109 | CHOWN_GROUP: 110 | 111 | ######################## 112 | ## MakeMKV Parameters ## 113 | ######################## 114 | 115 | # Method of MakeMKV to use for Blu Ray discs. Options are "mkv" or "backup". 116 | # backup decrypts the dvd and then copies it to the hard drive. This allows HandBrake to apply some of it's 117 | # analytical abilities such as the main-feature identification. This method seems to offer success on bluray 118 | # discs that fail in "mkv" mode. *** NOTE: MakeMKV only supports the backup method on BluRay discs. ARM does 119 | # not use MakeMKV for DVDs. 120 | RIPMETHOD: "backup" 121 | 122 | # MakeMKV Arguments 123 | # MakeMKV Profile used for controlling Audio Track Selection. 124 | # This is the default profile MakeMKV uses for Audio track selection. Updating this file or changing it is considered 125 | # to be advanced usage of MakeMKV. But this will allow users to alternatively tell makemkv to select HD audio tracks and etc. 126 | # MKV_ARGS: "--profile=/opt/arm/default.mmcp.xml" 127 | MKV_ARGS: "" 128 | 129 | # Remove the files created by MakeMKV after processing is complete 130 | DELRAWFILES: true 131 | 132 | # Automatically download hashed_keys. This is for UHD ripping only. You mush have a UHD friendly drive for this to work. 133 | # Check out this post: https://www.makemkv.com/forum2/viewtopic.php?f=12&t=16883&sid=93f1db30f6ceb99b494f3f37cd723841 before 134 | # changing this to True 135 | HASHEDKEYS: False 136 | 137 | ########################## 138 | ## HandBrake Parameters ## 139 | ########################## 140 | 141 | # Handbrake preset profile for DVDs 142 | # Execute "HandBrakeCLI -z" to see a list of all presets 143 | HB_PRESET_DVD: "HQ 720p30 Surround" 144 | 145 | # Handbrake preset profile for Blurays 146 | # Execute "HandBrakeCLI -z" to see a list of all presets 147 | HB_PRESET_BD: "HQ 1080p30 Surround" 148 | 149 | # Extension of the final video file 150 | DEST_EXT: mkv 151 | 152 | # Handbrake binary to call 153 | HANDBRAKE_CLI: HandBrakeCLI 154 | 155 | # Have HandBrake transcode the main feature only. BluRay discs must have RIPMETHOD="backup" for this to work. 156 | # If MAINFEATURE is true, blurays will be backed up to the HD and then HandBrake will go to work on the backed up 157 | # files. 158 | # This will require libdvdcss2 be installed. 159 | # NOTE: For the most part, HandBrake correctly identifies the main feature on movie DVD's, although it is not perfect. 160 | # However, it does not handle tv shows well at all. This setting is only used when the video is identified as a movie. 161 | MAINFEATURE: false 162 | 163 | # Additional HandBrake arguments for DVDs. 164 | HB_ARGS_DVD: "--subtitle scan -F" 165 | 166 | # Additional Handbrake arguments for Bluray Discs. 167 | HB_ARGS_BD: "--subtitle scan -F --subtitle-burned --audio-lang-list eng --all-audio" 168 | 169 | ##################### 170 | ## Emby Parameters ## 171 | ##################### 172 | 173 | # Parameters to enable automatic library scan in Emby. This will trigger only if MainFeature is true above. 174 | 175 | # Scan emby library after succesful placement of mainfeature (see above) 176 | EMBY_REFRESH: false 177 | 178 | # Server parameters 179 | # Server can be ip address or domain name 180 | EMBY_SERVER: "" 181 | EMBY_PORT: "8096" 182 | 183 | # Emby authentication fluff parameters. These can be anything. 184 | EMBY_CLIENT: "ARM" 185 | EMBY_DEVICE: "ARM" 186 | EMBY_DEVICEID: "ARM" 187 | 188 | # Emby authentication parameters. These are parameters that must be set to a current user in Emby. 189 | EMBY_USERNAME: "" 190 | 191 | # EMBY_USERID is the user ID associated with the username above. You can find this by going to the following address on your emby server 192 | # :/Users/Public and getting the ID value for the username above. 193 | EMBY_USERID: "" 194 | 195 | # This is the SHA1 encrypted password for the username above. You can generate the SHA1 hash of your password by executing the following at 196 | # the command line: 197 | # echo -n your-password | sha1sum | awk '{print $1}' 198 | # or using an online generator like the one located at http://www.sha1-online.com/ 199 | EMBY_PASSWORD: "" 200 | 201 | # Emby API key. This can be found (generated) by going to Advanced/Security in the 202 | # Emby dashboard 203 | EMBY_API_KEY: "" 204 | 205 | ############################# 206 | ## Notification Parameters ## 207 | ############################# 208 | 209 | # Notify after Rip? 210 | NOTIFY_RIP: true 211 | 212 | # Notify after transcode? 213 | NOTIFY_TRANSCODE: true 214 | 215 | # Pushbullet API Key 216 | # Leave empty to disable Pushbullet notifications 217 | PB_KEY: "" 218 | 219 | # IFTTT API KEY 220 | # Leave empty to disable IFTTT notifications 221 | IFTTT_KEY: "" 222 | 223 | # IFTTT Event Name 224 | IFTTT_EVENT: "arm_event" 225 | 226 | # Pushover API User and Application Key 227 | # Leave User key empty to disable Pushover notifications 228 | PO_USER_KEY: "" 229 | PO_APP_KEY: "" 230 | 231 | # OMDB_API_KEY 232 | # omdbapi.com API Key 233 | # See README-OMDBAPI for background and info 234 | # This is the API key for omdbapi.com queries. 235 | # More info at http://omdbapi.com/ 236 | OMDB_API_KEY: "" 237 | 238 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pycurl>=7.43.0 2 | pydvdid>=1.0 3 | requests>=2.9.1 4 | urllib3>=1.13.1 5 | xmltodict>=0.10.2 6 | pushbullet.py>=0.11.0 7 | pyfttt>=0.3.2 8 | python-pushover>=0.3 9 | pyudev>=0.21.0 10 | pyyaml>=3.12 11 | flake8>=2.5.0 12 | flask>=1.0.2 13 | flask-WTF>=0.14.2 14 | flask-sqlalchemy>=2.3.2 15 | flask-migrate>=2.2.1 16 | omdb>=0.10.0 17 | psutil>=5.4.6 18 | robobrowser>=0.5.3 19 | tinydownload>=0.1.0 20 | netifaces>=0.10.9 21 | 22 | -------------------------------------------------------------------------------- /scripts/arm_wrapper.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | DEVNAME=$1 4 | 5 | sleep 5 # allow the system enough time to load disc information such as title 6 | 7 | echo "[ARM] Starting ARM on ${DEVNAME}" | logger -t ARM -s 8 | /bin/su -l -c "echo /usr/bin/python3 /opt/arm/arm/ripper/main.py -d ${DEVNAME} | at now" -s /bin/bash arm 9 | 10 | # Check to see if the admin page is running, if not, start it 11 | if pgrep -f "runui.py" > /dev/null 12 | then 13 | echo "[ARM] ARM Webgui running. " | logger -t ARM -s 14 | else 15 | echo "[ARM] ARM Webgui not running; starting it "| logger -t ARM -s 16 | /bin/su -l -c "/usr/bin/python3 /opt/arm/arm/runui.py " -s /bin/bash arm 17 | # Try the below line if you want it to log to your log file of choice 18 | #/bin/su -l -c "/usr/bin/python3 /opt/arm/arm/runui.py &> [pathtologDir]/WebUI.log" -s /bin/bash arm 19 | fi 20 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = .git, */migrations/* 3 | max-line-length=160 4 | -------------------------------------------------------------------------------- /setup/.abcde.conf: -------------------------------------------------------------------------------- 1 | # -----------------$HOME/.abcde.conf----------------- # 2 | # 3 | # A sample configuration file to convert music cds to 4 | # FLAC using abcde version 2.7.2 5 | # 6 | # http://andrews-corner.org/abcde.html 7 | # -------------------------------------------------- # 8 | # 9 | # This script was copied from the above site with the following modification: 10 | # 11 | # Set to non-interactive 12 | # CDDMETHOD=cddb since it's more reliable (for my CDs) 13 | # Enabled getalbumart 14 | # Change output format from artist-album to artist/album 15 | 16 | INTERACTIVE=n 17 | 18 | # Encode tracks immediately after reading. Saves disk space, gives 19 | # better reading of 'scratchy' disks and better troubleshooting of 20 | # encoding process but slows the operation of abcde quite a bit: 21 | LOWDISK=y 22 | 23 | # Specify the method to use to retrieve the track information, 24 | # the alternative is to specify 'cddb': 25 | CDDBMETHOD=cddb 26 | 27 | # Make a local cache of cddb entries and then volunteer to use 28 | # these entries when and if they match the cd: 29 | CDDBCOPYLOCAL="y" 30 | CDDBLOCALDIR="$HOME/.cddb" 31 | CDDBLOCALRECURSIVE="y" 32 | CDDBUSELOCAL="y" 33 | 34 | # Specify the encoder to use for FLAC. In this case 35 | # flac is the only choice. 36 | FLACENCODERSYNTAX=flac 37 | 38 | # Specify the path to the selected encoder. In most cases the encoder 39 | # should be in your $PATH as I illustrate below, otherwise you will 40 | # need to specify the full path. For example: /usr/bin/flac 41 | FLAC=flac 42 | 43 | # Specify your required encoding options here. Multiple options can 44 | # be selected as '--best --another-option' etc. 45 | # Overall bitrate is about 880 kbs/s with level 8. 46 | FLACOPTS='-s -e -V -8' 47 | 48 | # Output type for FLAC. 49 | OUTPUTTYPE="flac" 50 | 51 | # The cd ripping program to use. There are a few choices here: cdda2wav, 52 | # dagrab, cddafs (Mac OS X only) and flac. New to abcde 2.7 is 'libcdio'. 53 | CDROMREADERSYNTAX=cdparanoia 54 | 55 | # Give the location of the ripping program and pass any extra options, 56 | # if using libcdio set 'CD_PARANOIA=cd-paranoia'. 57 | CDPARANOIA=cdparanoia 58 | CDPARANOIAOPTS="--never-skip=40" 59 | 60 | # Give the location of the CD identification program: 61 | CDDISCID=cd-discid 62 | 63 | # Give the base location here for the encoded music files. 64 | OUTPUTDIR="/home/arm/Music" 65 | 66 | # The default actions that abcde will take. 67 | ACTIONS=cddb,playlist,getalbumart,read,encode,replaygain,tag,move,clean 68 | 69 | # Decide here how you want the tracks labelled for a standard 'single-artist', 70 | # multi-track encode and also for a multi-track, 'various-artist' encode: 71 | OUTPUTFORMAT='${OUTPUT}/${ARTISTFILE}/${ALBUMFILE}/${TRACKNUM}.${TRACKFILE}' 72 | VAOUTPUTFORMAT='${OUTPUT}/Various Artists/${ALBUMFILE}/${TRACKNUM}.${ARTISTFILE}-${TRACKFILE}' 73 | 74 | # Decide here how you want the tracks labelled for a standard 'single-artist', 75 | # single-track encode and also for a single-track 'various-artist' encode. 76 | # (Create a single-track encode with 'abcde -1' from the commandline.) 77 | ONETRACKOUTPUTFORMAT='${OUTPUT}/${ARTISTFILE}/${ALBUMFILE}/${ALBUMFILE}' 78 | VAONETRACKOUTPUTFORMAT='${OUTPUT}/Various Artists/${ALBUMFILE}/${ALBUMFILE}' 79 | 80 | # Create playlists for single and various-artist encodes. I would suggest 81 | # commenting these out for single-track encoding. 82 | PLAYLISTFORMAT='${OUTPUT}/${ARTISTFILE}/${ALBUMFILE}/${ALBUMFILE}.m3u' 83 | VAPLAYLISTFORMAT='${OUTPUT}/Various Artists/${ALBUMFILE}/${ALBUMFILE}.m3u' 84 | 85 | # This function takes out dots preceding the album name, and removes a grab 86 | # bag of illegal characters. It allows spaces, if you do not wish spaces add 87 | # in -e 's/ /_/g' after the first sed command. 88 | mungefilename () 89 | { 90 | echo "$@" | sed -e 's/^\.*//' | tr -d ":><|*/\"'?[:cntrl:]" 91 | } 92 | 93 | # What extra options? 94 | MAXPROCS=2 # Run a few encoders simultaneously 95 | PADTRACKS=y # Makes tracks 01 02 not 1 2 96 | EXTRAVERBOSE=2 # Useful for debugging 97 | COMMENT='abcde version 2.8.1' # Place a comment... 98 | EJECTCD=y # Please eject cd when finished :-) 99 | -------------------------------------------------------------------------------- /setup/51-automedia.rules: -------------------------------------------------------------------------------- 1 | # ID_CDROM_MEDIA_BD = Bluray 2 | # ID_CDROM_MEDIA_DVD = DVD 3 | # ID_CDROM_MEDIA_CD = CD 4 | # ACTION=="change", SUBSYSTEM=="block", RUN+="/opt/arm/arm_wrapper.sh" 5 | # ACTION=="change", SUBSYSTEM=="block", RUN+="/opt/arm/arm/main.py -l '%E{ID_FS_LABEL}' -d '%E{DEVNAME}'" 6 | # ACTION=="change", SUBSYSTEM=="block", TAG+="systemd", KERNEL=="sr[0-9]*|vdisk*|xvd*", ENV{DEVTYPE}=="disk", RUN+="/bin/systemctl start arm@%k.service" 7 | ACTION=="change", SUBSYSTEM=="block", RUN+="/opt/arm/scripts/arm_wrapper.sh %k" 8 | 9 | 10 | --------------------------------------------------------------------------------