├── classes ├── __pycache__ │ ├── config.cpython-36.pyc │ ├── models.cpython-36.pyc │ ├── __init__.cpython-36.pyc │ ├── helpers.cpython-36.pyc │ ├── recording.cpython-36.pyc │ └── postprocessing.cpython-36.pyc ├── __init__.py ├── postprocessing.py ├── helpers.py ├── models.py ├── recording.py └── config.py ├── webapp ├── __pycache__ │ ├── views.cpython-36.pyc │ └── __init__.cpython-36.pyc ├── static │ ├── css │ │ ├── README.md │ │ └── style.css │ └── js │ │ ├── form.js │ │ └── sorter.js ├── __init__.py ├── assets │ └── style.scss ├── templates │ ├── login.html │ ├── start_page.html │ ├── layout.html │ ├── wanted.html │ └── config.html └── views.py ├── requirements.txt ├── scripts ├── test_postProcessing.py ├── symlink.py ├── postProcessing.py └── merge.py ├── wanted.json ├── convert.py ├── certs ├── snakeoil.cert └── snakeoil.key ├── mfcrecorder.py ├── add.py ├── config.conf └── README.md /classes/__pycache__/config.cpython-36.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Damianonymous/MFCRecorder/HEAD/classes/__pycache__/config.cpython-36.pyc -------------------------------------------------------------------------------- /classes/__pycache__/models.cpython-36.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Damianonymous/MFCRecorder/HEAD/classes/__pycache__/models.cpython-36.pyc -------------------------------------------------------------------------------- /webapp/__pycache__/views.cpython-36.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Damianonymous/MFCRecorder/HEAD/webapp/__pycache__/views.cpython-36.pyc -------------------------------------------------------------------------------- /classes/__pycache__/__init__.cpython-36.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Damianonymous/MFCRecorder/HEAD/classes/__pycache__/__init__.cpython-36.pyc -------------------------------------------------------------------------------- /classes/__pycache__/helpers.cpython-36.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Damianonymous/MFCRecorder/HEAD/classes/__pycache__/helpers.cpython-36.pyc -------------------------------------------------------------------------------- /webapp/__pycache__/__init__.cpython-36.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Damianonymous/MFCRecorder/HEAD/webapp/__pycache__/__init__.cpython-36.pyc -------------------------------------------------------------------------------- /classes/__pycache__/recording.cpython-36.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Damianonymous/MFCRecorder/HEAD/classes/__pycache__/recording.cpython-36.pyc -------------------------------------------------------------------------------- /webapp/static/css/README.md: -------------------------------------------------------------------------------- 1 | This folder will contain auto-generated .css files. Those will only be generated if this directory exists beforehand though. -------------------------------------------------------------------------------- /classes/__init__.py: -------------------------------------------------------------------------------- 1 | import classes.config 2 | import classes.models 3 | import classes.postprocessing 4 | import classes.recording 5 | import classes.helpers -------------------------------------------------------------------------------- /classes/__pycache__/postprocessing.cpython-36.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Damianonymous/MFCRecorder/HEAD/classes/__pycache__/postprocessing.cpython-36.pyc -------------------------------------------------------------------------------- /webapp/static/css/style.css: -------------------------------------------------------------------------------- 1 | h1 { 2 | text-transform: uppercase; } 3 | 4 | body { 5 | padding-top: 60px; } 6 | 7 | .popover { 8 | max-width: 45%; } 9 | -------------------------------------------------------------------------------- /webapp/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | from flask_scss import Scss 3 | 4 | app = Flask(__name__) 5 | app.secret_key = 'set_this_to_something_secret' #TODO 6 | scss = Scss(app) 7 | scss.update_scss() 8 | 9 | import webapp.views -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | blessings 2 | 3 | 4 | requests 5 | 6 | 7 | livestreamer 8 | 9 | 10 | flask 11 | 12 | 13 | flask-scss 14 | 15 | 16 | colorama 17 | 18 | 19 | gevent 20 | 21 | 22 | git+https://github.com/Damianonymous/mfcauto.py@master 23 | -------------------------------------------------------------------------------- /scripts/test_postProcessing.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import subprocess 3 | 4 | #change all strings to match your test environment. DO NOT TEST ON REAL RECORDINGS FOLDER 5 | postProcessingCommand = "python3 /path/to/postProcessing.py" 6 | subprocess.call(postProcessingCommand.split() + ["path/to/testVideo.mp4", "testVideo.mp4", "/path/to/", "someModel", "123456"]) 7 | -------------------------------------------------------------------------------- /webapp/static/js/form.js: -------------------------------------------------------------------------------- 1 | window.onload = function() { 2 | for (element of document.getElementById("form").getElementsByTagName("input")) { 3 | element.addEventListener("change", function(event) { submitForm(); } ); 4 | } 5 | }; 6 | 7 | function submitForm() { 8 | console.log("submission"); 9 | document.getElementById("form").submit(); 10 | } -------------------------------------------------------------------------------- /webapp/assets/style.scss: -------------------------------------------------------------------------------- 1 | h1{ 2 | text-transform: uppercase; 3 | } 4 | 5 | %absolute-centered{ 6 | position: absolute; 7 | left: 0; 8 | right: 0; 9 | margin-left: auto; 10 | margin-right: auto; 11 | } 12 | 13 | /* Bootstrap Tweaks*/ 14 | 15 | body { 16 | padding-top: 60px; 17 | } 18 | 19 | .popover{ 20 | max-width: 45%; 21 | } -------------------------------------------------------------------------------- /wanted.json: -------------------------------------------------------------------------------- 1 | { 2 | "4585086": { 3 | "enabled": true, 4 | "list_mode": 0, 5 | "custom_name": "CrazyM_", 6 | "comment": "", 7 | "min_viewers": 0, 8 | "stop_viewers": 0, 9 | "priority": 0 10 | }, 11 | "19647139": { 12 | "enabled": true, 13 | "list_mode": 0, 14 | "custom_name": "Kati3kat", 15 | "comment": "", 16 | "min_viewers": 0, 17 | "stop_viewers": 0, 18 | "priority": 0 19 | }, 20 | "21146523": { 21 | "enabled": true, 22 | "list_mode": 0, 23 | "custom_name": "AlicexMaia", 24 | "comment": "", 25 | "min_viewers": 0, 26 | "stop_viewers": 0, 27 | "priority": 0 28 | } 29 | } -------------------------------------------------------------------------------- /classes/postprocessing.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import queue 3 | import subprocess 4 | import os 5 | 6 | def init_workers(count): 7 | '''create the specified amount of workers''' 8 | [PostprocessingThread().start() for i in range(0, count)] 9 | 10 | def put_item(command, uid, name, path): 11 | '''add an item to the postprocessing queue''' 12 | directory, filename = os.path.split(path) 13 | PostprocessingThread.work.put(command.split() + [path, filename, directory, name, uid]) 14 | 15 | class PostprocessingThread(threading.Thread): 16 | '''a thread to perform post processing work''' 17 | work = queue.Queue() 18 | 19 | def run(self): 20 | while True: 21 | item = self.work.get(block=True) 22 | subprocess.call(item) 23 | self.work.task_done() 24 | -------------------------------------------------------------------------------- /scripts/symlink.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | #created by sKanoodle on GitHub 3 | import os, sys 4 | 5 | models = {123: "name1", 6 | 345: "name2"} 7 | 8 | encodedfilesdir = "/home/user/MFC/encoded" 9 | symlinkdir = "/home/user/MFC/models" 10 | wantedfile = "/home/user/MFC/wanted.txt" 11 | 12 | if not os.path.exists(symlinkdir): 13 | os.makedirs(symlinkdir) 14 | 15 | for file in os.listdir(symlinkdir): 16 | if os.path.islink(os.path.join(symlinkdir, file)): 17 | os.remove(os.path.join(symlinkdir, file)) 18 | 19 | #create symlinks to find recordings by model name 20 | for id, name in models.items(): 21 | os.symlink(os.path.join(encodedfilesdir, str(id)), os.path.join(symlinkdir, name)) 22 | 23 | #create wanted file 24 | wanted = open(wantedfile, "w") 25 | for id in models: 26 | wanted.write("{0}\n".format(id)) 27 | -------------------------------------------------------------------------------- /convert.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import asyncio 3 | import sys 4 | import os 5 | from mfcauto import Client 6 | import classes.config 7 | 8 | conf = classes.config.Config(os.path.join(sys.path[0], 'config.conf')) 9 | async def run(loop): 10 | client = Client(loop) 11 | await client.connect(False) 12 | with open(os.path.join(sys.path[0], sys.argv[1])) as source: 13 | for id in (int(line) for line in source.readlines()): 14 | if id not in conf.filter.wanted.dict.keys(): 15 | msg = await client.query_user(id) 16 | if msg != None: 17 | conf.filter.wanted.add(uid=id, custom_name=msg['nm']) 18 | print("{} with uid {} added to list".format(msg['nm'], msg['uid'])) 19 | 20 | loop = asyncio.get_event_loop() 21 | loop.run_until_complete(run(loop)) 22 | -------------------------------------------------------------------------------- /webapp/templates/login.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | {% block head %} 3 | {{ super() }} 4 | login 5 | {% endblock %} 6 | {% block body %} 7 |
8 |
9 |
10 |
11 | 12 | 13 |
14 |
15 | 16 | 17 |
18 | {% if error %}{% endif %} 19 | 20 |
21 |
22 |
23 | {% endblock %} -------------------------------------------------------------------------------- /scripts/postProcessing.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os, sys 3 | 4 | #targetDir because when used with post processing script the MFCRecorder doesnt move files 5 | targetDir = '/path/to/targetdir/' 6 | #command for ffmpeg {0} is source, {1} is target 7 | ffmpegCommand = 'ffmpeg -y -v error -i {0} -bsf:a aac_adtstoasc -codec copy {1}' 8 | #extension for target 9 | extension = '.mp4' 10 | 11 | #retrieving arguments that are passed from MFCRecorder 12 | #always crosscheck with readme 13 | fullPath = sys.argv[1] 14 | filename = sys.argv[2] 15 | directory = sys.argv[3] 16 | name = sys.argv[4] 17 | uid = sys.argv[5] 18 | 19 | #creating full target path, do whatever you want 20 | file, ext = os.path.splitext(filename) 21 | targetPath = os.path.join(targetDir, uid, file + extension) 22 | 23 | #create dir if it doesnt exist 24 | if not os.path.exists(os.path.dirname(targetPath)): 25 | os.makedirs(os.path.dirname(targetPath)) 26 | 27 | #final call for encoding 28 | os.system(ffmpegCommand.format(fullPath, targetPath)) 29 | -------------------------------------------------------------------------------- /certs/snakeoil.cert: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIC+zCCAeOgAwIBAgIJAIU5EoTQrFBLMA0GCSqGSIb3DQEBBQUAMBQxEjAQBgNV 3 | BAMMCWxvY2FsaG9zdDAeFw0xNzEwMTMwOTQzNDVaFw0yNzEwMTEwOTQzNDVaMBQx 4 | EjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC 5 | ggEBAMAL33iOz7RBPzzM74D/YAcEt9vuECqBvpzt+EH3AtAhd8NmI9wrVgT/GFbh 6 | FB8986t8hexgf4CcWYh1IJv7SEsMuo88H8Pqa5CbaF3BWX2AnX9+uk/CoWBQtyBe 7 | qithzNDE2S0uo+qK34VzqofLEb68WXRO3Uc6oTC7tQuuEdgxBT5ZoSYqy/Fb3ZNs 8 | GAiDvx6ji/2fXPAQqPOv+M9X57lQz/1jnKawxN/EvxImkFHAqb7//hijE0hsQ4aq 9 | xOrOLXIdVPvTxtmKiHGneFr3LYg/4STKe2iI/AP5bxH8Z7hNH1YpTDDAsmWBXhAw 10 | eTrwMrt4t+EzUHMZUXVmHGWoEnECAwEAAaNQME4wHQYDVR0OBBYEFOPIdaEYPduA 11 | O+vgCkiApJLsIg22MB8GA1UdIwQYMBaAFOPIdaEYPduAO+vgCkiApJLsIg22MAwG 12 | A1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAHKO73CQqu+3j+1+5jGJ+3H8 13 | xoF6JuXNuwBOk5SIbUwfX8FzzC5E6f5XZ9oD9kKVhpWZp7gcdaed8dXxcR8umP1b 14 | Zaitmg07aZafGfJ9Z0X61E7F8Lku54pdMh+FBFjXa7mZ77hnc4Ui7I17LQSTVmeB 15 | jztPyi22kCc8PPeO4f2eRtWSz8xBmBMb/4tgcYBVc6jFAb0Z7eH5dYp0UJVcEQUD 16 | PMzAvmhwLinMWl9w1DZFE7Mc5i7hhEJR+GjcVY4t9sXhcWV72jIRoVgoVPwu2R4H 17 | Y8qd5K1hqjJUwX6vSEePZFNTKyRUit0xDq96Gjf43saCrfZez+7Cogj/HbjnLg0= 18 | -----END CERTIFICATE----- 19 | -------------------------------------------------------------------------------- /mfcrecorder.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import time 3 | import os 4 | import sys 5 | import threading 6 | import mfcauto 7 | import classes 8 | 9 | if __name__ == '__main__': 10 | config = classes.config.Config(os.path.join(sys.path[0], 'config.conf')) 11 | #when config is edited at runtime and postprocessing is added, we cannot start it 12 | if config.settings.post_processing_command: 13 | classes.postprocessing.init_workers(config.settings.post_processing_thread_count) 14 | if config.settings.web_enabled: 15 | import webapp 16 | webapp.views.init_data(config) 17 | threading.Thread( 18 | target=webapp.app.run, 19 | kwargs={'host':'0.0.0.0', 'port': config.settings.port, 'threaded':'True', 'ssl_context':('certs/snakeoil.cert', 'certs/snakeoil.key')} 20 | ).start() 21 | 22 | next_run = datetime.datetime.now() 23 | while True: 24 | if datetime.datetime.now() < next_run: 25 | time.sleep(0.1) 26 | continue 27 | print("another run {}".format(datetime.datetime.now())) 28 | next_run += datetime.timedelta(seconds=config.settings.interval) 29 | config.refresh() 30 | for uid, model in classes.models.get_online_models().items(): 31 | if not config.does_model_pass_filter(model): 32 | continue 33 | classes.recording.start_recording(model.session, config) 34 | print("recording {}: {} ({} viewers) [{}]".format(model.name, model.session['uid'], model.session['rc'], model.tags)) 35 | print('finished run') 36 | -------------------------------------------------------------------------------- /certs/snakeoil.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpAIBAAKCAQEAwAvfeI7PtEE/PMzvgP9gBwS32+4QKoG+nO34QfcC0CF3w2Yj 3 | 3CtWBP8YVuEUHz3zq3yF7GB/gJxZiHUgm/tISwy6jzwfw+prkJtoXcFZfYCdf366 4 | T8KhYFC3IF6qK2HM0MTZLS6j6orfhXOqh8sRvrxZdE7dRzqhMLu1C64R2DEFPlmh 5 | JirL8Vvdk2wYCIO/HqOL/Z9c8BCo86/4z1fnuVDP/WOcprDE38S/EiaQUcCpvv/+ 6 | GKMTSGxDhqrE6s4tch1U+9PG2YqIcad4WvctiD/hJMp7aIj8A/lvEfxnuE0fVilM 7 | MMCyZYFeEDB5OvAyu3i34TNQcxlRdWYcZagScQIDAQABAoIBAQCISmTHvCIvfHaS 8 | gaYfWB0gHfsVe7fBUt6hpEihF9nJN1c1NtGQOLkNRrRRQ97x5Rd+xhqNDFawQUVR 9 | ED6aNBS0Hk5vxG2Orli0AXZpwwPti0864gb6/9di8SVlNYlyzC98oZa29/igRPoo 10 | TVPilvz67dRWNHnZSQSH/06XAWvsjm/lYbEJUs+DLxcv9+K/2ZPGmyfv+oxKJgqG 11 | uCxaxY0ih1dBu74BxugnRSEqIRpwjVf5NADXdH0h9sW7Y4Km+GCpZiEIJXF45/7G 12 | Aaw5zXQDOM7lSDacSCqdS3CvgPLZ6f7dEKilvFiSPX2nDb9CyvDMB79U1ootzRMI 13 | K9rCf4DZAoGBAP2v7Fvqbi1sdwUhSRKTidBsvqMcEqjvIgK61bdn8psO455XWx8C 14 | ssvrj9svtWhh4xThNUBHE0Ipakf7sQIk7zH5m2Y7wjGGMs5Cvm7z/nYWsx6Orc3k 15 | PWWE7iBeEBoFb99eVCNoaHiCZG1IxWz4cyvYNboJ7xV3LJ1/IuZCYc8LAoGBAMHM 16 | Fkp3vo1VxlQFKKz5FgzEz/2i+ax1hSybtwt1JUwXxSyULAyewhCysxwkNzRpT643 17 | NpRR6YgwJfh+Hzuvt2uSAnbSypTRNyqOjHdHoefMt353Sx9tx2IcPl1LdJt9fa6K 18 | nv/oAqJdURlPERi9gYgWew+PqF47Jl8O9eIp9IHzAoGAB2mrk2f/Pi6ML8cwNm2/ 19 | OirjSyrX1L3gFYpWElzkNumxdwAAdqCCjvjcJEB5oYoys3p9Ltqk0msZYu8U1gY0 20 | QxaTFuKqIv5T37kNjXpttg+lvj/XDkwcCypeNu3otInyIenEtaAoZVUSECtvMWb1 21 | HpPbxgriRJNN53A+mdHYv88CgYBiF38g6kNVHploZcQU6W8zv1rXsupVVJa34Ylh 22 | D0Z1oYJ38ffp22G3OfxWvi4DJVrjf6bspBlkiZpukMgWWfapSBpfMoq/kLYvoD+R 23 | EHzu6zvlk1Q+8D3/dvRKHKtFGNvIwPmB5lmO/VTTTqYEs8cgruBTHA/Iwn/Flbj3 24 | ZO0R6QKBgQC/7n4LCkzXli031x3Pri+MGYUU3iiJkBF7uJUoFKd+ML+t7kpT4gsc 25 | FmEr+GEvJ3BP+G0VwxKtKBUsw6DimdyFAcmyrmr/Zc+vfaExFBtT/QdlcCnBb7jH 26 | 4T7ak9hQSg5kj+1juUHQL7SBEozp6+kIjqjKm/AjatBdgJIIKnQDBw== 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /webapp/static/js/sorter.js: -------------------------------------------------------------------------------- 1 | window.onload = function() { 2 | var headCells = document.getElementById("thead").children; 3 | for (i = 0; i < headCells.length; i++) { 4 | headCells[i].addEventListener("click", function (event) { orderRow(this); } ); 5 | } 6 | }; 7 | 8 | function orderRow(btn) { 9 | var orderCol = btn.getAttribute("data-name"); 10 | //sort order, true for ascending 11 | var asc = !btn.classList.contains("ascending"); 12 | 13 | //delete markers from previous sorting 14 | for (element of btn.parentElement.children) { 15 | element.classList.remove("descending", "ascending"); 16 | } 17 | btn.classList.add(asc ? "ascending" : "descending"); 18 | 19 | function getValue(row) { 20 | for (var i = 0; i < row.children.length; i++) { 21 | if (row.children[i].getAttribute("data-name") == orderCol) { 22 | var result = row.children[i].value; 23 | if (btn.getAttribute("data-type") == "int") { 24 | result = parseInt(result); 25 | } 26 | else if (btn.getAttribute("data-type") == "str") { 27 | result = result.toLowerCase(); 28 | } 29 | else if (btn.getAttribute("data-type") == "bool"){ 30 | result = row.children[i].checked; 31 | } 32 | return result; 33 | } 34 | } 35 | } 36 | 37 | //compare differently depending on sort order 38 | function compare(a, b) { 39 | if (asc) { 40 | return a > b; 41 | } 42 | else { 43 | return a < b; 44 | } 45 | } 46 | 47 | body = document.getElementById("tbody"); 48 | var rows = body.children; 49 | var i = 1; 50 | //insertionsort 51 | while (i < rows.length) { 52 | var j = i; 53 | while (j > 0 && compare(getValue(rows[j-1]), getValue(rows[j]))) { 54 | body.insertBefore(rows[j], rows[j - 1]); 55 | j -= 1; 56 | } 57 | i++; 58 | } 59 | } -------------------------------------------------------------------------------- /classes/helpers.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import os 3 | import io 4 | import logging 5 | import hashlib 6 | import base64 7 | import enum 8 | import requests 9 | 10 | def try_eval(val): 11 | try: 12 | val = ast.literal_eval(val) 13 | except (ValueError, SyntaxError): 14 | #evaluation failed, so we most likely have a string 15 | pass 16 | return val 17 | 18 | def hash_password(password): 19 | salt = os.urandom(32) 20 | return base64.b64encode(_hash_password(password, salt)).decode('ascii') 21 | 22 | def _hash_password(password, salt): 23 | #Technically not secure, because the hash computes too fast. 24 | #This makes it possible to brute-force. Good enough for this purpose though. 25 | m = hashlib.sha256() 26 | m.update(password.encode()) 27 | m.update(salt) 28 | pw_hash = m.digest() 29 | return pw_hash + salt 30 | 31 | def verify_password(password, hash_): 32 | hash_ = base64.b64decode(hash_.encode('ascii')) 33 | salt = hash_[32:] 34 | return _hash_password(password, salt) == hash_ 35 | 36 | class Condition(enum.IntEnum): 37 | WANTED = 0 38 | TAGS = 1 39 | VIEWERS = 2 40 | NEW = 3 41 | SCORE = 4 42 | 43 | def condition_text(condition, text='', upper=False): 44 | texts = { 45 | 0: 'wanted', 46 | 1: 'tags', 47 | 2: 'viewers', 48 | 3: 'new', 49 | 4: 'score', 50 | } 51 | if text: 52 | return '({})'.format(text) 53 | condition = texts[condition].upper() if upper else texts[condition] 54 | return condition 55 | 56 | def get_avatar(uid): 57 | '''returns content type and content as BytesIO or None''' 58 | URL_TEMPLATE = "http://img.mfcimg.com/photos2/{}/{}/avatar.300x300.jpg" 59 | return _get_img_from_url(URL_TEMPLATE.format(str(uid)[0:3], uid)) 60 | 61 | def get_live_thumbnail(uid, camserver): 62 | '''returns content type and content as BytesIO or None''' 63 | URL_TEMPLATE = 'https://snap.mfcimg.com/snapimg/{}/320x240/mfc_{}' 64 | return _get_img_from_url(URL_TEMPLATE.format(camserver - 500, uid + 100_000_000)) 65 | 66 | def _get_img_from_url(url): 67 | try: 68 | response = requests.get(url) 69 | #returns clear.gif if model was not found 70 | if response.status_code != 200 or 'clear.gif' in response.url: 71 | return 72 | return (response.headers['Content-Type'], io.BytesIO(response.content)) 73 | except: 74 | logging.exception('error getting avatar') 75 | return 76 | -------------------------------------------------------------------------------- /webapp/templates/start_page.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | {% block head %} 3 | {{ super() }} 4 | welcome! 5 | {% endblock %} 6 | {% block body %} 7 |
8 |
9 |
10 |
11 | 12 |
13 |
14 |
15 | 16 | 17 |
18 |
19 |
20 |
21 |
 
