25 Commits

Author SHA1 Message Date
f37ce410eb (front) Improve search result stype
Template to show results with glyphicon

(cherry picked from commit 550d8e7b343dc262be87182ae13f231ccc995b55)

Improve style
Stealing CSS from the demo site

(cherry picked from commit 75bebac0ac6080a0210d2b156a14f8a0f70067db)
2021-08-22 17:34:56 +02:00
124a30ad8c (front) Suggester with ng-bootstrap
Really "complex" search method, but yea, it's work :s

(cherry picked from commit 0789a92b0a9153b6112ad39b1f61090bdbeab197)
2021-08-22 17:33:33 +02:00
52a0cde401 (front) Autocomplete search on dashboard with Datalist
Create Elasticsearch service to search suggestions

Send to page when choose a suggestion - bad way
It's impossible to use Object with datalist
So it's impossible to distinguish an album from an artist if they have
the same name
2021-08-22 17:04:29 +02:00
5fbcfbae68 (front) Update package-lock 2021-08-22 17:04:25 +02:00
74c6f99fa5 (back) Suggester: Change ELS search analyzer for specific char
Implementing search in dashboard revealed problems when search with specific char (francois perusse)
Note: it's also possible to specify an analyzer for ingestion

(cherry picked from commit 24da127750c995c55d54876a1be8a4ad3bf9ce9c)
2021-08-22 17:02:14 +02:00
60e1fb2e74 (back) Suggester: Improve processor - more generic
Process album & artist with a calculated fields
Separate main
Show progress

(cherry picked from commit fc8407cc6a51fe18b14169b3a3f0e4fc363beb4f)
2021-08-22 17:01:19 +02:00
520d0be595 (back) Suggester V3: Process album in a separate way
(cherry picked from commit ebbeeccfb8535dbb67240d2c68c7dc9a4da7e7f8)
2021-08-22 17:01:04 +02:00
8121f3d751 (back) Suggester V2: Process album data
(cherry picked from commit dd322405d047d49e51d528341cbd008d7a98b6ab)
2021-08-22 17:01:00 +02:00
436edaf3f2 (back) Suggester V1: Process artist data
(cherry picked from commit 02f5705fde37e1aaef5c68de62aafe45fc86d490)
2021-08-22 17:00:01 +02:00
797c88f946 (mapping) Tokenize location fields 2021-02-12 20:16:25 +01:00
1f427749cf (front) Albums: Improve query 2021-02-12 20:16:06 +01:00
153ca835cb (front) Albums: reset query button 2021-01-24 00:14:00 +01:00
8e79bee1c4 (front) Albums: changing layout with templating
Still not satisfied with this
2021-01-19 19:20:38 +01:00
66dc29be92 (front) Albums: Add select button
Improve edit ELS Query method
2021-01-19 19:18:23 +01:00
fb620f582f (front) Albums: link to artist/albums + Play count
Improve ngIf part
Use a script in ELS query to sort on avg play count
2021-01-19 18:47:24 +01:00
57e1c18a7f (front) Improve show rating stars
A big simplification of the display of rating
2020-12-23 00:39:13 +01:00
050270e890 (scripts) Improve retrieve rating for albums 2020-12-23 00:01:30 +01:00
541fd96a45 (front) Improve presentation
Bit rate presentation
Improve page + if for artist/album artist
2020-12-22 19:28:29 +01:00
93272d5894 (front) Albums: exlude some results and reload
Create a new service for albums to split big ElsService
2020-12-22 19:28:28 +01:00
7e6cc36750 (front) Add "All albums" page
Add nav button + button on dashboard
2020-12-22 19:27:56 +01:00
285ab197e8 (scripts) Put Bit rate (min & avg) in albums
Calc avg Bit Rate for a album
Rename average calc function
2020-12-20 19:33:22 +01:00
c92fb73e91 (send_data) Improve script output 2020-12-20 18:39:04 +01:00
748e3b8c38 (front) Remove last added link 2020-12-20 14:36:07 +01:00
d2cca5107b Update Angular Version
Use a new Angular application and move files to achieve this!
2020-12-20 14:12:11 +01:00
9b522866ea Why this files? 2020-10-17 18:57:48 +02:00
52 changed files with 26439 additions and 9101 deletions

27
dashboard/README.md Normal file
View File

@@ -0,0 +1,27 @@
# Dashboard
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 11.0.4.
## Development server
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files.
## Code scaffolding
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
## Build
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `--prod` flag for a production build.
## Running unit tests
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
## Running end-to-end tests
Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/).
## Further help
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page.

View File

@@ -4,18 +4,21 @@
"newProjectRoot": "projects",
"projects": {
"dashboard": {
"projectType": "application",
"schematics": {},
"root": "",
"sourceRoot": "src",
"projectType": "application",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist",
"outputPath": "dist/dashboard",
"index": "src/index.html",
"main": "src/main.ts",
"tsConfig": "src/tsconfig.app.json",
"tsConfig": "tsconfig.app.json",
"polyfills": "src/polyfills.ts",
"aot": true,
"assets": [
"src/assets",
"src/favicon.ico"
@@ -28,20 +31,30 @@
},
"configurations": {
"production": {
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"extractCss": true,
"namedChunks": false,
"aot": true,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true,
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"namedChunks": false,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true,
"budgets": [
{
"type": "initial",
"maximumWarning": "2mb",
"maximumError": "5mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "6kb",
"maximumError": "10kb"
}
]
}
}
@@ -67,68 +80,47 @@
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "src/test.ts",
"karmaConfig": "./karma.conf.js",
"polyfills": "src/polyfills.ts",
"tsConfig": "src/tsconfig.spec.json",
"scripts": [],
"tsConfig": "tsconfig.spec.json",
"karmaConfig": "karma.conf.js",
"assets": [
"src/assets",
"src/favicon.ico"
],
"styles": [
"src/styles.css",
"node_modules/bootstrap/dist/css/bootstrap.min.css"
],
"assets": [
"src/assets",
"src/favicon.ico"
]
"scripts": []
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": [
"src/tsconfig.app.json",
"src/tsconfig.spec.json"
"tsconfig.app.json",
"tsconfig.spec.json",
"e2e/tsconfig.json"
],
"exclude": [
"**/node_modules/**"
]
}
}
}
},
"dashboard-e2e": {
"root": "",
"sourceRoot": "",
"projectType": "application",
"architect": {
},
"e2e": {
"builder": "@angular-devkit/build-angular:protractor",
"options": {
"protractorConfig": "./protractor.conf.js",
"protractorConfig": "e2e/protractor.conf.js",
"devServerTarget": "dashboard:serve"
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": [
"e2e/tsconfig.e2e.json"
],
"exclude": [
"**/node_modules/**"
]
},
"configurations": {
"production": {
"devServerTarget": "dashboard:serve:production"
}
}
}
}
}
},
"defaultProject": "dashboard",
"schematics": {
"@schematics/angular:component": {
"prefix": "app",
"styleext": "css"
},
"@schematics/angular:directive": {
"prefix": "app"
}
}
"defaultProject": "dashboard"
}

View File

@@ -1,14 +0,0 @@
import { AppPage } from './app.po';
describe('dashboard App', () => {
let page: AppPage;
beforeEach(() => {
page = new AppPage();
});
it('should display welcome message', () => {
page.navigateTo();
expect(page.getParagraphText()).toEqual('Welcome to app!');
});
});

View File

@@ -1,11 +0,0 @@
import { browser, by, element } from 'protractor';
export class AppPage {
navigateTo() {
return browser.get('/');
}
getParagraphText() {
return element(by.css('app-root h1')).getText();
}
}

View File

