diff --git a/doubly_linked_list.py b/LinkedLists/doubly_linked_list.py similarity index 100% rename from doubly_linked_list.py rename to LinkedLists/doubly_linked_list.py diff --git a/singly_linked_list.py b/LinkedLists/singly_linked_list.py similarity index 100% rename from singly_linked_list.py rename to LinkedLists/singly_linked_list.py diff --git a/SearchPlayer/Dockerfile b/SearchPlayer/Dockerfile new file mode 100644 index 0000000..483b441 --- /dev/null +++ b/SearchPlayer/Dockerfile @@ -0,0 +1,26 @@ +FROM python:3.7.4-alpine3.10 + +ADD requirements.txt /app/requirements.txt + +RUN set -ex \ + && apk add --no-cache --virtual .build-deps postgresql-dev build-base \ + && python -m venv /env \ + && /env/bin/pip install --upgrade pip \ + && /env/bin/pip install --no-cache-dir -r /app/requirements.txt \ + && runDeps="$(scanelf --needed --nobanner --recursive /env \ + | awk '{ gsub(/,/, "\nso:", $2); print "so:" $2 }' \ + | sort -u \ + | xargs -r apk info --installed \ + | sort -u)" \ + && apk add --virtual rundeps $runDeps \ + && apk del .build-deps + +ADD . /app +WORKDIR /app + +ENV VIRTUAL_ENV /env +ENV PATH /env/bin:$PATH + +EXPOSE 8000 + +CMD ["gunicorn", "--bind", ":8000", "--workers", "3", "SearchPlayer.wsgi:application"] diff --git a/SearchPlayer/README.md b/SearchPlayer/README.md new file mode 100755 index 0000000..415e8a0 --- /dev/null +++ b/SearchPlayer/README.md @@ -0,0 +1,66 @@ +# SearchPlayer + +The purpose of this project is to provide a "power user" kind of interface for a desktop music player. +The basic idea is that for power users, typing is faster than clicking. + +This project is still in development. At the moment it is usable, but glitches may occur. Specifically, the interface is not responsive and works mostly for resolutions above 1024x768. + +**DEPENDENCIES NOTE**: This project depends on the modules: + - `django-mathfilters` + - `glob2` + +**COMPATIBILITY NOTE**: To keep the player compatible cross-browser, the code will first check if the browser supports MP3 file playback. If it doesn't, it will look for a file with the same name, but extension '.ogg' + +## Usage + +![main interface](https://raw.githubusercontent.com/codezapper/PythonCodeExercises/master/SearchPlayer/search_player_screenshot.png) + +A basic search will return results that include the search term in the artist name, album name or song title. +When more search terms are used, the results include *ANY* of the terms. +To filter the results using *ALL* of the terms, it's possible to combine multiple terms with a ":" (colon) separator. + +For instance: + - `meta` will return all matches from the word "meta" (e.g. "metallica") + - `meta mega` will return all matches from the words "meta" *OR* "mega" (e.g. "metallica" or "megadeth") + - `meta:one` will return all matches from the words "meta" *AND* "one" (e.g. song "one" by "metallica") + - `meta:one mega` will return all matches from the words "meta" *AND* "one" plus all the matches from the word "mega" (e.g. song "one" by "metallica" plus all songs by "megadeth") + +The standard search is done on a search key based on the combination of the three elements, so that looking for multiple words +in a single song can also be done by typing the title with no spaces (e.g. "blackice" will return the album "black ice"). + +The regex search is done applying the regular expression ONLY to the title. +This is not really for performance issues, but more for making it so that the query results are more intuitive. + +There are two reserved words in the search: + - "random" will pick a single result out of the current single query. This is done for a single search term. For instance: + - `random:meta` will return a random match for the word "meta" + - `random:meta:one` will return a random match for the word "meta" combined with the word "one" + - `random:meta:one mega` will return a random match for the word "meta" combined with the word "one" plus all the matches for the word "mega" + - "shuffle" will shuffle the results randomly for the current single query. By default, results are ordered by artist / album / track number. For instance: + - `shuffle:meta` will return all result for the word "meta", but they will be randomly shuffled. + - `shuffle:meta:one` will return all result for the word "meta:one", but they will be randomly shuffled. + - `shuffle:meta:one mega` will return all result for the word "meta:one", but they will be randomly shuffled plus all the matches for the word "mega" which will instead be ordered as default. + +The "random" and "shuffle" reserved words must be the first word in the current query. + +## Keyboard + +Pressing the enter key will either: + - start playback if a song is *NOT* already being played. + - add the results to the queue if a song is already being played. + - add the results to the top of the queue if the *SHIFT* button is also pressed. + - replace the current playlist with the current results if the *CTRL* button is also pressed. + +Pressing the arrow down button will move to the next track. +Pressing the arrow up button will move to the prev track. + +Pressing the Ctrl+X combination will enable regex mode. This will apply regular expressions to title, artist, album and show the results of those three matches combined. It is still possible to filter using the ":" (colon) separator as specified above. + +## Setup + +This project is done entirely in Python+Django and Javascript, so a working Python+Django environment is needed. +The database is a sqlite3 file, so it can be redistributed easily as a demo. + +The "file_indexer.py" script will insert in the database the titles and paths of the music files. +It can be configured so that it reads files from a specific directory, the default one is "Music/" from where the script is run. +It works by reading the MP3 files metadata, so if some fields are missing, check that the metadata in the MP3 files is correct. diff --git a/SearchPlayer/SearchPlayer/urls.py b/SearchPlayer/SearchPlayer/urls.py index 10b0d89..c184b5b 100755 --- a/SearchPlayer/SearchPlayer/urls.py +++ b/SearchPlayer/SearchPlayer/urls.py @@ -19,4 +19,5 @@ urlpatterns = [ url(r'^admin/', admin.site.urls), url(r'^lister/', include('lister.urls')), + url(r'^$', include('lister.urls')), ] diff --git a/SearchPlayer/TODO.txt b/SearchPlayer/TODO.txt index 6c600b9..83e32c3 100755 --- a/SearchPlayer/TODO.txt +++ b/SearchPlayer/TODO.txt @@ -1,9 +1,5 @@ - Cleanup main.js -- Cleanup python code -- Fix sorting when mixing filters and search words -- Fix playing when not submitted (e.g. when clicking with the mouse before pressing enter) -- Add prefixes: - - "repeat:" adds results and repeats them. This replaces the current list -- Add history of recently played songs -- Implement callback to play through shown items instead of having the path saved in the template +- Add case sensitive search (maybe mapping Ctrl+M) +- Keep track of how many times each song has been played +- Add "suggest:" filter based on most played songs diff --git a/SearchPlayer/db.sqlite3 b/SearchPlayer/db.sqlite3 index 0c48a2e..83a8890 100644 Binary files a/SearchPlayer/db.sqlite3 and b/SearchPlayer/db.sqlite3 differ diff --git a/SearchPlayer/docker-compose.yml b/SearchPlayer/docker-compose.yml new file mode 100644 index 0000000..70b015b --- /dev/null +++ b/SearchPlayer/docker-compose.yml @@ -0,0 +1,21 @@ +version: '3.7' + +services: + web: + build: . + command: gunicorn SearchPlayer.wsgi:application --bind 0.0.0.0:8000 + expose: + - 8000 + volumes: + - ./lister/static:/opt/app/searchplayer/lister/static + + nginx: + image: nginx:latest + container_name: nginx_01 + ports: + - "8000:8000" + volumes: + - ./:/app # for syncing with django source code + - ./nginx:/etc/nginx/conf.d + depends_on: + - web diff --git a/SearchPlayer/file_indexer.py b/SearchPlayer/file_indexer.py index 2ef16c5..6422cbb 100755 --- a/SearchPlayer/file_indexer.py +++ b/SearchPlayer/file_indexer.py @@ -28,7 +28,7 @@ def update_db(): artist_id += 1 artist_lookup[song.tags['ARTIST'][0]] = artist_id artists.append((artist_id, song.tags['ARTIST'][0])) - songs.append((album_id, artist_id, os.path.dirname(os.path.realpath(file)) + '/Folder.jpg', + songs.append((album_id, artist_id, os.path.dirname(os.path.realpath(file)) + '/Cover.png', song.tags['DATE'][0], 0, song.tags['TITLE'][0], song.tags['TRACKNUMBER'][0], file)) cursor.execute('DELETE FROM lister_song') diff --git a/SearchPlayer/lister/static/Music/Cover.png b/SearchPlayer/lister/static/Music/Cover.png new file mode 100644 index 0000000..74cd12b Binary files /dev/null and b/SearchPlayer/lister/static/Music/Cover.png differ diff --git a/SearchPlayer/lister/static/Music/demo.mp3 b/SearchPlayer/lister/static/Music/demo.mp3 new file mode 100644 index 0000000..64a6583 Binary files /dev/null and b/SearchPlayer/lister/static/Music/demo.mp3 differ diff --git a/SearchPlayer/lister/static/Music/demo.ogg b/SearchPlayer/lister/static/Music/demo.ogg new file mode 100644 index 0000000..c002927 Binary files /dev/null and b/SearchPlayer/lister/static/Music/demo.ogg differ diff --git a/SearchPlayer/lister/static/lister/main.css b/SearchPlayer/lister/static/lister/main.css index 60cf8be..6d391e8 100755 --- a/SearchPlayer/lister/static/lister/main.css +++ b/SearchPlayer/lister/static/lister/main.css @@ -12,12 +12,14 @@ body { -ms-text-overflow: ellipsis; } + /* Main content styling */ .wrapper-content { position: relative; + display: inline; } #container-frame { @@ -38,7 +40,7 @@ body { border-radius: 19px; } -.standard-header > li { +.standard-header>li { display: inline-block; color: #444; font-weight: bold; @@ -71,11 +73,11 @@ body { display: none; } -.main-content > ul { +.main-content>ul { border-radius: 35px; } -.main-content > ul:hover { +.main-content>ul:hover { background-color: #BBCCCC; font-weight: bold; } @@ -85,7 +87,7 @@ body { font-weight: bold; } -.main-content > ul > li { +.main-content>ul>li { display: inline-block; /* width: 18%; */ padding: 10px 5px; @@ -99,7 +101,7 @@ body { -ms-text-overflow: ellipsis; } -.player-controls > div { +.player-controls>div { display: inline-block; margin-top: 5px; } @@ -192,13 +194,13 @@ body { width: 20%; } -#current-track > img { +#current-track>img { width: 64px; height: 64px; border-radius: 15px; } -#current-track > #current-title { +#current-track>#current-title { position: absolute; margin-top: 5px; margin-left: 5px; @@ -206,7 +208,7 @@ body { width: 72%; } -#current-track > #current-artist { +#current-track>#current-artist { position: absolute; top: 60%; margin-left: 5px; @@ -234,6 +236,26 @@ a { color: #444; } +.regex-hint-container { + margin-left: 20px; +} + +.regex-hint { + font-size: 13px; + color: #7d7d7d; +} + +#regex-status { + font-size: 33px; + position: absolute; + left: 94%; + top: 16px; + border-radius: 35px; + border: 5px solid #888; + color: #7d7d7d; +} + + /* Search box styling */ @@ -249,85 +271,89 @@ a { -webkit-padding-start: 0; margin: 0 auto; } - + .mainsearch-form { overflow: hidden; position: relative; } - + .mainsearch-input-wrapper { padding: 0 66px 0 0; overflow: hidden; } .mainsearch-input { - width: 100%; + width: 93%; } + /*********************** * Configurable Styles * ***********************/ + .mainsearch { - padding: 0 0px 0 0px; /* Padding for other horizontal elements */ + padding: 0 0px 0 0px; + display: inline; } .mainsearch-input { - -webkit-box-sizing: content-box; + -webkit-box-sizing: content-box; -moz-box-sizing: content-box; box-sizing: content-box; height: 40px; - padding: 0 46px 0 10px; + padding: 0 46px 0 10px; border-color: #888; - border-radius: 35px; /* (height/2) + border-width */ - border-style: solid; + border-radius: 35px; + /* (height/2) + border-width */ + border-style: solid; border-width: 5px; - margin-top: 15px; - color: #333; - font-family: 'Enriqueta', arial; + margin-top: 15px; + color: #333; + font-family: 'Enriqueta', arial; font-size: 26px; -webkit-appearance: none; -moz-appearance: none; } - + .mainsearch-submit { - position: absolute; - right: 0; + position: absolute; + right: 6%; top: 0; display: block; width: 60px; height: 60px; - padding: 0; - border: none; + padding: 0; + border: none; margin-top: 9px; - margin-right: 5px; + margin-right: 5px; background: transparent; - color: #888; - font-family: 'Enriqueta', arial; - font-size: 40px; - line-height: 60px; - -webkit-transform: rotate(-45deg); - -moz-transform: rotate(-45deg); + color: #888; + font-family: 'Enriqueta', arial; + font-size: 40px; + line-height: 60px; + -webkit-transform: rotate(-45deg); + -moz-transform: rotate(-45deg); -o-transform: rotate(-45deg); } .mainsearch-input:focus { - outline: none; - border-color: #333; + outline: none; + border-color: #333; } .mainsearch-input:focus.mainsearch-submit { - color: #333; + color: #333; } .mainsearch-submit:hover { - color: #333; - cursor: pointer; + color: #333; + cursor: pointer; } ::-webkit-input-placeholder { - color: #888; + color: #888; } input:-moz-placeholder { - color: #888 -} + color: #888 +} \ No newline at end of file diff --git a/SearchPlayer/lister/static/lister/main.js b/SearchPlayer/lister/static/lister/main.js index 37ed6cd..6ab0186 100755 --- a/SearchPlayer/lister/static/lister/main.js +++ b/SearchPlayer/lister/static/lister/main.js @@ -10,6 +10,8 @@ var currentTitle = $('#current-title'); var currentArtist = $('#current-artist'); var finderBox = $('.mainsearch'); var inputBox = $('.mainsearch-input'); +var regexStatus = $('#regex-status'); +var regexEnabled = false; var trackList = []; var searchResults = []; var shuffledList = []; @@ -22,6 +24,7 @@ var playListTemplate; var prevSearchTerm; var currentSearchTerm; var displayedSongIDs = {}; +var mustRefresh = false; var trackListOperations = { REPLACE: 1, @@ -46,23 +49,25 @@ function clickPercent(e) { function showSongs(searchTerm) { songs = []; - if (currentSearchTerm !== searchTerm) { + if ((currentSearchTerm !== searchTerm) || (mustRefresh)) { + mustRefresh = false; currentSearchTerm = searchTerm; $.get('/lister/songs/', function(template) { playListTemplate = template; if (searchTerm === '') { - var html = Mustache.render(playListTemplate, {"search_results": [], "playlist": trackList}); + var html = Mustache.render(playListTemplate, { "search_results": [], "playlist": trackList }); $('#container-frame').html(html); - $('#search-results').css({display: 'block'}); - $('#playlist').css({display: 'none'}); + $('#search-results').css({ display: 'block' }); + $('#playlist').css({ display: 'none' }); return; } - $.getJSON('/lister/search/' + searchTerm, function(songs) { + var getUrl = regexEnabled ? '/lister/search/regex/' : '/lister/search/'; + $.getJSON(getUrl + searchTerm, function(songs) { searchResults = songs.songs_list; - var html = Mustache.render(playListTemplate, {"search_results": searchResults, "playlist": trackList}); + var html = Mustache.render(playListTemplate, { "search_results": searchResults, "playlist": trackList }); $('#container-frame').html(html); - $('#search-results').css({display: 'block'}); - $('#playlist').css({display: 'none'}); + $('#search-results').css({ display: 'block' }); + $('#playlist').css({ display: 'none' }); }); }); } @@ -82,6 +87,9 @@ function playTrack(track) { currentTitle.html(trackList[track].title); currentArtist.html(trackList[track].artist); playerElement.src = trackList[track].path; + if (playerElement.canPlayType('audio/mpeg') === '') { + playerElement.src = trackList[track].path.substr(0, trackList[track].path.lastIndexOf('.')) + '.ogg'; + } playerElement.load(); playerElement.play(); playButton.removeClass('play-button'); @@ -90,10 +98,10 @@ function playTrack(track) { } function getCoverPathFromSongPath(songPath) { - return songPath.substring(0, songPath.lastIndexOf('/')) + '/Folder.jpg'; + return songPath.substring(0, songPath.lastIndexOf('/')) + '/Cover.png'; } -function setCurrentTrackList(searchTerm, searchResults, operation = trackListOperations.APPEND ) { +function setCurrentTrackList(searchTerm, searchResults, operation = trackListOperations.APPEND) { prevSearchTerm = searchTerm; if (operation === trackListOperations.REPLACE) { trackList = searchResults; @@ -118,10 +126,17 @@ function setCurrentTrackList(searchTerm, searchResults, operation = trackListOpe trackList = trackList.concat(filteredSearchResults); } //TODO: This can be optimized to only render the new data and append it - var html = Mustache.render(playListTemplate, {"playlist": trackList}); + var html = Mustache.render(playListTemplate, { "playlist": trackList }); $('#container-frame').html(html); - $('#search-results').css({display: 'none'}); - $('#playlist').css({display: 'block'}); + updateDisplaySelectedTrack(); +} + +function updateDisplaySelectedTrack() { + $('#search-results').css({ display: 'none' }); + $('#playlist').css({ display: 'block' }); + if (!playerElement.paused || playerElement.currentTime) { + $('[data-index-playlist=' + trackList[currentTrack].song_id + ']').closest('ul').addClass('active-track'); + } } function timeUpdate() { @@ -130,7 +145,7 @@ function timeUpdate() { timeLineHead.css('left', playPercent + 'px'); } -window.addEventListener('load', function() {showSongs('')}, false); +window.addEventListener('load', function() { showSongs('') }, false); window.addEventListener('resize', function() { timelineWidth = $('#timeline-container').outerWidth() - 20; //Need to compensate for head size }); @@ -139,18 +154,39 @@ window.addEventListener('submit', function(event) { event.preventDefault(); }); +function toggleRegex() { + if (regexEnabled) { + regexStatus.css('border-color', '#7D7D7D'); + regexStatus.css('color', '#7D7D7D'); + regexEnabled = false; + } else { + regexStatus.css('border-color', '#FF8800'); + regexStatus.css('color', '#FF8800'); + regexEnabled = true; + } + mustRefresh = true; +} + function bindUI() { playerElement.load(); finderBox.bind('keyup', function(event) { - if (event.key != "Enter") { + if (event.key === "ArrowDown") { + playTrack(currentTrack + 1); + return; + } + if (event.key === "ArrowUp") { + playTrack(currentTrack - 1); + return; + } + if ((event.key === 'x') && (event.ctrlKey)) { + toggleRegex(); + } + if ((event.key != "Enter") || (mustRefresh)) { if (inputBox.val() !== '') { showSongs(inputBox.val()); } else { - $('#search-results').css({display: 'none'}); - $('#playlist').css({display: 'block'}); - if (!playerElement.paused || playerElement.currentTime) { - $('[data-index-playlist=' + trackList[currentTrack].song_id + ']').closest('ul').addClass('active-track'); - } + mustRefresh = false; + updateDisplaySelectedTrack(); } } else { if (searchResults.length > 0) { @@ -230,4 +266,4 @@ function bindUI() { }); } -$(document).ready(bindUI); +$(document).ready(bindUI); \ No newline at end of file diff --git a/SearchPlayer/lister/templates/lister/wrapper.html b/SearchPlayer/lister/templates/lister/wrapper.html index a9deb3e..e3c9071 100644 --- a/SearchPlayer/lister/templates/lister/wrapper.html +++ b/SearchPlayer/lister/templates/lister/wrapper.html @@ -13,14 +13,15 @@
-
-
-
- -
- -
-
+
+
+
+ +
 .* 
