aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/source/media-types.rst2
-rw-r--r--extlib/freesound/audioprocessing.py616
-rw-r--r--mediagoblin/config_spec.ini19
-rw-r--r--mediagoblin/db/sql/convert.py17
-rw-r--r--mediagoblin/db/sql/extratypes.py4
-rw-r--r--mediagoblin/db/sql/models_v0.py300
-rw-r--r--mediagoblin/media_types/__init__.py60
-rw-r--r--mediagoblin/media_types/ascii/__init__.py4
-rw-r--r--mediagoblin/media_types/ascii/asciitoimage.py1
-rw-r--r--mediagoblin/media_types/ascii/models.py6
-rw-r--r--mediagoblin/media_types/ascii/processing.py22
-rw-r--r--mediagoblin/media_types/audio/__init__.py25
l---------mediagoblin/media_types/audio/audioprocessing.py1
-rw-r--r--mediagoblin/media_types/audio/migrations.py17
-rw-r--r--mediagoblin/media_types/audio/models.py36
-rw-r--r--mediagoblin/media_types/audio/processing.py130
-rw-r--r--mediagoblin/media_types/audio/transcoders.py237
-rw-r--r--mediagoblin/media_types/image/__init__.py4
-rw-r--r--mediagoblin/media_types/image/models.py4
-rw-r--r--mediagoblin/media_types/image/processing.py134
-rw-r--r--mediagoblin/media_types/video/__init__.py12
-rw-r--r--mediagoblin/media_types/video/models.py4
-rw-r--r--mediagoblin/media_types/video/processing.py45
-rw-r--r--mediagoblin/media_types/video/transcoders.py142
-rw-r--r--mediagoblin/processing/__init__.py35
-rw-r--r--mediagoblin/static/css/audio.css84
-rw-r--r--mediagoblin/static/css/base.css89
-rw-r--r--mediagoblin/static/js/audio.js229
-rw-r--r--mediagoblin/static/js/geolocation-map.js5
-rw-r--r--mediagoblin/static/js/header_dropdown.js30
-rw-r--r--mediagoblin/static/js/show_password.js1
-rw-r--r--mediagoblin/submit/views.py19
-rw-r--r--mediagoblin/templates/mediagoblin/base.html23
-rw-r--r--mediagoblin/templates/mediagoblin/media_displays/audio.html61
-rw-r--r--mediagoblin/templates/mediagoblin/user_pages/media.html16
-rw-r--r--mediagoblin/tests/test_processing.py20
-rw-r--r--mediagoblin/tests/test_submission.py351
-rw-r--r--mediagoblin/tests/test_submission/bigblue.pngbin0 -> 3142 bytes
-rw-r--r--mediagoblin/tools/exif.py7
39 files changed, 2407 insertions, 405 deletions
diff --git a/docs/source/media-types.rst b/docs/source/media-types.rst
index ec068422..647f2b42 100644
--- a/docs/source/media-types.rst
+++ b/docs/source/media-types.rst
@@ -30,7 +30,7 @@ To enable video, first install gstreamer and the python-gstreamer
bindings (as well as whatever gstremaer extensions you want,
good/bad/ugly). On Debianoid systems::
- sudo apt-get install python-gst0.10
+ sudo apt-get install python-gst0.10 gstreamer0.10-plugins-{base,bad,good,ugly} gstreamer0.10-ffmpeg
Next, modify (and possibly copy over from ``mediagoblin.ini``) your
``mediagoblin_local.ini``. Uncomment this line in the ``[mediagoblin]``
diff --git a/extlib/freesound/audioprocessing.py b/extlib/freesound/audioprocessing.py
new file mode 100644
index 00000000..c1dfe2eb
--- /dev/null
+++ b/extlib/freesound/audioprocessing.py
@@ -0,0 +1,616 @@
+#!/usr/bin/env python
+# processing.py -- various audio processing functions
+# Copyright (C) 2008 MUSIC TECHNOLOGY GROUP (MTG)
+# UNIVERSITAT POMPEU FABRA
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+#
+# Authors:
+# Bram de Jong <bram.dejong at domain.com where domain in gmail>
+# 2012, Joar Wandborg <first name at last name dot se>
+
+import Image, ImageDraw, ImageColor #@UnresolvedImport
+from functools import partial
+import math
+import numpy
+import os
+import re
+import signal
+
+
+def get_sound_type(input_filename):
+ sound_type = os.path.splitext(input_filename.lower())[1].strip(".")
+
+ if sound_type == "fla":
+ sound_type = "flac"
+ elif sound_type == "aif":
+ sound_type = "aiff"
+
+ return sound_type
+
+
+try:
+ import scikits.audiolab as audiolab
+except ImportError:
+ print "WARNING: audiolab is not installed so wav2png will not work"
+import subprocess
+
+class AudioProcessingException(Exception):
+ pass
+
+class TestAudioFile(object):
+ """A class that mimics audiolab.sndfile but generates noise instead of reading
+ a wave file. Additionally it can be told to have a "broken" header and thus crashing
+ in the middle of the file. Also useful for testing ultra-short files of 20 samples."""
+ def __init__(self, num_frames, has_broken_header=False):
+ self.seekpoint = 0
+ self.nframes = num_frames
+ self.samplerate = 44100
+ self.channels = 1
+ self.has_broken_header = has_broken_header
+
+ def seek(self, seekpoint):
+ self.seekpoint = seekpoint
+
+ def read_frames(self, frames_to_read):
+ if self.has_broken_header and self.seekpoint + frames_to_read > self.num_frames / 2:
+ raise RuntimeError()
+
+ num_frames_left = self.num_frames - self.seekpoint
+ will_read = num_frames_left if num_frames_left < frames_to_read else frames_to_read
+ self.seekpoint += will_read
+ return numpy.random.random(will_read)*2 - 1
+
+
+def get_max_level(filename):
+ max_value = 0
+ buffer_size = 4096
+ audio_file = audiolab.Sndfile(filename, 'r')
+ n_samples_left = audio_file.nframes
+
+ while n_samples_left:
+ to_read = min(buffer_size, n_samples_left)
+
+ try:
+ samples = audio_file.read_frames(to_read)
+ except RuntimeError:
+ # this can happen with a broken header
+ break
+
+ # convert to mono by selecting left channel only
+ if audio_file.channels > 1:
+ samples = samples[:,0]
+
+ max_value = max(max_value, numpy.abs(samples).max())
+
+ n_samples_left -= to_read
+
+ audio_file.close()
+
+ return max_value
+
+class AudioProcessor(object):
+ """
+ The audio processor processes chunks of audio an calculates the spectrac centroid and the peak
+ samples in that chunk of audio.
+ """
+ def __init__(self, input_filename, fft_size, window_function=numpy.hanning):
+ max_level = get_max_level(input_filename)
+
+ self.audio_file = audiolab.Sndfile(input_filename, 'r')
+ self.fft_size = fft_size
+ self.window = window_function(self.fft_size)
+ self.spectrum_range = None
+ self.lower = 100
+ self.higher = 22050
+ self.lower_log = math.log10(self.lower)
+ self.higher_log = math.log10(self.higher)
+ self.clip = lambda val, low, high: min(high, max(low, val))
+
+ # figure out what the maximum value is for an FFT doing the FFT of a DC signal
+ fft = numpy.fft.rfft(numpy.ones(fft_size) * self.window)
+ max_fft = (numpy.abs(fft)).max()
+ # set the scale to normalized audio and normalized FFT
+ self.scale = 1.0/max_level/max_fft if max_level > 0 else 1
+
+ def read(self, start, size, resize_if_less=False):
+ """ read size samples starting at start, if resize_if_less is True and less than size
+ samples are read, resize the array to size and fill with zeros """
+
+ # number of zeros to add to start and end of the buffer
+ add_to_start = 0
+ add_to_end = 0
+
+ if start < 0:
+ # the first FFT window starts centered around zero
+ if size + start <= 0:
+ return numpy.zeros(size) if resize_if_less else numpy.array([])
+ else:
+ self.audio_file.seek(0)
+
+ add_to_start = -start # remember: start is negative!
+ to_read = size + start
+
+ if to_read > self.audio_file.nframes:
+ add_to_end = to_read - self.audio_file.nframes
+ to_read = self.audio_file.nframes
+ else:
+ self.audio_file.seek(start)
+
+ to_read = size
+ if start + to_read >= self.audio_file.nframes:
+ to_read = self.audio_file.nframes - start
+ add_to_end = size - to_read
+
+ try:
+ samples = self.audio_file.read_frames(to_read)
+ except RuntimeError:
+ # this can happen for wave files with broken headers...
+ return numpy.zeros(size) if resize_if_less else numpy.zeros(2)
+
+ # convert to mono by selecting left channel only
+ if self.audio_file.channels > 1:
+ samples = samples[:,0]
+
+ if resize_if_less and (add_to_start > 0 or add_to_end > 0):
+ if add_to_start > 0:
+ samples = numpy.concatenate((numpy.zeros(add_to_start), samples), axis=1)
+
+ if add_to_end > 0:
+ samples = numpy.resize(samples, size)
+ samples[size - add_to_end:] = 0
+
+ return samples
+
+
+ def spectral_centroid(self, seek_point, spec_range=110.0):
+ """ starting at seek_point read fft_size samples, and calculate the spectral centroid """
+
+ samples = self.read(seek_point - self.fft_size/2, self.fft_size, True)
+
+ samples *= self.window
+ fft = numpy.fft.rfft(samples)
+ spectrum = self.scale * numpy.abs(fft) # normalized abs(FFT) between 0 and 1
+ length = numpy.float64(spectrum.shape[0])
+
+ # scale the db spectrum from [- spec_range db ... 0 db] > [0..1]
+ db_spectrum = ((20*(numpy.log10(spectrum + 1e-60))).clip(-spec_range, 0.0) + spec_range)/spec_range
+
+ energy = spectrum.sum()
+ spectral_centroid = 0
+
+ if energy > 1e-60:
+ # calculate the spectral centroid
+
+ if self.spectrum_range == None:
+ self.spectrum_range = numpy.arange(length)
+
+ spectral_centroid = (spectrum * self.spectrum_range).sum() / (energy * (length - 1)) * self.audio_file.samplerate * 0.5
+
+ # clip > log10 > scale between 0 and 1
+ spectral_centroid = (math.log10(self.clip(spectral_centroid, self.lower, self.higher)) - self.lower_log) / (self.higher_log - self.lower_log)
+
+ return (spectral_centroid, db_spectrum)
+
+
+ def peaks(self, start_seek, end_seek):
+ """ read all samples between start_seek and end_seek, then find the minimum and maximum peak
+ in that range. Returns that pair in the order they were found. So if min was found first,
+ it returns (min, max) else the other way around. """
+
+ # larger blocksizes are faster but take more mem...
+ # Aha, Watson, a clue, a tradeof!
+ block_size = 4096
+
+ max_index = -1
+ max_value = -1
+ min_index = -1
+ min_value = 1
+
+ if start_seek < 0:
+ start_seek = 0
+
+ if end_seek > self.audio_file.nframes:
+ end_seek = self.audio_file.nframes
+
+ if end_seek <= start_seek:
+ samples = self.read(start_seek, 1)
+ return (samples[0], samples[0])
+
+ if block_size > end_seek - start_seek:
+ block_size = end_seek - start_seek
+
+ for i in range(start_seek, end_seek, block_size):
+ samples = self.read(i, block_size)
+
+ local_max_index = numpy.argmax(samples)
+ local_max_value = samples[local_max_index]
+
+ if local_max_value > max_value:
+ max_value = local_max_value
+ max_index = local_max_index
+
+ local_min_index = numpy.argmin(samples)
+ local_min_value = samples[local_min_index]
+
+ if local_min_value < min_value:
+ min_value = local_min_value
+ min_index = local_min_index
+
+ return (min_value, max_value) if min_index < max_index else (max_value, min_value)
+
+
+def interpolate_colors(colors, flat=False, num_colors=256):
+ """ given a list of colors, create a larger list of colors interpolating
+ the first one. If flatten is True a list of numers will be returned. If
+ False, a list of (r,g,b) tuples. num_colors is the number of colors wanted
+ in the final list """
+
+ palette = []
+
+ for i in range(num_colors):
+ index = (i * (len(colors) - 1))/(num_colors - 1.0)
+ index_int = int(index)
+ alpha = index - float(index_int)
+
+ if alpha > 0:
+ r = (1.0 - alpha) * colors[index_int][0] + alpha * colors[index_int + 1][0]
+ g = (1.0 - alpha) * colors[index_int][1] + alpha * colors[index_int + 1][1]
+ b = (1.0 - alpha) * colors[index_int][2] + alpha * colors[index_int + 1][2]
+ else:
+ r = (1.0 - alpha) * colors[index_int][0]
+ g = (1.0 - alpha) * colors[index_int][1]
+ b = (1.0 - alpha) * colors[index_int][2]
+
+ if flat:
+ palette.extend((int(r), int(g), int(b)))
+ else:
+ palette.append((int(r), int(g), int(b)))
+
+ return palette
+
+
+def desaturate(rgb, amount):
+ """
+ desaturate colors by amount
+ amount == 0, no change
+ amount == 1, grey
+ """
+ luminosity = sum(rgb) / 3.0
+ desat = lambda color: color - amount * (color - luminosity)
+
+ return tuple(map(int, map(desat, rgb)))
+
+
+class WaveformImage(object):
+ """
+ Given peaks and spectral centroids from the AudioProcessor, this class will construct
+ a wavefile image which can be saved as PNG.
+ """
+ def __init__(self, image_width, image_height, palette=1):
+ if image_height % 2 == 0:
+ raise AudioProcessingException, "Height should be uneven: images look much better at uneven height"
+
+ if palette == 1:
+ background_color = (0,0,0)
+ colors = [
+ (50,0,200),
+ (0,220,80),
+ (255,224,0),
+ (255,70,0),
+ ]
+ elif palette == 2:
+ background_color = (0,0,0)
+ colors = [self.color_from_value(value/29.0) for value in range(0,30)]
+ elif palette == 3:
+ background_color = (213, 217, 221)
+ colors = map( partial(desaturate, amount=0.7), [
+ (50,0,200),
+ (0,220,80),
+ (255,224,0),
+ ])
+ elif palette == 4:
+ background_color = (213, 217, 221)
+ colors = map( partial(desaturate, amount=0.8), [self.color_from_value(value/29.0) for value in range(0,30)])
+
+ self.image = Image.new("RGB", (image_width, image_height), background_color)
+
+ self.image_width = image_width
+ self.image_height = image_height
+
+ self.draw = ImageDraw.Draw(self.image)
+ self.previous_x, self.previous_y = None, None
+
+ self.color_lookup = interpolate_colors(colors)
+ self.pix = self.image.load()
+
+ def color_from_value(self, value):
+ """ given a value between 0 and 1, return an (r,g,b) tuple """
+
+ return ImageColor.getrgb("hsl(%d,%d%%,%d%%)" % (int( (1.0 - value) * 360 ), 80, 50))
+
+ def draw_peaks(self, x, peaks, spectral_centroid):
+ """ draw 2 peaks at x using the spectral_centroid for color """
+
+ y1 = self.image_height * 0.5 - peaks[0] * (self.image_height - 4) * 0.5
+ y2 = self.image_height * 0.5 - peaks[1] * (self.image_height - 4) * 0.5
+
+ line_color = self.color_lookup[int(spectral_centroid*255.0)]
+
+ if self.previous_y != None:
+ self.draw.line([self.previous_x, self.previous_y, x, y1, x, y2], line_color)
+ else:
+ self.draw.line([x, y1, x, y2], line_color)
+
+ self.previous_x, self.previous_y = x, y2
+
+ self.draw_anti_aliased_pixels(x, y1, y2, line_color)
+
+ def draw_anti_aliased_pixels(self, x, y1, y2, color):
+ """ vertical anti-aliasing at y1 and y2 """
+
+ y_max = max(y1, y2)
+ y_max_int = int(y_max)
+ alpha = y_max - y_max_int
+
+ if alpha > 0.0 and alpha < 1.0 and y_max_int + 1 < self.image_height:
+ current_pix = self.pix[x, y_max_int + 1]
+
+ r = int((1-alpha)*current_pix[0] + alpha*color[0])
+ g = int((1-alpha)*current_pix[1] + alpha*color[1])
+ b = int((1-alpha)*current_pix[2] + alpha*color[2])
+
+ self.pix[x, y_max_int + 1] = (r,g,b)
+
+ y_min = min(y1, y2)
+ y_min_int = int(y_min)
+ alpha = 1.0 - (y_min - y_min_int)
+
+ if alpha > 0.0 and alpha < 1.0 and y_min_int - 1 >= 0:
+ current_pix = self.pix[x, y_min_int - 1]
+
+ r = int((1-alpha)*current_pix[0] + alpha*color[0])
+ g = int((1-alpha)*current_pix[1] + alpha*color[1])
+ b = int((1-alpha)*current_pix[2] + alpha*color[2])
+
+ self.pix[x, y_min_int - 1] = (r,g,b)
+
+ def save(self, filename):
+ # draw a zero "zero" line
+ a = 25
+ for x in range(self.image_width):
+ self.pix[x, self.image_height/2] = tuple(map(lambda p: p+a, self.pix[x, self.image_height/2]))
+
+ self.image.save(filename)
+
+
+class SpectrogramImage(object):
+ """
+ Given spectra from the AudioProcessor, this class will construct a wavefile image which
+ can be saved as PNG.
+ """
+ def __init__(self, image_width, image_height, fft_size):
+ self.image_width = image_width
+ self.image_height = image_height
+ self.fft_size = fft_size
+
+ self.image = Image.new("RGBA", (image_height, image_width))
+
+ colors = [
+ (0, 0, 0, 0),
+ (58/4, 68/4, 65/4, 255),
+ (80/2, 100/2, 153/2, 255),
+ (90, 180, 100, 255),
+ (224, 224, 44, 255),
+ (255, 60, 30, 255),
+ (255, 255, 255, 255)
+ ]
+ self.palette = interpolate_colors(colors)
+
+ # generate the lookup which translates y-coordinate to fft-bin
+ self.y_to_bin = []
+ f_min = 100.0
+ f_max = 22050.0
+ y_min = math.log10(f_min)
+ y_max = math.log10(f_max)
+ for y in range(self.image_height):
+ freq = math.pow(10.0, y_min + y / (image_height - 1.0) *(y_max - y_min))
+ bin = freq / 22050.0 * (self.fft_size/2 + 1)
+
+ if bin < self.fft_size/2:
+ alpha = bin - int(bin)
+
+ self.y_to_bin.append((int(bin), alpha * 255))
+
+ # this is a bit strange, but using image.load()[x,y] = ... is
+ # a lot slower than using image.putadata and then rotating the image
+ # so we store all the pixels in an array and then create the image when saving
+ self.pixels = []
+
+ def draw_spectrum(self, x, spectrum):
+ # for all frequencies, draw the pixels
+ for (index, alpha) in self.y_to_bin:
+ self.pixels.append( self.palette[int((255.0-alpha) * spectrum[index] + alpha * spectrum[index + 1])] )
+
+ # if the FFT is too small to fill up the image, fill with black to the top
+ for y in range(len(self.y_to_bin), self.image_height): #@UnusedVariable
+ self.pixels.append(self.palette[0])
+
+ def save(self, filename, quality=80):
+ assert filename.lower().endswith(".jpg")
+ self.image.putdata(self.pixels)
+ self.image.transpose(Image.ROTATE_90).save(filename, quality=quality)
+
+
+def create_wave_images(input_filename, output_filename_w, output_filename_s, image_width, image_height, fft_size, progress_callback=None):
+ """
+ Utility function for creating both wavefile and spectrum images from an audio input file.
+ """
+ processor = AudioProcessor(input_filename, fft_size, numpy.hanning)
+ samples_per_pixel = processor.audio_file.nframes / float(image_width)
+
+ waveform = WaveformImage(image_width, image_height)
+ spectrogram = SpectrogramImage(image_width, image_height, fft_size)
+
+ for x in range(image_width):
+
+ if progress_callback and x % (image_width/10) == 0:
+ progress_callback((x*100)/image_width)
+
+ seek_point = int(x * samples_per_pixel)
+ next_seek_point = int((x + 1) * samples_per_pixel)
+
+ (spectral_centroid, db_spectrum) = processor.spectral_centroid(seek_point)
+ peaks = processor.peaks(seek_point, next_seek_point)
+
+ waveform.draw_peaks(x, peaks, spectral_centroid)
+ spectrogram.draw_spectrum(x, db_spectrum)
+
+ if progress_callback:
+ progress_callback(100)
+
+ waveform.save(output_filename_w)
+ spectrogram.save(output_filename_s)
+
+
+class NoSpaceLeftException(Exception):
+ pass
+
+def convert_to_pcm(input_filename, output_filename):
+ """
+ converts any audio file type to pcm audio
+ """
+
+ if not os.path.exists(input_filename):
+ raise AudioProcessingException, "file %s does not exist" % input_filename
+
+ sound_type = get_sound_type(input_filename)
+
+ if sound_type == "mp3":
+ cmd = ["lame", "--decode", input_filename, output_filename]
+ elif sound_type == "ogg":
+ cmd = ["oggdec", input_filename, "-o", output_filename]
+ elif sound_type == "flac":
+ cmd = ["flac", "-f", "-d", "-s", "-o", output_filename, input_filename]
+ else:
+ return False
+
+ process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ (stdout, stderr) = process.communicate()
+
+ if process.returncode != 0 or not os.path.exists(output_filename):
+ if "No space left on device" in stderr + " " + stdout:
+ raise NoSpaceLeftException
+ raise AudioProcessingException, "failed converting to pcm data:\n" + " ".join(cmd) + "\n" + stderr + "\n" + stdout
+
+ return True
+
+
+def stereofy_and_find_info(stereofy_executble_path, input_filename, output_filename):
+ """
+ converts a pcm wave file to two channel, 16 bit integer
+ """
+
+ if not os.path.exists(input_filename):
+ raise AudioProcessingException, "file %s does not exist" % input_filename
+
+ cmd = [stereofy_executble_path, "--input", input_filename, "--output", output_filename]
+
+ process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ (stdout, stderr) = process.communicate()
+
+ if process.returncode != 0 or not os.path.exists(output_filename):
+ if "No space left on device" in stderr + " " + stdout:
+ raise NoSpaceLeftException
+ raise AudioProcessingException, "failed calling stereofy data:\n" + " ".join(cmd) + "\n" + stderr + "\n" + stdout
+
+ stdout = (stdout + " " + stderr).replace("\n", " ")
+
+ duration = 0
+ m = re.match(r".*#duration (?P<duration>[\d\.]+).*", stdout)
+ if m != None:
+ duration = float(m.group("duration"))
+
+ channels = 0
+ m = re.match(r".*#channels (?P<channels>\d+).*", stdout)
+ if m != None:
+ channels = float(m.group("channels"))
+
+ samplerate = 0
+ m = re.match(r".*#samplerate (?P<samplerate>\d+).*", stdout)
+ if m != None:
+ samplerate = float(m.group("samplerate"))
+
+ bitdepth = None
+ m = re.match(r".*#bitdepth (?P<bitdepth>\d+).*", stdout)
+ if m != None:
+ bitdepth = float(m.group("bitdepth"))
+
+ bitrate = (os.path.getsize(input_filename) * 8.0) / 1024.0 / duration if duration > 0 else 0
+
+ return dict(duration=duration, channels=channels, samplerate=samplerate, bitrate=bitrate, bitdepth=bitdepth)
+
+
+def convert_to_mp3(input_filename, output_filename, quality=70):
+ """
+ converts the incoming wave file to a mp3 file
+ """
+
+ if not os.path.exists(input_filename):
+ raise AudioProcessingException, "file %s does not exist" % input_filename
+
+ command = ["lame", "--silent", "--abr", str(quality), input_filename, output_filename]
+
+ process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ (stdout, stderr) = process.communicate()
+
+ if process.returncode != 0 or not os.path.exists(output_filename):
+ raise AudioProcessingException, stdout
+
+def convert_to_ogg(input_filename, output_filename, quality=1):
+ """
+ converts the incoming wave file to n ogg file
+ """
+
+ if not os.path.exists(input_filename):
+ raise AudioProcessingException, "file %s does not exist" % input_filename
+
+ command = ["oggenc", "-q", str(quality), input_filename, "-o", output_filename]
+
+ process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ (stdout, stderr) = process.communicate()
+
+ if process.returncode != 0 or not os.path.exists(output_filename):
+ raise AudioProcessingException, stdout
+
+def convert_using_ffmpeg(input_filename, output_filename):
+ """
+ converts the incoming wave file to stereo pcm using fffmpeg
+ """
+ TIMEOUT = 3 * 60
+ def alarm_handler(signum, frame):
+ raise AudioProcessingException, "timeout while waiting for ffmpeg"
+
+ if not os.path.exists(input_filename):
+ raise AudioProcessingException, "file %s does not exist" % input_filename
+
+ command = ["ffmpeg", "-y", "-i", input_filename, "-ac","1","-acodec", "pcm_s16le", "-ar", "44100", output_filename]
+
+ process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ signal.signal(signal.SIGALRM,alarm_handler)
+ signal.alarm(TIMEOUT)
+ (stdout, stderr) = process.communicate()
+ signal.alarm(0)
+ if process.returncode != 0 or not os.path.exists(output_filename):
+ raise AudioProcessingException, stdout
diff --git a/mediagoblin/config_spec.ini b/mediagoblin/config_spec.ini
index 866d799b..e30825de 100644
--- a/mediagoblin/config_spec.ini
+++ b/mediagoblin/config_spec.ini
@@ -69,11 +69,28 @@ base_url = string(default="/mgoblin_media/")
storage_class = string(default="mediagoblin.storage.filestorage:BasicFileStorage")
base_dir = string(default="%(here)s/user_dev/media/queue")
+[media:medium]
+# Dimensions used when creating media display images.
+max_width = integer(default=640)
+max_height = integer(default=640)
+
+[media:thumb]
+# Dimensions used when creating media thumbnails
+# This is unfortunately not implemented in the media
+# types yet. You can help!
+# TODO: Make plugins follow the media size settings
+max_width = integer(default=180)
+max_height = integer(default=180)
-# Should we keep the original file?
[media_type:mediagoblin.media_types.video]
+# Should we keep the original file?
keep_original = boolean(default=False)
+[media_type:mediagoblin.media_types.audio]
+# vorbisenc qualiy
+quality = float(default=0.3)
+create_spectrogram = boolean(default=True)
+
[beaker.cache]
type = string(default="file")
diff --git a/mediagoblin/db/sql/convert.py b/mediagoblin/db/sql/convert.py
index ebf3037c..97f29bfc 100644
--- a/mediagoblin/db/sql/convert.py
+++ b/mediagoblin/db/sql/convert.py
@@ -16,14 +16,13 @@
from copy import copy
-from mediagoblin.init import setup_global_and_app_config, setup_database
-from mediagoblin.db.mongo.util import ObjectId
-
-from mediagoblin.db.sql.base import Base, Session
-from mediagoblin.db.sql.models import (User, MediaEntry, MediaComment,
- Tag, MediaTag, MediaFile, MediaAttachmentFile, MigrationData)
-from mediagoblin.media_types.image.models import ImageData
-from mediagoblin.media_types.video.models import VideoData
+from mediagoblin.init import setup_global_and_app_config
+
+from mediagoblin.db.sql.base import Session
+from mediagoblin.db.sql.models_v0 import Base_v0
+from mediagoblin.db.sql.models_v0 import (User, MediaEntry, MediaComment,
+ Tag, MediaTag, MediaFile, MediaAttachmentFile, MigrationData,
+ ImageData, VideoData)
from mediagoblin.db.sql.open import setup_connection_and_db_from_config as \
sql_connect
from mediagoblin.db.mongo.open import setup_connection_and_db_from_config as \
@@ -206,7 +205,7 @@ def run_conversion(config_name):
sql_conn, sql_db = sql_connect(app_config)
mk_conn, mk_db = mongo_connect(app_config)
- Base.metadata.create_all(sql_db.engine)
+ Base_v0.metadata.create_all(sql_db.engine)
convert_users(mk_db)
Session.remove()
diff --git a/mediagoblin/db/sql/extratypes.py b/mediagoblin/db/sql/extratypes.py
index 8e078f14..f2304af0 100644
--- a/mediagoblin/db/sql/extratypes.py
+++ b/mediagoblin/db/sql/extratypes.py
@@ -15,7 +15,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
-from sqlalchemy.types import TypeDecorator, Unicode, VARCHAR
+from sqlalchemy.types import TypeDecorator, Unicode, TEXT
import json
@@ -50,7 +50,7 @@ class PathTupleWithSlashes(TypeDecorator):
class JSONEncoded(TypeDecorator):
"Represents an immutable structure as a json-encoded string."
- impl = VARCHAR
+ impl = TEXT
def process_bind_param(self, value, dialect):
if value is not None:
diff --git a/mediagoblin/db/sql/models_v0.py b/mediagoblin/db/sql/models_v0.py
new file mode 100644
index 00000000..5dd6b38b
--- /dev/null
+++ b/mediagoblin/db/sql/models_v0.py
@@ -0,0 +1,300 @@
+# GNU MediaGoblin -- federated, autonomous media hosting
+# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+"""
+TODO: indexes on foreignkeys, where useful.
+"""
+
+
+import datetime
+import sys
+
+from sqlalchemy import (
+ Column, Integer, Unicode, UnicodeText, DateTime, Boolean, ForeignKey,
+ UniqueConstraint, PrimaryKeyConstraint, SmallInteger, Float)
+from sqlalchemy.ext.declarative import declarative_base
+from sqlalchemy.orm import relationship, backref
+from sqlalchemy.orm.collections import attribute_mapped_collection
+from sqlalchemy.ext.associationproxy import association_proxy
+from sqlalchemy.util import memoized_property
+
+from mediagoblin.db.sql.extratypes import PathTupleWithSlashes, JSONEncoded
+from mediagoblin.db.sql.base import GMGTableBase
+from mediagoblin.db.sql.base import Session
+
+
+Base_v0 = declarative_base(cls=GMGTableBase)
+
+
+class User(Base_v0):
+ """
+ TODO: We should consider moving some rarely used fields
+ into some sort of "shadow" table.
+ """
+ __tablename__ = "core__users"
+
+ id = Column(Integer, primary_key=True)
+ username = Column(Unicode, nullable=False, unique=True)
+ email = Column(Unicode, nullable=False)
+ created = Column(DateTime, nullable=False, default=datetime.datetime.now)
+ pw_hash = Column(Unicode, nullable=False)
+ email_verified = Column(Boolean, default=False)
+ status = Column(Unicode, default=u"needs_email_verification", nullable=False)
+ verification_key = Column(Unicode)
+ is_admin = Column(Boolean, default=False, nullable=False)
+ url = Column(Unicode)
+ bio = Column(UnicodeText) # ??
+ fp_verification_key = Column(Unicode)
+ fp_token_expire = Column(DateTime)
+
+ ## TODO
+ # plugin data would be in a separate model
+
+
+class MediaEntry(Base_v0):
+ """
+ TODO: Consider fetching the media_files using join
+ """
+ __tablename__ = "core__media_entries"
+
+ id = Column(Integer, primary_key=True)
+ uploader = Column(Integer, ForeignKey(User.id), nullable=False, index=True)
+ title = Column(Unicode, nullable=False)
+ slug = Column(Unicode)
+ created = Column(DateTime, nullable=False, default=datetime.datetime.now,
+ index=True)
+ description = Column(UnicodeText) # ??
+ media_type = Column(Unicode, nullable=False)
+ state = Column(Unicode, default=u'unprocessed', nullable=False)
+ # or use sqlalchemy.types.Enum?
+ license = Column(Unicode)
+
+ fail_error = Column(Unicode)
+ fail_metadata = Column(JSONEncoded)
+
+ queued_media_file = Column(PathTupleWithSlashes)
+
+ queued_task_id = Column(Unicode)
+
+ __table_args__ = (
+ UniqueConstraint('uploader', 'slug'),
+ {})
+
+ get_uploader = relationship(User)
+
+ media_files_helper = relationship("MediaFile",
+ collection_class=attribute_mapped_collection("name"),
+ cascade="all, delete-orphan"
+ )
+
+ attachment_files_helper = relationship("MediaAttachmentFile",
+ cascade="all, delete-orphan",
+ order_by="MediaAttachmentFile.created"
+ )
+
+ tags_helper = relationship("MediaTag",
+ cascade="all, delete-orphan"
+ )
+
+ def media_data_init(self, **kwargs):
+ """
+ Initialize or update the contents of a media entry's media_data row
+ """
+ session = Session()
+
+ media_data = session.query(self.media_data_table).filter_by(
+ media_entry=self.id).first()
+
+ # No media data, so actually add a new one
+ if media_data is None:
+ media_data = self.media_data_table(
+ media_entry=self.id,
+ **kwargs)
+ session.add(media_data)
+ # Update old media data
+ else:
+ for field, value in kwargs.iteritems():
+ setattr(media_data, field, value)
+
+ @memoized_property
+ def media_data_table(self):
+ # TODO: memoize this
+ models_module = self.media_type + '.models'
+ __import__(models_module)
+ return sys.modules[models_module].DATA_MODEL
+
+
+class FileKeynames(Base_v0):
+ """
+ keywords for various places.
+ currently the MediaFile keys
+ """
+ __tablename__ = "core__file_keynames"
+ id = Column(Integer, primary_key=True)
+ name = Column(Unicode, unique=True)
+
+ def __repr__(self):
+ return "<FileKeyname %r: %r>" % (self.id, self.name)
+
+ @classmethod
+ def find_or_new(cls, name):
+ t = cls.query.filter_by(name=name).first()
+ if t is not None:
+ return t
+ return cls(name=name)
+
+
+class MediaFile(Base_v0):
+ """
+ TODO: Highly consider moving "name" into a new table.
+ TODO: Consider preloading said table in software
+ """
+ __tablename__ = "core__mediafiles"
+
+ media_entry = Column(
+ Integer, ForeignKey(MediaEntry.id),
+ nullable=False)
+ name_id = Column(SmallInteger, ForeignKey(FileKeynames.id), nullable=False)
+ file_path = Column(PathTupleWithSlashes)
+
+ __table_args__ = (
+ PrimaryKeyConstraint('media_entry', 'name_id'),
+ {})
+
+ def __repr__(self):
+ return "<MediaFile %s: %r>" % (self.name, self.file_path)
+
+ name_helper = relationship(FileKeynames, lazy="joined", innerjoin=True)
+ name = association_proxy('name_helper', 'name',
+ creator=FileKeynames.find_or_new
+ )
+
+
+class MediaAttachmentFile(Base_v0):
+ __tablename__ = "core__attachment_files"
+
+ id = Column(Integer, primary_key=True)
+ media_entry = Column(
+ Integer, ForeignKey(MediaEntry.id),
+ nullable=False)
+ name = Column(Unicode, nullable=False)
+ filepath = Column(PathTupleWithSlashes)
+ created = Column(DateTime, nullable=False, default=datetime.datetime.now)
+
+
+class Tag(Base_v0):
+ __tablename__ = "core__tags"
+
+ id = Column(Integer, primary_key=True)
+ slug = Column(Unicode, nullable=False, unique=True)
+
+ def __repr__(self):
+ return "<Tag %r: %r>" % (self.id, self.slug)
+
+ @classmethod
+ def find_or_new(cls, slug):
+ t = cls.query.filter_by(slug=slug).first()
+ if t is not None:
+ return t
+ return cls(slug=slug)
+
+
+class MediaTag(Base_v0):
+ __tablename__ = "core__media_tags"
+
+ id = Column(Integer, primary_key=True)
+ media_entry = Column(
+ Integer, ForeignKey(MediaEntry.id),
+ nullable=False, index=True)
+ tag = Column(Integer, ForeignKey(Tag.id), nullable=False, index=True)
+ name = Column(Unicode)
+ # created = Column(DateTime, nullable=False, default=datetime.datetime.now)
+
+ __table_args__ = (
+ UniqueConstraint('tag', 'media_entry'),
+ {})
+
+ tag_helper = relationship(Tag)
+ slug = association_proxy('tag_helper', 'slug',
+ creator=Tag.find_or_new
+ )
+
+ def __init__(self, name=None, slug=None):
+ Base_v0.__init__(self)
+ if name is not None:
+ self.name = name
+ if slug is not None:
+ self.tag_helper = Tag.find_or_new(slug)
+
+
+class MediaComment(Base_v0):
+ __tablename__ = "core__media_comments"
+
+ id = Column(Integer, primary_key=True)
+ media_entry = Column(
+ Integer, ForeignKey(MediaEntry.id), nullable=False, index=True)
+ author = Column(Integer, ForeignKey(User.id), nullable=False)
+ created = Column(DateTime, nullable=False, default=datetime.datetime.now)
+ content = Column(UnicodeText, nullable=False)
+
+ get_author = relationship(User)
+
+
+class ImageData(Base_v0):
+ __tablename__ = "image__mediadata"
+
+ # The primary key *and* reference to the main media_entry
+ media_entry = Column(Integer, ForeignKey('core__media_entries.id'),
+ primary_key=True)
+ get_media_entry = relationship("MediaEntry",
+ backref=backref("image__media_data", cascade="all, delete-orphan"))
+
+ width = Column(Integer)
+ height = Column(Integer)
+ exif_all = Column(JSONEncoded)
+ gps_longitude = Column(Float)
+ gps_latitude = Column(Float)
+ gps_altitude = Column(Float)
+ gps_direction = Column(Float)
+
+
+class VideoData(Base_v0):
+ __tablename__ = "video__mediadata"
+
+ # The primary key *and* reference to the main media_entry
+ media_entry = Column(Integer, ForeignKey('core__media_entries.id'),
+ primary_key=True)
+ get_media_entry = relationship("MediaEntry",
+ backref=backref("video__media_data", cascade="all, delete-orphan"))
+
+ width = Column(SmallInteger)
+ height = Column(SmallInteger)
+
+
+######################################################
+# Special, migrations-tracking table
+#
+# Not listed in MODELS because this is special and not
+# really migrated, but used for migrations (for now)
+######################################################
+
+class MigrationData(Base_v0):
+ __tablename__ = "core__migrations"
+
+ name = Column(Unicode, primary_key=True)
+ version = Column(Integer, nullable=False, default=0)
+
+######################################################
diff --git a/mediagoblin/media_types/__init__.py b/mediagoblin/media_types/__init__.py
index 5128826b..93d2319f 100644
--- a/mediagoblin/media_types/__init__.py
+++ b/mediagoblin/media_types/__init__.py
@@ -16,10 +16,13 @@
import os
import sys
+import logging
+import tempfile
from mediagoblin import mg_globals
from mediagoblin.tools.translate import lazy_pass_to_ugettext as _
+_log = logging.getLogger(__name__)
class FileTypeNotSupported(Exception):
pass
@@ -28,6 +31,35 @@ class InvalidFileType(Exception):
pass
+def sniff_media(media):
+ '''
+ Iterate through the enabled media types and find those suited
+ for a certain file.
+ '''
+
+ try:
+ return get_media_type_and_manager(media.filename)
+ except FileTypeNotSupported:
+ _log.info('No media handler found by file extension. Doing it the expensive way...')
+ # Create a temporary file for sniffers suchs as GStreamer-based
+ # Audio video
+ media_file = tempfile.NamedTemporaryFile()
+ media_file.write(media.file.read())
+ media.file.seek(0)
+
+ for media_type, manager in get_media_managers():
+ _log.info('Sniffing {0}'.format(media_type))
+ if manager['sniff_handler'](media_file, media=media):
+ _log.info('{0} accepts the file'.format(media_type))
+ return media_type, manager
+ else:
+ _log.debug('{0} did not accept the file'.format(media_type))
+
+ raise FileTypeNotSupported(
+ # TODO: Provide information on which file types are supported
+ _(u'Sorry, I don\'t support that file type :('))
+
+
def get_media_types():
"""
Generator, yields the available media types
@@ -42,7 +74,7 @@ def get_media_managers():
'''
for media_type in get_media_types():
__import__(media_type)
-
+
yield media_type, sys.modules[media_type].MEDIA_MANAGER
@@ -67,22 +99,22 @@ def get_media_manager(_media_type):
def get_media_type_and_manager(filename):
'''
- Get the media type and manager based on a filename
+ Try to find the media type based on the file name, extension
+ specifically. This is used as a speedup, the sniffing functionality
+ then falls back on more in-depth bitsniffing of the source file.
'''
if filename.find('.') > 0:
# Get the file extension
ext = os.path.splitext(filename)[1].lower()
- else:
- raise InvalidFileType(
- _(u'Could not extract any file extension from "{filename}"').format(
- filename=filename))
- for media_type, manager in get_media_managers():
- # Omit the dot from the extension and match it against
- # the media manager
- if ext[1:] in manager['accepted_extensions']:
- return media_type, manager
+ for media_type, manager in get_media_managers():
+ # Omit the dot from the extension and match it against
+ # the media manager
+ if ext[1:] in manager['accepted_extensions']:
+ return media_type, manager
else:
- raise FileTypeNotSupported(
- # TODO: Provide information on which file types are supported
- _(u'Sorry, I don\'t support that file type :('))
+ _log.info('File {0} has no file extension, let\'s hope the sniffers get it.'.format(
+ filename))
+
+ raise FileTypeNotSupported(
+ _(u'Sorry, I don\'t support that file type :('))
diff --git a/mediagoblin/media_types/ascii/__init__.py b/mediagoblin/media_types/ascii/__init__.py
index 1c8ca562..856d1d7b 100644
--- a/mediagoblin/media_types/ascii/__init__.py
+++ b/mediagoblin/media_types/ascii/__init__.py
@@ -14,13 +14,15 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
-from mediagoblin.media_types.ascii.processing import process_ascii
+from mediagoblin.media_types.ascii.processing import process_ascii, \
+ sniff_handler
MEDIA_MANAGER = {
"human_readable": "ASCII",
"processor": process_ascii, # alternately a string,
# 'mediagoblin.media_types.image.processing'?
+ "sniff_handler": sniff_handler,
"display_template": "mediagoblin/media_displays/ascii.html",
"default_thumb": "images/media_thumbs/ascii.jpg",
"accepted_extensions": [
diff --git a/mediagoblin/media_types/ascii/asciitoimage.py b/mediagoblin/media_types/ascii/asciitoimage.py
index e1c4fb44..3017d2ad 100644
--- a/mediagoblin/media_types/ascii/asciitoimage.py
+++ b/mediagoblin/media_types/ascii/asciitoimage.py
@@ -23,6 +23,7 @@ import os
_log = logging.getLogger(__name__)
+
class AsciiToImage(object):
'''
Converter of ASCII art into image files, preserving whitespace
diff --git a/mediagoblin/media_types/ascii/models.py b/mediagoblin/media_types/ascii/models.py
index a35e6958..865c216c 100644
--- a/mediagoblin/media_types/ascii/models.py
+++ b/mediagoblin/media_types/ascii/models.py
@@ -18,8 +18,8 @@
from mediagoblin.db.sql.base import Base
from sqlalchemy import (
- Column, Integer, Unicode, UnicodeText, DateTime, Boolean, ForeignKey,
- UniqueConstraint)
+ Column, Integer, ForeignKey)
+from sqlalchemy.orm import relationship, backref
class AsciiData(Base):
@@ -28,6 +28,8 @@ class AsciiData(Base):
# The primary key *and* reference to the main media_entry
media_entry = Column(Integer, ForeignKey('core__media_entries.id'),
primary_key=True)
+ get_media_entry = relationship("MediaEntry",
+ backref=backref("ascii__media_data", cascade="all, delete-orphan"))
DATA_MODEL = AsciiData
diff --git a/mediagoblin/media_types/ascii/processing.py b/mediagoblin/media_types/ascii/processing.py
index 83b5ea33..a2a52e9d 100644
--- a/mediagoblin/media_types/ascii/processing.py
+++ b/mediagoblin/media_types/ascii/processing.py
@@ -19,11 +19,25 @@ import Image
import logging
from mediagoblin import mg_globals as mgg
-from mediagoblin.processing import create_pub_filepath, THUMB_SIZE
+from mediagoblin.processing import create_pub_filepath
from mediagoblin.media_types.ascii import asciitoimage
_log = logging.getLogger(__name__)
+SUPPORTED_EXTENSIONS = ['txt', 'asc', 'nfo']
+
+
+def sniff_handler(media_file, **kw):
+ if kw.get('media') is not None:
+ name, ext = os.path.splitext(kw['media'].filename)
+ clean_ext = ext[1:].lower()
+
+ if clean_ext in SUPPORTED_EXTENSIONS:
+ return True
+
+ return False
+
+
def process_ascii(entry):
'''
Code to process a txt file
@@ -69,7 +83,10 @@ def process_ascii(entry):
queued_file.read())
with file(tmp_thumb_filename, 'w') as thumb_file:
- thumb.thumbnail(THUMB_SIZE, Image.ANTIALIAS)
+ thumb.thumbnail(
+ (mgg.global_config['media:thumb']['max_width'],
+ mgg.global_config['media:thumb']['max_height']),
+ Image.ANTIALIAS)
thumb.save(thumb_file)
_log.debug('Copying local file to public storage')
@@ -84,7 +101,6 @@ def process_ascii(entry):
as original_file:
original_file.write(queued_file.read())
-
queued_file.seek(0) # Rewind *again*
unicode_filepath = create_pub_filepath(entry, 'ascii-portable.txt')
diff --git a/mediagoblin/media_types/audio/__init__.py b/mediagoblin/media_types/audio/__init__.py
new file mode 100644
index 00000000..9b33f9e3
--- /dev/null
+++ b/mediagoblin/media_types/audio/__init__.py
@@ -0,0 +1,25 @@
+# GNU MediaGoblin -- federated, autonomous media hosting
+# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+from mediagoblin.media_types.audio.processing import process_audio, \
+ sniff_handler
+
+MEDIA_MANAGER = {
+ 'human_readable': 'Audio',
+ 'processor': process_audio,
+ 'sniff_handler': sniff_handler,
+ 'display_template': 'mediagoblin/media_displays/audio.html',
+ 'accepted_extensions': ['mp3', 'flac', 'ogg', 'wav', 'm4a']}
diff --git a/mediagoblin/media_types/audio/audioprocessing.py b/mediagoblin/media_types/audio/audioprocessing.py
new file mode 120000
index 00000000..c5e3c52c
--- /dev/null
+++ b/mediagoblin/media_types/audio/audioprocessing.py
@@ -0,0 +1 @@
+../../../extlib/freesound/audioprocessing.py \ No newline at end of file
diff --git a/mediagoblin/media_types/audio/migrations.py b/mediagoblin/media_types/audio/migrations.py
new file mode 100644
index 00000000..f54c23ea
--- /dev/null
+++ b/mediagoblin/media_types/audio/migrations.py
@@ -0,0 +1,17 @@
+# GNU MediaGoblin -- federated, autonomous media hosting
+# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+MIGRATIONS = {}
diff --git a/mediagoblin/media_types/audio/models.py b/mediagoblin/media_types/audio/models.py
new file mode 100644
index 00000000..5f18d2c2
--- /dev/null
+++ b/mediagoblin/media_types/audio/models.py
@@ -0,0 +1,36 @@
+# GNU MediaGoblin -- federated, autonomous media hosting
+# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+from mediagoblin.db.sql.base import Base
+
+from sqlalchemy import (
+ Column, Integer, ForeignKey)
+from sqlalchemy.orm import relationship, backref
+
+
+class AudioData(Base):
+ __tablename__ = "audio__mediadata"
+
+ # The primary key *and* reference to the main media_entry
+ media_entry = Column(Integer, ForeignKey('core__media_entries.id'),
+ primary_key=True)
+ get_media_entry = relationship("MediaEntry",
+ backref=backref("audio__media_data", cascade="all, delete-orphan"))
+
+
+DATA_MODEL = AudioData
+MODELS = [AudioData]
diff --git a/mediagoblin/media_types/audio/processing.py b/mediagoblin/media_types/audio/processing.py
new file mode 100644
index 00000000..c0ff7bff
--- /dev/null
+++ b/mediagoblin/media_types/audio/processing.py
@@ -0,0 +1,130 @@
+# GNU MediaGoblin -- federated, autonomous media hosting
+# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import logging
+import tempfile
+import os
+
+from mediagoblin import mg_globals as mgg
+from mediagoblin.processing import create_pub_filepath, BadMediaFail
+
+from mediagoblin.media_types.audio.transcoders import AudioTranscoder, \
+ AudioThumbnailer
+
+_log = logging.getLogger(__name__)
+
+def sniff_handler(media_file, **kw):
+ try:
+ transcoder = AudioTranscoder()
+ data = transcoder.discover(media_file.name)
+ except BadMediaFail:
+ _log.debug('Audio discovery raised BadMediaFail')
+ return False
+
+ if data.is_audio == True and data.is_video == False:
+ return True
+
+ return False
+
+def process_audio(entry):
+ audio_config = mgg.global_config['media_type:mediagoblin.media_types.audio']
+
+ workbench = mgg.workbench_manager.create_workbench()
+
+ queued_filepath = entry.queued_media_file
+ queued_filename = workbench.localized_file(
+ mgg.queue_store, queued_filepath,
+ 'source')
+
+ ogg_filepath = create_pub_filepath(
+ entry,
+ '{original}.webm'.format(
+ original=os.path.splitext(
+ queued_filepath[-1])[0]))
+
+ transcoder = AudioTranscoder()
+
+ with tempfile.NamedTemporaryFile() as ogg_tmp:
+
+ transcoder.transcode(
+ queued_filename,
+ ogg_tmp.name,
+ quality=audio_config['quality'])
+
+ data = transcoder.discover(ogg_tmp.name)
+
+ _log.debug('Saving medium...')
+ mgg.public_store.get_file(ogg_filepath, 'wb').write(
+ ogg_tmp.read())
+
+ entry.media_files['ogg'] = ogg_filepath
+
+ # entry.media_data_init(length=int(data.audiolength))
+
+ if audio_config['create_spectrogram']:
+ spectrogram_filepath = create_pub_filepath(
+ entry,
+ '{original}-spectrogram.jpg'.format(
+ original=os.path.splitext(
+ queued_filepath[-1])[0]))
+
+ with tempfile.NamedTemporaryFile(suffix='.wav') as wav_tmp:
+ _log.info('Creating WAV source for spectrogram')
+ transcoder.transcode(
+ queued_filename,
+ wav_tmp.name,
+ mux_string='wavenc')
+
+ thumbnailer = AudioThumbnailer()
+
+ with tempfile.NamedTemporaryFile(suffix='.jpg') as spectrogram_tmp:
+ thumbnailer.spectrogram(
+ wav_tmp.name,
+ spectrogram_tmp.name,
+ width=mgg.global_config['media:medium']['max_width'])
+
+ _log.debug('Saving spectrogram...')
+ mgg.public_store.get_file(spectrogram_filepath, 'wb').write(
+ spectrogram_tmp.read())
+
+ entry.media_files['spectrogram'] = spectrogram_filepath
+
+ with tempfile.NamedTemporaryFile(suffix='.jpg') as thumb_tmp:
+ thumbnailer.thumbnail_spectrogram(
+ spectrogram_tmp.name,
+ thumb_tmp.name,
+ (mgg.global_config['media:thumb']['max_width'],
+ mgg.global_config['media:thumb']['max_height']))
+
+ thumb_filepath = create_pub_filepath(
+ entry,
+ '{original}-thumbnail.jpg'.format(
+ original=os.path.splitext(
+ queued_filepath[-1])[0]))
+
+ mgg.public_store.get_file(thumb_filepath, 'wb').write(
+ thumb_tmp.read())
+
+ entry.media_files['thumb'] = thumb_filepath
+ else:
+ entry.media_files['thumb'] = ['fake', 'thumb', 'path.jpg']
+
+ mgg.queue_store.delete_file(queued_filepath)
+
+ entry.save()
+
+ # clean up workbench
+ workbench.destroy_self()
diff --git a/mediagoblin/media_types/audio/transcoders.py b/mediagoblin/media_types/audio/transcoders.py
new file mode 100644
index 00000000..be80aa0e
--- /dev/null
+++ b/mediagoblin/media_types/audio/transcoders.py
@@ -0,0 +1,237 @@
+# GNU MediaGoblin -- federated, autonomous media hosting
+# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+import pdb
+import logging
+import Image
+
+from mediagoblin.processing import BadMediaFail
+from mediagoblin.media_types.audio import audioprocessing
+
+
+_log = logging.getLogger(__name__)
+
+CPU_COUNT = 2 # Just assuming for now
+
+# IMPORT MULTIPROCESSING
+try:
+ import multiprocessing
+ try:
+ CPU_COUNT = multiprocessing.cpu_count()
+ except NotImplementedError:
+ _log.warning('multiprocessing.cpu_count not implemented!\n'
+ 'Assuming 2 CPU cores')
+except ImportError:
+ _log.warning('Could not import multiprocessing, assuming 2 CPU cores')
+
+# IMPORT GOBJECT
+try:
+ import gobject
+ gobject.threads_init()
+except ImportError:
+ raise Exception('gobject could not be found')
+
+# IMPORT PYGST
+try:
+ import pygst
+
+ # We won't settle for less. For now, this is an arbitrary limit
+ # as we have not tested with > 0.10
+ pygst.require('0.10')
+
+ import gst
+
+ import gst.extend.discoverer
+except ImportError:
+ raise Exception('gst/pygst >= 0.10 could not be imported')
+
+import numpy
+
+
+class AudioThumbnailer(object):
+ def __init__(self):
+ _log.info('Initializing {0}'.format(self.__class__.__name__))
+
+ def spectrogram(self, src, dst, **kw):
+ width = kw['width']
+ height = int(kw.get('height', float(width) * 0.3))
+ fft_size = kw.get('fft_size', 2048)
+ callback = kw.get('progress_callback')
+
+ processor = audioprocessing.AudioProcessor(
+ src,
+ fft_size,
+ numpy.hanning)
+
+ samples_per_pixel = processor.audio_file.nframes / float(width)
+
+ spectrogram = audioprocessing.SpectrogramImage(width, height, fft_size)
+
+ for x in range(width):
+ if callback and x % (width / 10) == 0:
+ callback((x * 100) / width)
+
+ seek_point = int(x * samples_per_pixel)
+
+ (spectral_centroid, db_spectrum) = processor.spectral_centroid(
+ seek_point)
+
+ spectrogram.draw_spectrum(x, db_spectrum)
+
+ if callback:
+ callback(100)
+
+ spectrogram.save(dst)
+
+ def thumbnail_spectrogram(self, src, dst, thumb_size):
+ '''
+ Takes a spectrogram and creates a thumbnail from it
+ '''
+ if not (type(thumb_size) == tuple and len(thumb_size) == 2):
+ raise Exception('thumb_size argument should be a tuple(width, height)')
+
+ im = Image.open(src)
+
+ im_w, im_h = [float(i) for i in im.size]
+ th_w, th_h = [float(i) for i in thumb_size]
+
+ wadsworth_position = im_w * 0.3
+
+ start_x = max((
+ wadsworth_position - ((im_h * (th_w / th_h)) / 2.0),
+ 0.0))
+
+ stop_x = start_x + (im_h * (th_w / th_h))
+
+ th = im.crop((
+ int(start_x), 0,
+ int(stop_x), int(im_h)))
+
+ if th.size[0] > th_w or th.size[1] > th_h:
+ th.thumbnail(thumb_size, Image.ANTIALIAS)
+
+ th.save(dst)
+
+
+class AudioTranscoder(object):
+ def __init__(self):
+ _log.info('Initializing {0}'.format(self.__class__.__name__))
+
+ # Instantiate MainLoop
+ self._loop = gobject.MainLoop()
+ self._failed = None
+
+ def discover(self, src):
+ self._src_path = src
+ _log.info('Discovering {0}'.format(src))
+ self._discovery_path = src
+
+ self._discoverer = gst.extend.discoverer.Discoverer(
+ self._discovery_path)
+ self._discoverer.connect('discovered', self.__on_discovered)
+ self._discoverer.discover()
+
+ self._loop.run() # Run MainLoop
+
+ if self._failed:
+ raise self._failed
+
+ # Once MainLoop has returned, return discovery data
+ return getattr(self, '_discovery_data', False)
+
+ def __on_discovered(self, data, is_media):
+ if not is_media:
+ self._failed = BadMediaFail()
+ _log.error('Could not discover {0}'.format(self._src_path))
+ self.halt()
+
+ _log.debug('Discovered: {0}'.format(data.__dict__))
+
+ self._discovery_data = data
+
+ # Gracefully shut down MainLoop
+ self.halt()
+
+ def transcode(self, src, dst, **kw):
+ _log.info('Transcoding {0} into {1}'.format(src, dst))
+ self._discovery_data = kw.get('data', self.discover(src))
+
+ self.__on_progress = kw.get('progress_callback')
+
+ quality = kw.get('quality', 0.3)
+
+ mux_string = kw.get(
+ 'mux_string',
+ 'vorbisenc quality={0} ! webmmux'.format(quality))
+
+ # Set up pipeline
+ self.pipeline = gst.parse_launch(
+ 'filesrc location="{src}" ! '
+ 'decodebin2 ! queue ! audiorate tolerance={tolerance} ! '
+ 'audioconvert ! audio/x-raw-float,channels=2 ! '
+ '{mux_string} ! '
+ 'progressreport silent=true ! '
+ 'filesink location="{dst}"'.format(
+ src=src,
+ tolerance=80000000,
+ mux_string=mux_string,
+ dst=dst))
+
+ self.bus = self.pipeline.get_bus()
+ self.bus.add_signal_watch()
+ self.bus.connect('message', self.__on_bus_message)
+
+ self.pipeline.set_state(gst.STATE_PLAYING)
+
+ self._loop.run()
+
+ def __on_bus_message(self, bus, message):
+ _log.debug(message)
+
+ if (message.type == gst.MESSAGE_ELEMENT
+ and message.structure.get_name() == 'progress'):
+ data = dict(message.structure)
+
+ if self.__on_progress:
+ self.__on_progress(data)
+
+ _log.info('{0}% done...'.format(
+ data.get('percent')))
+ elif message.type == gst.MESSAGE_EOS:
+ _log.info('Done')
+ self.halt()
+
+ def halt(self):
+ if getattr(self, 'pipeline', False):
+ self.pipeline.set_state(gst.STATE_NULL)
+ del self.pipeline
+ _log.info('Quitting MainLoop gracefully...')
+ gobject.idle_add(self._loop.quit)
+
+if __name__ == '__main__':
+ import sys
+ logging.basicConfig()
+ _log.setLevel(logging.INFO)
+
+ #transcoder = AudioTranscoder()
+ #data = transcoder.discover(sys.argv[1])
+ #res = transcoder.transcode(*sys.argv[1:3])
+
+ thumbnailer = AudioThumbnailer()
+
+ thumbnailer.spectrogram(*sys.argv[1:], width=640)
+
+ pdb.set_trace()
diff --git a/mediagoblin/media_types/image/__init__.py b/mediagoblin/media_types/image/__init__.py
index 98e0c32a..d4720fab 100644
--- a/mediagoblin/media_types/image/__init__.py
+++ b/mediagoblin/media_types/image/__init__.py
@@ -14,13 +14,15 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
-from mediagoblin.media_types.image.processing import process_image
+from mediagoblin.media_types.image.processing import process_image, \
+ sniff_handler
MEDIA_MANAGER = {
"human_readable": "Image",
"processor": process_image, # alternately a string,
# 'mediagoblin.media_types.image.processing'?
+ "sniff_handler": sniff_handler,
"display_template": "mediagoblin/media_displays/image.html",
"default_thumb": "images/media_thumbs/image.jpg",
"accepted_extensions": ["jpg", "jpeg", "png", "gif", "tiff"]}
diff --git a/mediagoblin/media_types/image/models.py b/mediagoblin/media_types/image/models.py
index 5eb20ed4..fc518daa 100644
--- a/mediagoblin/media_types/image/models.py
+++ b/mediagoblin/media_types/image/models.py
@@ -19,6 +19,7 @@ from mediagoblin.db.sql.base import Base
from sqlalchemy import (
Column, Integer, Float, ForeignKey)
+from sqlalchemy.orm import relationship, backref
from mediagoblin.db.sql.extratypes import JSONEncoded
@@ -28,6 +29,9 @@ class ImageData(Base):
# The primary key *and* reference to the main media_entry
media_entry = Column(Integer, ForeignKey('core__media_entries.id'),
primary_key=True)
+ get_media_entry = relationship("MediaEntry",
+ backref=backref("image__media_data", cascade="all, delete-orphan"))
+
width = Column(Integer)
height = Column(Integer)
exif_all = Column(JSONEncoded)
diff --git a/mediagoblin/media_types/image/processing.py b/mediagoblin/media_types/image/processing.py
index a3bf2c20..bbfcd32d 100644
--- a/mediagoblin/media_types/image/processing.py
+++ b/mediagoblin/media_types/image/processing.py
@@ -16,12 +16,69 @@
import Image
import os
+import logging
from mediagoblin import mg_globals as mgg
from mediagoblin.processing import BadMediaFail, \
- create_pub_filepath, THUMB_SIZE, MEDIUM_SIZE
+ create_pub_filepath, FilenameBuilder
from mediagoblin.tools.exif import exif_fix_image_orientation, \
- extract_exif, clean_exif, get_gps_data, get_useful
+ extract_exif, clean_exif, get_gps_data, get_useful, \
+ exif_image_needs_rotation
+
+_log = logging.getLogger(__name__)
+
+
+def resize_image(entry, filename, new_path, exif_tags, workdir, new_size,
+ size_limits=(0, 0)):
+ """Store a resized version of an image and return its pathname.
+
+ Arguments:
+ entry -- the entry for the image to resize
+ filename -- the filename of the original image being resized
+ new_path -- public file path for the new resized image
+ exif_tags -- EXIF data for the original image
+ workdir -- directory path for storing converted image files
+ new_size -- 2-tuple size for the resized image
+ """
+ try:
+ resized = Image.open(filename)
+ except IOError:
+ raise BadMediaFail()
+ resized = exif_fix_image_orientation(resized, exif_tags) # Fix orientation
+ resized.thumbnail(new_size, Image.ANTIALIAS)
+
+ # Copy the new file to the conversion subdir, then remotely.
+ tmp_resized_filename = os.path.join(workdir, new_path[-1])
+ with file(tmp_resized_filename, 'w') as resized_file:
+ resized.save(resized_file)
+ mgg.public_store.copy_local_to_storage(tmp_resized_filename, new_path)
+
+
+SUPPORTED_FILETYPES = ['png', 'gif', 'jpg', 'jpeg']
+
+
+def sniff_handler(media_file, **kw):
+ if kw.get('media') is not None: # That's a double negative!
+ name, ext = os.path.splitext(kw['media'].filename)
+ clean_ext = ext[1:].lower() # Strip the . from ext and make lowercase
+
+ _log.debug('name: {0}\next: {1}\nlower_ext: {2}'.format(
+ name,
+ ext,
+ clean_ext))
+
+ if clean_ext in SUPPORTED_FILETYPES:
+ _log.info('Found file extension in supported filetypes')
+ return True
+ else:
+ _log.debug('Media present, extension not found in {0}'.format(
+ SUPPORTED_FILETYPES))
+ else:
+ _log.warning('Need additional information (keyword argument \'media\')'
+ ' to be able to handle sniffing')
+
+ return False
+
def process_image(entry):
"""
@@ -32,72 +89,48 @@ def process_image(entry):
conversions_subdir = os.path.join(
workbench.dir, 'conversions')
os.mkdir(conversions_subdir)
-
queued_filepath = entry.queued_media_file
queued_filename = workbench.localized_file(
mgg.queue_store, queued_filepath,
'source')
-
- filename_bits = os.path.splitext(queued_filename)
- basename = os.path.split(filename_bits[0])[1]
- extension = filename_bits[1].lower()
+ name_builder = FilenameBuilder(queued_filename)
# EXIF extraction
exif_tags = extract_exif(queued_filename)
gps_data = get_gps_data(exif_tags)
- try:
- thumb = Image.open(queued_filename)
- except IOError:
- raise BadMediaFail()
-
- thumb = exif_fix_image_orientation(thumb, exif_tags)
-
- thumb.thumbnail(THUMB_SIZE, Image.ANTIALIAS)
-
- # Copy the thumb to the conversion subdir, then remotely.
- thumb_filename = 'thumbnail' + extension
- thumb_filepath = create_pub_filepath(entry, thumb_filename)
-
- tmp_thumb_filename = os.path.join(
- conversions_subdir, thumb_filename)
-
- with file(tmp_thumb_filename, 'w') as thumb_file:
- thumb.save(thumb_file)
-
- mgg.public_store.copy_local_to_storage(
- tmp_thumb_filename, thumb_filepath)
+ # Always create a small thumbnail
+ thumb_filepath = create_pub_filepath(
+ entry, name_builder.fill('{basename}.thumbnail{ext}'))
+ resize_image(entry, queued_filename, thumb_filepath,
+ exif_tags, conversions_subdir,
+ (mgg.global_config['media:thumb']['max_width'],
+ mgg.global_config['media:thumb']['max_height']))
# If the size of the original file exceeds the specified size of a `medium`
- # file, a `medium.jpg` files is created and later associated with the media
+ # file, a `.medium.jpg` files is created and later associated with the media
# entry.
medium = Image.open(queued_filename)
-
- # Fix orientation
- medium = exif_fix_image_orientation(medium, exif_tags)
-
- if medium.size[0] > MEDIUM_SIZE[0] or medium.size[1] > MEDIUM_SIZE[1]:
- medium.thumbnail(MEDIUM_SIZE, Image.ANTIALIAS)
-
- medium_filename = 'medium' + extension
- medium_filepath = create_pub_filepath(entry, medium_filename)
-
- tmp_medium_filename = os.path.join(
- conversions_subdir, medium_filename)
-
- with file(tmp_medium_filename, 'w') as medium_file:
- medium.save(medium_file)
-
- mgg.public_store.copy_local_to_storage(
- tmp_medium_filename, medium_filepath)
+ if medium.size[0] > mgg.global_config['media:medium']['max_width'] \
+ or medium.size[1] > mgg.global_config['media:medium']['max_height'] \
+ or exif_image_needs_rotation(exif_tags):
+ medium_filepath = create_pub_filepath(
+ entry, name_builder.fill('{basename}.medium{ext}'))
+ resize_image(
+ entry, queued_filename, medium_filepath,
+ exif_tags, conversions_subdir,
+ (mgg.global_config['media:medium']['max_width'],
+ mgg.global_config['media:medium']['max_height']))
+ else:
+ medium_filepath = None
# we have to re-read because unlike PIL, not everything reads
# things in string representation :)
queued_file = file(queued_filename, 'rb')
with queued_file:
- #create_pub_filepath(entry, queued_filepath[-1])
- original_filepath = create_pub_filepath(entry, basename + extension)
+ original_filepath = create_pub_filepath(
+ entry, name_builder.fill('{basename}{ext}'))
with mgg.public_store.get_file(original_filepath, 'wb') \
as original_file:
@@ -111,7 +144,8 @@ def process_image(entry):
media_files_dict = entry.setdefault('media_files', {})
media_files_dict['thumb'] = thumb_filepath
media_files_dict['original'] = original_filepath
- media_files_dict['medium'] = medium_filepath
+ if medium_filepath:
+ media_files_dict['medium'] = medium_filepath
# Insert exif data into database
exif_all = clean_exif(exif_tags)
diff --git a/mediagoblin/media_types/video/__init__.py b/mediagoblin/media_types/video/__init__.py
index 579fdc6a..3faa5b9f 100644
--- a/mediagoblin/media_types/video/__init__.py
+++ b/mediagoblin/media_types/video/__init__.py
@@ -14,16 +14,16 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
-from mediagoblin.media_types.video.processing import process_video
+from mediagoblin.media_types.video.processing import process_video, \
+ sniff_handler
MEDIA_MANAGER = {
"human_readable": "Video",
- "processor": process_video, # alternately a string,
- # 'mediagoblin.media_types.image.processing'?
+ "processor": process_video, # alternately a string,
+ # 'mediagoblin.media_types.image.processing'?
+ "sniff_handler": sniff_handler,
"display_template": "mediagoblin/media_displays/video.html",
"default_thumb": "images/media_thumbs/video.jpg",
- # TODO: This list should be autogenerated based on gst plugins
"accepted_extensions": [
- "mp4", "mov", "webm", "avi", "3gp", "3gpp", "mkv", "ogv", "ogg",
- "m4v"]}
+ "mp4", "mov", "webm", "avi", "3gp", "3gpp", "mkv", "ogv", "m4v"]}
diff --git a/mediagoblin/media_types/video/models.py b/mediagoblin/media_types/video/models.py
index cf42b7c8..35ed92bf 100644
--- a/mediagoblin/media_types/video/models.py
+++ b/mediagoblin/media_types/video/models.py
@@ -19,6 +19,7 @@ from mediagoblin.db.sql.base import Base
from sqlalchemy import (
Column, Integer, SmallInteger, ForeignKey)
+from sqlalchemy.orm import relationship, backref
class VideoData(Base):
@@ -27,6 +28,9 @@ class VideoData(Base):
# The primary key *and* reference to the main media_entry
media_entry = Column(Integer, ForeignKey('core__media_entries.id'),
primary_key=True)
+ get_media_entry = relationship("MediaEntry",
+ backref=backref("video__media_data", cascade="all, delete-orphan"))
+
width = Column(SmallInteger)
height = Column(SmallInteger)
diff --git a/mediagoblin/media_types/video/processing.py b/mediagoblin/media_types/video/processing.py
index 3a479802..d4b4e983 100644
--- a/mediagoblin/media_types/video/processing.py
+++ b/mediagoblin/media_types/video/processing.py
@@ -20,7 +20,7 @@ import os
from mediagoblin import mg_globals as mgg
from mediagoblin.processing import mark_entry_failed, \
- THUMB_SIZE, MEDIUM_SIZE, create_pub_filepath
+ create_pub_filepath, FilenameBuilder
from . import transcoders
logging.basicConfig()
@@ -29,17 +29,27 @@ _log = logging.getLogger(__name__)
_log.setLevel(logging.DEBUG)
-def process_video(entry):
- """
- Code to process a video
+def sniff_handler(media_file, **kw):
+ transcoder = transcoders.VideoTranscoder()
+ data = transcoder.discover(media_file.name)
+
+ _log.debug('Discovered: {0}'.format(data))
+
+ if not data:
+ _log.error('Could not discover {0}'.format(
+ kw.get('media')))
+ return False
+
+ if data['is_video'] == True:
+ return True
- Much of this code is derived from the arista-transcoder script in
- the arista PyPI package and changed to match the needs of
- MediaGoblin
+ return False
- This function sets up the arista video encoder in some kind of new thread
- and attaches callbacks to that child process, hopefully, the
- entry-complete callback will be called when the video is done.
+
+def process_video(entry):
+ """
+ Process a video entry, transcode the queued media files (originals) and
+ create a thumbnail for the entry.
"""
video_config = mgg.global_config['media_type:mediagoblin.media_types.video']
@@ -49,24 +59,21 @@ def process_video(entry):
queued_filename = workbench.localized_file(
mgg.queue_store, queued_filepath,
'source')
+ name_builder = FilenameBuilder(queued_filename)
medium_filepath = create_pub_filepath(
- entry,
- '{original}-640p.webm'.format(
- original=os.path.splitext(
- queued_filepath[-1])[0] # Select the
- ))
+ entry, name_builder.fill('{basename}-640p.webm'))
thumbnail_filepath = create_pub_filepath(
- entry, 'thumbnail.jpg')
-
+ entry, name_builder.fill('{basename}.thumbnail.jpg'))
# Create a temporary file for the video destination
tmp_dst = tempfile.NamedTemporaryFile()
with tmp_dst:
# Transcode queued file to a VP8/vorbis file that fits in a 640x640 square
- transcoder = transcoders.VideoTranscoder(queued_filename, tmp_dst.name)
+ transcoder = transcoders.VideoTranscoder()
+ transcoder.transcode(queued_filename, tmp_dst.name)
# Push transcoded video to public storage
_log.debug('Saving medium...')
@@ -82,7 +89,7 @@ def process_video(entry):
height=transcoder.dst_data.videoheight)
# Create a temporary file for the video thumbnail
- tmp_thumb = tempfile.NamedTemporaryFile()
+ tmp_thumb = tempfile.NamedTemporaryFile(suffix='.jpg')
with tmp_thumb:
# Create a thumbnail.jpg that fits in a 180x180 square
diff --git a/mediagoblin/media_types/video/transcoders.py b/mediagoblin/media_types/video/transcoders.py
index 6137c3bf..74821877 100644
--- a/mediagoblin/media_types/video/transcoders.py
+++ b/mediagoblin/media_types/video/transcoders.py
@@ -17,47 +17,40 @@
from __future__ import division
import os
-os.putenv('GST_DEBUG_DUMP_DOT_DIR', '/tmp')
-
import sys
import logging
-import pdb
import urllib
+import multiprocessing
+import gobject
+import pygst
+pygst.require('0.10')
+import gst
+import struct
+import Image
+
+from gst.extend import discoverer
_log = logging.getLogger(__name__)
-logging.basicConfig()
-_log.setLevel(logging.DEBUG)
+
+gobject.threads_init()
CPU_COUNT = 2
-try:
- import multiprocessing
- try:
- CPU_COUNT = multiprocessing.cpu_count()
- except NotImplementedError:
- _log.warning('multiprocessing.cpu_count not implemented')
- pass
-except ImportError:
- _log.warning('Could not import multiprocessing, defaulting to 2 CPU cores')
- pass
try:
- import gtk
-except:
- raise Exception('Could not find pygtk')
+ CPU_COUNT = multiprocessing.cpu_count()
+except NotImplementedError:
+ _log.warning('multiprocessing.cpu_count not implemented')
-try:
- import gobject
- gobject.threads_init()
-except:
- raise Exception('gobject could not be found')
+os.putenv('GST_DEBUG_DUMP_DOT_DIR', '/tmp')
-try:
- import pygst
- pygst.require('0.10')
- import gst
- from gst.extend import discoverer
-except:
- raise Exception('gst/pygst 0.10 could not be found')
+
+def pixbuf_to_pilbuf(buf):
+ data = list()
+ for i in range(0, len(buf), 3):
+ r, g, b = struct.unpack('BBB', buf[i:i + 3])
+ data.append((r, g, b))
+
+ return data
class VideoThumbnailer:
@@ -232,21 +225,18 @@ class VideoThumbnailer:
width = filters["width"]
height = filters["height"]
- pixbuf = gtk.gdk.pixbuf_new_from_data(
- buff.data, gtk.gdk.COLORSPACE_RGB, False, 8,
- width, height, width * 3)
+ im = Image.new('RGB', (width, height))
- # NOTE: 200x136 is sort of arbitrary. it's larger than what
- # the ui uses at the time of this writing.
- # new_width, new_height = scaled_size((width, height), (200, 136))
+ data = pixbuf_to_pilbuf(buff.data)
- #pixbuf = pixbuf.scale_simple(
- #new_width, new_height, gtk.gdk.INTERP_BILINEAR)
+ im.putdata(data)
+
+ im.save(self.dest_path)
- pixbuf.save(self.dest_path, 'jpeg')
_log.info('Saved thumbnail')
- del pixbuf
+
self.shutdown()
+
except gst.QueryError:
pass
return False
@@ -270,7 +260,7 @@ class VideoThumbnailer:
return 0
try:
- return pipeline.query_duration(gst.FORMAT_TIME)[0]
+ return pipeline.query_duration(gst.FORMAT_TIME)[0]
except gst.QueryError:
return self._get_duration(pipeline, retries + 1)
@@ -320,12 +310,11 @@ class VideoThumbnailer:
self.bus.disconnect(self.watch_id)
self.bus = None
-
def __halt_final(self):
_log.info('Done')
if self.errors:
_log.error(','.join(self.errors))
-
+
self.loop.quit()
@@ -341,10 +330,15 @@ class VideoTranscoder:
that it was refined afterwards and therefore is done more
correctly.
'''
- def __init__(self, src, dst, **kwargs):
+ def __init__(self):
_log.info('Initializing VideoTranscoder...')
self.loop = gobject.MainLoop()
+
+ def transcode(self, src, dst, **kwargs):
+ '''
+ Transcode a video file into a 'medium'-sized version.
+ '''
self.source_path = src
self.destination_path = dst
@@ -358,6 +352,34 @@ class VideoTranscoder:
self._setup()
self._run()
+ def discover(self, src):
+ '''
+ Discover properties about a media file
+ '''
+ _log.info('Discovering {0}'.format(src))
+
+ self.source_path = src
+ self._setup_discover(discovered_callback=self.__on_discovered)
+
+ self.discoverer.discover()
+
+ self.loop.run()
+
+ if hasattr(self, '_discovered_data'):
+ return self._discovered_data.__dict__
+ else:
+ return None
+
+ def __on_discovered(self, data, is_media):
+ _log.debug('Discovered: {0}'.format(data))
+ if not is_media:
+ self.__stop()
+ raise Exception('Could not discover {0}'.format(self.source_path))
+
+ self._discovered_data = data
+
+ self.__stop_mainloop()
+
def _setup(self):
self._setup_discover()
self._setup_pipeline()
@@ -370,12 +392,14 @@ class VideoTranscoder:
_log.debug('Initializing MainLoop()')
self.loop.run()
- def _setup_discover(self):
+ def _setup_discover(self, **kw):
_log.debug('Setting up discoverer')
self.discoverer = discoverer.Discoverer(self.source_path)
# Connect self.__discovered to the 'discovered' event
- self.discoverer.connect('discovered', self.__discovered)
+ self.discoverer.connect(
+ 'discovered',
+ kw.get('discovered_callback', self.__discovered))
def __discovered(self, data, is_media):
'''
@@ -422,7 +446,7 @@ class VideoTranscoder:
self.ffmpegcolorspace = gst.element_factory_make(
'ffmpegcolorspace', 'ffmpegcolorspace')
self.pipeline.add(self.ffmpegcolorspace)
-
+
self.videoscale = gst.element_factory_make('ffvideoscale', 'videoscale')
#self.videoscale.set_property('method', 2) # I'm not sure this works
#self.videoscale.set_property('add-borders', 0)
@@ -516,7 +540,6 @@ class VideoTranscoder:
# Setup the message bus and connect _on_message to the pipeline
self._setup_bus()
-
def _on_dynamic_pad(self, dbin, pad, islast):
'''
Callback called when ``decodebin2`` has a pad that we can connect to
@@ -561,11 +584,11 @@ class VideoTranscoder:
t = message.type
- if t == gst.MESSAGE_EOS:
+ if message.type == gst.MESSAGE_EOS:
self._discover_dst_and_stop()
_log.info('Done')
- elif t == gst.MESSAGE_ELEMENT:
+ elif message.type == gst.MESSAGE_ELEMENT:
if message.structure.get_name() == 'progress':
data = dict(message.structure)
@@ -587,7 +610,6 @@ class VideoTranscoder:
self.dst_discoverer.discover()
-
def __dst_discovered(self, data, is_media):
self.dst_data = data
@@ -596,8 +618,9 @@ class VideoTranscoder:
def __stop(self):
_log.debug(self.loop)
- # Stop executing the pipeline
- self.pipeline.set_state(gst.STATE_NULL)
+ if hasattr(self, 'pipeline'):
+ # Stop executing the pipeline
+ self.pipeline.set_state(gst.STATE_NULL)
# This kills the loop, mercifully
gobject.idle_add(self.__stop_mainloop)
@@ -615,14 +638,15 @@ class VideoTranscoder:
if __name__ == '__main__':
os.nice(19)
+ logging.basicConfig()
from optparse import OptionParser
parser = OptionParser(
- usage='%prog [-v] -a [ video | thumbnail ] SRC DEST')
+ usage='%prog [-v] -a [ video | thumbnail | discover ] SRC [ DEST ]')
parser.add_option('-a', '--action',
dest='action',
- help='One of "video" or "thumbnail"')
+ help='One of "video", "discover" or "thumbnail"')
parser.add_option('-v',
dest='verbose',
@@ -646,13 +670,17 @@ if __name__ == '__main__':
_log.debug(args)
- if not len(args) == 2:
+ if not len(args) == 2 and not options.action == 'discover':
parser.print_help()
sys.exit()
+ transcoder = VideoTranscoder()
+
if options.action == 'thumbnail':
VideoThumbnailer(*args)
elif options.action == 'video':
def cb(data):
print('I\'m a callback!')
- transcoder = VideoTranscoder(*args, progress_callback=cb)
+ transcoder.transcode(*args, progress_callback=cb)
+ elif options.action == 'discover':
+ print transcoder.discover(*args).__dict__
diff --git a/mediagoblin/processing/__init__.py b/mediagoblin/processing/__init__.py
index ecdfa8a9..4a827af4 100644
--- a/mediagoblin/processing/__init__.py
+++ b/mediagoblin/processing/__init__.py
@@ -15,6 +15,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import logging
+import os
from mediagoblin.db.util import atomic_update
from mediagoblin import mg_globals as mgg
@@ -23,9 +24,6 @@ from mediagoblin.tools.translate import lazy_pass_to_ugettext as _
_log = logging.getLogger(__name__)
-THUMB_SIZE = 180, 180
-MEDIUM_SIZE = 640, 640
-
def create_pub_filepath(entry, filename):
return mgg.public_store.get_unique_filepath(
@@ -33,6 +31,37 @@ def create_pub_filepath(entry, filename):
unicode(entry._id),
filename])
+class FilenameBuilder(object):
+ """Easily slice and dice filenames.
+
+ Initialize this class with an original file path, then use the fill()
+ method to create new filenames based on the original.
+
+ """
+ MAX_FILENAME_LENGTH = 255 # VFAT's maximum filename length
+
+ def __init__(self, path):
+ """Initialize a builder from an original file path."""
+ self.dirpath, self.basename = os.path.split(path)
+ self.basename, self.ext = os.path.splitext(self.basename)
+ self.ext = self.ext.lower()
+
+ def fill(self, fmtstr):
+ """Build a new filename based on the original.
+
+ The fmtstr argument can include the following:
+ {basename} -- the original basename, with the extension removed
+ {ext} -- the original extension, always lowercase
+
+ If necessary, {basename} will be truncated so the filename does not
+ exceed this class' MAX_FILENAME_LENGTH in length.
+
+ """
+ basename_len = (self.MAX_FILENAME_LENGTH -
+ len(fmtstr.format(basename='', ext=self.ext)))
+ return fmtstr.format(basename=self.basename[:basename_len],
+ ext=self.ext)
+
def mark_entry_failed(entry_id, exc):
"""
diff --git a/mediagoblin/static/css/audio.css b/mediagoblin/static/css/audio.css
new file mode 100644
index 00000000..387278ec
--- /dev/null
+++ b/mediagoblin/static/css/audio.css
@@ -0,0 +1,84 @@
+.audio-spectrogram {
+ position: relative;
+}
+.playhead {
+ position: absolute;
+ top: 0;
+ left: 0;
+ background: rgba(134, 212, 177, 0.3);
+ border-right: thin solid #ffaa00;
+ height: 100%;
+ -webkit-transition: width .1s ease-out;
+ -moz-transition: width .1s ease-out;
+ transition: width .1s ease-out;
+}
+.audio-control-play-pause {
+ position: absolute;
+ bottom: 0;
+ left: 5px;
+ cursor: pointer;
+ /* background: rgba(0, 0, 0, 0.7); */
+ font-size: 40px;
+ width: 50px;
+ text-shadow: 0 0 10px black;
+}
+ .audio-control-play-pause.playing {
+ color: #b71500;
+ letter-spacing: -17px;
+ margin-left: -7px;
+ }
+ .audio-control-play-pause.paused {
+ /* Warning: this means the the play button shows! */
+ color: rgb(134, 212, 177);
+ }
+
+.buffered-indicators {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ height: 2px;
+}
+ .buffered-indicators div {
+ position: absolute;
+ height: 2px;
+ left: 0;
+ background: rgba(134, 177, 212, 1);
+
+ -webkit-transition: left 1s ease-out;
+ -moz-transition: left 1s ease-out;
+ transition: left 1s ease-out;
+
+ -webkit-transition: width 1s ease-out;
+ -moz-transition: width 1s ease-out;
+ transition: width 1s ease-out;
+
+ cursor: pointer;
+ }
+
+.seekbar {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+}
+
+.audio-currentTime {
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ background: rgba(0, 0, 0, 0.7);
+}
+
+.audio-volume {
+ position: absolute;
+ left: 50px;
+ bottom: 10px;
+ opacity: 0.3;
+ -moz-transition: opacity .1s ease-in-out;
+ -webkit-transition: opacity .1s ease-in-out;
+ transition: opacity .1s ease-in-out;
+}
+ .audio-spectrogram:hover .audio-volume {
+ opacity: 1;
+ }
diff --git a/mediagoblin/static/css/base.css b/mediagoblin/static/css/base.css
index 34be4f16..e4cd91ca 100644
--- a/mediagoblin/static/css/base.css
+++ b/mediagoblin/static/css/base.css
@@ -26,8 +26,7 @@
}
body {
- background-color: #111;
- background-image: url("../images/background.png");
+ background-color: #161616;
color: #C3C3C3;
padding: 0;
margin: 0px;
@@ -113,25 +112,71 @@ input, textarea {
}
header {
- width: 98%;
- padding: 6px 1% 0;
- margin-bottom: 20px;
- background-color: #222;
+ width: 100%;
+ padding: 0;
+ margin-bottom: 42px;
+ background-color: #303030;
+ border-bottom: 1px solid #252525;
}
.header_right {
+ margin: 8px;
+ display: inline-block;
float: right;
- margin: 8px 0px 8px 8px;
+}
+
+.header_right ul {
+ display: none;
+ position: absolute;
+ top: 42px;
+ right: 0px;
+ background: #252525;
+ padding: 20px;
+}
+
+.header_right li {
+ list-style: none;
+}
+
+.dropdown {
+ display: inline-block;
+ color: #c3c3c3;
+ background-color: #424242;
+ border: 1px solid;
+ border-color: #464646 #2B2B2B #252525;
+ border-radius: 4px;
+ padding: 3px 8px;
+ font-size: 16px;
+ text-decoration: none;
+ font-style: normal;
+ font-weight: bold;
+ cursor: pointer;
+ position: relative;
+}
+
+.dropdown_items {
+ position: absolute;
+ right: 0px;
+ top: 25px;
+ background-color: #424242;
+ padding: 10px;
+ width: 160px;
+ border-radius: 5px 0 5px 5px;
+ box-shadow: 0 2px 1px black;
+}
+
+.dropdown_items a {
+ display: block;
}
a.logo {
color: #fff;
font-weight: bold;
- margin: 8px 8px 8px 0;
}
.logo img {
vertical-align: middle;
+ margin: 6px 8px;
}
.mediagoblin_content {
@@ -241,7 +286,7 @@ text-align: center;
}
.media_sidebar p {
- padding-left: 8px;
+ margin-left: 8px;
}
/* forms */
@@ -310,18 +355,23 @@ textarea#description, textarea#bio {
/* comments */
+.comment_wrapper {
+ margin-top: 20px;
+ margin-bottom: 20px;
+}
+
+.comment_wrapper p {
+ margin-bottom: 2px;
+}
+
.comment_author {
- margin-bottom: 40px;
padding-top: 4px;
font-size: 0.9em;
}
.comment_content {
- margin-bottom: 30px;
-}
-
-.comment_content p {
- margin-bottom: 0px;
+ margin-left: 8px;
+ margin-top: 8px;
}
textarea#comment_content {
@@ -563,6 +613,15 @@ table.media_panel th {
header {
text-align: center;
}
+
+ .header_right {
+ margin-right: 2%;
+ float: none;
+ }
+
+ a.logo {
+ margin-left: 2%;
+ }
}
@media screen and (max-width: 570px) {
diff --git a/mediagoblin/static/js/audio.js b/mediagoblin/static/js/audio.js
new file mode 100644
index 00000000..f50908a1
--- /dev/null
+++ b/mediagoblin/static/js/audio.js
@@ -0,0 +1,229 @@
+/**
+ * GNU MediaGoblin -- federated, autonomous media hosting
+ * Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+var audioPlayer = new Object();
+
+(function (audioPlayer) {
+ audioPlayer.init = function (audioElement) {
+ audioPlayer.audioElement = audioElement;
+
+ console.log(audioElement);
+
+ attachEvents();
+
+ $(audioElement).hide();
+ };
+
+ function attachEvents () {
+ audioPlayer.audioElement.addEventListener(
+ 'durationchange', audioPlayer.durationChange, true);
+ audioPlayer.audioElement.addEventListener(
+ 'timeupdate', audioPlayer.timeUpdate, true);
+ audioPlayer.audioElement.addEventListener(
+ 'progress', audioPlayer.onProgress, true);
+ audioPlayer.audioElement.addEventListener(
+ 'ended', audioPlayer.onEnded, true);
+
+ $(document).ready( function () {
+ $('.audio-spectrogram').delegate(
+ '.seekbar', 'click', audioPlayer.onSeek);
+ $('.audio-spectrogram').delegate(
+ '.audio-control-play-pause', 'click', audioPlayer.playPause);
+ $('.audio-spectrogram').delegate(
+ '.audio-volume', 'change', audioPlayer.onVolumeChange);
+ $('.audio-media').delegate(
+ '.audio-spectrogram', 'attachedControls',
+ audioPlayer.onControlsAttached);
+ });
+ }
+
+ audioPlayer.onVolumeChange = function(e) {
+ console.log('volume change', e);
+ audioPlayer.audioElement.volume = e.target.value;
+ }
+
+ audioPlayer.onControlsAttached = function(e) {
+ console.log('Controls attached', e);
+ $('.audio-spectrogram .audio-volume').val(
+ Math.round(audioPlayer.audioElement.volume, 2));
+ }
+
+ audioPlayer.onProgress = function(e) {
+ /**
+ * Handler for file download progress
+ */
+ console.log(e);
+
+ var buffered = audioPlayer.audioElement.buffered;
+
+ ranges = new Array();
+
+ var indicators = $('.audio-spectrogram .buffered-indicators div');
+
+ for (var i = 0; i < buffered.length; i++) {
+ if (!(i in indicators)) {
+ $('<div style="display: none;"></div>')
+ .appendTo($('.audio-spectrogram .buffered-indicators'))
+ .fadeIn(500);
+ indicators = $('.audio-spectrogram .buffered-indicators div');
+ }
+ var posStart = ((buffered.start(i) / audioPlayer.audioElement.duration)
+ * audioPlayer.imageElement.width());
+ var posStop = ((buffered.end(i) / audioPlayer.audioElement.duration)
+ * audioPlayer.imageElement.width());
+ console.log('indicators', indicators);
+
+ var indicator = $(indicators[i]);
+
+ indicator.css('left', posStart);
+ indicator.css('width', posStop - posStart);
+ }
+
+ /*
+ * Clean up unused indicators
+ */
+ if (indicators.length > buffered.length) {
+ for (var i = buffered.length; i < indicators.length; i++) {
+ $(indicators[i]).fadeOut(500, function () {
+ this.remove();
+ });
+ }
+ }
+ };
+
+ audioPlayer.onSeek = function (e) {
+ /**
+ * Callback handler for seek event, which is a .click() event on the
+ * .seekbar element
+ */
+ console.log('onSeek', e);
+
+ var im = audioPlayer.imageElement;
+ var pos = (e.offsetX || e.originalEvent.layerX) / im.width();
+
+ audioPlayer.audioElement.currentTime = pos * audioPlayer.audioElement.duration;
+ audioPlayer.audioElement.play();
+ audioPlayer.setState(audioPlayer.PLAYING);
+ };
+
+ audioPlayer.onEnded = function (e) {
+ audioPlayer.setState(audioPlayer.PAUSED);
+ }
+
+ audioPlayer.playPause = function (e) {
+ console.log('playPause', e);
+ if (audioPlayer.audioElement.paused) {
+ audioPlayer.audioElement.play();
+ audioPlayer.setState(audioPlayer.PLAYING);
+ } else {
+ audioPlayer.audioElement.pause();
+ audioPlayer.setState(audioPlayer.PAUSED);
+ }
+ };
+
+ audioPlayer.NULL = null;
+ audioPlayer.PLAYING = 2;
+ audioPlayer.PAUSED = 4;
+
+ audioPlayer.state = audioPlayer.NULL;
+
+ audioPlayer.setState = function (state) {
+ if (state == audioPlayer.state) {
+ return;
+ } else {
+ audioPlayer.state = state;
+ }
+
+ switch (state) {
+ case audioPlayer.PLAYING:
+ $('.audio-spectrogram .audio-control-play-pause')
+ .removeClass('paused').addClass('playing')
+ .text('▮▮');
+ break;
+ case audioPlayer.PAUSED:
+ $('.audio-spectrogram .audio-control-play-pause')
+ .removeClass('playing').addClass('paused')
+ .text('▶');
+ break;
+ }
+ };
+
+ audioPlayer.durationChange = function () {
+ // ???
+ };
+
+ audioPlayer.timeUpdate = function () {
+ /**
+ * Callback handler for the timeupdate event, responsible for
+ * updating the playhead
+ */
+ var currentTime = audioPlayer.audioElement.currentTime;
+ var playhead = audioPlayer.imageElement.parent().find('.playhead');
+ playhead.css('width', (currentTime / audioPlayer.audioElement.duration)
+ * audioPlayer.imageElement.width());
+ var time = formatTime(currentTime);
+ var duration = formatTime(audioPlayer.audioElement.duration);
+ audioPlayer.imageElement.parent()
+ .find('.audio-currentTime')
+ .text(time + '/' + duration);
+ };
+
+ function formatTime(seconds) {
+ /**
+ * Format a time duration in (hh:)?mm:ss manner
+ */
+ var h = Math.floor(seconds / (60 * 60));
+ var m = Math.floor((seconds - h * 60 * 60) / 60);
+ var s = Math.round(seconds - h * 60 * 60 - m * 60);
+ return '' + (h ? (h < 10 ? '0' + h : h) + ':' : '') + (m < 10 ? '0' + m : m) + ':' + (s < 10 ? '0' + s : s);
+ }
+
+ audioPlayer.formatTime = formatTime;
+
+ audioPlayer.attachToImage = function (imageElement) {
+ /**
+ * Attach the player to an image element
+ */
+ console.log(imageElement);
+
+ var im = $(imageElement);
+
+ audioPlayer.imageElement = im;
+
+ $('<div class="playhead"></div>').appendTo(im.parent());
+ $('<div class="buffered-indicators"></div>').appendTo(im.parent());
+ $('<div class="seekbar"></div>').appendTo(im.parent());
+ $('<div class="audio-control-play-pause paused">▶</div>').appendTo(im.parent());
+ $('<div class="audio-currentTime">00:00</div>').appendTo(im.parent());
+ $('<input placeholder="Range input not supported" class="audio-volume"'
+ +'type="range" min="0" max="1" step="0.01" />').appendTo(im.parent());
+ $('.audio-spectrogram').trigger('attachedControls');
+ };
+})(audioPlayer);
+
+$(document).ready(function () {
+ if (!$('.audio-media').length) {
+ return;
+ }
+
+ console.log('Initializing audio player');
+
+ audioElements = $('.audio-media .audio-player');
+ audioPlayer.init(audioElements[0]);
+ audioPlayer.attachToImage($('.audio-spectrogram img')[0]);
+});
diff --git a/mediagoblin/static/js/geolocation-map.js b/mediagoblin/static/js/geolocation-map.js
index a2c62045..de49a37d 100644
--- a/mediagoblin/static/js/geolocation-map.js
+++ b/mediagoblin/static/js/geolocation-map.js
@@ -17,6 +17,11 @@
*/
$(document).ready(function () {
+ if (!$('#tile-map').length) {
+ return;
+ }
+ console.log('Initializing map');
+
var longitude = Number(
$('#tile-map #gps-longitude').val());
var latitude = Number(
diff --git a/mediagoblin/static/js/header_dropdown.js b/mediagoblin/static/js/header_dropdown.js
new file mode 100644
index 00000000..643bafa4
--- /dev/null
+++ b/mediagoblin/static/js/header_dropdown.js
@@ -0,0 +1,30 @@
+/**
+ * GNU MediaGoblin -- federated, autonomous media hosting
+ * Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+$(document).ready(function() {
+ $(".dropdown_items").hide();
+ $(document).mouseup(function(e) {
+ if($(e.target).is(".dropdown")) {
+ $(".dropdown_items").toggle();
+ } else if($(e.target).is(".dropdown_items")) {
+ return;
+ } else {
+ $(".dropdown_items").hide();
+ }
+ });
+});
diff --git a/mediagoblin/static/js/show_password.js b/mediagoblin/static/js/show_password.js
index e42d44ea..b3fbc862 100644
--- a/mediagoblin/static/js/show_password.js
+++ b/mediagoblin/static/js/show_password.js
@@ -17,6 +17,7 @@
*/
$(document).ready(function(){
+ //Create a duplicate password field. We could change the input type dynamically, but this angers the IE gods (not just IE6).
$("#password").after('<input type="text" value="" name="password_clear" id="password_clear" /><label><input type="checkbox" id="password_boolean" />Show password</label>');
$('#password_clear').hide();
$('#password_boolean').click(function(){
diff --git a/mediagoblin/submit/views.py b/mediagoblin/submit/views.py
index 1df676ab..517fb646 100644
--- a/mediagoblin/submit/views.py
+++ b/mediagoblin/submit/views.py
@@ -20,7 +20,8 @@ from os.path import splitext
from cgi import FieldStorage
from celery import registry
-import urllib,urllib2
+import urllib
+import urllib2
import logging
_log = logging.getLogger(__name__)
@@ -36,7 +37,7 @@ from mediagoblin.submit import forms as submit_forms
from mediagoblin.processing import mark_entry_failed
from mediagoblin.processing.task import ProcessMedia
from mediagoblin.messages import add_message, SUCCESS
-from mediagoblin.media_types import get_media_type_and_manager, \
+from mediagoblin.media_types import sniff_media, \
InvalidFileType, FileTypeNotSupported
@@ -56,7 +57,11 @@ def submit_start(request):
else:
try:
filename = request.POST['file'].filename
- media_type, media_manager = get_media_type_and_manager(filename)
+
+ # Sniff the submitted media to determine which
+ # media plugin should handle processing
+ media_type, media_manager = sniff_media(
+ request.POST['file'])
# create entry and save in database
entry = request.db.MediaEntry()
@@ -131,9 +136,10 @@ def submit_start(request):
raise
if mg_globals.app_config["push_urls"]:
- feed_url=request.urlgen(
+ feed_url = request.urlgen(
'mediagoblin.user_pages.atom_feed',
- qualified=True,user=request.user.username)
+ qualified=True,
+ user=request.user.username)
hubparameters = {
'hub.mode': 'publish',
'hub.url': feed_url}
@@ -160,10 +166,9 @@ def submit_start(request):
user=request.user.username)
except Exception as e:
'''
- This section is intended to catch exceptions raised in
+ This section is intended to catch exceptions raised in
mediagobling.media_types
'''
-
if isinstance(e, InvalidFileType) or \
isinstance(e, FileTypeNotSupported):
submit_form.file.errors.append(
diff --git a/mediagoblin/templates/mediagoblin/base.html b/mediagoblin/templates/mediagoblin/base.html
index c2d5457d..16882a98 100644
--- a/mediagoblin/templates/mediagoblin/base.html
+++ b/mediagoblin/templates/mediagoblin/base.html
@@ -28,6 +28,8 @@
<link rel="shortcut icon"
href="{{ request.staticdirect('/images/goblin.ico') }}" />
<script src="{{ request.staticdirect('/js/extlib/jquery.js') }}"></script>
+ <script type="text/javascript"
+ src="{{ request.staticdirect('/js/header_dropdown.js') }}"></script>
<!--[if lt IE 9]>
<script src="{{ request.staticdirect('/js/extlib/html5shiv.js') }}"></script>
<![endif]-->
@@ -44,12 +46,6 @@
><img src="{{ request.staticdirect('/images/logo.png') }}"
alt="{% trans %}MediaGoblin logo{% endtrans %}" /></a>
{% endblock mediagoblin_logo %}
- {% if request.user and request.user.status == 'active' %}
- <a class="button_action"
- href="{{ request.urlgen('mediagoblin.submit.start') }}">
- {% trans %}Add media{% endtrans %}
- </a>
- {% endif %}
{% block mediagoblin_header_title %}{% endblock %}
<div class="header_right">
{% if request.user %}
@@ -60,15 +56,22 @@
class="button_action_highlight">
{% trans %}Verify your email!{% endtrans %}</a>
{% endif %}
- <a href="{{ request.urlgen('mediagoblin.user_pages.user_home',
- user= request.user.username) }}">
- {{ request.user.username }}</a>
- (<a href="{{ request.urlgen('mediagoblin.auth.logout') }}">{% trans %}log out{% endtrans %}</a>)
+ <div class="dropdown">
+ {{ request.user.username }} ▾
+ <div class="dropdown_items">
+ {% if request.user and request.user.status == 'active' %}
+ <a href="{{ request.urlgen('mediagoblin.submit.start') }}">+ Add media</a>
+ {% endif %}
+ <a href="{{ request.urlgen('mediagoblin.user_pages.user_home', user= request.user.username) }}">View your profile</a>
+ <a class="button_action" href="{{ request.urlgen('mediagoblin.auth.logout') }}">{% trans %}Log out{% endtrans %}</a>
+ </div>
+ </div>
{% else %}
<a href="{{ request.urlgen('mediagoblin.auth.login') }}">
{% trans %}Log in{% endtrans %}</a>
{% endif %}
</div>
+ <div class="clear"></div>
</header>
{% endblock %}
<div class="container">
diff --git a/mediagoblin/templates/mediagoblin/media_displays/audio.html b/mediagoblin/templates/mediagoblin/media_displays/audio.html
new file mode 100644
index 00000000..36bd9d1d
--- /dev/null
+++ b/mediagoblin/templates/mediagoblin/media_displays/audio.html
@@ -0,0 +1,61 @@
+{#
+# GNU MediaGoblin -- federated, autonomous media hosting
+# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+#}
+
+{% extends 'mediagoblin/user_pages/media.html' %}
+
+{% block mediagoblin_head %}
+ {{ super() }}
+ <link rel="stylesheet" type="text/css" href="{{ request.staticdirect('/css/audio.css') }}" />
+ <script type="text/javascript" src="{{ request.staticdirect(
+ '/js/audio.js') }}"></script>
+{% endblock %}
+
+{% block mediagoblin_media %}
+ <div class="audio-media">
+ {% if 'spectrogram' in media.media_files %}
+ <div class="audio-spectrogram">
+ <img src="{{ request.app.public_store.file_url(
+ media.media_files.spectrogram) }}"
+ alt="Spectrogram" />
+ </div>
+ {% endif %}
+ <audio class="audio-player" controls="controls"
+ preload="metadata">
+ <source src="{{ request.app.public_store.file_url(
+ media.media_files.ogg) }}" type="video/webm; encoding=&quot;vorbis&quot;" />
+ <div class="no_html5">
+ {%- trans -%}Sorry, this audio will not work because
+ your web browser does not support HTML5
+ audio.{%- endtrans -%}<br/>
+ {%- trans -%}You can get a modern web browser that
+ can play the audio at <a href="http://getfirefox.com">
+ http://getfirefox.com</a>!{%- endtrans -%}
+ </div>
+ </audio>
+ </div>
+ {% if 'original' in media.media_files %}
+ <p>
+ <a href="{{ request.app.public_store.file_url(
+ media.media_files['original']) }}">
+ {%- trans -%}
+ Original
+ {%- endtrans -%}
+ </a>
+ </p>
+ {% endif %}
+{% endblock %}
diff --git a/mediagoblin/templates/mediagoblin/user_pages/media.html b/mediagoblin/templates/mediagoblin/user_pages/media.html
index 70a367f9..eadf712e 100644
--- a/mediagoblin/templates/mediagoblin/user_pages/media.html
+++ b/mediagoblin/templates/mediagoblin/user_pages/media.html
@@ -114,8 +114,6 @@
</p>
{% endif %}
{% if comments %}
- <h3>
- <div class="right_align">
<a
{% if not request.user %}
href="{{ request.urlgen('mediagoblin.auth.login') }}"
@@ -123,8 +121,6 @@
class="button_action" id="button_addcomment" title="Add a comment">
{% trans %}Add a comment{% endtrans %}
</a>
- </div>
- </h3>
{% if request.user %}
<form action="{{ request.urlgen('mediagoblin.user_pages.media_post_comment',
user= media.get_uploader.username,
@@ -147,10 +143,7 @@
{% else %}
<div class="comment_wrapper" id="comment-{{ comment._id }}">
{% endif %}
- <div class="comment_content">
- {% autoescape False %}
- {{ comment.content_html }}
- {% endautoescape %}
+ <div class="comment_author">
<img src="{{ request.staticdirect('/images/icon_comment.png') }}" />
<a href="{{ request.urlgen('mediagoblin.user_pages.user_home',
user = comment_author.username) }}">
@@ -162,7 +155,12 @@
user = media.get_uploader.username,
media = media.slug_or_id) }}#comment">
{{ comment.created.strftime("%I:%M%p %Y-%m-%d") }}
- </a>
+ </a>:
+ </div>
+ <div class="comment_content">
+ {% autoescape False %}
+ {{ comment.content_html }}
+ {% endautoescape %}
</div>
</div>
{% endfor %}
diff --git a/mediagoblin/tests/test_processing.py b/mediagoblin/tests/test_processing.py
new file mode 100644
index 00000000..417f91f3
--- /dev/null
+++ b/mediagoblin/tests/test_processing.py
@@ -0,0 +1,20 @@
+#!/usr/bin/env python
+
+from nose.tools import assert_equal, assert_true, assert_false
+
+from mediagoblin import processing
+
+class TestProcessing(object):
+ def run_fill(self, input, format, output=None):
+ builder = processing.FilenameBuilder(input)
+ result = builder.fill(format)
+ if output is None:
+ return result
+ assert_equal(output, result)
+
+ def test_easy_filename_fill(self):
+ self.run_fill('/home/user/foo.TXT', '{basename}bar{ext}', 'foobar.txt')
+
+ def test_long_filename_fill(self):
+ self.run_fill('{0}.png'.format('A' * 300), 'image-{basename}{ext}',
+ 'image-{0}.png'.format('A' * 245))
diff --git a/mediagoblin/tests/test_submission.py b/mediagoblin/tests/test_submission.py
index 9b503f4f..8d1b3745 100644
--- a/mediagoblin/tests/test_submission.py
+++ b/mediagoblin/tests/test_submission.py
@@ -15,30 +15,35 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import urlparse
-import pkg_resources
+import os
import re
from nose.tools import assert_equal, assert_true, assert_false
+from pkg_resources import resource_filename
-from mediagoblin.tests.tools import setup_fresh_app, get_test_app, \
+from mediagoblin.tests.tools import get_test_app, \
fixture_add_user
from mediagoblin import mg_globals
-from mediagoblin.tools import template, common
-
-GOOD_JPG = pkg_resources.resource_filename(
- 'mediagoblin.tests', 'test_submission/good.jpg')
-GOOD_PNG = pkg_resources.resource_filename(
- 'mediagoblin.tests', 'test_submission/good.png')
-EVIL_FILE = pkg_resources.resource_filename(
- 'mediagoblin.tests', 'test_submission/evil')
-EVIL_JPG = pkg_resources.resource_filename(
- 'mediagoblin.tests', 'test_submission/evil.jpg')
-EVIL_PNG = pkg_resources.resource_filename(
- 'mediagoblin.tests', 'test_submission/evil.png')
+from mediagoblin.tools import template
+
+
+def resource(filename):
+ return resource_filename('mediagoblin.tests', 'test_submission/' + filename)
+
+
+GOOD_JPG = resource('good.jpg')
+GOOD_PNG = resource('good.png')
+EVIL_FILE = resource('evil')
+EVIL_JPG = resource('evil.jpg')
+EVIL_PNG = resource('evil.png')
+BIG_BLUE = resource('bigblue.png')
GOOD_TAG_STRING = 'yin,yang'
BAD_TAG_STRING = 'rage,' + 'f' * 26 + 'u' * 26
+FORM_CONTEXT = ['mediagoblin/submit/start.html', 'submit_form']
+REQUEST_CONTEXT = ['mediagoblin/user_pages/user.html', 'request']
+
class TestSubmission:
def setUp(self):
@@ -61,85 +66,74 @@ class TestSubmission:
def logout(self):
self.test_app.get('/auth/logout/')
+ def do_post(self, data, *context_keys, **kwargs):
+ url = kwargs.pop('url', '/submit/')
+ do_follow = kwargs.pop('do_follow', False)
+ template.clear_test_template_context()
+ response = self.test_app.post(url, data, **kwargs)
+ if do_follow:
+ response.follow()
+ context_data = template.TEMPLATE_TEST_CONTEXT
+ for key in context_keys:
+ context_data = context_data[key]
+ return response, context_data
+
+ def upload_data(self, filename):
+ return {'upload_files': [('file', filename)]}
+
+ def check_comments(self, request, media_id, count):
+ comments = request.db.MediaComment.find({'media_entry': media_id})
+ assert_equal(count, len(list(comments)))
+
def test_missing_fields(self):
# Test blank form
# ---------------
- template.clear_test_template_context()
- response = self.test_app.post(
- '/submit/', {})
- context = template.TEMPLATE_TEST_CONTEXT['mediagoblin/submit/start.html']
- form = context['submit_form']
- assert form.file.errors == [u'You must provide a file.']
+ response, form = self.do_post({}, *FORM_CONTEXT)
+ assert_equal(form.file.errors, [u'You must provide a file.'])
# Test blank file
# ---------------
- template.clear_test_template_context()
- response = self.test_app.post(
- '/submit/', {
- 'title': 'test title'})
- context = template.TEMPLATE_TEST_CONTEXT['mediagoblin/submit/start.html']
- form = context['submit_form']
- assert form.file.errors == [u'You must provide a file.']
-
+ response, form = self.do_post({'title': 'test title'}, *FORM_CONTEXT)
+ assert_equal(form.file.errors, [u'You must provide a file.'])
- def test_normal_uploads(self):
- # Test JPG
- # --------
- template.clear_test_template_context()
- response = self.test_app.post(
- '/submit/', {
- 'title': 'Normal upload 1'
- }, upload_files=[(
- 'file', GOOD_JPG)])
-
- # User should be redirected
- response.follow()
- assert_equal(
- urlparse.urlsplit(response.location)[2],
- '/u/chris/')
- assert template.TEMPLATE_TEST_CONTEXT.has_key(
- 'mediagoblin/user_pages/user.html')
+ def check_url(self, response, path):
+ assert_equal(urlparse.urlsplit(response.location)[2], path)
+ def check_normal_upload(self, title, filename):
+ response, context = self.do_post({'title': title}, do_follow=True,
+ **self.upload_data(filename))
+ self.check_url(response, '/u/{0}/'.format(self.test_user.username))
+ assert_true('mediagoblin/user_pages/user.html' in context)
# Make sure the media view is at least reachable, logged in...
- self.test_app.get('/u/chris/m/normal-upload-1/')
+ url = '/u/{0}/m/{1}/'.format(self.test_user.username,
+ title.lower().replace(' ', '-'))
+ self.test_app.get(url)
# ... and logged out too.
self.logout()
- self.test_app.get('/u/chris/m/normal-upload-1/')
- # Log back in for the remaining tests.
- self.login()
+ self.test_app.get(url)
- # Test PNG
- # --------
- template.clear_test_template_context()
- response = self.test_app.post(
- '/submit/', {
- 'title': 'Normal upload 2'
- }, upload_files=[(
- 'file', GOOD_PNG)])
+ def test_normal_jpg(self):
+ self.check_normal_upload('Normal upload 1', GOOD_JPG)
- response.follow()
- assert_equal(
- urlparse.urlsplit(response.location)[2],
- '/u/chris/')
- assert template.TEMPLATE_TEST_CONTEXT.has_key(
- 'mediagoblin/user_pages/user.html')
+ def test_normal_png(self):
+ self.check_normal_upload('Normal upload 2', GOOD_PNG)
+
+ def check_media(self, request, find_data, count=None):
+ media = request.db.MediaEntry.find(find_data)
+ if count is not None:
+ assert_equal(media.count(), count)
+ if count == 0:
+ return
+ return media[0]
def test_tags(self):
# Good tag string
# --------
- template.clear_test_template_context()
- response = self.test_app.post(
- '/submit/', {
- 'title': 'Balanced Goblin',
- 'tags': GOOD_TAG_STRING
- }, upload_files=[(
- 'file', GOOD_JPG)])
-
- # New media entry with correct tags should be created
- response.follow()
- context = template.TEMPLATE_TEST_CONTEXT['mediagoblin/user_pages/user.html']
- request = context['request']
- media = request.db.MediaEntry.find({'title': 'Balanced Goblin'})[0]
+ response, request = self.do_post({'title': 'Balanced Goblin',
+ 'tags': GOOD_TAG_STRING},
+ *REQUEST_CONTEXT, do_follow=True,
+ **self.upload_data(GOOD_JPG))
+ media = self.check_media(request, {'title': 'Balanced Goblin'}, 1)
assert media.tags[0]['name'] == u'yin'
assert media.tags[0]['slug'] == u'yin'
@@ -148,150 +142,117 @@ class TestSubmission:
# Test tags that are too long
# ---------------
- template.clear_test_template_context()
- response = self.test_app.post(
- '/submit/', {
- 'title': 'Balanced Goblin',
- 'tags': BAD_TAG_STRING
- }, upload_files=[(
- 'file', GOOD_JPG)])
-
- # Too long error should be raised
- context = template.TEMPLATE_TEST_CONTEXT['mediagoblin/submit/start.html']
- form = context['submit_form']
- assert form.tags.errors == [
- u'Tags must be shorter than 50 characters. Tags that are too long'\
- ': ffffffffffffffffffffffffffuuuuuuuuuuuuuuuuuuuuuuuuuu']
+ response, form = self.do_post({'title': 'Balanced Goblin',
+ 'tags': BAD_TAG_STRING},
+ *FORM_CONTEXT,
+ **self.upload_data(GOOD_JPG))
+ assert_equal(form.tags.errors, [
+ u'Tags must be shorter than 50 characters. ' \
+ 'Tags that are too long: ' \
+ 'ffffffffffffffffffffffffffuuuuuuuuuuuuuuuuuuuuuuuuuu'])
def test_delete(self):
- template.clear_test_template_context()
- response = self.test_app.post(
- '/submit/', {
- 'title': 'Balanced Goblin',
- }, upload_files=[(
- 'file', GOOD_JPG)])
-
- # Post image
- response.follow()
-
- request = template.TEMPLATE_TEST_CONTEXT[
- 'mediagoblin/user_pages/user.html']['request']
-
- media = request.db.MediaEntry.find({'title': 'Balanced Goblin'})[0]
-
- # Does media entry exist?
- assert_true(media)
+ response, request = self.do_post({'title': 'Balanced Goblin'},
+ *REQUEST_CONTEXT, do_follow=True,
+ **self.upload_data(GOOD_JPG))
+ media = self.check_media(request, {'title': 'Balanced Goblin'}, 1)
+ media_id = media.id
# Add a comment, so we can test for its deletion later.
- media_id = media.id
- get_comments = lambda: list(
- request.db.MediaComment.find({'media_entry': media_id}))
- assert_false(get_comments())
- response = self.test_app.post(
- request.urlgen('mediagoblin.user_pages.media_post_comment',
- user=self.test_user.username,
- media=media._id),
- {'comment_content': 'i love this test'})
- response.follow()
- assert_true(get_comments())
+ self.check_comments(request, media_id, 0)
+ comment_url = request.urlgen(
+ 'mediagoblin.user_pages.media_post_comment',
+ user=self.test_user.username, media=media_id)
+ response = self.do_post({'comment_content': 'i love this test'},
+ url=comment_url, do_follow=True)[0]
+ self.check_comments(request, media_id, 1)
# Do not confirm deletion
# ---------------------------------------------------
- response = self.test_app.post(
- request.urlgen('mediagoblin.user_pages.media_confirm_delete',
- # No work: user=media.uploader().username,
- user=self.test_user.username,
- media=media_id),
- # no value means no confirm
- {})
-
- response.follow()
-
- request = template.TEMPLATE_TEST_CONTEXT[
- 'mediagoblin/user_pages/user.html']['request']
-
- media = request.db.MediaEntry.find({'title': 'Balanced Goblin'})[0]
-
- # Does media entry still exist?
- assert_true(media)
+ delete_url = request.urlgen(
+ 'mediagoblin.user_pages.media_confirm_delete',
+ user=self.test_user.username, media=media_id)
+ # Empty data means don't confirm
+ response = self.do_post({}, do_follow=True, url=delete_url)[0]
+ media = self.check_media(request, {'title': 'Balanced Goblin'}, 1)
+ media_id = media.id
# Confirm deletion
# ---------------------------------------------------
- response = self.test_app.post(
- request.urlgen('mediagoblin.user_pages.media_confirm_delete',
- # No work: user=media.uploader().username,
- user=self.test_user.username,
- media=media._id),
- {'confirm': 'y'})
-
- response.follow()
-
- request = template.TEMPLATE_TEST_CONTEXT[
- 'mediagoblin/user_pages/user.html']['request']
-
- # Does media entry still exist?
- assert_false(
- request.db.MediaEntry.find(
- {'_id': media._id}).count())
+ response, request = self.do_post({'confirm': 'y'}, *REQUEST_CONTEXT,
+ do_follow=True, url=delete_url)
+ self.check_media(request, {'_id': media_id}, 0)
+ self.check_comments(request, media_id, 0)
- # How about the comment?
- assert_false(get_comments())
-
- def test_malicious_uploads(self):
+ def test_evil_file(self):
# Test non-suppoerted file with non-supported extension
# -----------------------------------------------------
+ response, form = self.do_post({'title': 'Malicious Upload 1'},
+ *FORM_CONTEXT,
+ **self.upload_data(EVIL_FILE))
+ assert_equal(len(form.file.errors), 1)
+ assert 'Sorry, I don\'t support that file type :(' == \
+ str(form.file.errors[0])
+
+ def test_sniffing(self):
+ '''
+ Test sniffing mechanism to assert that regular uploads work as intended
+ '''
template.clear_test_template_context()
response = self.test_app.post(
'/submit/', {
- 'title': 'Malicious Upload 1'
+ 'title': 'UNIQUE_TITLE_PLS_DONT_CREATE_OTHER_MEDIA_WITH_THIS_TITLE'
}, upload_files=[(
- 'file', EVIL_FILE)])
+ 'file', GOOD_JPG)])
+
+ response.follow()
+
+ context = template.TEMPLATE_TEST_CONTEXT['mediagoblin/user_pages/user.html']
+
+ request = context['request']
+
+ media = request.db.MediaEntry.find_one({
+ u'title': u'UNIQUE_TITLE_PLS_DONT_CREATE_OTHER_MEDIA_WITH_THIS_TITLE'})
- context = template.TEMPLATE_TEST_CONTEXT['mediagoblin/submit/start.html']
- form = context['submit_form']
- assert re.match(r'^Could not extract any file extension from ".*?"$', str(form.file.errors[0]))
- assert len(form.file.errors) == 1
+ assert media.media_type == 'mediagoblin.media_types.image'
+ def check_false_image(self, title, filename):
# NOTE: The following 2 tests will ultimately fail, but they
# *will* pass the initial form submission step. Instead,
# they'll be caught as failures during the processing step.
+ response, context = self.do_post({'title': title}, do_follow=True,
+ **self.upload_data(filename))
+ self.check_url(response, '/u/{0}/'.format(self.test_user.username))
+ entry = mg_globals.database.MediaEntry.find_one({'title': title})
+ assert_equal(entry.state, 'failed')
+ assert_equal(entry.fail_error, u'mediagoblin.processing:BadMediaFail')
+ def test_evil_jpg(self):
# Test non-supported file with .jpg extension
# -------------------------------------------
- template.clear_test_template_context()
- response = self.test_app.post(
- '/submit/', {
- 'title': 'Malicious Upload 2'
- }, upload_files=[(
- 'file', EVIL_JPG)])
- response.follow()
- assert_equal(
- urlparse.urlsplit(response.location)[2],
- '/u/chris/')
-
- entry = mg_globals.database.MediaEntry.find_one(
- {'title': 'Malicious Upload 2'})
- assert_equal(entry.state, 'failed')
- assert_equal(
- entry.fail_error,
- u'mediagoblin.processing:BadMediaFail')
+ self.check_false_image('Malicious Upload 2', EVIL_JPG)
+ def test_evil_png(self):
# Test non-supported file with .png extension
# -------------------------------------------
- template.clear_test_template_context()
- response = self.test_app.post(
- '/submit/', {
- 'title': 'Malicious Upload 3'
- }, upload_files=[(
- 'file', EVIL_PNG)])
- response.follow()
- assert_equal(
- urlparse.urlsplit(response.location)[2],
- '/u/chris/')
-
- entry = mg_globals.database.MediaEntry.find_one(
- {'title': 'Malicious Upload 3'})
- assert_equal(entry.state, 'failed')
- assert_equal(
- entry.fail_error,
- u'mediagoblin.processing:BadMediaFail')
+ self.check_false_image('Malicious Upload 3', EVIL_PNG)
+
+ def test_processing(self):
+ data = {'title': 'Big Blue'}
+ response, request = self.do_post(data, *REQUEST_CONTEXT, do_follow=True,
+ **self.upload_data(BIG_BLUE))
+ media = self.check_media(request, data, 1)
+ last_size = 1024 ** 3 # Needs to be larger than bigblue.png
+ for key, basename in (('original', 'bigblue.png'),
+ ('medium', 'bigblue.medium.png'),
+ ('thumb', 'bigblue.thumbnail.png')):
+ # Does the processed image have a good filename?
+ filename = resource_filename(
+ 'mediagoblin.tests',
+ os.path.join('test_user_dev/media/public',
+ *media.media_files.get(key, [])))
+ assert_true(filename.endswith('_' + basename))
+ # Is it smaller than the last processed image we looked at?
+ size = os.stat(filename).st_size
+ assert_true(last_size > size)
+ last_size = size
diff --git a/mediagoblin/tests/test_submission/bigblue.png b/mediagoblin/tests/test_submission/bigblue.png
new file mode 100644
index 00000000..2b2c2a44
--- /dev/null
+++ b/mediagoblin/tests/test_submission/bigblue.png
Binary files differ
diff --git a/mediagoblin/tools/exif.py b/mediagoblin/tools/exif.py
index de6dd128..448a342e 100644
--- a/mediagoblin/tools/exif.py
+++ b/mediagoblin/tools/exif.py
@@ -32,6 +32,13 @@ USEFUL_TAGS = [
'EXIF UserComment',
]
+def exif_image_needs_rotation(exif_tags):
+ """
+ Returns True if EXIF orientation requires rotation
+ """
+ return 'Image Orientation' in exif_tags \
+ and exif_tags['Image Orientation'].values[0] != 1
+
def exif_fix_image_orientation(im, exif_tags):
"""
Translate any EXIF orientation to raw orientation