@@ -0,0 +1,23 @@
import { AppPage } from './app.po';
import { browser, logging } from 'protractor';
describe('workspace-project App', () => {
let page: AppPage;
beforeEach(() => {
page = new AppPage();
});
it('should display welcome message', async () => {
await page.navigateTo();
expect(await page.getTitleText()).toEqual('dashboard app is running!');
});
afterEach(async () => {
// Assert that there are no errors emitted from the browser
const logs = await browser.manage().logs().get(logging.Type.BROWSER);
expect(logs).not.toContain(jasmine.objectContaining({
level: logging.Level.SEVERE,
} as logging.Entry));
});
});

View File

@@ -0,0 +1,11 @@
import { browser, by, element } from 'protractor';
export class AppPage {
async navigateTo(): Promise<unknown> {
return browser.get(browser.baseUrl);
}
async getTitleText(): Promise<string> {
return element(by.css('app-root .content span')).getText();
}
}

View File

@@ -1,13 +1,12 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../out-tsc/e2e",
"baseUrl": "./",
"module": "commonjs",
"target": "es5",
"target": "es2018",
"types": [
"jasmine",
"jasminewd2",
"node"
]
}

View File

@@ -9,18 +9,28 @@ module.exports = function (config) {
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage-istanbul-reporter'),
require('karma-coverage'),
require('@angular-devkit/build-angular/plugins/karma')
],
client:{
client: {
jasmine: {
// you can add configuration options for Jasmine here
// the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html
// for example, you can disable the random execution with `random: false`
// or set a specific seed with `seed: 4321`
},
clearContext: false // leave Jasmine Spec Runner output visible in browser
},
coverageIstanbulReporter: {
dir: require('path').join(__dirname, 'coverage'), reports: [ 'html', 'lcovonly' ],
fixWebpackSourcePaths: true
jasmineHtmlReporter: {
suppressAll: true // removes the duplicated traces
},
angularCli: {
environment: 'dev'
coverageReporter: {
dir: require('path').join(__dirname, './coverage/dashboard'),
subdir: '.',
reporters: [
{ type: 'html' },
{ type: 'text-summary' }
]
},
reporters: ['progress', 'kjhtml'],
port: 9876,
@@ -28,6 +38,7 @@ module.exports = function (config) {
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['Chrome'],
singleRun: false
singleRun: false,
restartOnFileChange: true
});
};

33878
dashboard/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,6 @@
{
"name": "dashboard",
"version": "0.0.0",
"license": "MIT",
"scripts": {
"ng": "ng",
"start": "ng serve",
@@ -12,40 +11,38 @@
},
"private": true,
"dependencies": {
"@angular/animations": "6.0.3",
"@angular/common": "6.0.3",
"@angular/compiler": "6.0.3",
"@angular/core": "6.0.3",
"@angular/forms": "6.0.3",
"@angular/http": "6.0.3",
"@angular/platform-browser": "6.0.3",
"@angular/platform-browser-dynamic": "6.0.3",
"@angular/router": "6.0.3",
"core-js": "^2.4.1",
"rxjs": "^6.2.0",
"zone.js": "^0.8.26",
"bootstrap": "3.3.7"
"@angular/animations": "~11.0.4",
"@angular/common": "~11.0.4",
"@angular/compiler": "~11.0.4",
"@angular/core": "~11.0.4",
"@angular/forms": "~11.0.4",
"@angular/platform-browser": "~11.0.4",
"@angular/platform-browser-dynamic": "~11.0.4",
"@angular/router": "~11.0.4",
"@ng-bootstrap/ng-bootstrap": "^9.1.3",
"bootstrap": "^3.3.7",
"rxjs": "~6.6.0",
"tslib": "^2.0.0",
"zone.js": "~0.10.2"
},
"devDependencies": {
"@angular-devkit/build-angular": "~0.6.6",
"@angular/cli": "^6.0.7",
"@angular/compiler-cli": "6.0.3",
"@angular/language-service": "6.0.3",
"@types/jasmine": "~2.5.53",
"@types/jasminewd2": "~2.0.2",
"@types/node": "~6.0.60",
"codelyzer": "~3.1.1",
"jasmine-core": "~2.6.2",
"jasmine-spec-reporter": "~4.1.0",
"karma": "^2.0.2",
"karma-chrome-launcher": "~2.1.1",
"karma-cli": "~1.0.1",
"karma-coverage-istanbul-reporter": "^1.2.1",
"karma-jasmine": "~1.1.0",
"karma-jasmine-html-reporter": "^0.2.2",
"protractor": "^5.3.2",
"ts-node": "~3.2.0",
"tslint": "~5.3.2",
"typescript": "2.7.2"
"@angular-devkit/build-angular": "~0.1100.4",
"@angular/cli": "~11.0.4",
"@angular/compiler-cli": "~11.0.4",
"@angular/localize": "^11.0.4",
"@types/jasmine": "~3.6.0",
"@types/node": "^12.11.1",
"codelyzer": "^6.0.0",
"jasmine-core": "~3.6.0",
"jasmine-spec-reporter": "~5.0.0",
"karma": "~5.1.0",
"karma-chrome-launcher": "~3.1.0",
"karma-coverage": "~2.0.3",
"karma-jasmine": "~4.0.0",
"karma-jasmine-html-reporter": "^1.5.0",
"protractor": "~7.0.0",
"ts-node": "~8.3.0",
"tslint": "~6.1.0",
"typescript": "~4.0.2"
}
}

View File

@@ -1,28 +0,0 @@
// Protractor configuration file, see link for more information
// https://github.com/angular/protractor/blob/master/lib/config.ts
const { SpecReporter } = require('jasmine-spec-reporter');
exports.config = {
allScriptsTimeout: 11000,
specs: [
'./e2e/**/*.e2e-spec.ts'
],
capabilities: {
'browserName': 'chrome'
},
directConnect: true,
baseUrl: 'http://localhost:4200/',
framework: 'jasmine',
jasmineNodeOpts: {
showColors: true,
defaultTimeoutInterval: 30000,
print: function() {}
},
onPrepare() {
require('ts-node').register({
project: 'e2e/tsconfig.e2e.json'
});
jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } }));
}
};

View File

@@ -0,0 +1,34 @@
/*** BOTTOM BUTTON PART ***/
/* Thank to https://codyhouse.co/gem/back-to-top/ */
.btn-top {
display: inline-block;
height: 40px;
width: 40px;
position: fixed;
bottom: 20px;
right: 20px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.05);
/* image replacement properties */
/*overflow: hidden;*/
/*text-indent: 100%;*/
/*white-space: nowrap;*/
/*background: rgba(232, 98, 86, 0.8);*/
/*background: #ff9000;*/
visibility: hidden;
opacity: 0;
-webkit-transition: opacity .3s 0s, visibility 0s .3s;
-moz-transition: opacity .3s 0s, visibility 0s .3s;
transition: opacity .3s 0s, visibility 0s .3s;
}
.btn-top.btn-top-is-visible, .no-touch .btn-top:hover {
-webkit-transition: opacity .3s 0s, visibility 0s 0s;
-moz-transition: opacity .3s 0s, visibility 0s 0s;
transition: opacity .3s 0s, visibility 0s 0s;
}
.btn-top.btn-top-is-visible {
/* the button becomes visible */
visibility: visible;
opacity: 1;
}
/*** END BOTTOM BUTTON PART ***/

View File