+
+ +
+
@@ -50,7 +51,7 @@
{{ block.super }}
-
+ @@ -58,4 +59,4 @@ -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/SearchPlayer/lister/urls.py b/SearchPlayer/lister/urls.py index 9ad8230..edd9929 100755 --- a/SearchPlayer/lister/urls.py +++ b/SearchPlayer/lister/urls.py @@ -5,6 +5,7 @@ url(r'^$', views.index, name='index'), url(r'^songs/$', views.songs, name='songs'), url(r'^play/([0-9]+)/$', views.play, name='play'), + url(r'^search/regex/(?P.+)$', views.search_regex, name='search'), url(r'^search/(?P.+)$', views.search, name='search'), url(r'^search/$', views.search, name='search'), ] diff --git a/SearchPlayer/lister/utils/__init__.py b/SearchPlayer/lister/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/SearchPlayer/lister/data_utils.py b/SearchPlayer/lister/utils/data.py similarity index 51% rename from SearchPlayer/lister/data_utils.py rename to SearchPlayer/lister/utils/data.py index 470a199..9b9653a 100644 --- a/SearchPlayer/lister/data_utils.py +++ b/SearchPlayer/lister/utils/data.py @@ -1,15 +1,45 @@ +''' +Handles all data queries, including filters +''' + + +import random +import re +import os from django.db import connection from django.http import JsonResponse -import random -inner_sql = '(search_key like %s)' -selecting_tables = ['lister_song', 'lister_album', 'lister_artist'] -selecting_fields = ['title', 'lister_album.description album', + +SELECTING_TABLES = ['lister_song', 'lister_album', 'lister_artist'] +SELECTING_FIELDS = ['title', 'lister_album.description album', 'lister_artist.description artist', 'image_file', 'path', 'year', 'track_number', 'lister_song.id song_id'] -common_conditions = ['lister_artist.artist_id = lister_song.artist_id', + +# By default, regex will search only in title. +# This is not really for performance issues, but more for +# making it so that the query results are more intuitive. +# If you prefer a full search, use the commented line below. +# REGEX_FIELDS = ['title', 'lister_album.description', +# 'lister_artist.description'] +REGEX_FIELDS = ['title'] +COMMON_CONDITIONS = ['lister_artist.artist_id = lister_song.artist_id', 'lister_album.album_id = lister_song.album_id'] -sorting_fields = ['artist', 'album', 'track_number'] +SORTING_FIELDS = ['artist', 'album', 'track_number'] + +INNER_SQL = '(search_key LIKE %s)' +INNER_SQL_REGEX = '(' + ' OR '.join( + ['(lower(' + field + ') REGEXP %s)' for field in REGEX_FIELDS]) + ')' + +if 'TESTING_DB' in os.environ: + import sqlite3 + DB_CONNECTION = sqlite3.connect(os.environ.get('TESTING_DB')) +else: + DB_CONNECTION = connection + + +def regexp(expr, item): + reg = re.compile(expr) + return reg.search(item) is not None def get_single_random(songs_list): @@ -17,18 +47,19 @@ def get_single_random(songs_list): def get_shuffled_list(songs_list): - random.shuffle(songs_list) - return songs_list + songs_list_copy = list(songs_list) + random.shuffle(songs_list_copy) + return songs_list_copy -do_something = { +PREFIXES = { 'random': get_single_random, 'shuffle': get_shuffled_list } -def data_for_songs_list(request, search_string=''): - if (search_string == ''): +def data_for_songs_list(request, search_string='', regex=False): + if search_string == '': return JsonResponse({}) search_filters = [] @@ -38,16 +69,17 @@ def data_for_songs_list(request, search_string=''): results_lookup = {} songs_list = [] - if (len(search_filters) > 0): - (queries, params, filter_actions) = get_filters_queries(search_filters) + if search_filters: + (queries, params, filter_actions) = get_filters_queries( + search_filters, regex) query_index = 0 for query in queries: results = get_rows_as_dict( - query, params[query_index], results_lookup) - if (len(results) > 0): - if (filter_actions[query_index]): + query, params[query_index], results_lookup, regex) + if results: + if filter_actions[query_index]: songs_list.extend( - do_something[filter_actions[query_index]](results)) + PREFIXES[filter_actions[query_index]](results)) else: songs_list.extend(results) query_index += 1 @@ -58,13 +90,20 @@ def data_for_songs_list(request, search_string=''): }) -def get_rows_as_dict(sql, params, lookup={}): +def get_rows_as_dict(sql, params, lookup={}, regex=False): results = [] - cursor = connection.cursor() + cursor = DB_CONNECTION.cursor() + if (regex): + # If sqlite3 extension is installed, uncomment the + # next two lines to use native regex functionality + # DB_CONNECTION.connection.enable_load_extension(True) + # cursor.execute("SELECT load_extension('/usr/lib/sqlite3/pcre.so')") + DB_CONNECTION.connection.create_function("REGEXP", 2, regexp) + cursor.execute(sql, params) row_dict = _row_as_dict(cursor.fetchone()) - while (row_dict is not None): - if (lookup.get(row_dict['song_id']) is None): + while row_dict is not None: + if lookup.get(row_dict['song_id']) is None: lookup[row_dict['song_id']] = 1 results.append(row_dict) row_dict = _row_as_dict(cursor.fetchone()) @@ -76,38 +115,39 @@ def get_search_filter(search_word): search_filters = [] for search_filter in search_word.split(): - if (':' in search_word): + if ':' in search_word: search_filters.extend( - [refinement for refinement in - search_word.split(':') if refinement != '']) + [refinement for refinement in search_word.split(':') if refinement != '']) else: search_filters.append(search_filter) return search_filters -def get_filters_queries(search_filters): - global inner_sql +def get_filters_queries(search_filters, regex=False): single_queries = [] filter_actions = [] for search_filter in search_filters: - if (search_filter[0] in do_something.keys()): - if (len(search_filter) > 1): + if search_filter[0] in PREFIXES.keys(): + if len(search_filter) > 1: filter_actions.append(search_filter[0]) search_filter.pop(0) else: filter_actions.append(None) - query_strings = [inner_sql] * len(search_filter) + if regex: + query_strings = [INNER_SQL_REGEX] * len(search_filter) + else: + query_strings = [INNER_SQL] * len(search_filter) single_queries.append('(' + ' AND '.join(query_strings) + ') ') single_statements = [] index = 0 for single_query in single_queries: - sql = 'SELECT ' + ','.join(selecting_fields) + \ - ' FROM ' + ','.join(selecting_tables) + \ - ' WHERE ' + ' AND '.join(common_conditions) + \ + sql = 'SELECT ' + ','.join(SELECTING_FIELDS) + \ + ' FROM ' + ','.join(SELECTING_TABLES) + \ + ' WHERE ' + ' AND '.join(COMMON_CONDITIONS) + \ ' AND ' + single_query + \ - ' ORDER BY ' + ','.join(sorting_fields) + ' ORDER BY ' + ','.join(SORTING_FIELDS) single_statements.append(sql) index += 1 @@ -116,14 +156,17 @@ def get_filters_queries(search_filters): for search_filter_terms in search_filters: search_params = [] for filter_term in search_filter_terms: - search_params.extend(['%' + filter_term + '%']) + if regex: + search_params.extend([filter_term] * len(REGEX_FIELDS)) + else: + search_params.extend(['%' + filter_term + '%']) ret_search_params.append(search_params) return (single_statements, ret_search_params, filter_actions) def _row_as_dict(row): - if (row is None): + if row is None: return None return { @@ -135,7 +178,7 @@ def _row_as_dict(row): def get_counters(): counters = {} - cursor = connection.cursor() + cursor = DB_CONNECTION.cursor() cursor.execute('''SELECT count(*) FROM lister_song''') counters['songs'] = cursor.fetchone()[0] cursor.execute('''SELECT count(*) FROM lister_album''') diff --git a/SearchPlayer/lister/utils.py b/SearchPlayer/lister/utils/general.py similarity index 85% rename from SearchPlayer/lister/utils.py rename to SearchPlayer/lister/utils/general.py index fcec141..adf3059 100755 --- a/SearchPlayer/lister/utils.py +++ b/SearchPlayer/lister/utils/general.py @@ -1,5 +1,5 @@ import glob2 -from .models import Song +from ..models import Song def get_files(): diff --git a/SearchPlayer/lister/streaming_utils.py b/SearchPlayer/lister/utils/streaming.py similarity index 89% rename from SearchPlayer/lister/streaming_utils.py rename to SearchPlayer/lister/utils/streaming.py index f47391d..c2555d6 100755 --- a/SearchPlayer/lister/streaming_utils.py +++ b/SearchPlayer/lister/utils/streaming.py @@ -1,7 +1,3 @@ -from django.db import models -from django.template import loader -from .models import Song - '''It is possible (and documented) to use a response like: HttpResponse(open('test.file')) but Django emits bytes using iter(), diff --git a/SearchPlayer/lister/templating_utils.py b/SearchPlayer/lister/utils/templating.py similarity index 100% rename from SearchPlayer/lister/templating_utils.py rename to SearchPlayer/lister/utils/templating.py diff --git a/SearchPlayer/lister/views.py b/SearchPlayer/lister/views.py index 7a91dcb..6b0f4a6 100755 --- a/SearchPlayer/lister/views.py +++ b/SearchPlayer/lister/views.py @@ -1,17 +1,24 @@ -from django.http import HttpResponse, Http404 -from django.shortcuts import render -from .models import Song +''' +Directs views request to the data utils +''' + + import os -import utils -import data_utils as du -import templating_utils as tu -from streaming_utils import StreamWrapper +from django.http import HttpResponse +import lister.utils.general as gu +import lister.utils.data as du +import lister.utils.templating as tu +from lister.utils.streaming import StreamWrapper def search(request, search_string=''): return HttpResponse(du.data_for_songs_list(request, search_string)) +def search_regex(request, search_string=''): + return HttpResponse(du.data_for_songs_list(request, search_string, True)) + + def index(request): return HttpResponse(tu.render_wrapper(request)) @@ -21,7 +28,7 @@ def songs(request): def play(request, song_id): - file_path = utils.get_path_by_id(song_id) + file_path = gu.get_path_by_id(song_id) streaming_response = HttpResponse(StreamWrapper( file_path), content_type='audio/mpeg') streaming_response[ diff --git a/SearchPlayer/nginx/nginx.conf b/SearchPlayer/nginx/nginx.conf new file mode 100644 index 0000000..40b26da --- /dev/null +++ b/SearchPlayer/nginx/nginx.conf @@ -0,0 +1,18 @@ +upstream web { + ip_hash; + server web:8000; +} + +server { + + location /static/ { + autoindex on; + alias /app/lister/static/; + } + + location / { + proxy_pass http://web/; + } + listen 8000; + server_name localhost; +} diff --git a/SearchPlayer/run_all_tests.sh b/SearchPlayer/run_all_tests.sh new file mode 100755 index 0000000..34810a2 --- /dev/null +++ b/SearchPlayer/run_all_tests.sh @@ -0,0 +1,2 @@ +python -m unittest discover + diff --git a/SearchPlayer/run_selenium_tests_in_xephyr.sh b/SearchPlayer/run_selenium_tests_in_xephyr.sh new file mode 100644 index 0000000..c4697c3 --- /dev/null +++ b/SearchPlayer/run_selenium_tests_in_xephyr.sh @@ -0,0 +1,3 @@ +Xephyr -ac :5 & +DISPLAY=':5.0' python test/automated_search.py + diff --git a/SearchPlayer/run_single_test.sh b/SearchPlayer/run_single_test.sh new file mode 100755 index 0000000..fc8a087 --- /dev/null +++ b/SearchPlayer/run_single_test.sh @@ -0,0 +1 @@ +PYTHONPATH=$PYTHONPATH:. python $1 diff --git a/SearchPlayer/search_player_screenshot.png b/SearchPlayer/search_player_screenshot.png new file mode 100644 index 0000000..f2eaba0 Binary files /dev/null and b/SearchPlayer/search_player_screenshot.png differ diff --git a/SearchPlayer/test/__init__.py b/SearchPlayer/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/SearchPlayer/test/automated_search.py b/SearchPlayer/test/automated_search.py new file mode 100644 index 0000000..c7a1f01 --- /dev/null +++ b/SearchPlayer/test/automated_search.py @@ -0,0 +1,31 @@ +from selenium import webdriver +from selenium.webdriver.common.keys import Keys + +# create a new Firefox session +driver = webdriver.Firefox() +driver.implicitly_wait(30) +driver.maximize_window() + +# navigate to the application home page +driver.get("http://localhost:8000/lister/") + +# get the search textbox +search_field = driver.find_element_by_id("main-input") +search_field.clear() + +# enter search keyword and submit +search_field.send_keys("meta:one") +seaarch_results = driver.find_elements_by_class_name("search-results") + +search_field.submit() + +# get the list of elements which are displayed after the search +# currently on result page using find_elements_by_class_name method +# lists= driver.find_elements_by_class_name("_Rm") +playlist = driver.find_elements_by_id("playlist") + +# get the number of elements found +print('Found ' + str(len(search_results)) + ' search_results') +print('Found ' + str(len(playlist)) + ' playlist') + +driver.quit() diff --git a/SearchPlayer/test/test_templating_utils.py b/SearchPlayer/test/test_templating_utils.py new file mode 100644 index 0000000..1b2e11f --- /dev/null +++ b/SearchPlayer/test/test_templating_utils.py @@ -0,0 +1,11 @@ +import unittest +import lister.utils.data as du + + +class TestStringMethods(unittest.TestCase): + + def test_upper(self): + pass + +if __name__ == '__main__': + unittest.main() diff --git a/SearchPlayer/test/test_utils_data.py b/SearchPlayer/test/test_utils_data.py new file mode 100644 index 0000000..8cfd567 --- /dev/null +++ b/SearchPlayer/test/test_utils_data.py @@ -0,0 +1,54 @@ +import unittest +import lister.utils.data as du +import random +import string + + +class TestStringMethods(unittest.TestCase): + + def generate_random_string(self, string_size=10): + return ''.join(random.choice(string.ascii_uppercase + string.digits) + for _ in range(string_size)) + + def test_get_single_random(self): + test_data = [self.generate_random_string() for i in xrange(0, 10)] + random_song = du.get_single_random(test_data)[0] + self.assertTrue(random_song in test_data, + 'Chosen song is in the provided list') + + def test_get_shuffled_list(self): + test_data = [self.generate_random_string() for i in xrange(0, 10)] + shuffled_list = du.get_shuffled_list(test_data) + self.assertTrue(len(test_data) == len(shuffled_list), + 'The same amount of entries is returned') + for song in test_data: + self.assertTrue(song in shuffled_list, + 'Song from shuffled list is in the provided list') + different = False + for index in xrange(len(test_data)): + if (shuffled_list[index] != test_data[index]): + different = True + self.assertTrue(different) + + def test_get_search_filter(self): + test_data = [self.generate_random_string() for i in xrange(0, 10)] + cases = [{'label': 'Single string does not return a 1-element array ', + 'input': test_data[0], + 'expected': [test_data[0]]}, + {'label': 'Two strings do not return two separate strings', + 'input': ' '.join((test_data[0], test_data[1])), + 'expected': [test_data[0], test_data[1]]}, + {'label': 'Single filter does not return correct count', + 'input': ':'.join((test_data[0], test_data[1])), + 'expected': [test_data[0], test_data[1]]}, + {'label': 'Multiple filter does not return correct count', + 'input': ':'.join((test_data[0], test_data[1], test_data[2])), + 'expected': [test_data[0], test_data[1], test_data[2]]}] + + for case in cases: + result = du.get_search_filter(case['input']) + self.assertEqual(result, case['expected'], case['label'] + ' ' + str(result)) + + +if __name__ == '__main__': + unittest.main()