22 |
23 | {% for uid, session in recording.items() %} 24 | {% set is_wanted = uid in wanted %} 25 |
26 |
27 | 28 | {{ session['nm'] }} 29 | 30 | ({{ session['rc'] }}) 31 |
32 |
{{ condition_text(session['condition'], session.get('condition-text')) }}
33 | {{ session['nm'] }} thumbnail 34 |
35 | {% if not is_wanted %} 36 | add | 37 | {% else %} 38 | remove | 39 | {% endif %} 40 | chat 41 |
42 |
43 | {% else %} 44 |
no recordings
45 | {% endfor %} 46 |
47 |
48 | {% endblock %} -------------------------------------------------------------------------------- /classes/models.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import asyncio 3 | import requests 4 | import mfcauto 5 | 6 | SERVER_CONFIG_URL = 'http://www.myfreecams.com/_js/serverconfig.js' 7 | 8 | def get_online_models(): 9 | '''returns a dictionary of all online models in free chat''' 10 | server_config = requests.get(SERVER_CONFIG_URL).json() 11 | servers = server_config['h5video_servers'].keys() 12 | models = {} 13 | 14 | def on_tags(): 15 | '''function for the TAGS event in mfcclient''' 16 | nonlocal models 17 | 18 | try: 19 | all_results = mfcauto.Model.find_models(lambda m: True) 20 | models = {int(model.uid): Model(model) for model in all_results 21 | if model.uid > 0 and model.bestsession['vs'] == mfcauto.STATE.FreeChat 22 | and str(model.bestsession['camserv']) in servers} 23 | 24 | print('{} models online'.format(len(models))) 25 | client.disconnect() 26 | except Exception as e: 27 | print(e) 28 | 29 | #setting a new event loop, because it gets closed in the mfcauo client (feels dirty) 30 | asyncio.set_event_loop(asyncio.new_event_loop()) 31 | #we dont want to query the models in CLIENT_MODELSLOADED, because we are 32 | #missing the tags at this point. Rather query everything on TAGS 33 | client = mfcauto.SimpleClient() 34 | client.on(mfcauto.FCTYPE.CLIENT_TAGSLOADED, on_tags) 35 | 36 | #put the blocking connect call into another thread in case the loop becomes unresponsive 37 | t = threading.Thread(target=client.connect) 38 | t.start() 39 | #wait up to a minute for the model list from mfcauto 40 | t.join(60) 41 | if t.is_alive(): 42 | print("fetching online model list timed out") 43 | 44 | return models 45 | 46 | def get_model(uid_or_name): 47 | '''returns a tuple with uid and name''' 48 | async def query(loop): 49 | client = mfcauto.Client(loop) 50 | await client.connect(False) 51 | msg = await client.query_user(uid_or_name) 52 | client.disconnect() 53 | return msg 54 | 55 | #asyncio in a threaded environment... 56 | asyncio.set_event_loop(asyncio.new_event_loop()) 57 | loop = asyncio.get_event_loop() 58 | msg = loop.run_until_complete(query(loop)) 59 | return msg if msg is None else (msg['uid'], msg['nm']) 60 | 61 | class Model(): 62 | '''custom Model class to preserve the session information''' 63 | def __init__(self, model): 64 | self.name = model.nm 65 | self.uid = model.uid 66 | self.tags = model.tags 67 | #vs info will be lost 68 | self.session = model.bestsession 69 | 70 | def __repr__(self): 71 | return '{{"name": {}, "uid": {}, "tags": {}, "session": {}}}'.format( 72 | self.name, self.uid, self.tags, self.session) 73 | -------------------------------------------------------------------------------- /add.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import asyncio 3 | import sys 4 | import os 5 | import argparse 6 | from mfcauto import Client 7 | import classes.config 8 | 9 | parser = argparse.ArgumentParser(description="Add models to the MFC list to enable or disable their recordings. If a model already exists, her values will be updated to any new values passed in the arguments",\ 10 | usage=list(os.path.split(sys.argv[0]))[1] + ' [model display name or uid] [options]') 11 | parser.add_argument("model",nargs="*"[0], help='REQUIRED: models name or uid.') 12 | parser.add_argument('-n', '--custom_name', dest='custom_name', default=False, help='set a custom name for the model, otherwise the models current display name will be used.') 13 | parser.add_argument('-c', '--comment', dest='comment', default=False, help='specify a comment or not for the user.') 14 | parser.add_argument('-m', '--min_viewers', dest='min_viewers', type=int, default=False, help='set the minimum number of viewers this model must have before recording starts') 15 | parser.add_argument('-s', '--stop_viewers', dest='stop_viewers', type=int, default=False, help='set the number of viewers in which the recording will stop (should be less than minviewers') 16 | parser.add_argument('-l', '--list_mode', dest='list_mode', default=False, help='set the list mode for the model') 17 | parser.add_argument('-b', '--block', action='store_false', default=True, dest='enabled', help='will add the model as blocked so she will not be recorded even if auto recording conditions are met') 18 | parser.add_argument('-p', '--priority', dest='priority', type=int, default=False, help='set the priority value for the model') 19 | args = vars(parser.parse_args()) 20 | try: 21 | id = str(args['model'][0]) 22 | except: 23 | parser.print_help() 24 | print() 25 | parser.print_usage() 26 | exit() 27 | kwargs = {} 28 | conf = classes.config.Config(os.path.join(sys.path[0], 'config.conf')) 29 | 30 | for arg in args.keys(): 31 | if args[arg] and arg is not 'model': kwargs[arg] = args[arg] 32 | elif arg in ['enabled', 'stop_viewers', 'min_viewers']: kwargs[arg] = args[arg] 33 | 34 | def run(id): 35 | async def run(loop, id): 36 | client = Client(loop) 37 | await client.connect(False) 38 | try: 39 | id = int(id) 40 | except ValueError: 41 | pass 42 | try: 43 | msg = await client.query_user(id) 44 | if msg != None: 45 | if msg['uid'] not in conf.filter.wanted.dict.keys(): 46 | new = True 47 | else: 48 | new = False 49 | current = conf.filter.wanted.dict[msg['uid']] 50 | for key in set(current.keys()) - set(kwargs.keys()): 51 | kwargs[key] = current[key] 52 | if 'custom_name' not in kwargs.keys(): kwargs['custom_name'] = msg['nm'] 53 | conf.filter.wanted._set_data(msg['uid'], **kwargs) 54 | print("model {} with uid {} added to list".format(msg['nm'], msg['uid'])) if new else\ 55 | print("model {} with uid {} has been updated".format(msg['nm'], msg['uid'])) 56 | except: 57 | print('something went wrong, model was not added') 58 | 59 | loop = asyncio.get_event_loop() 60 | loop.run_until_complete(run(loop, id)) 61 | if __name__ == '__main__': 62 | run(id) 63 | -------------------------------------------------------------------------------- /webapp/templates/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% block head %} 5 | 6 | MFCRecorder 7 | 10 | 11 | 12 | {% endblock %} 13 | 14 | 15 | {% if session.logged_in %} 16 | 38 | 39 | {% endif %} 40 | {% for category, message in get_flashed_messages(with_categories=True) %} 41 |
42 |
{{ message }}
43 |
44 | {% endfor %} 45 | {% block body %}{% endblock %} 46 | 47 | 51 | 55 | 68 | {% block script %}{% endblock %} 69 | -------------------------------------------------------------------------------- /classes/recording.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import datetime 3 | import os 4 | import livestreamer 5 | from colorama import Fore 6 | import classes.postprocessing as postprocessing 7 | import classes.helpers as helpers 8 | 9 | def start_recording(session, settings): 10 | '''starts recording a session if it is not already being recorded''' 11 | #possible race condition? 12 | already_recording = RecordingThread.currently_recording_models.get(session['uid']) 13 | if already_recording: 14 | #TODO: recordings based on min_viewers won't get their rc updated here 15 | already_recording['rc'] = session['rc'] 16 | else: 17 | RecordingThread(session, settings).start() 18 | 19 | class RecordingThread(threading.Thread): 20 | '''thread for recording a MFC session''' 21 | URL_TEMPLATE = "hlsvariant://http://video{srv}.myfreecams.com:1935/NxServer/ngrp:mfc_{id}.f4v_mobile/playlist.m3u8" 22 | READING_BLOCK_SIZE = 1024 23 | currently_recording_models = {} 24 | total_data = 0 25 | file_count = 0 26 | _lock = threading.Lock() 27 | 28 | def __init__(self, session, config): 29 | super().__init__() 30 | self.file_size = 0 31 | self.session = session 32 | self.config = config 33 | self.currently_recording_models[session['uid']] = session 34 | print(Fore.GREEN + "started recording {}".format(self.session['nm']) + Fore.RESET) 35 | 36 | def run(self): 37 | stream = self.stream 38 | if not stream: 39 | return 40 | 41 | start_time = datetime.datetime.now() 42 | file_path = self.create_path(self.config.settings.directory_structure, start_time) 43 | self.session['dl_path'] = file_path 44 | os.makedirs(os.path.dirname(file_path), exist_ok=True) 45 | with self._lock: 46 | self.file_count += 1 47 | 48 | with stream.open() as source, open(file_path, 'wb') as target: 49 | while self.config.keep_recording(self.session): 50 | try: 51 | target.write(source.read(self.READING_BLOCK_SIZE)) 52 | except: 53 | break 54 | with self._lock: 55 | self.total_data += self.READING_BLOCK_SIZE 56 | self.file_size += self.READING_BLOCK_SIZE 57 | 58 | if self.file_size == 0: 59 | with self._lock: 60 | self.file_count -= 1 61 | os.remove(file_path) 62 | 63 | elif self.config.settings.post_processing_command: 64 | postprocessing.put_item(self.config.settings.post_processing_command, 65 | self.session['uid'], self.session['nm'], file_path) 66 | 67 | elif self.config.settings.completed_directory: 68 | directory = self.create_path(self.config.settings.completed_directory, start_time) 69 | os.makedirs(directory, exist_ok=True) 70 | os.rename(file_path, os.path.join(directory, os.path.basename(file_path))) 71 | 72 | self.currently_recording_models.pop(self.session['uid'], None) 73 | print(Fore.RED + "{}'s session has ended".format(self.session['nm']) + Fore.RESET) 74 | 75 | @property 76 | def stream(self): 77 | '''returns a dictionary with available streams''' 78 | streams = {} #not sure this is needed for the finally to work 79 | try: 80 | streams = livestreamer.Livestreamer().streams(self.URL_TEMPLATE.format( 81 | id=int(self.session['uid']) + 100_000_000, 82 | srv=int(self.session['camserv']) - 500)) 83 | finally: 84 | return streams.get('best') 85 | 86 | def create_path(self, template, time): 87 | '''builds a recording-specific path from a template''' 88 | return template.format( 89 | path=self.config.settings.save_directory, model=self.session['nm'], uid=self.session['uid'], 90 | seconds=time.strftime("%S"), day=time.strftime("%d"), 91 | minutes=time.strftime("%M"), hour=time.strftime("%H"), 92 | month=time.strftime("%m"), year=time.strftime("%Y"), 93 | auto='{}_'.format(helpers.condition_text(self.session['condition'], self.session.get('condition-text'), True))) 94 | -------------------------------------------------------------------------------- /webapp/templates/wanted.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | {% block head %} 3 | {{ super() }} 4 | MFCRecorder - Wanted 5 | {% endblock %} 6 | {% block body %} 7 | 8 |
9 |
10 | Save Changes 11 |
12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | {% for uid, settings in wanted.items() %} 28 | 29 | 30 | 34 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | {% endfor %} 47 | 48 |
UIDEnabledModeCustom NameCommentMin ViewersStop ViewersPriority
{{uid}} 31 | 32 | 33 | 35 | 39 |
49 |
50 |
51 | {% endblock %} 52 | {% block script %} 53 | 54 | 75 | {% endblock %} -------------------------------------------------------------------------------- /webapp/views.py: -------------------------------------------------------------------------------- 1 | from webapp import app 2 | import flask 3 | import classes 4 | 5 | def init_data(config): 6 | global CONFIG 7 | CONFIG = config 8 | 9 | def check_login(): 10 | print(flask.session.get('logged_in', None)) 11 | if flask.session.get('logged_in', None) is None: 12 | return flask.redirect(flask.url_for('login')) 13 | 14 | @app.route('/login', methods=['GET', 'POST']) 15 | def login(): 16 | error = None 17 | if flask.request.method == 'POST': 18 | if (flask.request.form['username'] != CONFIG.settings.username 19 | or not classes.helpers.verify_password(flask.request.form['password'], CONFIG.settings.password)): 20 | error = 'Invalid username/password' 21 | else: 22 | flask.session['logged_in'] = True 23 | flask.flash('Successfully logged in!', 'success') 24 | return flask.redirect(flask.url_for('start_page')) 25 | return flask.render_template('login.html', error=error) 26 | 27 | @app.route('/logout') 28 | def logout(): 29 | flask.session.pop('logged_in', None) 30 | return flask.redirect(flask.url_for('start_page')) 31 | 32 | @app.route('/') 33 | def start_page(): 34 | return check_login() or flask.render_template( 35 | 'start_page.html', recording=classes.recording.RecordingThread.currently_recording_models, 36 | wanted=CONFIG.filter.wanted.dict, 37 | condition_text=classes.helpers.condition_text) 38 | 39 | @app.route('/MFC/wanted', methods=['GET', 'POST']) 40 | def wanted(): 41 | check = check_login() 42 | if check is not None: 43 | return check 44 | 45 | if flask.request.method == 'POST': 46 | CONFIG.filter.wanted.set_dict(flask.request.form) 47 | 48 | return flask.render_template('wanted.html', wanted=CONFIG.filter.wanted.dict) 49 | 50 | @app.route('/MFC/config', methods=['GET', 'POST']) 51 | def config(): 52 | check = check_login() 53 | if check is not None: 54 | return check 55 | 56 | if flask.request.method == 'POST': 57 | #special treatment for password 58 | #form data is immutable dict, we want to edit that here 59 | #dict(form) would give us a list of values per key (since it allows multiple values per key) 60 | #when iterating over form.items(), we only get the first entry per key, so we do that here 61 | #(mutliple entries for bool values, since they always send False and only additionally True) 62 | dict_ = {key:value for key, value in flask.request.form.items()} 63 | print(dict_) 64 | old = dict_.pop('password0') 65 | pw1 = dict_.pop('password1') 66 | pw2 = dict_.pop('password2') 67 | if old != '': 68 | if not classes.helpers.verify_password(old, CONFIG.settings.password): 69 | flask.flash('wrong old password, new password not set', 'danger') 70 | elif pw1 != pw2: 71 | flask.flash('new passwords didn\'t match, new password not set', 'danger') 72 | elif pw1 == '': 73 | flask.flash('new password is empty, not setting new password', 'danger') 74 | else: 75 | dict_['web:password'] = classes.helpers.hash_password(pw1) 76 | 77 | CONFIG.update(dict_) 78 | flask.flash('settings have been saved', 'success') 79 | 80 | return flask.render_template('config.html', config=CONFIG) 81 | 82 | @app.route('/MFC/add', methods=['GET']) 83 | def add(): 84 | return check_login() or add_or_remove(_add) 85 | 86 | def _add(uid, name): 87 | result = CONFIG.filter.wanted.add(uid, name) 88 | if result is None: 89 | flask.flash('{} with uid {} successfully added'.format(name, uid), 'success') 90 | else: 91 | flask.flash('{} with uid {} already in wanted list (named "{}")'.format(name, uid, result['custom_name']), 'info') 92 | 93 | @app.route('/MFC/remove', methods=['GET']) 94 | def remove(): 95 | return check_login() or add_or_remove(_remove) 96 | 97 | def _remove(uid, name): 98 | result = CONFIG.filter.wanted.remove(uid) 99 | if result is not None: 100 | flask.flash('{} with uid {} (named "{}") successfully removed'.format(name, uid, result['custom_name']), 'success') 101 | else: 102 | flask.flash('{} with uid {} not in wanted list'.format(name, uid), 'info') 103 | 104 | def add_or_remove(action): 105 | uid_or_name = classes.helpers.try_eval(flask.request.args['uid_or_name']) 106 | result = classes.models.get_model(uid_or_name) 107 | if result is None: 108 | flask.flash('uid or name "{}" not found'.format(uid_or_name), 'danger') 109 | else: 110 | action(*result) 111 | return flask.redirect(flask.url_for('start_page')) 112 | 113 | @app.route('/MFC/thumbnails/') 114 | def thumbnail(uid): 115 | #TODO: this might take very long and caching would probably be a good idea 116 | uid = int(uid) 117 | #try to get thumbnail from current video 118 | result = classes.helpers.get_live_thumbnail( 119 | uid, classes.recording.RecordingThread.currently_recording_models.get(uid, {}).get('camserv')) 120 | if result is None: 121 | #fallback to avatar from mfc 122 | result = classes.helpers.get_avatar(uid) 123 | if result is not None: 124 | mimetype, img = result 125 | return flask.send_file(img, mimetype=mimetype) 126 | return flask.abort(404) 127 | -------------------------------------------------------------------------------- /config.conf: -------------------------------------------------------------------------------- 1 | [paths] 2 | #all paths (wishlist, save_directory, blacklist and completed_directory) can be relative to the config.conf location 3 | wishlist_path = ./wanted.json 4 | save_directory = ./captures 5 | 6 | # set the directory structure - default is "{path}/{uid}/{year}.{month}.{day}_{hour}.{minutes}.{seconds}_{model}.mp4" 7 | # {auto} = for autoRecording conditions below, it is the reason why the recording was started followed by "_" - These are: TAGS_, VIEWERS_, SCORE_, and NEW_. If a model is in the wanted list, this will be blank 8 | # {path} = save_directory set above. (your directory structure should start with this) 9 | # {model} = the models name 10 | # {uid} = models uid or broadcaster id. This is a number which is always consistent. The models name can be changed, so this is what I find best for keeping all videos of a model in the same directory 11 | # "{year}, {month}, {day}, {hour}, {minutes}, {seconds}" should all be easy enough to figure out what these are 12 | # you can also change ".mp4" to another extension. ".ts" is what the stream is actual broadcast as. 13 | # example using a madeup "hannah" who has the uid 208562: {path}/{uid}/{year}/{year}.{month}.{day}_{hour}.{minutes}.{seconds}_{model}.mp4 = "/Users/Joe/MFC/208562/2017/2017.07.26_19.34.47_hannah.mp4" 14 | # This will also be the "Download directory" or temp directory if you set a "completed_directory" 15 | 16 | directory_structure = {path}/{model}/{year}.{month}.{day}_{hour}.{minutes}.{seconds}_{model}.mp4 17 | 18 | # (OPTIONAL) - leave blank if you dont want files moved after completed 19 | # The files will be moved here once the stream has ended. The same naming structure will be used as above 20 | # if this is left empty, the videos wll remain in the same directory they were originally saved to 21 | # This path should be to a directory, not a filename! so do not include the filename portion, only the directory. 22 | 23 | completed_directory = 24 | 25 | 26 | [settings] 27 | check_interval = 20 28 | 29 | #minimum space in GB - when the available disk space available on the mnt where the download directory is located, the recordings will stop. 30 | min_space = 0 31 | 32 | # (OPTIONAL) - leave blank if you dont want to run a post processing script on the files 33 | # You can set a command to be ran on the file once it is completed. This can be any sort of a script you would like. 34 | # You can create a script to convert the video via ffmpeg to make it compatible for certain devices, create a contact sheet of the video 35 | # upload the video to a cloud storage drive via rclone, or whatever else you see fit. 36 | # set the string to be the same as you you would type into terminal to call the script manually. 37 | # The peramaters which will be passed to the script are as follows: 38 | # 1 = full file path (ie: /Users/Joe/MFC/208562/2017/2017.07.26_19.34.47_hannah.mp4) 39 | # 2 = filename (ie : 2017.07.26_19.34.47_hannah.mp4) 40 | # 3 = directory (ie : /Users/Joe/MFC/208562/hannah/2017/) 41 | # 4 = models name (ie: hannah) 42 | # 5 = uid (ie: 208562) (explained above) 43 | # to call a bash script called "MoveToGoogleDrive.sh" and located in the user Joes home directory, you would use: 44 | # postProcessingCommand = "bash /Users/Joe/home/MoveToGoogleDrive.sh" 45 | # this script will be ran on the files "download location" prior to it being moved to its "completed location". 46 | # The moving of the file will not take place if a post processing script is ran, so if you want to move it once it is completed, do so through commands in the post processing script. 47 | 48 | post_processing_command = 49 | 50 | 51 | # Because depending on what the post processing script does, it may be demanding on the system. 52 | # Set the maximum number of concurrent post processing scripts you would like to be ran at one time. 53 | # (required if using a post processing script) 54 | 55 | post_processing_thread_count = 1 56 | 57 | # minimum number of viewers required in a room before the model will be recorded. 0 = disabled 58 | # if stopViewers is greater that this number, this number will be set to stopViewers 59 | min_viewers = 0 60 | 61 | # stop recording if the number of viewers becomes less than stopViewers value. This should not be more than "minViewers" value. 62 | # if it is greater than minViewers value, minViewers will be automatically adjusted to the value of stopViewers. 63 | # I would suggest keeping a fair difference between minViewers and stopViewers, otherwise you may get many very short clips if the 64 | # number of viewers in a room is "teetering" between the two numbers. 65 | # 0 = disabled 66 | stop_viewers = 0 67 | 68 | # only records models with priority value greater than or equal to this number 69 | priority = 0 70 | 71 | 72 | [auto_recording] 73 | # This section is for grabbing models who are not on the wanted list, but meet certian conditions. 74 | # for all settings 0 is disabled. 75 | # a blacklist file must be set and must exist to use these settings. The the minViewers setting above applies. 76 | 77 | # start recording if the number of viewers in there room is greater than "viewers" variable 78 | # if autoStopViewers is greater that this number, this number will be set to autoStopViewers 79 | viewers = 0 80 | 81 | # same as "stopViewers" from above, but only applies to the models who are not on the wanted list grabbed 82 | auto_stop_viewers = 0 83 | 84 | # start recording if camscore is greater than "score" variable 85 | score = 0 86 | 87 | # start recording any model whos account was created less than "newerThanHours" hours ago. 88 | newer_than_hours = 0 89 | 90 | # automatically record models who are not in the wanted list, but have set certain tags. 91 | # set the desired tags, with each tag separated by a comma. 92 | # set the minimum number of tags required to start recording a model. 93 | tags = 0 94 | min_tags = 0 95 | # minimum numbers of viewers to start tag recordings 96 | # if tag_stop_viewers is greater that this number, this number will be set to tag_stop_viewers 97 | tag_viewers = 0 98 | # number of viewers to stop tag recordings 99 | tag_stop_viewers = 0 100 | 101 | [web] 102 | port = 8778 103 | enabled = true 104 | # login username for webapp 105 | username = user 106 | # password for webapp 107 | # Dont't put plain text here, use classes.helpers.hash_password(). Default password is 'change this' 108 | password = CVezFsDBdW4+0cgMV1VEnvbqferiQKliMWTcDN4+mkge3a9QWE/jYH8+x5xDocAoSD7pmxv48q8FPbq4Y6ZQiA== -------------------------------------------------------------------------------- /webapp/templates/config.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | 3 | {% block head %} 4 | {{ super() }} 5 | MFCRecorder - config 6 | 7 | 8 | {% endblock %} 9 | 10 | {% macro input(name, label, value, tooltip='', width_class='w30', type='text') %} 11 |
12 | 27 |
28 | {% endmacro %} 29 | 30 | {% set wishlist_tooltip %} 31 |