@@ -0,0 +1,74 @@
<div class="container">
<h1>Albums - {{this.albums.length}}</h1>
<div class="col-md-12">
<table class="table table-striped" style="white-space: nowrap;">
<thead>
<tr>
<th>Album</th>
<th>Track Count</th>
<th></th>
<th></th>
<th>Artist/Album Artist</th>
<th>Avg Bit Rate (min)</th>
<th>Play Count</th>
<th>Rating</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let album of albums">
<td>
<a [routerLink]="['/album', album.Album]">{{album.Album}}</a>&nbsp;
<span class="glyphicon glyphicon-remove-circle" style="cursor:pointer;color:red;" (click)="exlude('Album', album)"></span>
</td>
<td>{{album['Track Count']}}</td>
<ng-template [ngIf]="album['Album Artist']" [ngIfElse]="artistSection">
<td>
<span class="glyphicon glyphicon-ban-circle" style="cursor:pointer;color:red;" (click)="exlude('Album Artist', album)"></span>
</td>
<td>
<span class="glyphicon glyphicon-zoom-in" style="cursor:pointer;" (click)="select('Album Artist', album)"></span>
</td>
<td>
<a [routerLink]="['/artist', album['Album Artist']]">{{album['Album Artist']}}</a>&nbsp;
</td>
</ng-template>
<ng-template #artistSection>
<td>
<span class="glyphicon glyphicon-ban-circle" style="cursor:pointer;color:red;" (click)="exlude('Artist', album)"></span>
</td>
<td>
<span class="glyphicon glyphicon-zoom-in" style="cursor:pointer;" (click)="select('Artist', album)"></span>
</td>
<td>
<a [routerLink]="['/artist', album.Artist[0]]">{{album.Artist}}</a>&nbsp;
</td>
</ng-template>
<td>
{{album['Avg Bit Rate']}}
<span *ngIf="album['Avg Bit Rate'] != album['Min Bit Rate']">({{album['Min Bit Rate']}})</span>
</td>
<td>
{{album['Play Count']}} ({{album['Play Count']/album['Track Count'] | number:'1.0-0'}}/songs)
</td>
<td class="star" [title]="(album['Album Rating Computed']?'Computed Rating: ':'Rating: ') + album['Album Rating']">
<span *ngFor="let item of numberToArray(album['Album Rating'], 20)">
<span class="glyphicon" [ngClass]="album['Album Rating Computed']?'glyphicon-star-empty':'glyphicon-star'"></span>
</span>
</td>
</tr>
</tbody>
</table>
</div>
<button type="button" class="btn btn-danger btn-top" [class.btn-top-is-visible]="queryEdited"
aria-label="Reset filters" (click)="resetQuery()" title="Reset filters and reload datas">
<span class="glyphicon glyphicon-refresh" aria-hidden="true"></span>
</button>
</div>

View File

