Audio CDs are working well!
This commit is contained in:
parent
91a332ca53
commit
ede1fd22be
58
README.md
58
README.md
|
@ -11,15 +11,22 @@ and then re-encode the content to a compressed format.
|
||||||
|
|
||||||
At the time I'm writing this README, it will:
|
At the time I'm writing this README, it will:
|
||||||
|
|
||||||
* ~~Rip audio CDs, look them up in cddb, encode them to VBR MP3, then tag them.~~ A rewrite broke this; I plan to fix it soon.
|
* Rip audio CDs, look them up in cddb, encode them to VBR MP3, then tag them.
|
||||||
|
* It also writes a shell script you can modify to quickly change the tags, since this is a pretty common thing to want to do.
|
||||||
* Rip video DVDs, transcode them to mkv
|
* Rip video DVDs, transcode them to mkv
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
* HandBrakeCLI
|
The requirements are fairly light: a few CD tools, cdparanoia, HandBrakeCLI, and some
|
||||||
* cdparanoia
|
DVD libraries.
|
||||||
* cd-discid
|
|
||||||
*
|
Most notably, you do *not* need a relational database (SQLite, Postgres, MySQL).
|
||||||
|
You just need a file system.
|
||||||
|
|
||||||
|
For a complete list of requirements,
|
||||||
|
look at the [Dockerfile](Dockerfile)
|
||||||
|
to see what Debian packages it installs.
|
||||||
|
|
||||||
|
|
||||||
## How To Run This
|
## How To Run This
|
||||||
|
|
||||||
|
@ -34,9 +41,8 @@ Mine is `/srv/ext/incoming`.
|
||||||
-v /srv/ext/incoming:/incoming \
|
-v /srv/ext/incoming:/incoming \
|
||||||
registry.gitlab.com/dartcatcher/media-sucker/media-sucker
|
registry.gitlab.com/dartcatcher/media-sucker/media-sucker
|
||||||
|
|
||||||
I can't get it to work with docker swarm.
|
I can't get it to work with docker swarm,
|
||||||
Presumably some magic is happening with `--device`.
|
which doesn't support `--device`.
|
||||||
It probably has something to do with selinux.
|
|
||||||
|
|
||||||
Stick a video DVD or audio CD in,
|
Stick a video DVD or audio CD in,
|
||||||
and the drive should spin up for a while,
|
and the drive should spin up for a while,
|
||||||
|
@ -46,9 +52,14 @@ or a new directory of `.mp3` files (for audio).
|
||||||
|
|
||||||
You can watch what it's doing at http://localhost:8080/
|
You can watch what it's doing at http://localhost:8080/
|
||||||
|
|
||||||
|
|
||||||
## A note on filenames and tags
|
## A note on filenames and tags
|
||||||
|
|
||||||
This program does the absolute minimum to try and tag your media properly.
|
This program does the absolute minimum to try and tag your media properly.
|
||||||
|
Partly because I'm a lazy programmer,
|
||||||
|
but mostly because the computer can only guess at things that you,
|
||||||
|
the operator,
|
||||||
|
can just read off the box.
|
||||||
|
|
||||||
For DVDs, that means reading the "title" stored on the DVD,
|
For DVDs, that means reading the "title" stored on the DVD,
|
||||||
which I've seen vary from very helpful (eg. "Barbie A Fashion Fairytale")
|
which I've seen vary from very helpful (eg. "Barbie A Fashion Fairytale")
|
||||||
|
@ -62,13 +73,10 @@ so CDDB takes the length of every track in seconds and tries to match that
|
||||||
against something a user has uploaded in the past.
|
against something a user has uploaded in the past.
|
||||||
This is wrong a whole lot of the time.
|
This is wrong a whole lot of the time.
|
||||||
|
|
||||||
If CDDB can't find a match for an audio CD,
|
|
||||||
this program will append the datestamp of the rip to the album name,
|
|
||||||
in the hopes that you can remember about what time you put each CD in the drive.
|
|
||||||
So for stuff like multi-CD audiobooks, that's pretty helpful.
|
|
||||||
|
|
||||||
But the end result in almost every case is that you're going to have to
|
But the end result in almost every case is that you're going to have to
|
||||||
manually edit the metadata.
|
rename the movie file, or re-tag the audio files.
|
||||||
|
This is why you get a `tag.sh` file with every audio CD rip.
|
||||||
|
|
||||||
|
|
||||||
## Answers
|
## Answers
|
||||||
|
|
||||||
|
@ -76,35 +84,23 @@ I'm skipping the part where I make up questions I think people might have.
|
||||||
|
|
||||||
### Why I Wrote This
|
### Why I Wrote This
|
||||||
|
|
||||||
The `automatic-ripping-machine` looks really badass.
|
The automatic-ripping-machine looks really badass.
|
||||||
But after multiple attempts across multiple months
|
But after multiple attempts across multiple months
|
||||||
to get it running,
|
to get it running,
|
||||||
I decided it would probably be faster just to write my own.
|
I decided it would probably be faster just to write my own.
|
||||||
|
|
||||||
This isn't as cool as the aumomatic-ripping-machine.
|
media-sucker isn't as cool as the automatic-ripping-machine.
|
||||||
But, at least for me,
|
But, at least for me,
|
||||||
it's a lot more functional,
|
it's more useful,
|
||||||
in that it actually does something.
|
in that I can get it to actually do something.
|
||||||
|
|
||||||
### Why You Should Run This
|
### Why You Should Run This
|
||||||
|
|
||||||
The only reason I can think of that anybody would want to use this is if they,
|
The only reason I can think of that anybody would want to use this is if they,
|
||||||
like me,
|
like me,
|
||||||
are too dumb to get the `automatic-ripping-machine` to work.
|
are too dumb to get the automatic-ripping-machine to work.
|
||||||
|
|
||||||
### What Kind Of Hardware I Use
|
### What Kind Of Hardware I Use
|
||||||
|
|
||||||
I run it on a Raspberry Pi 4,
|
I run it on a Raspberry Pi 4,
|
||||||
with a Samsung DVD drive from the stone age.
|
with a Samsung DVD drive from the stone age.
|
||||||
|
|
||||||
|
|
||||||
## Parting note
|
|
||||||
|
|
||||||
As of 2022-08-22, large sections of this code were written under COVID brain-fog.
|
|
||||||
This means it's going to look a lot like a 13-year-old wrote it.
|
|
||||||
|
|
||||||
I hope one day to clean it up a bit,
|
|
||||||
but it's working fairly well,
|
|
||||||
despite the mess.
|
|
||||||
Please don't judge me for the organization of things.
|
|
||||||
Judge bizarro universe Neale instead.
|
|
||||||
|
|
39
src/cd.py
39
src/cd.py
|
@ -25,6 +25,7 @@ def scan(state, device):
|
||||||
)
|
)
|
||||||
discid = p.stdout.strip()
|
discid = p.stdout.strip()
|
||||||
state["discid"] = discid
|
state["discid"] = discid
|
||||||
|
cddb_id = discid.split()[0]
|
||||||
|
|
||||||
# Look it up in cddb
|
# Look it up in cddb
|
||||||
email = os.environ.get("EMAIL") # You should really set this variable, tho
|
email = os.environ.get("EMAIL") # You should really set this variable, tho
|
||||||
|
@ -44,10 +45,9 @@ def scan(state, device):
|
||||||
for k in ("title", "artist", "genre", "year", "tracks"):
|
for k in ("title", "artist", "genre", "year", "tracks"):
|
||||||
state[k] = disc[k]
|
state[k] = disc[k]
|
||||||
else:
|
else:
|
||||||
now = time.strftime("%Y-%m-%dT%H%M%S")
|
|
||||||
num_tracks = int(discid.split()[1])
|
num_tracks = int(discid.split()[1])
|
||||||
state["title"] = "Unknown CD - %s" % now
|
state["title"] = "Unknown CD - %s" % cddb_id
|
||||||
state["tracks"] = ["Track %02d" % i for i in range(num_tracks)]
|
state["tracks"] = ["Track %02d" % (i+1) for i in range(num_tracks)]
|
||||||
|
|
||||||
|
|
||||||
def copy(state, device, directory):
|
def copy(state, device, directory):
|
||||||
|
@ -84,9 +84,20 @@ def copy(state, device, directory):
|
||||||
|
|
||||||
def encode(state, directory):
|
def encode(state, directory):
|
||||||
track_num = 1
|
track_num = 1
|
||||||
|
total_tracks = len(state["tracks"])
|
||||||
durations = [int(d) for d in state["discid"].split()[2:-1]]
|
durations = [int(d) for d in state["discid"].split()[2:-1]]
|
||||||
total_duration = sum(durations)
|
total_duration = sum(durations)
|
||||||
encoded_duration = 0
|
encoded_duration = 0
|
||||||
|
|
||||||
|
tag_script = io.StringIO()
|
||||||
|
tag_script.write("#! /bin/sh\n")
|
||||||
|
tag_script.write("\n")
|
||||||
|
tag_script.write("ALBUM=%s\n" % state["title"])
|
||||||
|
tag_script.write("ARTIST=%s\n" % state.get("artist", ""))
|
||||||
|
tag_script.write("GENRE=%s\n" % state.get("genre", ""))
|
||||||
|
tag_script.write("YEAR=%s\n" % state.get("year", ""))
|
||||||
|
tag_script.write("\n")
|
||||||
|
|
||||||
for track_name in state["tracks"]:
|
for track_name in state["tracks"]:
|
||||||
logging.debug("Encoding track %d (%s)" % (track_num, track_name))
|
logging.debug("Encoding track %d (%s)" % (track_num, track_name))
|
||||||
duration = durations[track_num-1]
|
duration = durations[track_num-1]
|
||||||
|
@ -97,8 +108,13 @@ def encode(state, directory):
|
||||||
"--disptime", "1",
|
"--disptime", "1",
|
||||||
"--preset", "standard",
|
"--preset", "standard",
|
||||||
"--tl", state["title"],
|
"--tl", state["title"],
|
||||||
"--tn", "%d/%d" % (track_num, len(state["tracks"])),
|
"--tn", "%d/%d" % (track_num, total_tracks),
|
||||||
]
|
]
|
||||||
|
tag_script.write("id3v2")
|
||||||
|
tag_script.write(" --album \"$ALBUM\"")
|
||||||
|
tag_script.write(" --artist \"$ARTIST\"")
|
||||||
|
tag_script.write(" --genre \"$GENRE\"")
|
||||||
|
tag_script.write(" --year \"$YEAR\"")
|
||||||
if state.get("artist"):
|
if state.get("artist"):
|
||||||
argv.extend(["--ta", state["artist"]])
|
argv.extend(["--ta", state["artist"]])
|
||||||
if state.get("genre"):
|
if state.get("genre"):
|
||||||
|
@ -107,11 +123,16 @@ def encode(state, directory):
|
||||||
argv.extend(["--ty", state["year"]])
|
argv.extend(["--ty", state["year"]])
|
||||||
if track_name:
|
if track_name:
|
||||||
argv.extend(["--tt", track_name])
|
argv.extend(["--tt", track_name])
|
||||||
|
tag_script.write(" --song \"%s\"" % track_name)
|
||||||
outfn = "%02d - %s.mp3" % (track_num, track_name)
|
outfn = "%02d - %s.mp3" % (track_num, track_name)
|
||||||
else:
|
else:
|
||||||
outfn = "%02d.mp3" % track_num
|
outfn = "%02d.mp3" % track_num
|
||||||
argv.append("track%02d.cdda.wav" % track_num)
|
argv.append("track%02d.cdda.wav" % track_num)
|
||||||
argv.append(outfn)
|
argv.append(outfn)
|
||||||
|
tag_script.write("\\\n ")
|
||||||
|
tag_script.write(" --track %d/%d" % (track_num, total_tracks))
|
||||||
|
tag_script.write(" \"%s\"\n" % outfn)
|
||||||
|
|
||||||
p = subprocess.Popen(
|
p = subprocess.Popen(
|
||||||
argv,
|
argv,
|
||||||
cwd = directory,
|
cwd = directory,
|
||||||
|
@ -125,14 +146,18 @@ def encode(state, directory):
|
||||||
p = p.split("%")[0]
|
p = p.split("%")[0]
|
||||||
pct = int(p) / 100
|
pct = int(p) / 100
|
||||||
yield (encoded_duration + (duration * pct)) / total_duration
|
yield (encoded_duration + (duration * pct)) / total_duration
|
||||||
|
|
||||||
encoded_duration += duration
|
encoded_duration += duration
|
||||||
track_num += 1
|
track_num += 1
|
||||||
|
|
||||||
|
with open(os.path.join(directory, "tag.sh"), "w") as f:
|
||||||
|
f.write(tag_script.getvalue())
|
||||||
|
|
||||||
|
|
||||||
def clean(state, directory):
|
def clean(state, directory):
|
||||||
pass
|
for fn in os.listdir(directory):
|
||||||
|
if fn.endswith(".wav"):
|
||||||
|
os.remove(os.path.join(directory, fn))
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import pprint
|
import pprint
|
||||||
|
|
|
@ -43,7 +43,7 @@ def scan(state, device):
|
||||||
title = lsdvd["provider_id"]
|
title = lsdvd["provider_id"]
|
||||||
if title == "$PACKAGE_STRING":
|
if title == "$PACKAGE_STRING":
|
||||||
title = "DVD"
|
title = "DVD"
|
||||||
now = time.strftime(r"%Y-%m-%dT%H%M%S")
|
now = time.strftime(r"%Y-%m-%dT%H:%M:%S")
|
||||||
title = "%s %s" % (title, now)
|
title = "%s %s" % (title, now)
|
||||||
|
|
||||||
# Go through all the tracks, looking for the largest referenced sector.
|
# Go through all the tracks, looking for the largest referenced sector.
|
||||||
|
|
|
@ -12,6 +12,7 @@ import re
|
||||||
import logging
|
import logging
|
||||||
import dvd
|
import dvd
|
||||||
import cd
|
import cd
|
||||||
|
import traceback
|
||||||
import worker
|
import worker
|
||||||
|
|
||||||
class Encoder(worker.Worker):
|
class Encoder(worker.Worker):
|
||||||
|
@ -24,16 +25,20 @@ class Encoder(worker.Worker):
|
||||||
while True:
|
while True:
|
||||||
wait = True
|
wait = True
|
||||||
self.status = {"type": "encoder", "state": "idle"}
|
self.status = {"type": "encoder", "state": "idle"}
|
||||||
for fn in glob.glob(self.workdir("*", "state.json")):
|
for fn in glob.glob(self.workdir("*", "sucker.json")):
|
||||||
self.encode(os.path.dirname(fn), obj)
|
directory = os.path.dirname(fn)
|
||||||
|
state = self.read_state(directory)
|
||||||
|
try:
|
||||||
|
self.encode(directory, state)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error("Error encoding %s: %s" % (directory, e))
|
||||||
|
logging.error(traceback.format_exc())
|
||||||
wait = False
|
wait = False
|
||||||
if wait:
|
if wait:
|
||||||
time.sleep(12)
|
time.sleep(12)
|
||||||
|
|
||||||
def encode(self, directory, obj):
|
def encode(self, directory, state):
|
||||||
self.status["state"] = "encoding"
|
self.status["state"] = "encoding"
|
||||||
|
|
||||||
state = self.read_state(directory)
|
|
||||||
self.status["title"] = state["title"]
|
self.status["title"] = state["title"]
|
||||||
|
|
||||||
if state["video"]:
|
if state["video"]:
|
||||||
|
@ -46,6 +51,7 @@ class Encoder(worker.Worker):
|
||||||
self.status["complete"] = pct
|
self.status["complete"] = pct
|
||||||
|
|
||||||
media.clean(state, directory)
|
media.clean(state, directory)
|
||||||
|
self.clear_state(directory)
|
||||||
|
|
||||||
logging.info("Finished encoding")
|
logging.info("Finished encoding")
|
||||||
|
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
class MediaHandler:
|
|
||||||
def __init__(self, basedir, state):
|
|
||||||
self.basedir = basedir
|
|
||||||
self.state = state
|
|
||||||
|
|
||||||
def
|
|
|
@ -31,7 +31,7 @@ CDROM_EJECT = 0x5309
|
||||||
|
|
||||||
class Reader(worker.Worker):
|
class Reader(worker.Worker):
|
||||||
def __init__(self, device, directory):
|
def __init__(self, device, directory):
|
||||||
super().__init__(device)
|
super().__init__(directory)
|
||||||
self.device = device
|
self.device = device
|
||||||
self.status["type"] = "reader"
|
self.status["type"] = "reader"
|
||||||
self.status["device"] = device
|
self.status["device"] = device
|
||||||
|
@ -66,24 +66,22 @@ class Reader(worker.Worker):
|
||||||
rv = fcntl.ioctl(self.drive, CDROM_DISC_STATUS)
|
rv = fcntl.ioctl(self.drive, CDROM_DISC_STATUS)
|
||||||
try:
|
try:
|
||||||
if rv == CDS_AUDIO:
|
if rv == CDS_AUDIO:
|
||||||
self.handle(false)
|
self.handle(False)
|
||||||
elif rv in [CDS_DATA_1, CDS_DATA_2]:
|
elif rv in [CDS_DATA_1, CDS_DATA_2]:
|
||||||
self.handle(true)
|
self.handle(True)
|
||||||
else:
|
else:
|
||||||
logging.info("Can't handle disc type %d" % rv)
|
logging.info("Can't handle disc type %d" % rv)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error("Error in disc handler: %s" % e)
|
logging.error("Error in disc handler: %s" % e)
|
||||||
logging.error(traceback.format_exc())
|
logging.error(traceback.format_exc())
|
||||||
self.eject()
|
self.eject()
|
||||||
elif rv in (CDS_TRAY_OPEN, CDS_NO_DISC):
|
elif rv in (CDS_TRAY_OPEN, CDS_NO_DISC, CDS_DRIVE_NOT_READ):
|
||||||
time.sleep(3)
|
time.sleep(3)
|
||||||
else:
|
else:
|
||||||
logging.info("CDROM_DRIVE_STATUS: %d (%s)" % (rv, CDS_STR[rv]))
|
logging.info("CDROM_DRIVE_STATUS: %d (%s)" % (rv, CDS_STR[rv]))
|
||||||
time.sleep(3)
|
time.sleep(3)
|
||||||
|
|
||||||
def eject(self):
|
def eject(self):
|
||||||
self.status["state"] = "ejecting"
|
|
||||||
|
|
||||||
for i in range(20):
|
for i in range(20):
|
||||||
try:
|
try:
|
||||||
fcntl.ioctl(self.drive, CDROM_LOCKDOOR, 0)
|
fcntl.ioctl(self.drive, CDROM_LOCKDOOR, 0)
|
||||||
|
@ -100,16 +98,18 @@ class Reader(worker.Worker):
|
||||||
state = {}
|
state = {}
|
||||||
state["video"] = video
|
state["video"] = video
|
||||||
if video:
|
if video:
|
||||||
media = cd
|
|
||||||
else:
|
|
||||||
media = dvd
|
media = dvd
|
||||||
|
else:
|
||||||
|
media = cd
|
||||||
|
|
||||||
media.scan(state, self.device)
|
media.scan(state, self.device)
|
||||||
self.status["title"] = state["title"]
|
self.status["title"] = state["title"]
|
||||||
subdir = slugify.slugify(state["title"])
|
subdir = slugify.slugify(state["title"])
|
||||||
|
workdir = self.workdir(subdir)
|
||||||
|
os.makedirs(workdir, exist_ok=True)
|
||||||
|
|
||||||
self.status["state"] = "copying"
|
self.status["state"] = "copying"
|
||||||
for pct in media.copy(device, self.workdir(subdir)):
|
for pct in media.copy(state, self.device, workdir):
|
||||||
self.status["complete"] = pct
|
self.status["complete"] = pct
|
||||||
|
|
||||||
self.write_state(subdir, state)
|
self.write_state(subdir, state)
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
import threading
|
import threading
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
class Worker(threading.Thread):
|
class Worker(threading.Thread):
|
||||||
def __init__(self, directory, **kwargs):
|
def __init__(self, directory, **kwargs):
|
||||||
self.directory = directory
|
self.directory = directory
|
||||||
self.status = {
|
self.status = {
|
||||||
"state": "idle",
|
"state": "idle",
|
||||||
"directory": directory,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
kwargs["daemon"] = True
|
kwargs["daemon"] = True
|
||||||
|
@ -17,9 +17,16 @@ class Worker(threading.Thread):
|
||||||
return os.path.join(self.directory, *path)
|
return os.path.join(self.directory, *path)
|
||||||
|
|
||||||
def write_state(self, subdir, state):
|
def write_state(self, subdir, state):
|
||||||
with open(self.workdir(subdir, "state.json"), "w") as f:
|
logging.debug("Writing state: %s" % repr(state))
|
||||||
json.dump(f, state)
|
statefn = self.workdir(subdir, "sucker.json")
|
||||||
|
newstatefn = statefn + ".new"
|
||||||
|
with open(newstatefn, "w") as f:
|
||||||
|
json.dump(state, f)
|
||||||
|
os.rename(newstatefn, statefn)
|
||||||
|
|
||||||
def read_state(self, subdir):
|
def read_state(self, subdir):
|
||||||
with open(self.workdir(subdir, "state.json")) as f:
|
with open(self.workdir(subdir, "sucker.json")) as f:
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
|
|
||||||
|
def clear_state(self, subdir):
|
||||||
|
os.unlink(self.workdir(subdir, "sucker.json"))
|
||||||
|
|
Loading…
Reference in New Issue