relative from config.conf or absolute path to the wishlist

32 | {% endset %} 33 | 34 | {% set directory_structure_tooltip %} 35 |

set the directory structure - default is "{path}/{uid}/{year}.{month}.{day}_{hour}.{minutes}.{seconds}_{model}.mp4"

36 |

{auto} = for autoRecording conditions below, it is the reason why the recording was started followed by "_" - These are: TAGS_, VIEWERS_, SCORE_, and NEW_. If a model is in the wanted list, this will be blank

37 |

{path} = save_directory set above. (your directory structure should start with this)

38 |

{model} = the models name

39 |

{uid} = models uid or broadcaster id. This is a number which is always consistent. The models name can be changed, so this is what I find best for keeping all videos of a model in the same directory

40 |

{year}, {month}, {day}, {hour}, {minutes}, {seconds} should all be easy enough to figure out what these are

41 |

you can also change ".mp4" to another extension. ".ts" is what the stream is actual broadcast as.

42 |

example using a madeup "hannah" who has the uid 208562: {path}/{uid}/{year}/{year}.{month}.{day}_{hour}.{minutes}.{seconds}_{model}.mp4 = "/Users/Joe/MFC/208562/2017/2017.07.26_19.34.47_hannah.mp4"

43 |

This will also be the "Download directory" or temp directory if you set a "completed_directory"