@@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AlbumsComponent } from './albums.component';
describe('AlbumsComponent', () => {
let component: AlbumsComponent;
let fixture: ComponentFixture<AlbumsComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ AlbumsComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(AlbumsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,71 @@
import { Component, OnInit } from '@angular/core';
import { ElsAlbumService } from '../els-album.service';
import { Album } from '../model/album';
import { Utils } from '../utils';
enum query_edit_type {
exclude = 'must_not',
select = 'must'
}
@Component({
selector: 'app-albums',
templateUrl: './albums.component.html',
styleUrls: ['./albums.component.css']
})
export class AlbumsComponent implements OnInit {
numberToArray = Utils.numberToArray;
albums: Album[] = [];
filterQuery = Object.assign({}, ElsAlbumService.GET_ALBUMS_DEFAULT_QUERY);
queryEdited = false;
constructor(private elsService : ElsAlbumService) { }
ngOnInit(): void {
this.loadData();
}
private editQuery(field: string, value: string, type: query_edit_type): void {
// TODO Move this method to a service
if (value[field] instanceof Array) {
value[field] = value[field][0]
}
// If firt edit, add needed fields in ELS Query
if (!this.filterQuery['query']) {
this.filterQuery['query']['bool'][type].push({ 'must': [] })
this.filterQuery['query']['bool'][type].push({ 'must_not': [] })
}
this.filterQuery['query']['bool'][type].push({
'match_phrase': {
[field]: value[field]
}
})
this.queryEdited = true;
}
exlude(field: string, value: string): void {
this.editQuery(field, value, query_edit_type.exclude)
this.loadData()
}
select(field: string, value: string): void {
this.editQuery(field, value, query_edit_type.select)
this.loadData()
}
resetQuery(): void {
this.filterQuery = Object.assign({}, ElsAlbumService.GET_ALBUMS_DEFAULT_QUERY);
this.loadData();
}
loadData(): void {
// console.log(JSON.stringify(this.filterQuery))
this.elsService.getAlbums(this.filterQuery).subscribe(data => this.albums = data);
}
}

View File

@@ -5,16 +5,16 @@ import { DashboardComponent } from './dashboard/dashboard.component';
import { AlbumComponent } from './album/album.component';
import { ArtistComponent } from './artist/artist.component';
import { GenreComponent } from './genre/genre.component';
import { LastAddedComponent } from './last-added/last-added.component';
import { TopPlayedComponent } from './top-played/top-played.component';
import { AlbumsComponent } from './albums/albums.component';
const routes: Routes = [
{ path: '', redirectTo: '/dashboard', pathMatch: 'full' },
{ path: 'dashboard', component: DashboardComponent },
{ path: 'album/:name', component: AlbumComponent },
{ path: 'album', component: AlbumsComponent },
{ path: 'artist/:name', component: ArtistComponent },
{ path: 'genre/:name', component: GenreComponent },
{ path: 'lastAdded', component: LastAddedComponent },
{ path: 'top-played', component: TopPlayedComponent }
];

View File

@@ -1,4 +1,5 @@
<nav>
<a class="btn-top" routerLink="/dashboard" routerLinkActive="active"><span class="glyphicon glyphicon-home"></span></a>
<a class="btn-top" routerLink="/dashboard" routerLinkActive="active"><span class="glyphicon glyphicon-home"></span></a><br />
<a class="btn-top" routerLink="/album" routerLinkActive="active"><span class="glyphicon glyphicon-cd"></span></a>
</nav>
<router-outlet></router-outlet>

View File

@@ -1,6 +1,7 @@
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { HttpClientModule } from '@angular/common/http';
import { FormsModule } from '@angular/forms'; // <-- NgModel lives here
import { AppComponent } from './app.component';
import { DashboardComponent } from './dashboard/dashboard.component';
@@ -8,10 +9,10 @@ import { AlbumComponent } from './album/album.component';
import { ArtistComponent } from './artist/artist.component';
import { GenreComponent } from './genre/genre.component';
import { SongTableComponent } from './song-table/song-table.component';
import { LastAddedComponent } from './last-added/last-added.component';
import { TopPlayedComponent } from './top-played/top-played.component';
import { ElsService } from './els.service';
import { ElsAlbumService } from './els-album.service';
import { AppRoutingModule } from './app-routing.module';
@@ -20,21 +21,26 @@ import { ConvertMoreExactPipe } from './pipes/convert-more-exact.pipe';
import { SortByPipe } from './pipes/sort-by.pipe';
import { ConvertSizeToStringPipe } from './pipes/convert-size-to-string.pipe';
import { RoundPipe } from './pipes/round.pipe';
import { AlbumsComponent } from './albums/albums.component';
import {NgbModule} from '@ng-bootstrap/ng-bootstrap';
@NgModule({
imports: [
BrowserModule,
HttpClientModule,
AppRoutingModule
FormsModule,
AppRoutingModule,
NgbModule
],
declarations: [
AppComponent,
DashboardComponent,
AlbumComponent,
AlbumsComponent,
ArtistComponent,
GenreComponent,
SongTableComponent,
LastAddedComponent,
ConvertMsPipe,
ConvertMoreExactPipe,
SortByPipe,
@@ -43,7 +49,8 @@ import { RoundPipe } from './pipes/round.pipe';
RoundPipe
],
providers: [
ElsService
ElsService,
ElsAlbumService
],
bootstrap: [ AppComponent ]
})

View File

@@ -34,5 +34,44 @@
border-radius: 50%;
background-color: #9b59b6;
margin: 0 auto;
}
/* Style for autocompletion */
/* Stolen from https://ng-bootstrap.github.io/#/components/typeahead/examples */
::ng-deep .dropdown-item {
display: block;
width: 100%;
padding: .25rem 1.5rem;
clear: both;
font-weight: 400;
color: #212529;
text-align: inherit;
white-space: nowrap;
background-color: transparent;
border: 0
}
::ng-deep .dropdown-item:focus,
::ng-deep .dropdown-item:hover {
color: #16181b;
text-decoration: none;
background-color: #f8f9fa
}
::ng-deep .dropdown-item.active,
::ng-deep .dropdown-item:active {
color: #fff;
text-decoration: none;
background-color: #007bff
}
::ng-deep .dropdown-item.disabled,
::ng-deep .dropdown-item:disabled {
color: #6c757d;
pointer-events: none;
background-color: transparent
}
::ng-deep .dropdown-menu.show {
display: block
}

View File

@@ -77,9 +77,31 @@
</div>
</div>
<div class="col-md-12">
<h3><a [routerLink]="['/lastAdded']">Last Added</a></h3>
<ng-template #rt let-r="result" let-t="term">
<!-- glyphicon glyphicon-cd -->
<span *ngIf="r.type == 'artist'" class="glyphicon glyphicon-user"></span>
<span *ngIf="r.type == 'album'" class="glyphicon glyphicon-cd"></span>
&nbsp; <ngb-highlight [result]="r.name" [term]="t"></ngb-highlight>
</ng-template>
<form class="navbar-form">
<div class="form-group" style="display:inline;">
<div class="input-group" style="display:table;">
<span class="input-group-addon" style="width:1%;">
<span class="glyphicon glyphicon-search"></span>
</span>
<input class="form-control" type="text" name="search" placeholder="Search Here" autocomplete="off" autofocus="autofocus"
id="searchSuggest"
[(ngModel)]="searchTerm"
[ngbTypeahead]="search"
[resultTemplate]="rt"
(selectItem)="onSelectSearch($event)">
</div>
</div>
</form>
<div class="col-md-12">
<table class="table table-striped">
<thead>
<tr>
@@ -98,6 +120,8 @@
</table>
</div>
<input type="button" class="btn btn-primary btn-lg btn-block" value="All Albums" routerLink="/album">
<div class="col-md-12">
<h3>Top Played Songs</h3>
<table class="table table-striped">

View File

@@ -1,17 +1,19 @@
import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { ElsService } from './../els.service';
import { Song } from './../model/song';
import { Bucket } from './../model/bucket';
import { Album } from './../model/album';
import { Artist } from './../model/artist';
import { Suggested } from '../model/suggested';
import {Observable, of, OperatorFunction} from 'rxjs';
import {catchError, debounceTime, distinctUntilChanged, map, tap, switchMap} from 'rxjs/operators';
@Component({
selector: 'app-dashboard',
templateUrl: './dashboard.component.html',
styleUrls: [ './dashboard.component.css' ]
})
export class DashboardComponent implements OnInit {
totalTime = 0;
totalSize = 0;
@@ -30,7 +32,10 @@ export class DashboardComponent implements OnInit {
lastAddedAlbums: Bucket[] = [];
albumArtists = [];
constructor(private elsService: ElsService) { }
searchTerm = ''
suggested : Suggested[] = []
constructor(private elsService: ElsService, private route: Router) { }
ngOnInit(): void {
this.elsService.getTime().then(result => {
@@ -113,4 +118,27 @@ export class DashboardComponent implements OnInit {
}
});
}
onSelectSearch($event) {
this.route.navigate(['/' + $event.item.type + '/' + $event.item.name])
}
searching = false;
searchFailed = false;
search: OperatorFunction<string, readonly string[]> = (text$: Observable<string>) =>
text$.pipe(
debounceTime(300),
distinctUntilChanged(),
tap(() => this.searching = true),
switchMap(term =>
this.elsService.getSuggest(term).pipe(
tap(() => this.searchFailed = false),
catchError(() => {
this.searchFailed = true;
return of([]);
}))
),
tap(() => this.searching = false)
)
}

View File

@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { ElsAlbumService } from './els-album.service';
describe('ElsAlbumService', () => {
let service: ElsAlbumService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(ElsAlbumService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,96 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http'
import { Observable } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
import { Album } from './model/album';
import { ElsService } from './els.service';
@Injectable()
export class ElsAlbumService extends ElsService {
public static readonly GET_ALBUMS_DEFAULT_QUERY = {
'query': {
'bool': {
'must': [],
'must_not': [],
'filter': [
{ 'range': { 'Avg Bit Rate': { 'lte': '128'}}}
]
}
},
'sort': [ {
'_script' : {
'type' : 'number',
'script' : {
'lang': 'painless',
'source': 'doc[\'Play Count\'].value / doc[\'Track Count\'].value'
},
'order' : 'desc'
}
}, {
'Avg Bit Rate': {
'order': 'asc'
}
} ],
'size': 500
}
constructor(protected http: HttpClient) {
super(http);
}
getAlbums(query: any): Observable<Album[]> {
console.info('getAlbums');
console.info(query);
return this.http
.post(this.elsUrl + ElsService.ALBUM_INDEX_NAME + ElsService.ACTION_SEARCH,
JSON.stringify(query), {headers: this.headers})
.pipe(
map(res => this.responseToAlbums(res)),
catchError(error => this.handleError(error, 'getAlbums'))
);
}
getAlbumsFiltered(size: number): Observable<Album[]> {
// http://localhost:9200/itunes-albums/_search
console.info('getAlbums');
return this.http
.post(this.elsUrl + ElsService.ALBUM_INDEX_NAME + ElsService.ACTION_SEARCH,
JSON.stringify({
'sort': [ {
'Avg Bit Rate': {
'order': 'asc'
}
} ],
'size': size,
'query': {
'bool': {
'must': [],
'filter': [
{
'match_all': {}
}
],
'should': [],
'must_not': [
{
'match_phrase': {
'Artist': 'François Pérusse'
}
},
{
'match_phrase': {
'Album Artist': 'Comédiens'
}
}
]
}
}
}), {headers: this.headers})
.pipe(
map(res => this.responseToAlbums(res)),
catchError(error => this.handleError(error, 'getAlbums'))
);
}
}

View File

@@ -8,6 +8,7 @@ import { Song } from './model/song';
import { Album } from './model/album';
import { Artist } from './model/artist';
import { Bucket } from './model/bucket';
import { Suggested } from './model/suggested';
@Injectable()
export class ElsService {
@@ -15,14 +16,15 @@ export class ElsService {
public static readonly SONG_INDEX_NAME = '/itunes-songs';
public static readonly ARTIST_INDEX_NAME = '/itunes-artists';
public static readonly ALBUM_INDEX_NAME = '/itunes-albums';
public static readonly SUGGEST_INDEX_NAME = '/itunes-suggest';
private static readonly ACTION_SEARCH = '/_search';
private static readonly ACTION_COUNT = '/_count';
protected static readonly ACTION_SEARCH = '/_search';
protected static readonly ACTION_COUNT = '/_count';
private elsUrl = 'http://localhost:9200';
private headers = new HttpHeaders({'Content-Type': 'application/json'});
protected elsUrl = 'http://localhost:9200';
protected headers = new HttpHeaders({'Content-Type': 'application/json'});
constructor(private http: HttpClient) { }
constructor(protected http: HttpClient) { }
getTime(): Promise<number> {
return this.http
@@ -381,6 +383,34 @@ export class ElsService {
);
}
getSuggest(text: string): Observable<Suggested[]> {
console.log('search sugget: ' + text);
return this.http
.post<any>(this.elsUrl + ElsService.SUGGEST_INDEX_NAME + ElsService.ACTION_SEARCH,
JSON.stringify({
'_source': ['album', 'artist'],
'suggest': {
'album-suggest': {
'prefix': text,
'completion': {
'field': 'album_suggest'
}
},
'artist-suggest': {
'prefix': text,
'completion': {
'field': 'artist_suggest'
}
}
}
}), {headers: this.headers})
.pipe(
map(res => this.responseSuggesterToSuggested(res, 'album-suggest', 'artist-suggest')),
catchError(error => this.handleError(error, 'getSuggest(' + text + ')'))
);
}
/** Process a result to return just one result.
* Used to get an album or an artist.
* Take a name to put in console output if no result or more than one result.
@@ -418,7 +448,7 @@ export class ElsService {
*
* @param res Response to process
*/
private responseToAlbums(res: any): Album[] {
protected responseToAlbums(res: any): Album[] {
const result: Array<Album> = [];
res.hits.hits.forEach((hit) => {
result.push(hit._source);
@@ -462,7 +492,21 @@ export class ElsService {
return result;
}
private handleError(error: any, origin: string): Promise<any> {
protected responseSuggesterToSuggested(res: any, ...suggestName: string[]): Suggested[] {
const result: Array<Suggested> = []
suggestName.forEach(sname => {
res['suggest'][sname][0]['options'].forEach(option => {
let suggest = new Suggested()
// TODO If more than one key, raise exception
suggest.type = String(Object.keys(option['_source']))
suggest.name = option['_source'][suggest.type]
result.push(suggest)
});
});
return result;
}
protected handleError(error: any, origin: string): Promise<any> {
console.error('An error occurred!');
console.error('Origin function: ', origin);
console.error('An error occurred!', error); // for demo purposes only

View File

@@ -1,2 +0,0 @@
<div class="container">
</div>

View File

@@ -1,25 +0,0 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { LastAddedComponent } from './last-added.component';
describe('LastAddedComponent', () => {
let component: LastAddedComponent;
let fixture: ComponentFixture<LastAddedComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ LastAddedComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(LastAddedComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should be created', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -1,15 +0,0 @@
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-last-added',
templateUrl: './last-added.component.html',
styleUrls: ['./last-added.component.css']
})
export class LastAddedComponent implements OnInit {
constructor() { }
ngOnInit() {
}
}

View File

@@ -0,0 +1,4 @@
export class Suggested {
name: string;
type: string;
}

View File

@@ -43,16 +43,6 @@
}
/*** END BOTTOM BUTTON PART ***/
/*** RATING STAR ***/
.star {
color:rgb(38, 135, 251);
}
.table tbody > tr > td.star {
white-space: nowrap;
}
/*** HEART ***/
.heart {
color: rgb(251, 18, 79);

View File

@@ -32,20 +32,11 @@
<td [title]="song['Bit Rate']">
<span [class]="'signal signal-' + roundBitRate(song['Bit Rate'])"></span>
</td>
<td *ngIf="!song['Rating Computed']" [title]="'Rating: ' + song.Rating" class="star">
<span *ngIf="song.Rating >= 20" class="glyphicon glyphicon-star"></span>
<span *ngIf="song.Rating >= 40" class="glyphicon glyphicon-star"></span>
<span *ngIf="song.Rating >= 60" class="glyphicon glyphicon-star"></span>
<span *ngIf="song.Rating >= 80" class="glyphicon glyphicon-star"></span>
<span *ngIf="song.Rating >= 100" class="glyphicon glyphicon-star"></span>
</td>
<td *ngIf="song['Rating Computed']" [title]="'Computed Rating: ' + song.Rating" class="star">
<span *ngIf="song.Rating >= 20" class="glyphicon glyphicon-star-empty"></span>
<span *ngIf="song.Rating >= 40" class="glyphicon glyphicon-star-empty"></span>
<span *ngIf="song.Rating >= 60" class="glyphicon glyphicon-star-empty"></span>
<span *ngIf="song.Rating >= 80" class="glyphicon glyphicon-star-empty"></span>
<span *ngIf="song.Rating >= 100" class="glyphicon glyphicon-star-empty"></span>
</td>
<td class="star" [title]="(song['Rating Computed']?'Computed Rating: ':'Rating: ') + song.Rating">
<span *ngFor="let item of numberToArray(song.Rating, 20)">
<span class="glyphicon" [ngClass]="song['Rating Computed']?'glyphicon-star-empty':'glyphicon-star'"></span>
</span>
</td>
<td *ngIf="song.Loved" class="loved">
<span class="glyphicon glyphicon-heart heart"></span>
</td>

View File

@@ -1,4 +1,5 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { Utils } from '../utils';
import { Song } from './../model/song';
import { SortByPipe } from './../pipes/sort-by.pipe';
@@ -9,6 +10,7 @@ import { SortByPipe } from './../pipes/sort-by.pipe';
styleUrls: ['./song-table.component.css']
})
export class SongTableComponent {
numberToArray = Utils.numberToArray;
@Input() songs: Array<Song> = [];
@Output() private atBottom = new EventEmitter();
@@ -25,7 +27,7 @@ export class SongTableComponent {
*
* @param event scroll event
*/
private onScroll(event: any) {
onScroll(event: any) {
if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight) {
this.atBottom.emit();
}

View File

@@ -0,0 +1,7 @@
import { Utils } from './utils';
describe('Utils', () => {
it('should create an instance', () => {
expect(new Utils()).toBeTruthy();
});
});

View File

@@ -0,0 +1,16 @@
export class Utils {
/**
* Convert a number to an array.
* Mainly used for rating: need to convert the number to use a ngFor
* @param n number to convert on a array of n cases
* @param d if you want to divide number, e.g. by 20 for rating (rating is one value out of one hundred)
*/
public static numberToArray(n: number, d: number = 1): any[] {
if (!n) {
return Array(0)
}
return Array(n / d);
}
}

View File

@@ -1,8 +1,16 @@
// The file contents for the current environment will overwrite these during build.
// The build system defaults to the dev environment which uses `environment.ts`, but if you do
// `ng build --env=prod` then `environment.prod.ts` will be used instead.
// The list of which env maps to which file can be found in `.angular-cli.json`.
// This file can be replaced during build by using the `fileReplacements` array.
// `ng build --prod` replaces `environment.ts` with `environment.prod.ts`.
// The list of file replacements can be found in `angular.json`.
export const environment = {
production: false
};
/*
* For easier debugging in development mode, you can import the following file
* to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`.
*
* This import should be commented out in production mode because it will have a negative impact
* on performance if an error is thrown.
*/
// import 'zone.js/dist/zone-error'; // Included with Angular CLI.

60
dashboard/src/fontello.css vendored Normal file
View File

@@ -0,0 +1,60 @@
@font-face {
font-family: 'fontello';
src: url('./assets/fonts/fontello/fontello.eot?59321355');
src: url('./assets/fonts/fontello/fontello.eot?59321355#iefix') format('embedded-opentype'),
url('./assets/fonts/fontello/fontello.woff2?59321355') format('woff2'),
url('./assets/fonts/fontello/fontello.woff?59321355') format('woff'),
url('./assets/fonts/fontello/fontello.ttf?59321355') format('truetype'),
url('./assets/fonts/fontello/fontello.svg?59321355#fontello') format('svg');
font-weight: normal;
font-style: normal;
}
/* Chrome hack: SVG is rendered more smooth in Windozze. 100% magic, uncomment if you need it. */
/* Note, that will break hinting! In other OS-es font will be not as sharp as it could be */
/*
@media screen and (-webkit-min-device-pixel-ratio:0) {
@font-face {
font-family: 'fontello';
src: url('../font/fontello.svg?59321355#fontello') format('svg');
}
}
*/
[class^="icon-"]:before, [class*=" icon-"]:before {
font-family: "fontello";
font-style: normal;
font-weight: normal;
speak: none;
display: inline-block;
text-decoration: inherit;
width: 1em;
margin-right: .2em;
text-align: center;
/* opacity: .8; */
/* For safety - reset parent styles, that can break glyph codes*/
font-variant: normal;
text-transform: none;
/* fix buttons height, for twitter bootstrap */
line-height: 1em;
/* Animation center compensation - margins should be symmetric */
/* remove if not needed */
margin-left: .2em;
/* you can be more comfortable with increased icons size */
font-size: 130%;
/* Font smoothing. That was taken from TWBS */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/* Uncomment for 3D effect */
/* text-shadow: 1px 1px 1px rgba(127, 127, 127, 0.3); */
}
.icon-videocam:before { content: '\e800'; } /* '' */
.icon-tv:before { content: '\e801'; } /* '' */
.icon-user:before { content: '\e802'; } /* '' */

View File

@@ -8,4 +8,5 @@ if (environment.production) {
enableProdMode();
}
platformBrowserDynamic().bootstrapModule(AppModule);
platformBrowserDynamic().bootstrapModule(AppModule)
.catch(err => console.error(err));

View File

@@ -1,3 +1,7 @@
/***************************************************************************************************
* Load `$localize` onto the global scope - used if i18n tags appear in Angular templates.
*/
import '@angular/localize/init';
/**
* This file includes polyfills needed by Angular and is loaded before the app.
* You can add your own extra polyfills to this file.
@@ -11,62 +15,53 @@
* automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera),
* Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile.
*
* Learn more in https://angular.io/docs/ts/latest/guide/browser-support.html
* Learn more in https://angular.io/guide/browser-support
*/
/***************************************************************************************************
* BROWSER POLYFILLS
*/
/** IE9, IE10 and IE11 requires all of the following polyfills. **/
// import 'core-js/es6/symbol';
// import 'core-js/es6/object';
// import 'core-js/es6/function';
// import 'core-js/es6/parse-int';
// import 'core-js/es6/parse-float';
// import 'core-js/es6/number';
// import 'core-js/es6/math';
// import 'core-js/es6/string';
// import 'core-js/es6/date';
// import 'core-js/es6/array';
// import 'core-js/es6/regexp';
// import 'core-js/es6/map';
// import 'core-js/es6/weak-map';
// import 'core-js/es6/set';
/** IE10 and IE11 requires the following for NgClass support on SVG elements */
/** IE11 requires the following for NgClass support on SVG elements */
// import 'classlist.js'; // Run `npm install --save classlist.js`.
/** Evergreen browsers require these. **/
import 'core-js/es6/reflect';
import 'core-js/es7/reflect';
/**
* Required to support Web Animations `@angular/animation`.
* Needed for: All but Chrome, Firefox and Opera. http://caniuse.com/#feat=web-animation
**/
* Web Animations `@angular/platform-browser/animations`
* Only required if AnimationBuilder is used within the application and using IE/Edge or Safari.
* Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0).
*/
// import 'web-animations-js'; // Run `npm install --save web-animations-js`.
/**
* By default, zone.js will patch all possible macroTask and DomEvents
* user can disable parts of macroTask/DomEvents patch by setting following flags
* because those flags need to be set before `zone.js` being loaded, and webpack
* will put import in the top of bundle, so user need to create a separate file
* in this directory (for example: zone-flags.ts), and put the following flags
* into that file, and then add the following code before importing zone.js.
* import './zone-flags';
*
* The flags allowed in zone-flags.ts are listed here.
*
* The following flags will work for all browsers.
*
* (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame
* (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick
* (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames
*
* in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js
* with the following flag, it will bypass `zone.js` patch for IE/Edge
*
* (window as any).__Zone_enable_cross_context_check = true;
*
*/
/***************************************************************************************************
* Zone JS is required by Angular itself.
* Zone JS is required by default for Angular itself.
*/
import 'zone.js/dist/zone'; // Included with Angular CLI.
/***************************************************************************************************
* APPLICATION IMPORTS
*/
/**
* Date, currency, decimal and percent pipes.
* Needed for: All but Chrome, Firefox, Edge, IE11 and Safari 10
*/
// import 'intl'; // Run `npm install --save intl`.
/**
* Need to import at least one locale-data with intl.
*/
// import 'intl/locale-data/jsonp/en';

View File

@@ -30,6 +30,13 @@ body, input[text], button {
to { -webkit-transform: rotate(360deg);}
}
/*** RATING STAR ***/
.star {
color:rgb(38, 135, 251);
}
.table tbody > tr > td.star {
white-space: nowrap;
}
/* . . . */
/* everywhere else */

View File

@@ -1,23 +1,18 @@
// This file is required by karma.conf.js and loads recursively all the .spec and framework files
import 'zone.js/dist/long-stack-trace-zone';
import 'zone.js/dist/proxy.js';
import 'zone.js/dist/sync-test';
import 'zone.js/dist/jasmine-patch';
import 'zone.js/dist/async-test';
import 'zone.js/dist/fake-async-test';
import 'zone.js/dist/zone-testing';
import { getTestBed } from '@angular/core/testing';
import {
BrowserDynamicTestingModule,
platformBrowserDynamicTesting
} from '@angular/platform-browser-dynamic/testing';
// Unfortunately there's no typing for the `__karma__` variable. Just declare it as any.
declare const __karma__: any;
declare const require: any;
// Prevent Karma from running prematurely.
__karma__.loaded = function () {};
declare const require: {
context(path: string, deep?: boolean, filter?: RegExp): {
keys(): string[];
<T>(id: string): T;
};
};
// First, initialize the Angular testing environment.
getTestBed().initTestEnvironment(
@@ -28,5 +23,3 @@ getTestBed().initTestEnvironment(
const context = require.context('./', true, /\.spec\.ts$/);
// And load the modules.
context.keys().map(context);
// Finally, start Karma to run the tests.
__karma__.start();

View File

@@ -1,13 +0,0 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../out-tsc/app",
"baseUrl": "./",
"module": "es2015",
"types": []
},
"exclude": [
"test.ts",
"**/*.spec.ts"
]
}

View File

@@ -1,21 +0,0 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../out-tsc/spec",
"baseUrl": "./",
"module": "commonjs",
"target": "es5",
"types": [
"jasmine",
"node"
]
},
"files": [
"test.ts",
"polyfills.ts"
],
"include": [
"**/*.spec.ts",
"**/*.d.ts"
]
}

View File

@@ -1,5 +0,0 @@
/* SystemJS module definition */
declare var module: NodeModule;
interface NodeModule {
id: string;
}

View File

@@ -0,0 +1,15 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": [
"src/main.ts",
"src/polyfills.ts"
],
"include": [
"src/**/*.d.ts"
]
}

View File

@@ -1,18 +1,19 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"compileOnSave": false,
"compilerOptions": {
"baseUrl": "./",
"outDir": "./dist/out-tsc",
"sourceMap": true,
"declaration": false,
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"downlevelIteration": true,
"experimentalDecorators": true,
"target": "es5",
"typeRoots": [
"node_modules/@types"
],
"moduleResolution": "node",
"importHelpers": true,
"target": "es2015",
"module": "es2020",
"lib": [
"es2017",
"es2018",
"dom"
]
}

View File

@@ -0,0 +1,18 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"jasmine"
]
},
"files": [
"src/test.ts",
"src/polyfills.ts"
],
"include": [
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}

View File

@@ -1,33 +1,37 @@
{
"extends": "tslint:recommended",
"rulesDirectory": [
"node_modules/codelyzer"
"codelyzer"
],
"rules": {
"align": {
"options": [
"parameters",
"statements"
]
},
"array-type": false,
"arrow-return-shorthand": true,
"callable-types": true,
"class-name": true,
"comment-format": [
true,
"check-space"
],
"curly": true,
"deprecation": {
"severity": "warning"
},
"eofline": true,
"forin": true,
"import-blacklist": [
true
true,
"rxjs/Rx"
],
"import-spacing": true,
"indent": [
true,
"spaces"
],
"interface-over-type-literal": true,
"label-position": true,
"indent": {
"options": [
"spaces"
]
},
"max-classes-per-file": false,
"max-line-length": [
true,
140
],
"member-access": false,
"member-ordering": [
true,
{
@@ -39,79 +43,99 @@
]
}
],
"no-arg": true,
"no-bitwise": true,
"no-console": [
true,
"debug",
"info",
"time",
"timeEnd",
"trace"
],
"no-construct": true,
"no-debugger": true,
"no-duplicate-super": true,
"no-empty": false,
"no-empty-interface": true,
"no-eval": true,
"no-inferrable-types": [
true,
"ignore-params"
],
"no-misused-new": true,
"no-non-null-assertion": true,
"no-shadowed-variable": true,
"no-string-literal": false,
"no-string-throw": true,
"no-redundant-jsdoc": true,
"no-switch-case-fall-through": true,
"no-trailing-whitespace": true,
"no-unnecessary-initializer": true,
"no-unused-expression": true,
"no-use-before-declare": true,
"no-var-keyword": true,
"object-literal-sort-keys": false,
"one-line": [
"no-var-requires": false,
"object-literal-key-quotes": [
true,
"check-open-brace",
"check-catch",
"check-else",
"check-whitespace"
"as-needed"
],
"prefer-const": true,
"quotemark": [
true,
"single"
],
"radix": true,
"semicolon": [
true,
"always"
],
"triple-equals": [
true,
"allow-null-check"
],
"typedef-whitespace": [
true,
{
"call-signature": "nospace",
"index-signature": "nospace",
"parameter": "nospace",
"property-declaration": "nospace",
"variable-declaration": "nospace"
"semicolon": {
"options": [
"always"
]
},
"space-before-function-paren": {
"options": {
"anonymous": "never",
"asyncArrow": "always",
"constructor": "never",
"method": "never",
"named": "never"
}
],
"typeof-compare": true,
"unified-signatures": true,
"variable-name": false,
"whitespace": [
},
"typedef": [
true,
"check-branch",
"check-decl",
"check-operator",
"check-separator",
"check-type"
"call-signature"
],
"typedef-whitespace": {
"options": [
{
"call-signature": "nospace",
"index-signature": "nospace",
"parameter": "nospace",
"property-declaration": "nospace",
"variable-declaration": "nospace"
},
{
"call-signature": "onespace",
"index-signature": "onespace",
"parameter": "onespace",
"property-declaration": "onespace",
"variable-declaration": "onespace"
}
]
},
"variable-name": {
"options": [
"ban-keywords",
"check-format",
"allow-pascal-case"
]
},
"whitespace": {
"options": [
"check-branch",
"check-decl",
"check-operator",
"check-separator",
"check-type",
"check-typecast"
]
},
"component-class-suffix": true,
"contextual-lifecycle": true,
"directive-class-suffix": true,
"no-conflicting-lifecycle": true,
"no-host-metadata-property": true,
"no-input-rename": true,
"no-inputs-metadata-property": true,
"no-output-native": true,
"no-output-on-prefix": true,
"no-output-rename": true,
"no-outputs-metadata-property": true,
"template-banana-in-box": true,
"template-no-negated-async": true,
"use-lifecycle-interface": true,
"use-pipe-transform-interface": true,
"directive-selector": [
true,
"attribute",
@@ -123,18 +147,6 @@
"element",
"app",
"kebab-case"
],
"use-input-property-decorator": true,
"use-output-property-decorator": true,
"use-host-property-decorator": true,
"no-input-rename": true,
"no-output-rename": true,
"use-life-cycle-interface": true,
"use-pipe-transform-interface": true,
"component-class-suffix": true,
"directive-class-suffix": true,
"no-access-missing-member": true,
"templates-use-public": true,
"invoke-injectable": true
]
}
}

View File

@@ -154,7 +154,7 @@ class ITunesParser:
play_count = track['Play Count'] if 'Play Count' in track else 0
rating = track['Rating'] if 'Rating' in track else 0
rating = self.calc_rating(rating, self._artists[akey]['Rating'], self._artists[akey]['Track Count'])
rating = self.calc_average(rating, self._artists[akey]['Rating'], self._artists[akey]['Track Count'])
self._artists[akey]['Track Count'] += 1
self._artists[akey]['Rating'] = rating
@@ -187,9 +187,10 @@ class ITunesParser:
'Album': akey,
'Track Count': 0,
'Play Count': 0,
'Rating': 0,
'Genre': set(),
'Artist': set(),
'Avg Bit Rate': track['Bit Rate'],
'Min Bit Rate': track['Bit Rate'],
# 'Album Artist': '',
'Total Time': 0
}
@@ -197,16 +198,18 @@ class ITunesParser:
# Compute information
play_count = track['Play Count'] if 'Play Count' in track else 0
rating = track['Rating'] if 'Rating' in track else 0
rating = self.calc_rating(rating, self._albums[akey]['Rating'], self._albums[akey]['Track Count'])
total_time = track['Total Time'] if 'Total Time' in track else 0
avg_bitrate = self.calc_average(track['Bit Rate'], self._albums[akey]['Avg Bit Rate'], self._albums[akey]['Track Count'])
self._albums[akey]['Avg Bit Rate'] = avg_bitrate
self._albums[akey]['Track Count'] += 1
self._albums[akey]['Rating'] = rating
self._albums[akey]['Play Count'] += play_count
self._albums[akey]['Total Time'] += total_time
if self._albums[akey]['Min Bit Rate'] > track['Bit Rate']:
self._albums[akey]['Min Bit Rate'] = track['Bit Rate']
if 'Genre' in track:
# Split up the Genres
genre_parts = track['Genre'].split('/')
@@ -217,17 +220,19 @@ class ITunesParser:
if 'Album Rating' in track:
self._albums[akey]['Album Rating'] = track['Album Rating']
self._albums[akey]['Album Rating Computed'] = True
if 'Album Rating Computed' in track:
self._albums[akey]['Album Rating Computed'] = track['Album Rating Computed']
if 'Album Artist' in track:
self._albums[akey]['Album Artist'] = track['Album Artist']
@classmethod
def calc_rating(cls, added_value, current_rating, count):
def calc_average(cls, added_value, current_value, nb_values):
"""
Calculate average rating from a current rating, a rating value to add and the number of elements
Calculate average value from a current value, a value to add and the number of values
"""
return (current_rating * count + added_value) / (count + 1)
return (current_value * nb_values + added_value) / (nb_values + 1)
@classmethod
def calc_id(cls, key):
@@ -270,7 +275,7 @@ class WriteElsJson:
file_albums = io.open(output_file, 'wb')
for _, album in albums.items():
persistent_id = album['Persistent ID']
album['Rating'] = round(album['Rating'])
album['Avg Bit Rate'] = round(album['Avg Bit Rate'])
json_track_index = {
"index": {"_index": ITunesParser.ALBUM_INDEX, "_id": persistent_id}

View File

@@ -1,4 +1,28 @@
{
"settings": {
"analysis": {
"analyzer": {
"custom_path_tree": {
"tokenizer": "custom_hierarchy"
},
"custom_path_tree_reversed": {
"tokenizer": "custom_hierarchy_reversed"
}
},
"tokenizer": {
"custom_hierarchy": {
"type": "path_hierarchy",
"delimiter": "/",
"skip": 3
},
"custom_hierarchy_reversed": {
"type": "path_hierarchy",
"delimiter": "/",
"reverse": "true"
}
}
}
},
"mappings" : {
"properties": {
"Artist": {
@@ -27,6 +51,19 @@
},
"Kind": {
"type": "keyword"
},
"Location": {
"type": "text",
"fields": {
"tree": {
"type": "text",
"analyzer": "custom_path_tree"
},
"tree_reversed": {
"type": "text",
"analyzer": "custom_path_tree_reversed"
}
}
}
}
}

View File

@@ -10,6 +10,17 @@ import json
import time
import requests
class bcolors:
HEADER = '\033[95m'
OKBLUE = '\033[94m'
OKCYAN = '\033[96m'
OKGREEN = '\033[92m'
WARNING = '\033[93m'
FAIL = '\033[91m'
ENDC = '\033[0m'
BOLD = '\033[1m'
UNDERLINE = '\033[4m'
# Default file names
DEFAULT_SONG_FILE = 'es-songs.json'
DEFAULT_ALBUM_FILE = 'es-albums.json'
@@ -231,7 +242,7 @@ def send_data(file, quiet=False):
print(res.text)
else:
if not quiet:
print("File '{}' sent to Elasticsearch!".format(file.name))
print(bcolors.OKGREEN + "File '{}' sent to Elasticsearch!".format(file.name) + bcolors.ENDC)
def delete_index(index_name, quiet=False):
"""
@@ -242,9 +253,9 @@ def delete_index(index_name, quiet=False):
res = requests.delete(url=ELASTICSEARCH_URL + index_name)
if res.status_code == 200:
if not quiet:
print("Deleted!")
print(bcolors.OKGREEN + "Index deleted!" + bcolors.ENDC)
else:
print("An error occured")
print(bcolors.FAIL + "An error occured" + bcolors.ENDC)
if res.json()['error']['type'] == 'index_not_found_exception':
print("Index '{}' doesn't exist and can't be deleted".format(index_name))
else:
@@ -261,11 +272,11 @@ def put_mapping(index_name, mapping_file, quiet=False):
data=mapping_file,
headers={'Content-Type': 'application/json'})
if res.status_code != 200:
print("An error occured")
print(res.text)
print(bcolors.FAIL + "An error occured")
print(res.text + bcolors.ENDC)
else:
if not quiet:
print("File '{}' sent to Elasticsearch!".format(mapping_file.name))
print(bcolors.OKGREEN + "Mapping sent" + bcolors.ENDC)
put_setting(index_name, 0, quiet)
@@ -292,8 +303,8 @@ def check_all_data_is_saved(data_file, index_name, quiet=False):
data=json.dumps(payload),
headers={'Content-Type': 'application/x-ndjson'})
if res.status_code != 200:
print("An error occured")
print(res.text)
print(bcolors.FAIL + "An error occured")
print(res.text + bcolors.ENDC)
els_nb_doc = res.json()['hits']['total']['value']
@@ -301,9 +312,9 @@ def check_all_data_is_saved(data_file, index_name, quiet=False):
print("\tFound: {} documents in index '{}' in ELS".format(els_nb_doc, index_name))
if file_nb_line != els_nb_doc:
print('Look out! Not all the data has been found in ELS')
print(bcolors.WARNING + 'Look out! Not all the data has been found in ELS' + bcolors.ENDC)
elif not quiet:
print('All data is in ELS, it\'s ok')
print(bcolors.OKGREEN + 'All data is in ELS, it\'s ok' + bcolors.ENDC)
return file_nb_line == els_nb_doc

137
suggester.es Normal file
View File

@@ -0,0 +1,137 @@
DELETE itunes-suggest
PUT /itunes-suggest
{
"settings": {
"analysis": {
"filter": {
"french_stop": {
"type": "stop",
"stopwords": "_french_"
},
"english_stop": {
"type": "stop",
"stopwords": "_english_"
}
},
"analyzer": {
"names": {
"tokenizer": "standard",
"filter": [
"lowercase",
"asciifolding",
"french_stop",
"english_stop"
]
}
}
}
},
"mappings": {
"properties": {
"artist_suggest": {
"type": "completion",
"search_analyzer": "names"
},
"artist": {
"type": "keyword"
},
"album_suggest": {
"type": "completion",
"search_analyzer": "names"
},
"album": {
"type": "keyword"
}
}
}
}
// Also possible to specify analyze for ingesting => https://stackoverflow.com/questions/48304499/elasticsearch-completion-suggester-not-working-with-whitespace-analyzer
// Problem with word EP, SP
GET itunes-suggest/_analyze
{
"analyzer": "names",
"text": "the servent"
}
GET itunes-suggest/_search
POST itunes-suggest/_search
{
"_source" : "artist",
"suggest": {
"name-suggest": {
"prefix": "sou",
"completion": {
"field": "artist_suggest"
}
}
}
}
POST itunes-suggest/_search
{
"_source" : "album",
"suggest": {
"name-suggest": {
"prefix": "new",
"completion": {
"field": "album_suggest",
"size": 20
}
}
}
}
POST itunes-suggest/_search
{
"_source": ["album", "artist"],
"suggest": {
"alb-suggest": {
"prefix": "sou",
"completion": {
"field": "album_suggest"
}
},
"ar-suggest": {
"prefix": "sou",
"completion": {
"field": "artist_suggest"
}
}
}
}
POST itunes-suggest/_search
{
"_source": ["album", "artist"],
"suggest": {
"alb-suggest": {
"prefix": "Francois",
"completion": {
"field": "album_suggest"
}
},
"ar-suggest": {
"prefix": "Francois",
"completion": {
"field": "artist_suggest"
}
}
}
}
POST itunes-suggest/_search
{
"suggest": {
"ar-suggest": {
"prefix": "Femme",
"completion": {
"field": "artist_suggest"
}
}
}
}

83
suggester.py Normal file
View File

@@ -0,0 +1,83 @@
import requests
import json
import sys
ELS_URL ='http://localhost:9200'
INDEX = 'itunes-suggest'
class NoGoodDataException(Exception):
def __init__(self, message):
super().__init__(message)
def get_tokens(data: str) -> list:
if not data:
return []
query = {
"analyzer": "names",
"text" : data
}
url = '{}/{}/_analyze'.format(ELS_URL, INDEX)
r = requests.get(url, json=query)
if not 'tokens' in r.json():
print('ERROR: Not tokens in result')
print('Input: ' + str(data))
print('Request: ' + str(r.json()))
raise NoGoodDataException('Data is not correct to get tokens')
return [t['token'] for t in r.json()['tokens']]
def post_document(name: str, input: list, field_name: str) -> bool:
suggest_name = field_name + '_suggest'
element = {
field_name: name,
suggest_name: input
}
# Filter empty keys
# element = {k: v for k, v in element.items() if v}
url = '{}/{}/_doc'.format(ELS_URL, INDEX)
resp = requests.post(url, json=element)
if resp.status_code != 201:
print('ELS Response KO')
print(resp.status_code)
print(resp.text)
return
el_id = resp.json()['_id']
# print('Post_element - Element created: ' + el_id)
return el_id
def process_file(file_name: str, field_name: str) -> int:
print('Process file: ' + file_name)
with open(file_name, 'r') as o_file:
lines = o_file.readlines()
count = 0
i = 0
for line in lines:
i += 1
sys.stdout.write(str(int((i/len(lines))*100)) + '%')
sys.stdout.flush()
sys.stdout.write("\b" * (40+1)) # return to start of line, after '['
data = json.loads(line)
if "Artist" in data:
try :
input = get_tokens(data[field_name])
post_document(name=data[field_name], input=input, field_name=field_name.lower())
count += 1
except NoGoodDataException:
print('ERROR WITH DATA')
print(str(data))
print('File processed\n')
return count
if __name__ == '__main__':
# Using readlines()
count = 0
count += process_file('/home/budd/workspace/iTunes/es-albums.json', 'Album')
count += process_file('/home/budd/workspace/iTunes/es-artists.json', 'Artist')
print('Created documents: ' + str(count))