44 | {% endset %} 45 | 46 | {% set completed_directory_tooltip %} 47 |

(OPTIONAL) - leave blank if you dont want files moved after completed

48 |

The files will be moved here once the stream has ended. The same naming structure will be used as above

49 |

if this is left empty, the videos wll remain in the same directory they were originally saved to

50 |

This path should be to a directory, not a filename! so do not include the filename portion, only the directory.

51 | {% endset %} 52 | 53 | {% set min_space_tooltip %} 54 |

minimum space in GB - when the available disk space available on the mnt where the download directory is located, the recordings will stop.

55 | {% endset %} 56 | 57 | {% set post_processing_command_tooltip %} 58 |

(OPTIONAL) - leave blank if you dont want to run a post processing script on the files

59 |

You can set a command to be ran on the file once it is completed. This can be any sort of a script you would like.

60 |

You can create a script to convert the video via ffmpeg to make it compatible for certain devices, create a contact sheet of the video

61 |

upload the video to a cloud storage drive via rclone, or whatever else you see fit.

62 |

set the string to be the same as you you would type into terminal to call the script manually.

63 |

The peramaters which will be passed to the script are as follows:

64 |

1 = full file path (ie: /Users/Joe/MFC/208562/2017/2017.07.26_19.34.47_hannah.mp4)

65 |

2 = filename (ie : 2017.07.26_19.34.47_hannah.mp4)

66 |

3 = directory (ie : /Users/Joe/MFC/208562/hannah/2017/)

67 |

4 = models name (ie: hannah)

68 |

5 = uid (ie: 208562) (explained above)

69 |

to call a bash script called "MoveToGoogleDrive.sh" and located in the user Joes home directory, you would use:

70 |

postProcessingCommand = "bash /Users/Joe/home/MoveToGoogleDrive.sh"

71 |

this script will be ran on the files "download location" prior to it being moved to its "completed location".

72 |

The moving of the file will not take place if a post processing script is ran, so if you want to move it once it is completed, do so through commands in the post processing script.

73 | {% endset %} 74 | 75 | {% set post_processing_thread_count_tooltip %} 76 |

Because depending on what the post processing script does, it may be demanding on the system.

77 |

Set the maximum number of concurrent post processing scripts you would like to be ran at one time.

78 |

(required if using a post processing script)

79 | {% endset %} 80 | 81 | {% set priority_tooltip %} 82 |

only records models with priority value greater than or equal to this number

83 | {% endset %} 84 | 85 | {% set viewers_tooltip %} 86 |

start recording if the number of viewers in there room is greater than "viewers" variable

87 |

if autoStopViewers is greater that this number, this number will be set to autoStopViewers

88 | {% endset %} 89 | 90 | {% set auto_stop_viewers_tooltip %} 91 |

stop recordings started because of viewers when less than this amount of people are watching

92 | {% endset %} 93 | 94 | {% set score_tooltip %} 95 |

start recording if camscore is greater than "score" variable

96 | {% endset %} 97 | 98 | {% set newer_than_hours_tooltip %} 99 |

start recording any model whos account was created less than this amount of hours ago.

100 | {% endset %} 101 | 102 | {% set tags_tooltip %} 103 |

automatically record models who are not in the wanted list, but have set certain tags.

104 |

set the desired tags, with each tag separated by a comma.

105 | {% endset %} 106 | 107 | {% set min_tags_tooltip %} 108 |

set the minimum number of tags required to start recording a model.

109 | {% endset %} 110 | 111 | {% set old_password_tooltip %} 112 |

Old password. Only required for password changes.

113 | {% endset %} 114 | 115 | {% set new_password_tooltip %} 116 |

Type your new password twice. Leave empty if no password change is wanted.

117 | {% endset %} 118 | 119 | {% block body %} 120 |
121 |
122 |

paths

123 | {{ input('paths:wishlist_path', 'wishlist', config.settings.conf_wishlist_path, wishlist_tooltip) }} 124 | {{ input('paths:save_directory', 'save directory', config.settings.conf_save_directory) }} 125 | {{ input('paths:directory_structure', 'directory structure', config.settings.directory_structure, directory_structure_tooltip) }} 126 | {{ input('paths:completed_directory', 'completed directory', config.settings.conf_completed_directory, completed_directory_tooltip) }} 127 |

settings

128 | {{ input('settings:check_interval', 'check interval', config.settings.interval, type='number') }} 129 | {{ input('settings:min_space', 'minimum space', config.settings.min_space, min_space_tooltip, type='number') }} 130 | {{ input('settings:post_processing_command', 'post processing command', config.settings.post_processing_command, post_processing_command_tooltip, 'w60') }} 131 | {{ input('settings:post_processing_thread_count', 'post processing thread count', config.settings.post_processing_thread_count, post_processing_thread_count_tooltip, type='number') }} 132 | 133 | {{ input('settings:priority', 'priority', config.settings.priority, priority_tooltip, type='number') }} 134 |

auto recording

135 | {{ input('auto_recording:viewers', 'viewers', config.filter.viewers, viewers_tooltip, type='number') }} 136 | {{ input('auto_recording:auto_stop_viewers', 'auto stop viewers', config.filter.auto_stop_viewers, auto_stop_viewers_tooltip, type='number') }} 137 | {{ input('auto_recording:score', 'score', config.filter.score, score_tooltip, type='number') }} 138 | {{ input('auto_recording:newer_than_hours', 'newer than (hours)', config.filter.newer_than_hours, newer_than_hours_tooltip, type='number') }} 139 | {{ input('auto_recording:tags', 'tags', config.filter.wanted_tags_str, tags_tooltip) }} 140 | {{ input('auto_recording:min_tags', 'minimum tags', config.filter.min_tags, min_tags_tooltip, type='number') }} 141 | {{ input('auto_recording:tag_viewers', 'tag viewers', config.filter.tag_viewers, type='number') }} 142 | {{ input('auto_recording:tag_stop_viewers', 'tag stop viewers', config.filter.tag_stop_viewers, type='number') }} 143 |

web

144 | {{ input('web:port', 'port', config.settings.port, type='number') }} 145 | {{ input('web:enabled', 'enabled', config.settings.web_enabled, type='checkbox') }} 146 | {{ input('web:username', 'username', config.settings.username) }} 147 | {{ input('password0', 'old password', '', old_password_tooltip, type='password') }} 148 | {{ input('password1', 'new password', '', new_password_tooltip, type='password') }} 149 | {{ input('password2', 'repeat new password', '', type='password') }} 150 | 151 |
152 |
153 | {% endblock %} -------------------------------------------------------------------------------- /classes/config.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import time 3 | import os 4 | import platform 5 | import ctypes 6 | import json 7 | import threading 8 | import classes.helpers as helpers 9 | 10 | LIST_MODE_WANTED = 0 11 | LIST_MODE_BLACKLISTED = 1 12 | 13 | class Settings(): 14 | def __init__(self, parser, make_absolute): 15 | self._make_absolute = make_absolute 16 | self.conf_save_directory = parser.get('paths', 'save_directory') 17 | self.conf_wishlist_path = parser.get('paths', 'wishlist_path') 18 | self.interval = parser.getint('settings', 'check_interval') 19 | self.directory_structure = parser.get('paths', 'directory_structure').lower() 20 | self.post_processing_command = parser.get('settings', 'post_processing_command') 21 | self.post_processing_thread_count = parser.getint('settings', 'post_processing_thread_count') 22 | self.port = parser.getint('web', 'port') 23 | self.web_enabled = parser.getboolean('web', 'enabled') 24 | self.min_space = parser.getint('settings', 'min_space') 25 | self.conf_completed_directory = parser.get('paths', 'completed_directory').lower() 26 | self.priority = parser.getint('settings', 'priority') 27 | self.username = parser.get('web', 'username') 28 | self.password = parser.get('web', 'password') 29 | 30 | #make save directory so that _get_free_diskspace can work 31 | os.makedirs(self.save_directory, exist_ok=True) 32 | 33 | @property 34 | def save_directory(self): 35 | return self._make_absolute(self.conf_save_directory) 36 | 37 | @property 38 | def wishlist_path(self): 39 | return self._make_absolute(self.conf_wishlist_path) 40 | 41 | @property 42 | def completed_directory(self): 43 | return self._make_absolute(self.conf_completed_directory) 44 | 45 | class Filter(): 46 | def __init__(self, parser, settings): 47 | self.newer_than_hours = parser.getint('auto_recording', 'newer_than_hours') 48 | self.score = parser.getint('auto_recording', 'score') 49 | self.auto_stop_viewers = parser.getint('auto_recording', 'auto_stop_viewers') 50 | self.stop_viewers = parser.getint('settings', 'stop_viewers') 51 | self.min_tags = max(1, parser.getint('auto_recording', 'min_tags')) 52 | self._wanted_tags_str = parser.get('auto_recording', 'tags') 53 | self._update_tags() 54 | self.tag_stop_viewers = parser.getint('auto_recording', 'tag_stop_viewers') 55 | #account for when stop is greater than min 56 | self.min_viewers = max(self.stop_viewers, parser.getint('settings', 'min_viewers')) 57 | self.viewers = max(self.auto_stop_viewers, parser.getint('auto_recording', 'viewers')) 58 | self.tag_viewers = max(self.tag_stop_viewers, parser.getint('auto_recording', 'tag_viewers')) 59 | 60 | self.wanted = Wanted(settings) 61 | 62 | @property 63 | def wanted_tags_str(self): 64 | return self._wanted_tags_str 65 | 66 | @wanted_tags_str.setter 67 | def wanted_tags_str(self, value): 68 | self._wanted_tags_str = value 69 | self._update_tags() 70 | 71 | def _update_tags(self): 72 | self.wanted_tags = {s.strip().lower() for s in self._wanted_tags_str.split(',')} 73 | 74 | class Config(): 75 | def __init__(self, config_file_path): 76 | self._lock = threading.Lock() 77 | self._config_file_path = config_file_path 78 | self._parser = configparser.ConfigParser() 79 | self.refresh() 80 | 81 | @property 82 | def settings(self): 83 | return self._settings 84 | 85 | @property 86 | def filter(self): 87 | return self._filter 88 | 89 | def _make_absolute(self, path): 90 | if not path or os.path.isabs(path): 91 | return path 92 | return os.path.join(os.path.dirname(self._config_file_path), path) 93 | 94 | def refresh(self): 95 | '''load config again to get fresh values''' 96 | self._parse() 97 | self._settings = Settings(self._parser, self._make_absolute) 98 | self._filter = Filter(self._parser, self.settings) 99 | self._available_space = self._get_free_diskspace() 100 | 101 | def _parse(self): 102 | with self._lock: 103 | self._parser.read(self._config_file_path) 104 | 105 | def update(self, data): 106 | '''expects a dictionary with section:option as key and the value as value''' 107 | #will delete comments in the config, but when this method is used, config was edited in webapp, 108 | #so there are comments there and in the sample config 109 | with self._lock: 110 | for key, value in data.items(): 111 | section, option = key.split(':') 112 | self._parser.set(section, option, value) 113 | self._write() 114 | self.refresh() 115 | 116 | def _write(self): 117 | with open(self._config_file_path, 'w') as target: 118 | self._parser.write(target) 119 | 120 | #maybe belongs more into a filter class, but then we would have to create one 121 | def does_model_pass_filter(self, model): 122 | '''determines whether a recording should start''' 123 | f = self.filter 124 | try: 125 | if f.wanted.is_wanted(model.uid): 126 | #TODO: do we want a global min_viewers if model specific is not set?? 127 | m_settings = f.wanted.dict[model.uid] 128 | if model.session['rc'] < max(m_settings['min_viewers'], m_settings['stop_viewers']): 129 | return False 130 | else: 131 | model.session['condition'] = helpers.Condition.WANTED 132 | return True 133 | if f.wanted.is_blacklisted(model.uid): 134 | return False 135 | if f.wanted_tags: 136 | matches = f.wanted_tags.intersection(model.tags if model.tags is not None else []) 137 | if len(matches) >= f.min_tags and model.session['rc'] >= f.tag_viewers: 138 | model.session['condition'] = helpers.Condition.TAGS 139 | model.session['condition-text'] = ','.join(matches) 140 | return True 141 | if f.newer_than_hours and model.session['creation'] > int(time.time()) - f.newer_than_hours * 60 * 60: 142 | model.session['condition'] = helpers.Condition.NEW 143 | return True 144 | if f.score and model.session['camscore'] > f.score: 145 | model.session['condition'] = helpers.Condition.SCORE 146 | return True 147 | if f.viewers and model.session['rc'] > f.viewers: 148 | model.session['condition'] = helpers.Condition.VIEWERS 149 | return True 150 | return False 151 | except Exception as e: 152 | print(e) 153 | return False 154 | 155 | def _get_free_diskspace(self): 156 | '''https://stackoverflow.com/questions/51658/cross-platform-space-remaining-on-volume-using-python''' 157 | if platform.system() == 'Windows': 158 | free_bytes = ctypes.c_ulonglong(0) 159 | ctypes.windll.kernel32.GetDiskFreeSpaceExW(ctypes.c_wchar_p(self.settings.save_directory), None, None, ctypes.pointer(free_bytes)) 160 | return free_bytes.value / 1024 / 1024 161 | st = os.statvfs(self.settings.save_directory) 162 | return st.f_bavail * st.f_frsize / 1024 / 1024 / 1024 163 | 164 | def keep_recording(self, session): 165 | '''determines whether a recording should continue''' 166 | try: 167 | #would it be possible that no entry is existing if we are already recording? 168 | #TODO: global stop_viewers if no model specific is set?? 169 | if session['condition'] == helpers.Condition.VIEWERS: 170 | min_viewers = self.filter.auto_stop_viewers 171 | elif session['condition'] == helpers.Condition.WANTED: 172 | min_viewers = self.filter.wanted.dict[session['uid']]['stop_viewers'] 173 | elif session['condition'] == helpers.Condition.TAGS: 174 | min_viewers = self.filter.tag_stop_viewers 175 | else: 176 | min_viewers = 0 177 | return session['rc'] >= min_viewers and self._available_space > self.settings.min_space 178 | except Exception as e: 179 | print(e) 180 | return True 181 | 182 | class Wanted(): 183 | def __init__(self, settings): 184 | self._lock = threading.RLock() 185 | self._settings = settings 186 | #create new empty wanted file 187 | try: 188 | with open(self._settings.wishlist_path, 'x') as file: 189 | file.write('{}') 190 | except FileExistsError: 191 | pass 192 | self._load() 193 | 194 | def _load(self): 195 | with self._lock: 196 | with open(self._settings.wishlist_path, 'r+') as file: 197 | self.dict = {int(uid): data for uid, data in json.load(file).items()} 198 | 199 | def _save(self): 200 | with open(self._settings.wishlist_path, 'w') as file: 201 | json.dump(self.dict, file, indent=4) 202 | 203 | def set_dict(self, data): 204 | '''expects dictionary with uid:key as keys and value as value''' 205 | 206 | #building the new wanted dict 207 | new = {} 208 | for key, value in data.items(): 209 | uid, key = key.split(':') 210 | uid = int(uid) 211 | #relies on enabled being the first argument that is passed per model, maybe a bit dirty 212 | if key == 'enabled': 213 | new[uid] = {} 214 | print(value) 215 | new[uid][key] = helpers.try_eval(value) 216 | 217 | with self._lock: 218 | self.dict = new 219 | self._save() 220 | 221 | def add(self, uid, custom_name='', list_mode=LIST_MODE_WANTED): 222 | '''Adds model to dict and returns None. If already existing, returns model settings.''' 223 | with self._lock: 224 | settings = self.dict.get(uid) 225 | if settings is not None: 226 | return settings 227 | self._set_data(uid, list_mode=list_mode, custom_name=custom_name) 228 | 229 | def remove(self, uid): 230 | '''removes model from dict and returns settings, if not existing returns None''' 231 | with self._lock: 232 | result = self.dict.pop(uid, None) 233 | self._save() 234 | return result 235 | 236 | def _set_data(self, uid, enabled=True, list_mode=LIST_MODE_WANTED, 237 | custom_name='', comment='', min_viewers=0, stop_viewers=0, priority=0): 238 | '''same as _set_data_dict, but takes named arguments instead of a dict''' 239 | data = { 240 | 'enabled': enabled, 241 | 'list_mode': list_mode, 242 | 'custom_name': custom_name, 243 | 'comment': comment, 244 | 'min_viewers': min_viewers, 245 | 'stop_viewers': stop_viewers, 246 | 'priority': priority, 247 | } 248 | with self._lock: 249 | self._set_data_dict(uid, data) 250 | 251 | def _set_data_dict(self, uid, data): 252 | '''Set data dictionary for model uid, existing or not''' 253 | with self._lock: 254 | self.dict[uid] = data 255 | self._save() 256 | 257 | def is_wanted(self, uid): 258 | '''determines if model is enabled and wanted''' 259 | return self._is_list_mode_value(uid, LIST_MODE_WANTED) 260 | 261 | def is_blacklisted(self, uid): 262 | '''determines if model is enabled and blacklisted''' 263 | return self._is_list_mode_value(uid, LIST_MODE_BLACKLISTED) 264 | 265 | def _is_list_mode_value(self, uid, value): 266 | '''determines if list_mode equals the specified one, but only if the item is enabled''' 267 | entry = self.dict.get(uid) 268 | if not (entry and entry['enabled'] and self._settings.priority <= entry['priority']): 269 | return False 270 | return entry['list_mode'] == value 271 | -------------------------------------------------------------------------------- /scripts/merge.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | #created GitHub user sKanoodle 3 | import os, subprocess, argparse, time, re 4 | from datetime import datetime, timedelta 5 | 6 | #directory with model ID subdirectories 7 | sourcefolder = "/home/user/MFC/src" 8 | #directory to save the encoded files in 9 | destinationfolder = "/home/user/MFC/encoded" 10 | #creation date regex (works for default file names, if changed in config change here as well) 11 | #is applied to the whole path, so custom folders (for example for the year) are possible here 12 | creationregex = '(?P\d{4}).(?P\d{2}).(?P\d{2})_(?P\d{2})\.(?P\d{2})\.(?P\d{2})' 13 | #logfile path (leave as empty string if no logging is desired) 14 | logfilepath = "/home/user/MFC/encoding.log" 15 | #{0} is the absoulte source file path, {1} is the absolute target file path 16 | ffmpegcommand = "ffmpeg -loglevel quiet -i {0} -vcodec libx264 -crf 23 {1}" 17 | #extension of the encoded file 18 | extension = ".mp4" 19 | #{0} is the absolute path of the file with parts to concat, {1} is the absolute target file path 20 | ffmpegmergecommand = "ffmpeg -v error -f concat -safe 0 -i {0} -c copy {1}" 21 | #filename must not exist already directly in the sourcefolder 22 | tmpconcatfilename = "concat.mp4" 23 | #max time in minutes that is allowed between the end of a video and the beginning of the next video to concatinate them 24 | concatmaxtime = 60 25 | #time in minutes that has to have passed since the last modification of a recording to include it for encoding 26 | #(should always be larger than concatmaxtime, otherwise the file will be encoded even if a next file would have been eligible to be concatinated to it) 27 | ignorefreshvideostime = 60 28 | #datetime format for logging purposes 29 | datetimeformat = "{:%Y-%m-%d %X}" 30 | 31 | parser = argparse.ArgumentParser() 32 | parser.add_argument("-d", "--dryrun", action="store_true", dest="dryrun", help="Simulates encoding of all files in the source folder. Size and duration of some videos might differ, because there is no concatination performed, although the status output expects concatinated videos. It will therefore only show size and duration of the first file that should be concatinated.") 33 | parser.add_argument("-c", "--copy", action="store_true", dest="copy", help="Only copies the video files instead of encoding them, but still merges them beforehand.") 34 | parser.add_argument("-r", "--remove", action="store_true", dest="remove", help="Deletes video when detected as faulty when trying to merge videos, otherwise the file will just be ignored") 35 | args = parser.parse_args() 36 | 37 | def log_and_print(string): 38 | if not args.dryrun and logfilepath: 39 | with open(logfilepath, "a") as file: 40 | file.write(string + "\n") 41 | print(string) 42 | 43 | def format_seconds(totalseconds): 44 | totalseconds = int(totalseconds) 45 | totalminutes, seconds = divmod(totalseconds, 60) 46 | totalhours, minutes = divmod(totalminutes, 60) 47 | return "{0}:{1:02d}:{2:02d}".format(totalhours, minutes, seconds) 48 | 49 | def get_video_length_seconds(path): 50 | if not os.path.exists(path): 51 | return 0 52 | try: 53 | lengthraw = subprocess.check_output("ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 {0}".format(path), shell=True) 54 | return float(lengthraw.strip()) 55 | except: 56 | return 0 57 | 58 | def get_file_encoding_infos(sourcepath): 59 | if args.dryrun: 60 | if not os.path.exists(sourcepath): 61 | return {"source": sourcepath, "target": "", "size": 0, "length": 0} 62 | 63 | #relies on set structure: sourcefolder/modelID/video 64 | directory, file = os.path.split(sourcepath) 65 | filename, ext = os.path.splitext(file) 66 | return {"source": sourcepath, 67 | "target": os.path.join(destinationfolder, os.path.basename(directory), filename + extension), 68 | "size": os.path.getsize(sourcepath) / 1024 / 1024, 69 | "length": get_video_length_seconds(sourcepath)} 70 | 71 | def parse_creation_time(path): 72 | m = re.search(creationregex, path) 73 | if not m: 74 | print('error in creation date regex') 75 | return 76 | dict = {k:int(v) for k, v in m.groupdict().items()} 77 | return datetime(dict['year'], dict['month'], dict['day'], dict['hour'], dict['minute'], dict['second']) 78 | 79 | def calculate_eta(starttime, progress): 80 | if progress <= 0: 81 | return "calculating ETA" 82 | if progress >= 1: 83 | return "done at {}".format(datetimeformat.format(datetime.now())) 84 | passedseconds = (datetime.now() - starttime).total_seconds() 85 | estimatedduration = passedseconds / progress 86 | return "ETA: {}".format(datetimeformat.format(starttime + timedelta(seconds=estimatedduration))) 87 | 88 | def concat_files(files, name): 89 | log_and_print("{0} merging into {1}:".format(datetimeformat.format(datetime.now()), name)) 90 | for file in files: 91 | log_and_print("[{:>10,.2f} MiB] [{}] {}".format(os.path.getsize(file) / 1024 / 1024, format_seconds(get_video_length_seconds(file)), file)) 92 | mergefilepath = os.path.join(os.path.dirname(name), "tempmergefile.txt") 93 | tmp = os.path.join(sourcefolder, tmpconcatfilename) 94 | ffmpeg = ffmpegmergecommand.format(mergefilepath, tmp) 95 | if args.dryrun: 96 | print("[DRYRUN] would create mergefile {0}".format(mergefilepath)) 97 | print("[DRYRUN] would run {0}".format(ffmpeg)) 98 | print("[DRYRUN] would move {0} to {1}".format(tmp, name)) 99 | else: 100 | #create mergefile with info about parts 101 | mergefile = open(mergefilepath, "w") 102 | for file in files: 103 | mergefile.write("file '{0}'\n".format(file)) 104 | mergefile.close() 105 | #concat videos 106 | os.system(ffmpeg) 107 | #delete source parts 108 | for file in files: 109 | os.remove(file) 110 | #move concatinated video from temp location to final location 111 | os.rename(tmp, name) 112 | #remmove the mergefile 113 | os.remove(mergefilepath) 114 | 115 | 116 | def merge_files_in_model_directory(directory): 117 | #the files need to be scanned into a list first, so we can look ahead to the next file 118 | entries = [] 119 | for file in sorted(os.listdir(directory)): 120 | filepath = os.path.join(directory, file) 121 | if not file.endswith(".mp4"): 122 | continue 123 | #detects empty files 124 | length = get_video_length_seconds(filepath) 125 | if not length: 126 | if args.remove: 127 | log_and_print("removing empty or faulty video file: {}".format(filepath)) 128 | os.remove(filepath) 129 | else: 130 | log_and_print("ignoring empty or faulty video file: {}".format(filepath)) 131 | continue 132 | entries.append({"creation": parse_creation_time(file), 133 | "modification": datetime.fromtimestamp(os.path.getmtime(filepath)), 134 | "length": length, 135 | "file": filepath}) 136 | 137 | #now we can traverse the files we found and check if the next file is directly following the previous and merge them if necessary 138 | filestoencode = [] 139 | concatlist = [] 140 | for i in range(len(entries)): 141 | #last run of the loop, we dont want further execution here, just adding the last file/performing the last concatination 142 | if i == len(entries) - 1: 143 | #make sure the latest file is not written to anymore 144 | if entries[i]["modification"] + timedelta(minutes=ignorefreshvideostime) > datetime.now(): 145 | log_and_print("ignoring {0} and possible previous mergable files".format(entries[i]["file"])) 146 | #exit the loop and dont add the files in the concat list to filestoencode, so we can merge them the next time this script runs 147 | break 148 | #last file is not being merged, add it to the filestoencode list 149 | if len(concatlist) < 2: 150 | filestoencode.append(get_file_encoding_infos(entries[i]["file"])) 151 | #last file is being merged, merge and then add merged file to the filestoencode list 152 | else: 153 | concat_files(concatlist, concatlist[0]) 154 | filestoencode.append(get_file_encoding_infos(concatlist[0])) 155 | concatlist = [] 156 | break 157 | #print("{3} {0} {1} {2}".format(entries[i]["creation"], entries[i]["modification"], entries[i]["file"], i)) 158 | m = entries[i]["modification"] 159 | c = entries[i + 1]["creation"] 160 | #current file has a following up file that needs to be merged 161 | if m < c and m + timedelta(minutes=concatmaxtime) > c: 162 | if not entries[i]["file"] in concatlist: 163 | concatlist.append(entries[i]["file"]) 164 | concatlist.append(entries[i + 1]["file"]) 165 | #there is nothig more to be merged 166 | else: 167 | #concatlist is empty, so treat the file as normal video to be encoded 168 | if len(concatlist) < 1: 169 | filestoencode.append(get_file_encoding_infos(entries[i]["file"])) 170 | continue 171 | #concat list has a single entry, should never happen, because there is nothing to concat 172 | elif len(concatlist) == 1: 173 | log_and_print("single file in concat list?????? {0}".format(concatlist[0])) 174 | #concat the files and then encode the resulting new video 175 | else: 176 | concat_files(concatlist, concatlist[0]) 177 | filestoencode.append(get_file_encoding_infos(concatlist[0])) 178 | concatlist = [] 179 | return filestoencode 180 | 181 | def merge_and_encode_everything(): 182 | print("finding files to encode ...", end="\r") 183 | 184 | entries = [] 185 | #each ID in the source folder 186 | for id in os.listdir(sourcefolder): 187 | #ID-directory 188 | dir = os.path.join(sourcefolder, id) 189 | if os.path.isdir(dir): 190 | entries.extend(merge_files_in_model_directory(dir)) 191 | 192 | index = 0 193 | #hack to prevent division by 0 194 | totalsize = max(sum([entry["size"] for entry in entries]), 1) 195 | sizedone = 0 196 | #hack to prevent division by 0 197 | totallength = max(sum([entry["length"] for entry in entries]), 1) 198 | lengthdone = 0 199 | starttime = datetime.now() 200 | progresstemplate = " {0:,.2f}/{1:,.2f} MiB ({2:.2%}) [{7}] | {3}/{4} ({5:.2%}) [{8}] | [{6}]" 201 | 202 | def get_stats(): 203 | return [sizedone, 204 | totalsize, 205 | sizedone / totalsize, 206 | format_seconds(lengthdone), 207 | format_seconds(totallength), 208 | lengthdone / totallength, 209 | format_seconds((datetime.now() - starttime).total_seconds()), 210 | calculate_eta(starttime, sizedone / totalsize), 211 | calculate_eta(starttime, lengthdone / totallength)] 212 | 213 | for entry in entries: 214 | #create encoding target folder in case it doesnt exist 215 | if not args.dryrun and not os.path.exists(os.path.dirname(entry["target"])): 216 | os.makedirs(os.path.dirname(entry["target"])) 217 | index += 1 218 | log_and_print("{5} {0}: [{1:>10,.2f} MiB] [{2}] source: {3}, target: {4}" 219 | .format(datetimeformat.format(datetime.now()), entry["size"], format_seconds(entry["length"]), entry["source"], entry["target"], "{0}/{1}".format(index, len(entries)).rjust(9))) 220 | #print with carriage return at the end, so that this line can be overwritten by the next print 221 | print(progresstemplate.format(*get_stats()), end="\r") 222 | sizedone += entry["size"] 223 | lengthdone += entry["length"] 224 | if not args.dryrun: 225 | if not args.copy: 226 | #actual call to encode the video 227 | os.system(ffmpegcommand.format(entry["source"], entry["target"])) 228 | os.remove(entry["source"]) 229 | else: 230 | #only move the video file without encoding 231 | os.rename(entry["source"], entry["target"]) 232 | 233 | #final progress, should always show 100% 234 | print(progresstemplate.format(*get_stats())) 235 | 236 | merge_and_encode_everything() 237 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MFCRecorder 2 | 3 | This is script automates the recording of public webcam shows from myfreecams. 4 | 5 | 6 | ## Requirements 7 | 8 | I have only tested this on debian(7+8) and Mac OS X (10.10.4), but it should run on other OSs 9 | 10 | Requires python3.6 or newer. You can grab the newest python release from https://www.python.org/downloads/ 11 | and mfcauto.py (https://github.com/Damianonymous/mfcauto.py) 12 | 13 | ## installing and Cloning with the required modules: 14 | 15 | to install the required modules, run: (For Debain/Ubuntu) 16 | ``` 17 | sudo apt-install update && sudo apt-install upgrade 18 | sudo apt-install python3-pip && sudo apt-get install git 19 | cd /home/yourusername 20 | git clone https://github.com/Damianonymous/MFCRecorder 21 | cd MFCRecorder 22 | python3.6 -m pip install -r requirements.txt 23 | python3.6 -m pip install --upgrade git+https://github.com/Damianonymous/mfcauto.py@master 24 | Now edit the config.conf file and set the appropirate paths to your directories and wanted.txt file (see Setup) 25 | 26 | ``` 27 | 28 | to install required modules, run: (For Arch Linux, Antergos, Manjaro, etc.) 29 | ``` 30 | pacman -Syuu 31 | pacman -S python-pip git 32 | cd /home/yourusername 33 | git clone https://github.com/Damianonymous/MFCRecorder 34 | cd MFCRecorder 35 | python3.6 -m pip install -r requirements.txt 36 | python3.6 -m pip install --upgrade git+https://github.com/Damianonymous/mfcauto.py@master 37 | Now edit the config.conf file and set the appropirate paths to your directories and wanted.txt file (see Setup) 38 | 39 | ``` 40 | 41 | to install the required modules, run: (For CentOS/Red Hat/Fedora) NOT TESTED BUT GOES LIKE: 42 | ``` 43 | yum update 44 | yum upgrade 45 | yum python-pip 46 | yum install git 47 | cd /home/yourusername 48 | git clone https://github.com/Damianonymous/MFCRecorder 49 | cd MFCRecorder 50 | python3.6 -m pip install -r requirements.txt 51 | python3.6 -m pip install --upgrade git+https://github.com/Damianonymous/mfcauto.py@master 52 | Now edit the config.conf file and set the appropirate paths to your directories and wanted.txt file (see Setup) 53 | 54 | ``` 55 | 56 | ## Setup 57 | 58 | edit the config.conf file and set the appropirate paths to your directories and wanted.txt file. 59 | 60 | 61 | ## adding models to wanted list 62 | 63 | Add models UID (user ID) to the "wanted.txt" file (only one model per line). This uses the UID instead of the name becaue the models can change their name at anytime, but their UID always stays the same. There is a number of ways to get the models UID, but the easiest would probably be to get it from the URL for their profile image. The profile image URL is formatted as (or similar to): 64 | ``` 65 | https://img.mfcimg.com/photos2/###/{uid}/avatar.90x90.jpg 66 | ``` 67 | "{uid}" is the models UID which is the number you will want to add to the "wanted.txt" file. the "###" is the first 3 digits of the models UID. For example, if the models UID is "123456789" the URL for their profile picture will be: 68 | ``` 69 | https://img.mfcimg.com/photos2/123/123456789/avatar.90x90.jpg 70 | ``` 71 | 72 | alternatively, you can use the add.py script to add models to the MFC list to enable or disable their recordings. If a model already exists, her values will be updated to any new values passed in the arguments. (requires python3.5 or newer) 73 | 74 | Its usage is as follows: 75 | 76 | add.py [model display name or uid] [options] 77 | 78 | Add models to the MFC list to enable or disable their recordings. If a model 79 | already exists, her values will be updated to any new values passed in the 80 | arguments 81 | 82 | positional arguments: 83 | model REQUIRED: models name or uid. 84 | 85 | optional arguments: 86 | -h, --help show this help message and exit 87 | -n CUSTOM_NAME, --custom_name CUSTOM_NAME 88 | set a custom name for the model, otherwise the models 89 | current display name will be used. 90 | -c COMMENT, --comment COMMENT 91 | specify a comment or not for the user. 92 | -m MIN_VIEWERS, --min_viewers MIN_VIEWERS 93 | set the minimum number of viewers this model must have 94 | before recording starts 95 | -s STOP_VIEWERS, --stop_viewers STOP_VIEWERS 96 | set the number of viewers in which the recording will 97 | stop (should be less than minviewers 98 | -l LIST_MODE, --list_mode LIST_MODE 99 | set the list mode for the model 100 | -b, --block will add the model as blocked so she will not be 101 | recorded even if auto recording conditions are met 102 | -p PRIORITY, --priority PRIORITY 103 | set the priority value for the model 104 | 105 | Not passing options will add a model with all the default values, and the models current display name will be the default custom name if custom_name is not specified. 106 | ``` 107 | python3 add.py AspenRae 108 | ``` 109 | 110 | ## Web Interface (added Sep 4 2017) 111 | There is also a web interface now, although very limited. It currently only displays the recording models along with some system statistics. If you click on a models avatar, it will open their chatroom on myfreecams site. If you click on their name above their image, it will load their profile. Below their avatar it indicates the condition for their recording (wanted, tags, viewers...) as well as the number of viewers in their room at the moment. In addition to this, there is an "Add Model" text box where you can add a model by typing in their username and submitting it. (Doing anything with the web interface, html, or css is completely new to me, so I plan to add more features, but it will take me some time since Im learning as I go.) 112 | 113 | The default port is 8778, but it can be changed in the config file. So to view the web interface, open (https://127.0.0.1:8778/) in any web browser (note the https). If you want to access it from another computer on the same lan, replace the ip address with the ip address of that machine. 114 | 115 | ![Image of web interface](https://i.imgur.com/9AlK3tm.jpg) 116 | 117 | 118 | ## Additional options 119 | 120 | you can now set a custom "completed" directory where the videos will be moved when the stream ends. The variables which can be used in the naming are as follows: 121 | 122 | **{path}** = the value set to "save directory" 123 | 124 | **{model}** = the display name of the model 125 | 126 | **{uid}** = the uid (user id) or broadcasters id as its often reffered in MFCs code which is a static number for the model 127 | 128 | **{year}** = the current 4 digit year (ie:2017) 129 | 130 | **{month}** = the current two digit month (ie: 01 for January) 131 | 132 | **{day}** = the two digit day of the month 133 | 134 | **{hour}** = the two digit hour in 24 hour format (ie: 1pm = 13) 135 | 136 | **{minute}** = the current minute value in two digit format (ie: 1:28 = 28) 137 | 138 | **{seconds}** = the current times seconds value in 2 digit format 139 | 140 | **{auto}** = reason why the model was recorded if not in wanted list (see auto recording based on conditions below) 141 | 142 | For example, if a made up model named "hannah" who has the uid 208562, and the "save_directory" in the config file == "/Users/Joe/MFC/": {path}/{uid}/{year}/{year}.{month}.{day}_{hour}.{minutes}.{seconds}_{model}.mp4 = "/Users/Joe/MFC/208562/2017/2017.07.26_19.34.47_hannah.mp4" 143 | 144 | 145 | You can create your own "post processing" script which can be called at the end of the stream. The parameters which will be passed to the script are as follows: 146 | 147 | 1 = full file path (ie: /Users/Joe/MFC/208562/2017/2017.07.26_19.34.47_hannah.mp4) 148 | 149 | 2 = filename (ie : 2017.07.26_19.34.47_hannah.mp4) 150 | 151 | 3 = directory (ie : /Users/Joe/MFC/208562/hannah/2017/) 152 | 153 | 4 = models name (ie: hannah) 154 | 155 | 5 = uid (ie: 208562 as given in the directory/file naming structure example above) 156 | 157 | 158 | ## Conditional recording 159 | 160 | In the config file you can specify conditions in which models who are not in the wanted list should be recorded. There is also a blacklist you can create and add models UID to if you want to specify models who will not be recorded even if these conditions are met. 161 | 162 | **Tags**: you can add a comma (,) separated list of tags which models will be checked against each models specified tags. 163 | 164 | **minTags**: indicates the minimum number of tags from the "Tags" option which must be met to start recording a model. 165 | 166 | **newerThanHours**: If a model has joined the site in less than the number of hours specified here, the model will be recorded until she has been a model for longer than this time (it will continue to record any active recording started prior to this time). 167 | 168 | **score**: any model with a camscore greater than this number will be recorded 169 | 170 | **viewers**: when a model reachest this number of viewers in her chatroom, she will be recorded. This can be used to catch models as many users are entering the chat which usually indicates some sort of show has started. 171 | 172 | **autoStopViewers**: This only apples to models who are being recorded based on the viewers condition above. The session will stop recording when the number of viewers drops below this number. Make sure there is enough of a difference between these two numbers (viewers and autoStopViewers) to avoid the show continuously starting and stopping as the number of viewers moves above/below these numbers. 173 | 174 | 175 | 176 | 177 | # User Submitted Scripts 178 | 179 | User submitted scripts can be found in the 'scripts' directory. These are not scripts which are created by me (beaston02), but other users who are sharing with the comunity. 180 | 181 | ## merge.py 182 | Created by [sKanoodle](https://github.com/sKanoodle) 183 | 184 | This script will encode and merge recordings from individual models. 185 | 186 | #### SETTINGS 187 | 188 | **sourcefolder**: directory with model ID subdirectories 189 | 190 | **destinationfolder**: directory to save the encoded files in 191 | 192 | **creationregex**: regex for parsing creation date and time from filename 193 | 194 | **logfilepath**: logfile path (leave as empty string if no logging is desired) 195 | 196 | **ffmpegcommand**: {0} is the absoulte source file path, {1} is the absolute target file path 197 | 198 | **extension**: extension of the encoded file 199 | 200 | **ffmpegmergecommand**: {0} is the absolute path of the file with parts to concat, {1} is the absolute target file path 201 | 202 | **tmpconcatfilename**: name to use for the temp file. filename must not exist already directly in the sourcefolder 203 | 204 | **concatmaxtime**: max time in minutes that is allowed between the end of a video and the beginning of the next video to concatinate them 205 | 206 | **ignorefreshvideostime**: time in minutes that has to have passed since the last modification of a recording to include it for encoding. (should always be larger than concatmaxtime, otherwise the file will be encoded even if a next file would have been eligible to be concatinated to it) 207 | 208 | **datetimeformat**: format of time and date in the file names 209 | 210 | 211 | 212 | #### OPTIONS 213 | 214 | **-d, --dryrun**: Simulates encoding of all files in the source folder. Size and duration of some videos might differ, because there is no concatination performed, although the status output expects concatinated videos. It will therefore only show size and duration of the first file that should be concatinated 215 | 216 | 217 | **-c, --copy**: Only copies the video files instead of encoding them, but still merges them beforehand 218 | 219 | **-r, --remove**: Deletes video when detected as faulty when trying to merge videos, otherwise the file will just be ignored 220 | 221 | 222 | 223 | #### NOTES 224 | 225 | only tested on linux. 226 | Can be ran as a cron job to automatically merge and encode files. Encoding should reduce the size of the files 227 | 228 | ## symlink.py 229 | Created by [sKanoodle](https://github.com/sKanoodle) 230 | 231 | #### SETTINGS 232 | 233 | **models**: a dictionary where the keys are the models UIDs, and the values are their usernames. 234 | 235 | **encodedfilesdir**: the directory containing the recorded videos 236 | 237 | **symlinkdir**: the directory where you want the recordings to be linked to using the models name instead of their UID as the directory name. 238 | 239 | **wantedfile**: the path to the wanted file used by MFCRecorder. 240 | 241 | #### NOTES 242 | 243 | This script will create a symlink for the models UID directories to a directory using the models name. This will make it easier to browse through the recorded files by having directories named after the models instead of their UIDs, while still keeping all of their recordings in a single directory if/when the model changes her display name. 244 | 245 | Only tested on linux 246 | 247 | ## postProcessing.py and test_postProcessing.py 248 | 249 | Example of a post processing script to encode (or move, filter, etc.) files directly after the recording ended. test_postProcessing.py emulates the call from the main script (MFCRecorder.py), so that postProcessing.py can be tested independently. For more information refer to the comments inside the scripts. 250 | --------------------------------------------------------------------------------